From bd9eff5c18838518988063c3db9a2a7ac9b703c6 Mon Sep 17 00:00:00 2001 From: saurabhhhcodes <157192462+saurabhhhcodes@users.noreply.github.com> Date: Tue, 19 May 2026 00:42:22 +0530 Subject: [PATCH] Add Playwright E2E smoke tests --- .github/workflows/e2e.yml | 49 +++++++++ e2e/dashboard-widgets.spec.js | 200 ++++++++++++++++++++++++++++++++++ e2e/landing.spec.js | 21 ++++ e2e/public-profile.spec.js | 13 +++ playwright.config.mjs | 44 ++++++++ 5 files changed, 327 insertions(+) create mode 100644 .github/workflows/e2e.yml create mode 100644 e2e/dashboard-widgets.spec.js create mode 100644 e2e/landing.spec.js create mode 100644 e2e/public-profile.spec.js create mode 100644 playwright.config.mjs diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..9be35f7 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,49 @@ +name: E2E + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + workflow_dispatch: + +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + playwright: + name: Playwright smoke tests + runs-on: ubuntu-latest + env: + NEXTAUTH_SECRET: playwright-placeholder-secret-that-is-long-enough + NEXTAUTH_URL: http://127.0.0.1:3000 + NEXT_PUBLIC_APP_URL: http://127.0.0.1:3000 + GITHUB_ID: playwright-github-id + GITHUB_SECRET: playwright-github-secret + NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder-anon-key + SUPABASE_SERVICE_ROLE_KEY: placeholder-service-role-key + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install app dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx -y @playwright/test@1.49.1 install --with-deps chromium + + - name: Run Playwright tests + run: npx -y @playwright/test@1.49.1 test + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/e2e/dashboard-widgets.spec.js b/e2e/dashboard-widgets.spec.js new file mode 100644 index 0000000..482cf58 --- /dev/null +++ b/e2e/dashboard-widgets.spec.js @@ -0,0 +1,200 @@ +import { expect, test } from "@playwright/test"; +import { encode } from "next-auth/jwt"; + +const authSecret = "playwright-placeholder-secret-that-is-long-enough"; + +test.beforeEach(async ({ page }) => { + await page.context().addCookies([ + { + name: "next-auth.session-token", + value: await encode({ + secret: authSecret, + token: { + name: "Playwright User", + email: "playwright@example.com", + sub: "12345", + githubLogin: "playwright-user", + githubId: "12345", + accessToken: "test-token", + }, + maxAge: 60 * 60, + }), + domain: "127.0.0.1", + path: "/", + httpOnly: true, + sameSite: "Lax", + secure: false, + expires: Math.floor(Date.now() / 1000) + 60 * 60, + }, + ]); + + await page.route("**/api/auth/session", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + user: { name: "Playwright User", email: "playwright@example.com" }, + githubLogin: "playwright-user", + githubId: "12345", + accessToken: "test-token", + expires: "2099-01-01T00:00:00.000Z", + }), + }); + }); + + await page.route("**/api/user/settings", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ is_public: true }), + }); + }); + + await page.route("**/api/metrics/contributions**", async (route) => { + const url = new URL(route.request().url()); + const days = Number(url.searchParams.get("days") ?? 30); + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + data: { + "2026-05-16": days >= 7 ? 3 : 1, + "2026-05-17": 5, + "2026-05-18": 2, + }, + }), + }); + }); + + await page.route("**/api/goals", async (route) => { + if (route.request().method() === "POST") { + await route.fulfill({ + contentType: "application/json", + status: 201, + body: JSON.stringify({ ok: true }), + }); + return; + } + + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + goals: [ + { + id: "goal-1", + title: "Make 10 commits", + target: 10, + current: 4, + unit: "commits", + recurrence: "weekly", + period_start: "2026-05-18", + }, + ], + }), + }); + }); + + const metricRoutes = [ + "**/api/metrics/prs**", + "**/api/metrics/pr-breakdown**", + "**/api/metrics/issues**", + "**/api/metrics/repos**", + "**/api/metrics/languages**", + "**/api/metrics/streak**", + "**/api/metrics/pinned-repos**", + "**/api/metrics/weekly-summary**", + "**/api/metrics/compare**", + "**/api/metrics/repo-health**", + "**/api/streak/freeze**", + "**/api/user/github-accounts**", + ]; + + for (const pattern of metricRoutes) { + await page.route(pattern, async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify(mockMetricResponse(route.request().url())), + }); + }); + } +}); + +test("dashboard widgets render with mocked metrics", async ({ page }) => { + await page.goto("/dashboard"); + + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Commit Activity" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "PR Analytics" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Weekly Goals" })).toBeVisible(); + await expect(page.getByText("Make 10 commits")).toBeVisible(); +}); + +test("contribution graph range buttons request a new range", async ({ page }) => { + const contributionRequests = []; + page.on("request", (request) => { + if (request.url().includes("/api/metrics/contributions")) { + contributionRequests.push(request.url()); + } + }); + + await page.goto("/dashboard"); + await page.getByRole("button", { name: "Show 90-day range" }).click(); + + await expect.poll(() => contributionRequests.some((url) => url.includes("days=90"))).toBe(true); +}); + +test("goal form posts a new goal", async ({ page }) => { + const goalPosts = []; + page.on("request", (request) => { + if (request.url().endsWith("/api/goals") && request.method() === "POST") { + goalPosts.push(request.postDataJSON()); + } + }); + + await page.goto("/dashboard"); + await page.getByLabel("Goal title").fill("Ship one PR"); + await page.getByLabel("Target").fill("1"); + await page.getByLabel("Unit").fill("PR"); + await page.getByRole("button", { name: "Add goal" }).click(); + + await expect.poll(() => goalPosts).toHaveLength(1); + expect(goalPosts[0]).toMatchObject({ + title: "Ship one PR", + target: 1, + unit: "PR", + }); +}); + +function mockMetricResponse(url) { + if (url.includes("/api/metrics/prs")) { + return { open: 2, merged: 8, avgReviewHours: 6, mergeRate: "80%" }; + } + if (url.includes("/api/metrics/pr-breakdown")) { + return { merged: 8, open: 2, closed: 1 }; + } + if (url.includes("/api/metrics/issues")) { + return { opened: 4, closed: 3, open: 1 }; + } + if (url.includes("/api/metrics/repos") || url.includes("/api/metrics/pinned-repos")) { + return { repos: [{ name: "demo/repo", commits: 12, url: "https://github.com/demo/repo" }] }; + } + if (url.includes("/api/metrics/languages")) { + return { languages: [{ language: "TypeScript", count: 12 }] }; + } + if (url.includes("/api/metrics/streak")) { + return { current: 3, longest: 9, lastCommitDate: "2026-05-18", totalActiveDays: 12 }; + } + if (url.includes("/api/metrics/weekly-summary")) { + return { commits: 10, pullRequests: 3, mergedPullRequests: 2 }; + } + if (url.includes("/api/metrics/compare")) { + return { user: { commits: 10 }, friend: { commits: 8 } }; + } + if (url.includes("/api/metrics/repo-health")) { + return { repositories: [] }; + } + if (url.includes("/api/streak/freeze")) { + return { freezes: [] }; + } + if (url.includes("/api/user/github-accounts")) { + return { accounts: [] }; + } + return {}; +} diff --git a/e2e/landing.spec.js b/e2e/landing.spec.js new file mode 100644 index 0000000..db1fd5d --- /dev/null +++ b/e2e/landing.spec.js @@ -0,0 +1,21 @@ +import { expect, test } from "@playwright/test"; + +test("landing page renders GitHub sign-in entrypoint", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByRole("heading", { name: "DevTrack" })).toBeVisible(); + await expect( + page.getByRole("link", { name: "Sign in with GitHub" }), + ).toHaveAttribute("href", /\/api\/auth\/signin\/github\?callbackUrl=\/dashboard/); + await expect(page.getByRole("link", { name: "View on GitHub" })).toHaveAttribute( + "href", + "https://github.com/Priyanshu-byte-coder/devtrack", + ); +}); + +test("dashboard stays protected for unauthenticated users", async ({ page }) => { + await page.goto("/dashboard"); + + await expect(page).toHaveURL(/\/$/); + await expect(page.getByRole("link", { name: "Sign in with GitHub" })).toBeVisible(); +}); diff --git a/e2e/public-profile.spec.js b/e2e/public-profile.spec.js new file mode 100644 index 0000000..b950b8f --- /dev/null +++ b/e2e/public-profile.spec.js @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; + +test("public profile route renders without requiring authentication", async ({ page }) => { + await page.goto("/u/playwright-user"); + + await expect(page).toHaveURL(/\/u\/playwright-user$/); + await expect( + page.getByRole("heading", { + name: /(@playwright-user's Profile|Profile Not Found)/, + }), + ).toBeVisible(); + await expect(page.getByRole("link", { name: "Sign in with GitHub" })).toHaveCount(0); +}); diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 0000000..672e20e --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,44 @@ +import { defineConfig, devices } from "@playwright/test"; + +const PORT = Number(process.env.PORT ?? 3000); +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + expect: { + timeout: 8_000, + }, + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "list", + use: { + baseURL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + webServer: { + command: `node node_modules/next/dist/bin/next dev -H 127.0.0.1 -p ${PORT}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + NEXTAUTH_SECRET: "playwright-placeholder-secret-that-is-long-enough", + NEXTAUTH_URL: baseURL, + NEXT_PUBLIC_APP_URL: baseURL, + GITHUB_ID: "playwright-github-id", + GITHUB_SECRET: "playwright-github-secret", + NEXT_PUBLIC_SUPABASE_URL: "https://placeholder.supabase.co", + NEXT_PUBLIC_SUPABASE_ANON_KEY: "placeholder-anon-key", + SUPABASE_SERVICE_ROLE_KEY: "placeholder-service-role-key", + }, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +});