Skip to content
Merged
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
18 changes: 18 additions & 0 deletions .github/workflows/visual-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ on:
- "playwright.visual.config.mjs"
- ".github/workflows/visual-regression.yml"
workflow_dispatch:
inputs:
update_snapshots:
description: "Regenerate baselines and upload them as an artifact (for intentional UI changes)"
type: boolean
default: false

permissions:
contents: read
Expand Down Expand Up @@ -64,8 +69,21 @@ jobs:
run: pnpm run build

- name: Run visual regression tests
if: ${{ !inputs.update_snapshots }}
run: npx playwright test -c playwright.visual.config.mjs

- name: Regenerate baselines
if: ${{ inputs.update_snapshots }}
run: npx playwright test -c playwright.visual.config.mjs --update-snapshots=all

- name: Upload regenerated baselines
if: ${{ inputs.update_snapshots }}
uses: actions/upload-artifact@v7
with:
name: visual-baselines
path: tests/snapshots/
retention-days: 7

- name: Upload visual regression artifacts
uses: actions/upload-artifact@v7
if: failure()
Expand Down
37 changes: 21 additions & 16 deletions e2e/dashboard-widgets.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,16 +229,25 @@ test("dashboard widgets render with mocked metrics", async ({ page }) => {
await expect(
page.getByRole("heading", { name: "Dashboard", exact: true })
).toBeVisible({ timeout: 30000 });
await expect(page.getByRole("heading", { name: "Your Commits" }).first()).toBeVisible(
{ timeout: 10000 }
await expect(
page.getByRole("heading", { name: "Your Commits" }).first()
).toBeVisible({ timeout: 10000 });

await page.goto("/dashboard/pull-requests", { waitUntil: "load" });
await expect(
page.getByRole("heading", { level: 1, name: "Pull requests" })
).toBeVisible({ timeout: 30000 });
await expect(page.getByRole("heading", { name: "PR Analytics" })).toHaveCount(
1
);
await expect(page.getByRole("heading", { name: "PR Analytics" })).toHaveCount(1);
await expect(page.getByRole("heading", { name: "PR Analytics" })).toBeVisible(
{ timeout: 10000 }
);

await page.goto("/dashboard/goals", { waitUntil: "load" });
await expect(
page.getByRole("heading", { name: "Goals", exact: true }).first()
).toBeVisible({ timeout: 10000 });
).toBeVisible({ timeout: 30000 });
await expect(page.getByText("Make 10 commits")).toBeVisible({
timeout: 10000,
});
Expand Down Expand Up @@ -279,9 +288,9 @@ test("goal form posts a new goal", async ({ page }) => {
}
});

await page.goto("/dashboard", { waitUntil: "load" });
await page.goto("/dashboard/goals", { waitUntil: "load" });
await expect(
page.getByRole("heading", { name: "Dashboard", exact: true })
page.getByRole("heading", { level: 1, name: "Goals" })
).toBeVisible({ timeout: 30000 });
await page.getByLabel("Goal title").fill("Ship one PR");
await page.getByLabel("Target").fill("1");
Expand Down Expand Up @@ -349,9 +358,9 @@ function mockMetricResponse(url) {
thisWeek: { opened: 3, merged: 2 },
lastWeek: { opened: 1, merged: 1 },
},
issues: {
thisWeek: { opened: 4, closed: 2 },
lastWeek: { opened: 3, closed: 1 }
issues: {
thisWeek: { opened: 4, closed: 2 },
lastWeek: { opened: 3, closed: 1 },
},
productivityScore: { current: 85, previous: 80 },
activeDays: {
Expand All @@ -360,13 +369,9 @@ function mockMetricResponse(url) {
},
streak: 3,
topRepo: "demo/repo",
repoBreakdown: [
{ repoName: "demo/devtrack", commits: 10 },
],
dailyCommits: [
{ date: "2023-10-07", commits: 5 },
],
mostActiveDay: "2023-10-07"
repoBreakdown: [{ repoName: "demo/devtrack", commits: 10 }],
dailyCommits: [{ date: "2023-10-07", commits: 5 }],
mostActiveDay: "2023-10-07",
};
}
if (url.includes("/api/metrics/compare")) {
Expand Down
57 changes: 34 additions & 23 deletions e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,17 +244,17 @@ async function injectMockSession(page: import("@playwright/test").Page) {
thisWeek: { opened: 3, merged: 2 },
lastWeek: { opened: 1, merged: 1 },
},
issues: {
thisWeek: { opened: 5, closed: 0 },
lastWeek: { opened: 3, closed: 0 }
issues: {
thisWeek: { opened: 5, closed: 0 },
lastWeek: { opened: 3, closed: 0 },
},
productivityScore: { current: 88, previous: 75 },
activeDays: { thisWeek: 5, lastWeek: 4 },
streak: 7,
topRepo: "demo/devtrack",
repoBreakdown: [],
dailyCommits: [],
mostActiveDay: "2023-10-07"
mostActiveDay: "2023-10-07",
}),
})
);
Expand All @@ -267,7 +267,7 @@ async function injectMockSession(page: import("@playwright/test").Page) {
activeCount: 12,
growthTrend: 15,
sponsors: [],
sparklineData: []
sparklineData: [],
}),
})
);
Expand Down Expand Up @@ -394,19 +394,21 @@ test("[Dashboard E2E] Commits widget renders", async ({ page }) => {
});

