From 6178b520fbf511806e9afec38f921acce935a8bd Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai Date: Sun, 17 May 2026 22:55:52 +0530 Subject: [PATCH] test(e2e): add Playwright dashboard coverage --- .github/workflows/ci.yml | 28 ++- package-lock.json | 77 ++++++-- package.json | 5 +- playwright.config.ts | 37 ++++ src/app/dashboard/page.tsx | 8 +- tests/e2e/devtrack.spec.ts | 361 +++++++++++++++++++++++++++++++++++++ 6 files changed, 498 insertions(+), 18 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/devtrack.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b286b2..217bc6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,13 +101,36 @@ jobs: - name: Check all imports exist in package.json run: node scripts/check-deps.js + # ── 5. End-to-end tests ─────────────────────────────────────────────────── + # Exercises the public auth entry point and authenticated dashboard flows with + # deterministic API fixtures so CI does not depend on GitHub OAuth or Supabase. + e2e: + name: Playwright E2E + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests + run: npm run test:e2e + # ── Summary gate ─────────────────────────────────────────────────────────── # Single status check to add to branch protection rules. # Branch: main → require "CI passed" before merge. ci-pass: name: CI passed runs-on: ubuntu-latest - needs: [typecheck, lint, build, dep-check] + needs: [typecheck, lint, build, dep-check, e2e] if: always() steps: - name: Check all jobs passed @@ -115,7 +138,8 @@ jobs: if [[ "${{ needs.typecheck.result }}" != "success" || "${{ needs.lint.result }}" != "success" || "${{ needs.build.result }}" != "success" || - "${{ needs.dep-check.result }}" != "success" ]]; then + "${{ needs.dep-check.result }}" != "success" || + "${{ needs.e2e.result }}" != "success" ]]; then echo "One or more CI jobs failed." exit 1 fi diff --git a/package-lock.json b/package-lock.json index 467c781..a246cfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "recharts": "^2.12.7" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -515,6 +516,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -746,7 +763,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1185,7 +1201,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1638,7 +1653,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2508,7 +2522,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2677,7 +2690,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4118,7 +4130,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4190,7 +4201,6 @@ "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", @@ -5003,6 +5013,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5033,7 +5090,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5204,7 +5260,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -5295,7 +5350,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5308,7 +5362,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6362,7 +6415,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6532,7 +6584,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 807987b..e4bf7aa 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "build": "next build", "start": "next start", "lint": "next lint", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@supabase/supabase-js": "^2.43.4", @@ -22,6 +24,7 @@ "recharts": "^2.12.7" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..bc49e56 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, devices } from "@playwright/test"; + +const PORT = Number(process.env.PORT ?? 3100); +const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${PORT}`; +const npmCommand = process.env.npm_execpath + ? `node ${process.env.npm_execpath}` + : "npm"; + +export default defineConfig({ + testDir: "./tests/e2e", + timeout: 30_000, + expect: { + timeout: 10_000, + }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + webServer: { + command: `PORT=${PORT} E2E_TEST_AUTH_BYPASS=true NEXTAUTH_SECRET=e2e-secret-that-is-long-enough-for-nextauth NEXTAUTH_URL=${baseURL} NEXT_PUBLIC_APP_URL=${baseURL} GITHUB_ID=e2e-client GITHUB_SECRET=e2e-secret NEXT_PUBLIC_SUPABASE_URL=https://example.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=e2e-anon-key SUPABASE_SERVICE_ROLE_KEY=e2e-service-role-key ${npmCommand} run dev`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 9e4bdb6..7a93272 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -19,10 +19,14 @@ import { authOptions } from "@/lib/auth"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; +const isE2EAuthBypassEnabled = + process.env.NODE_ENV !== "production" && + process.env.E2E_TEST_AUTH_BYPASS === "true"; + export default async function DashboardPage() { const session = await getServerSession(authOptions); - if (!session) { + if (!session && !isE2EAuthBypassEnabled) { redirect("/"); } @@ -80,4 +84,4 @@ export default async function DashboardPage() { ); -} \ No newline at end of file +} diff --git a/tests/e2e/devtrack.spec.ts b/tests/e2e/devtrack.spec.ts new file mode 100644 index 0000000..b3634e3 --- /dev/null +++ b/tests/e2e/devtrack.spec.ts @@ -0,0 +1,361 @@ +import { expect, type Page, test } from "@playwright/test"; + +type Goal = { + id: string; + title: string; + target: number; + current: number; + unit: string; + recurrence: "none" | "weekly" | "monthly"; + period_start: string; +}; + +const jsonHeaders = { + "content-type": "application/json", +}; + +function contributionPayload(days: number) { + const now = new Date("2026-05-17T00:00:00.000Z"); + const data: Record = {}; + + for (let index = 0; index < Math.min(days, 5); index += 1) { + const day = new Date(now); + day.setUTCDate(now.getUTCDate() - index); + data[day.toISOString().slice(0, 10)] = index + 1; + } + + return { + days, + total: Object.values(data).reduce((sum, commits) => sum + commits, 0), + data, + }; +} + +async function mockDashboardApis(page: Page) { + const goals: Goal[] = []; + const contributionRequests: number[] = []; + + await page.route("**/api/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname; + + if (path === "/api/auth/session") { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ + user: { name: "Saurabh Kumar Bajpai" }, + githubId: "12345", + githubLogin: "saurabhhhcodes", + expires: "2099-01-01T00:00:00.000Z", + }), + }); + return; + } + + if (path === "/api/user/settings") { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ is_public: true }), + }); + return; + } + + if (path === "/api/user/github-accounts") { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ accounts: [] }), + }); + return; + } + + if (path === "/api/goals" && request.method() === "GET") { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ goals }), + }); + return; + } + + if (path === "/api/goals" && request.method() === "POST") { + const body = request.postDataJSON() as { + title: string; + target: number; + unit: string; + recurrence: Goal["recurrence"]; + }; + const goal: Goal = { + id: `goal-${goals.length + 1}`, + title: body.title, + target: body.target, + current: 0, + unit: body.unit, + recurrence: body.recurrence, + period_start: "1970-01-01T00:00:00.000Z", + }; + goals.unshift(goal); + await route.fulfill({ + status: 201, + headers: jsonHeaders, + body: JSON.stringify({ goal }), + }); + return; + } + + if (path.startsWith("/api/goals/") && request.method() === "DELETE") { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ ok: true }), + }); + return; + } + + if (path === "/api/metrics/contributions") { + const days = Number(url.searchParams.get("days") ?? "30"); + contributionRequests.push(days); + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify(contributionPayload(days)), + }); + return; + } + + if (path === "/api/metrics/streak") { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ + current: 4, + longest: 12, + lastCommitDate: "2026-05-17", + totalActiveDays: 18, + }), + }); + return; + } + + if (path === "/api/streak/freeze") { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ hasFreeze: false }), + }); + return; + } + + if (path === "/api/metrics/prs") { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ + open: 3, + merged: 8, + avgReviewHours: 6, + mergeRate: "73%", + }), + }); + return; + } + + if (path === "/api/metrics/issues") { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ + opened: 5, + closed: 4, + currentlyOpen: 2, + avgCloseTimeDays: 1.7, + trend: 2, + }), + }); + return; + } + + if (path === "/api/metrics/repos") { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ + repos: [ + { + name: "saurabhhhcodes/devtrack", + commits: 14, + url: "https://github.com/saurabhhhcodes/devtrack", + }, + ], + }), + }); + return; + } + + if (path === "/api/metrics/repo-health") { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ + repos: [ + { + repo: "saurabhhhcodes/devtrack", + score: 92, + grade: "green", + signals: { + commitFrequency: 14, + prMergeRate: 0.9, + avgPrOpenTimeHours: 4, + openIssuesCount: 2, + daysSinceLastCommit: 0, + }, + }, + ], + }), + }); + return; + } + + if (path === "/api/metrics/pinned-repos") { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ + pinnedRepos: [ + { + name: "devtrack", + description: "Developer productivity dashboard", + url: "https://github.com/Priyanshu-byte-coder/devtrack", + stargazerCount: 29, + forkCount: 8, + primaryLanguage: { name: "TypeScript", color: "#3178c6" }, + }, + ], + }), + }); + return; + } + + if (path === "/api/metrics/languages") { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ + languages: [ + { name: "TypeScript", bytes: 7000, percentage: 70 }, + { name: "JavaScript", bytes: 3000, percentage: 30 }, + ], + }), + }); + return; + } + + if (path === "/api/metrics/pr-breakdown") { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ draft: 1, open: 3, merged: 8, closed: 2 }), + }); + return; + } + + if (path === "/api/metrics/weekly-summary") { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ + commits: { current: 18, last: 11, delta: 7 }, + pullRequests: { opened: 4, merged: 3 }, + activeDays: 5, + streak: 4, + mostActiveRepo: "saurabhhhcodes/devtrack", + weekStart: "2026-05-11T00:00:00.000Z", + generatedAt: "2026-05-17T00:00:00.000Z", + }), + }); + return; + } + + if (path === "/api/metrics/compare") { + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ + username: "me", + commits: 18, + prs: 4, + issues: 5, + }), + }); + return; + } + + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({}), + }); + }); + + return { contributionRequests }; +} + +test("landing page exposes the GitHub sign-in flow", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByRole("heading", { name: "DevTrack" })).toBeVisible(); + const signInLink = page.getByRole("link", { name: "Sign in with GitHub" }); + await expect(signInLink).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 renders core widgets with mocked authenticated data", async ({ + page, +}) => { + await mockDashboardApis(page); + + await page.goto("/dashboard"); + + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "This Week" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Commit Activity" })).toBeVisible(); + await expect(page.getByText("Open PRs")).toBeVisible(); + await expect(page.getByText("Merged (30d)")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Issue Analytics" })).toBeVisible(); + await expect(page.getByText("saurabhhhcodes/devtrack").first()).toBeVisible(); + await expect(page.getByRole("heading", { name: "Language Breakdown" })).toBeVisible(); +}); + +test("dashboard supports contribution range switching and goal creation", async ({ + page, +}) => { + const { contributionRequests } = await mockDashboardApis(page); + + await page.goto("/dashboard"); + await expect(page.getByRole("heading", { name: "Commit Activity" })).toBeVisible(); + + await page.getByRole("button", { name: "Show 7-day range" }).click(); + await expect + .poll(() => contributionRequests.includes(7)) + .toBe(true); + + await expect(page.getByText("No goals yet. Create one below.")).toBeVisible(); + await page.getByLabel("Goal title").fill("Ship e2e tests"); + await page.getByLabel("Target").fill("3"); + await page.getByLabel("Unit").fill("pull requests"); + await page.getByRole("button", { name: "Weekly" }).click(); + await page.getByRole("button", { name: "Add goal" }).click(); + + await expect(page.getByText("Ship e2e tests")).toBeVisible(); + await expect(page.getByText("0/3 pull requests")).toBeVisible(); + await expect(page.getByText("Weekly").first()).toBeVisible(); +});