From 2eed06d733929765974975d78929b7a0d0f2ece6 Mon Sep 17 00:00:00 2001 From: Prerak Yadav Date: Sat, 27 Jun 2026 10:24:51 +0530 Subject: [PATCH] test(dashboard): add regression guards for duplicate widget renders The duplicate inline widgets bug was fixed in aba0430; these tests prevent reintroduction via a static page.tsx guard and E2E DOM/API assertions. Co-authored-by: Cursor --- e2e/dashboard-widgets.spec.js | 3 +- e2e/dashboard.spec.ts | 121 ++++++++++++++++++ e2e/helpers/dashboard-mocks.js | 4 + ...ashboard-page-no-duplicate-widgets.test.ts | 41 ++++++ 4 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 test/dashboard-page-no-duplicate-widgets.test.ts diff --git a/e2e/dashboard-widgets.spec.js b/e2e/dashboard-widgets.spec.js index 0730f81e3..2338d6030 100644 --- a/e2e/dashboard-widgets.spec.js +++ b/e2e/dashboard-widgets.spec.js @@ -232,7 +232,8 @@ test("dashboard widgets render with mocked metrics", async ({ page }) => { await expect(page.getByRole("heading", { name: "Your Commits" })).toBeVisible( { timeout: 10000 } ); - await expect(page.getByRole("heading", { name: "PR Analytics" }).first()).toBeVisible( + await expect(page.getByRole("heading", { name: "PR Analytics" })).toHaveCount(1); + await expect(page.getByRole("heading", { name: "PR Analytics" })).toBeVisible( { timeout: 10000 } ); await expect( diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 4850d05a9..f7dc53c5d 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -203,6 +203,13 @@ async function injectMockSession(page: import("@playwright/test").Page) { }) ); + await page.route("**/api/metrics/pr-review-time**", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ weeks: [] }), + }) + ); + // ── Issues (widget 4) ──────────────────────────────────────────────────── await page.route("**/api/metrics/issues**", (route) => route.fulfill({ @@ -300,6 +307,20 @@ async function injectMockSession(page: import("@playwright/test").Page) { }) ); + await page.route("**/api/daily-focus**", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ goal: "" }), + }) + ); + + await page.route("**/api/user/dashboard-layout**", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ layout: null, source: "default" }), + }) + ); + // ── Remaining metric routes (stub to empty) ────────────────────────────── const stubRoutes = [ "**/api/metrics/repos**", @@ -424,4 +445,104 @@ test("[Dashboard E2E] weekly summary widget renders", async ({ page }) => { await expect( page.getByRole("heading", { name: "This Week" }).first() ).toBeVisible({ timeout: 10_000 }); +}); + +const TRACKED_METRICS_ENDPOINTS = [ + "/api/metrics/repo-explorer", + "/api/metrics/prs", + "/api/metrics/pr-breakdown", + "/api/metrics/pr-review-time", +] as const; + +// React Strict Mode in dev double-invokes effects. Duplicate widget instances +// would exceed these per-endpoint ceilings (e.g. 2x PRMetrics → 4+ /api/metrics/prs). +const METRICS_REQUEST_BOUNDS: Record< + (typeof TRACKED_METRICS_ENDPOINTS)[number], + { min: number; max: number } +> = { + "/api/metrics/repo-explorer": { min: 1, max: 2 }, + "/api/metrics/prs": { min: 1, max: 2 }, + "/api/metrics/pr-breakdown": { min: 1, max: 2 }, + // MiniPRTrendChart (inside PR Metrics) + PR Review Trend widget both call this route. + "/api/metrics/pr-review-time": { min: 1, max: 4 }, +}; + +function countMetricRequests(urls: string[], endpoint: string): number { + return urls.filter((url) => { + try { + return new URL(url).pathname === endpoint; + } catch { + return false; + } + }).length; +} + +test("[Dashboard E2E] analytics widgets render once without duplicate metric requests", async ({ + page, +}) => { + const requestUrls: string[] = []; + + page.on("request", (request) => { + if (request.method() !== "GET") return; + + const url = request.url(); + if ( + TRACKED_METRICS_ENDPOINTS.some((endpoint) => url.includes(endpoint)) + ) { + requestUrls.push(url); + } + }); + + await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); + await expect( + page.getByRole("heading", { name: "Dashboard", exact: true }) + ).toBeVisible({ timeout: 30_000 }); + + const lazyAnalyticsWidgets = [ + { + region: "Repository Analytics", + heading: "Repo Analytics", + }, + { + region: "PR Breakdown", + heading: "PR Breakdown", + }, + { + region: "PR Review Trend", + heading: "PR Review Time Trend", + }, + ] as const; + + await page.getByRole("region", { name: "PR Metrics" }).scrollIntoViewIfNeeded(); + await expect( + page.getByRole("heading", { name: "PR Analytics", level: 2 }) + ).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("region", { name: "PR Metrics" })).toHaveCount(1); + await expect( + page.getByRole("heading", { name: "PR Analytics", level: 2 }) + ).toHaveCount(1); + + for (const { region, heading } of lazyAnalyticsWidgets) { + await page.getByRole("region", { name: region }).scrollIntoViewIfNeeded(); + await expect( + page.getByRole("heading", { name: heading, level: 2 }) + ).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("region", { name: region })).toHaveCount(1); + await expect( + page.getByRole("heading", { name: heading, level: 2 }) + ).toHaveCount(1); + } + + for (const endpoint of TRACKED_METRICS_ENDPOINTS) { + const { min, max } = METRICS_REQUEST_BOUNDS[endpoint]; + await expect + .poll( + () => { + const count = countMetricRequests(requestUrls, endpoint); + return count >= min && count <= max; + }, + { timeout: 15_000 }, + ) + .toBe(true); + } }); \ No newline at end of file diff --git a/e2e/helpers/dashboard-mocks.js b/e2e/helpers/dashboard-mocks.js index 45beb6856..24dca76b1 100644 --- a/e2e/helpers/dashboard-mocks.js +++ b/e2e/helpers/dashboard-mocks.js @@ -103,6 +103,9 @@ export function mockMetricResponse(url) { return { total: 0, answered: 0 }; } if (url.includes("/api/metrics/pr-review-trend")) return { trend: [] }; + if (url.includes("/api/metrics/pr-review-time")) { + return { weeks: [] }; + } if (url.includes("/api/metrics/inactive-repos")) return { repos: [] }; if (url.includes("/api/metrics/coding-time") || url.includes("/api/wakatime")) { return { @@ -314,6 +317,7 @@ export async function installDashboardApiMocks(page, options = {}) { "**/api/metrics/prs**", "**/api/metrics/pr-breakdown**", "**/api/metrics/pr-review-trend**", + "**/api/metrics/pr-review-time**", "**/api/metrics/issues**", "**/api/metrics/languages**", "**/api/metrics/repos**", diff --git a/test/dashboard-page-no-duplicate-widgets.test.ts b/test/dashboard-page-no-duplicate-widgets.test.ts new file mode 100644 index 000000000..0b70b8094 --- /dev/null +++ b/test/dashboard-page-no-duplicate-widgets.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const PAGE_PATH = join( + process.cwd(), + "src/app/dashboard/page.tsx", +); + +const FORBIDDEN_WIDGETS = [ + "RepoAnalyticsExplorer", + "PRMetrics", + "PRBreakdownChart", + "PRReviewTrendChart", +] as const; + +describe("dashboard page duplicate widget guard", () => { + const pageSource = readFileSync(PAGE_PATH, "utf-8"); + + it("delegates widgets to CustomizableDashboard", () => { + expect(pageSource).toContain("CustomizableDashboard"); + expect(pageSource).toMatch(//); + }); + + it("does not import duplicated analytics widgets directly", () => { + for (const widget of FORBIDDEN_WIDGETS) { + expect(pageSource).not.toMatch( + new RegExp(`from\\s+["']@/components/.*${widget}`), + ); + expect(pageSource).not.toMatch( + new RegExp(`import\\(["']@/components/${widget}`), + ); + } + }); + + it("does not render duplicated analytics widgets in JSX", () => { + for (const widget of FORBIDDEN_WIDGETS) { + expect(pageSource).not.toMatch(new RegExp(`<${widget}[\\s/>]`)); + } + }); +});