test("[Dashboard E2E] PR Analytics widget renders", async ({ page }) => {
await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await page.goto("/dashboard/pull-requests", {
waitUntil: "domcontentloaded",
});
await expect(
page.getByRole("heading", { name: "Dashboard", exact: true })
page.getByRole("heading", { level: 1, name: "Pull requests" })
).toBeVisible({ timeout: 30_000 });
await scrollToWidget(page, "PR Analytics");
});

test("[Dashboard E2E] Goals widget renders with mocked goal", async ({
page,
}) => {
await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await page.goto("/dashboard/goals", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: "Dashboard", exact: true })
page.getByRole("heading", { level: 1, name: "Goals" })
).toBeVisible({ timeout: 30_000 });
await scrollToWidget(page, "Goals");
await expect(page.getByText("Make 10 commits")).toBeVisible({
Expand Down Expand Up @@ -455,9 +457,9 @@ test("[Dashboard E2E] no uncaught console errors on dashboard load", async ({
});

test("[Dashboard E2E] weekly summary widget renders", async ({ page }) => {
await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await page.goto("/dashboard/activity", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: "Dashboard", exact: true })
page.getByRole("heading", { level: 1, name: "Activity" })
).toBeVisible({ timeout: 30_000 });
// Weekly summary section should appear somewhere on the dashboard.
await expect(
Expand Down Expand Up @@ -504,23 +506,19 @@ test("[Dashboard E2E] analytics widgets render once without duplicate metric req
if (request.method() !== "GET") return;

const url = request.url();
if (
TRACKED_METRICS_ENDPOINTS.some((endpoint) => url.includes(endpoint))
) {
if (TRACKED_METRICS_ENDPOINTS.some((endpoint) => url.includes(endpoint))) {
requestUrls.push(url);
}
});

await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await page.goto("/dashboard/pull-requests", {
waitUntil: "domcontentloaded",
});
await expect(
page.getByRole("heading", { name: "Dashboard", exact: true })
page.getByRole("heading", { level: 1, name: "Pull requests" })
).toBeVisible({ timeout: 30_000 });

const lazyAnalyticsWidgets = [
{
region: "Repository Analytics",
heading: "Repo Analytics",
},
{
region: "PR Breakdown",
heading: "PR Breakdown",
Expand All @@ -531,7 +529,9 @@ test("[Dashboard E2E] analytics widgets render once without duplicate metric req
},
] as const;

await page.getByRole("region", { name: "PR Metrics" }).scrollIntoViewIfNeeded();
await page
.getByRole("region", { name: "PR Metrics" })
.scrollIntoViewIfNeeded();
await expect(
page.getByRole("heading", { name: "PR Analytics", level: 2 })
).toBeVisible({ timeout: 15_000 });
Expand All @@ -551,6 +551,17 @@ test("[Dashboard E2E] analytics widgets render once without duplicate metric req
).toHaveCount(1);
}

await page.goto("/dashboard/repositories", { waitUntil: "domcontentloaded" });
await page
.getByRole("region", { name: "Repository Analytics" })
.scrollIntoViewIfNeeded();
await expect(
page.getByRole("heading", { name: "Repo Analytics", level: 2 })
).toBeVisible({ timeout: 15_000 });
await expect(
page.getByRole("region", { name: "Repository Analytics" })
).toHaveCount(1);

for (const endpoint of TRACKED_METRICS_ENDPOINTS) {
const { min, max } = METRICS_REQUEST_BOUNDS[endpoint];
await expect
Expand All @@ -559,8 +570,8 @@ test("[Dashboard E2E] analytics widgets render once without duplicate metric req
const count = countMetricRequests(requestUrls, endpoint);
return count >= min && count <= max;
},
{ timeout: 15_000 },
{ timeout: 15_000 }
)
.toBe(true);
}
});
});
65 changes: 50 additions & 15 deletions e2e/goals.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,28 +106,35 @@ async function setupGoalsMocks(page: import("@playwright/test").Page) {
await page.route("**/api/goals/sync**", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ ok: true, last_synced_at: new Date().toISOString() }),
body: JSON.stringify({
ok: true,
last_synced_at: new Date().toISOString(),
}),
})
);

