Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion e2e/dashboard-widgets.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
121 changes: 121 additions & 0 deletions e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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**",
Expand Down Expand Up @@ -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);
}
});
4 changes: 4 additions & 0 deletions e2e/helpers/dashboard-mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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**",
Expand Down
41 changes: 41 additions & 0 deletions test/dashboard-page-no-duplicate-widgets.test.ts
Original file line number Diff line number Diff line change
@@ -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(/<CustomizableDashboard\s*\/>/);
});

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/>]`));
}
});
});
Loading