await installDashboardApiMocks(page);
}

async function openGoalsWidget(page: import("@playwright/test").Page) {
await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
await page.goto("/dashboard/goals", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: "Dashboard", exact: true })
page.getByRole("heading", { level: 1, name: "Goals" })
).toBeVisible({ timeout: 30_000 });
await scrollToWidget(page, "Goals");
}

test("[Goals E2E] goals widget renders on dashboard", async ({ page }) => {
page.on("console", msg => console.log("BROWSER CONSOLE:", msg.text()));
page.on("console", (msg) => console.log("BROWSER CONSOLE:", msg.text()));
await setupGoalsMocks(page);

await page.route(/\/api\/goals(\?|$)/, (route) => {
if (route.request().method() === "POST") {
return route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ ok: true }) });
return route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
}
return route.fulfill({
contentType: "application/json",
Expand Down Expand Up @@ -173,8 +180,14 @@ test("[Goals E2E] creating a goal sends POST /api/goals with correct payload", a
await page.getByLabel("Unit", { exact: true }).selectOption("prs");
await page.getByRole("button", { name: "Create goal" }).click();

await expect.poll(() => goalPosts.filter(Boolean), { timeout: 10_000 }).toHaveLength(1);
expect(goalPosts.find(Boolean)).toMatchObject({ title: "Ship one PR", target: 1, unit: "prs" });
await expect
.poll(() => goalPosts.filter(Boolean), { timeout: 10_000 })
.toHaveLength(1);
expect(goalPosts.find(Boolean)).toMatchObject({
title: "Ship one PR",
target: 1,
unit: "prs",
});
});

test("[Goals E2E] newly created goal appears in the goals list", async ({
Expand All @@ -198,10 +211,17 @@ test("[Goals E2E] newly created goal appears in the goals list", async ({
// Use regex so only /api/goals is matched — NOT /api/goals/sync or /api/goals/:id.
await page.route(/\/api\/goals(\?|$)/, async (route) => {
if (route.request().method() === "POST") {
const body = route.request().postDataJSON() as Record<string, unknown> | null;
const body = route.request().postDataJSON() as Record<
string,
unknown
> | null;
// Guard: /api/goals/sync POSTs (if leaked) have null body — skip them.
if (!body) {
return route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ ok: true }) });
return route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
}
goalsStore.push({
id: `g-new-${Date.now()}`,
Expand All @@ -227,7 +247,9 @@ test("[Goals E2E] newly created goal appears in the goals list", async ({

await openGoalsWidget(page);

await expect(page.getByText("Existing Goal")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("Existing Goal")).toBeVisible({
timeout: 10_000,
});

// Scroll goal form into view and wait for it to be interactive.
const titleInput = page.getByLabel("Goal title");
Expand All @@ -241,7 +263,9 @@ test("[Goals E2E] newly created goal appears in the goals list", async ({
await page.getByRole("button", { name: "Create goal" }).click();

// The new goal should appear without a page reload.
await expect(page.getByText("Ship five PRs")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("Ship five PRs")).toBeVisible({
timeout: 10_000,
});
});

test("[Goals E2E] deleting a goal removes it from the list", async ({
Expand All @@ -264,7 +288,11 @@ test("[Goals E2E] deleting a goal removes it from the list", async ({

await page.route(/\/api\/goals(\?|$)/, async (route) => {
if (route.request().method() === "POST") {
return route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ ok: true }) });
return route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
}
return route.fulfill({
contentType: "application/json",
Expand Down Expand Up @@ -294,15 +322,22 @@ test("[Goals E2E] deleting a goal removes it from the list", async ({
});

await openGoalsWidget(page);
await expect(page.getByText("Goal to Delete")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("Goal to Delete")).toBeVisible({
timeout: 10_000,
});

// Click the delete button (trash icon) next to this goal.
const goalRow = page.locator("li").filter({ hasText: "Goal to Delete" }).first();
const goalRow = page
.locator("li")
.filter({ hasText: "Goal to Delete" })
.first();
await goalRow.getByRole("button", { name: /delete goal/i }).click();

// A confirmation modal appears — click "Permanently Delete" to confirm.
await page.getByRole("button", { name: /permanently delete/i }).click();

// Goal should be gone.
await expect(page.getByText("Goal to Delete", { exact: true })).not.toBeVisible({ timeout: 10_000 });
await expect(
page.getByText("Goal to Delete", { exact: true })
).not.toBeVisible({ timeout: 10_000 });
});
Loading
Loading