test(e2e): mock the dashboard snapshot endpoint for tile specs

The v1.7.2 default-ON snapshot flag makes the dashboard read every tile
from GET /api/dashboard/snapshot; the e2e build sets no opt-out var, so
the specs that only mocked the legacy cells hit the real route, got
empty summaries for the zero-measurement seed user, and timed out the
tile/chart assertions.

Add a shared, correctly-typed mock helper and wire it into the five
tile-asserting specs alongside the existing legacy mocks (kept to
protect the reversible path).
This commit is contained in:
Marc-André Bombeck committed 2026-05-31 20:00:16 +02:00
1 parent d6ce53a318
commit a3e1d369e6
6 files changed
+224

No files matched your search

+9
View File
@@ -1,6 +1,10 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
mockDashboardSnapshot,
WEIGHT_ONLY_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
/**
* v1.4.18 — per-chart overlay-controls popover.
@@ -19,6 +23,11 @@ test.describe("chart overlay controls", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
// v1.7.2 — snapshot flag default-ON; mock the snapshot cell with a
// WEIGHT-only populated summary so the weight chart card paints.
// Legacy mocks below stay for the reversible `=false` path.
await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES });
// Mock analytics + measurements so the dashboard paints at least
// one chart card. Same seed shape the dashboard.spec.ts uses.
//
+6
View File
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
/**
* v1.4.19 A2 — mobile-viewport regression spec for chart cards.
@@ -31,6 +32,11 @@ test.describe("charts — mobile-viewport regression", () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
// v1.7.2 — snapshot flag default-ON; mock the snapshot cell with the
// same populated four-metric set so the charts paint. Legacy mocks
// below stay for the reversible `=false` path.
await mockDashboardSnapshot(page);
// Mock analytics + measurements so the chart wrappers all clear
// their data-floor gates. Same pattern as `dashboard.spec.ts`.
//
+9
View File
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
/**
* Authenticated dashboard render — proves the seed user can reach the
@@ -20,6 +21,14 @@ test.describe("authenticated dashboard render", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
// v1.7.2 — the dashboard snapshot flag is default-ON, so the page
// reads every tile from `/api/dashboard/snapshot` and ignores the
// legacy cells below. Mock the snapshot with the same populated
// four-metric set so the tile strip + charts paint. The legacy
// `/api/analytics` + `/api/mood/analytics` mocks stay for the
// reversible `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` path.
await mockDashboardSnapshot(page);
// Mock analytics with a populated weight summary so the weight tile
// and chart render. `count` must be >0 for the tile to clear the
// data-floor gate in `src/app/page.tsx`.
+9
View File
@@ -1,6 +1,10 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
mockDashboardSnapshot,
WEIGHT_ONLY_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
/**
* Mobile-viewport smoke (Pixel 5 profile only). Asserts:
@@ -31,6 +35,11 @@ test.describe("mobile-viewport smoke", () => {
test("dashboard has no horizontal scroll, bottom-nav respects content, all CTAs ≥ 44 px tall", async ({
page,
}) => {
// v1.7.2 — snapshot flag default-ON; mock the snapshot cell with a
// WEIGHT-only populated summary so the weight tile paints. Legacy
// mocks below stay for the reversible `=false` path.
await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES });
// v1.4.37 W-CI — match `/api/analytics` AND any sliced variant
// (`?slice=summaries`). The previous string glob `**/api/analytics`
// didn't match the query-string form so the IW1 slim-slice fetch
+19
View File
@@ -1,6 +1,10 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
mockDashboardSnapshot,
WEIGHT_ONLY_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
/**
* v1.4.15 phase-A3 fix #3 — onboarding card flicker on dashboard load.
@@ -102,6 +106,17 @@ test.describe("dashboard onboarding card flicker guard", () => {
}),
}),
);
// v1.7.2 — snapshot flag default-ON. The dashboard tile strip reads
// the snapshot cell; mock it with the populated WEIGHT summary
// (count 30 >= the 5-reading setup gate) and the same ~250 ms delay
// so the page's snapshot-driven tiles resolve on the same race
// window as the slowed analytics query the checklist reads. (The
// `GettingStartedChecklist` itself reads `/api/analytics?slice=...`
// unconditionally, so its flicker gate is unchanged.)
await mockDashboardSnapshot(page, {
summaries: WEIGHT_ONLY_SUMMARIES,
delayMs: 250,
});
// Same `/api/dashboard/widgets` stub as the incomplete-onboarding
// test below — keeps the dashboard from error-boundary-bailing
// when this query 500s in CI without a real session.
@@ -192,6 +207,10 @@ test.describe("dashboard onboarding card flicker guard", () => {
}),
}),
);
// v1.7.2 — snapshot flag default-ON. Empty snapshot summaries keep
// the tile strip blank (the user is still in setup) so the page
// renders the onboarding card path deterministically.
await mockDashboardSnapshot(page, { summaries: {} });
await page.route("**/api/mood/analytics", (route) =>
route.fulfill({
status: 200,
+172
View File
@@ -0,0 +1,172 @@
import type { Page } from "@playwright/test";
import {
DASHBOARD_WIDGET_CATALOGUE_IDS,
DEFAULT_DASHBOARD_LAYOUT,
} from "@/lib/dashboard-layout";
import type { DataSummary } from "@/lib/analytics/trends";
import type {
DashboardSnapshot,
DashboardLayoutCatalogueEntry,
} from "@/lib/dashboard/snapshot";
/**
* v1.7.2 — shared mock for `GET /api/dashboard/snapshot`.
*
* The dashboard snapshot flag flipped default-ON in v1.7.2, so
* `src/app/page.tsx` reads every above-the-fold tile from the single
* `/api/dashboard/snapshot` cell unless `NEXT_PUBLIC_DASHBOARD_SNAPSHOT`
* is `"false"`. The e2e build sets no such var, so the snapshot path is
* live in CI. Specs that previously mocked only the legacy cells
* (`/api/analytics` + `/api/mood/analytics` + `/api/dashboard/widgets`)
* now hit the REAL snapshot route, which returns empty `summaries` for
* the seed user (zero measurements) and blanks the tile strip.
*
* `mockDashboardSnapshot` registers a `page.route` for the snapshot
* endpoint returning a correctly-typed `DashboardSnapshot` so the
* default-ON path paints deterministic tiles. Callers keep their legacy
* mocks alongside it — harmless, and they protect the reversible
* `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` path.
*/
/** Fill a `DataSummary` from a sparse partial, defaulting the rest. */
function summary(partial: Partial<DataSummary>): DataSummary {
return {
count: 0,
latest: null,
min: null,
max: null,
mean: null,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
...partial,
};
}
/**
* Four-metric populated tile set mirroring the legacy `/api/analytics`
* mock shared by `dashboard.spec.ts` / `charts-mobile.spec.ts` /
* `chart-overlay-controls.spec.ts`. Every summary has `count > 0` so the
* tile clears the data-floor gate in `src/app/page.tsx`.
*/
export const POPULATED_SUMMARIES: Record<string, DataSummary> = {
WEIGHT: summary({
latest: 78.5,
avg7: 78.2,
avg30: 77.9,
slope30: { slope: -0.05, direction: "down", confidence: 0.8 },
count: 30,
}),
BLOOD_PRESSURE_SYS: summary({
latest: 124,
avg7: 122,
avg30: 121,
slope30: { slope: 0.1, direction: "stable", confidence: 0.3 },
count: 30,
}),
BLOOD_PRESSURE_DIA: summary({
latest: 80,
avg7: 79,
avg30: 78,
slope30: { slope: 0.05, direction: "stable", confidence: 0.2 },
count: 30,
}),
PULSE: summary({
latest: 68,
avg7: 70,
avg30: 71,
slope30: { slope: -0.2, direction: "down", confidence: 0.6 },
count: 30,
}),
};
/** WEIGHT-only populated set for specs that only need one tile + chart. */
export const WEIGHT_ONLY_SUMMARIES: Record<string, DataSummary> = {
WEIGHT: POPULATED_SUMMARIES.WEIGHT,
};
function buildLayoutCatalogue(): DashboardLayoutCatalogueEntry[] {
return DASHBOARD_WIDGET_CATALOGUE_IDS.map((id, order) => ({
id,
visible: true,
order,
}));
}
export interface MockSnapshotOptions {
/**
* Tile summaries keyed by `MeasurementType`. Defaults to the
* four-metric populated set. Pass `{}` for an empty strip (e.g. the
* incomplete-onboarding branch).
*/
summaries?: Record<string, DataSummary>;
/** Artificial latency in ms before the route fulfils (race specs). */
delayMs?: number;
}
/** Assemble a fully-typed snapshot body from the requested tile data. */
export function buildMockSnapshot(
options: MockSnapshotOptions = {},
): DashboardSnapshot {
const summaries = options.summaries ?? POPULATED_SUMMARIES;
const now = new Date().toISOString();
const lastSeenByType: Record<
string,
{ lastSeenAt: string; daysAgo: number } | null
> = {};
for (const type of Object.keys(summaries)) {
lastSeenByType[type] = { lastSeenAt: now, daysAgo: 0 };
}
return {
user: {
username: "e2e-tester",
timezone: "Europe/Berlin",
heightCm: 180,
dateOfBirth: "1990-01-01T00:00:00.000Z",
gender: "MALE",
glucoseUnit: "mg/dL",
onboardingTourCompleted: true,
greetingHour: 9,
},
layout: DEFAULT_DASHBOARD_LAYOUT,
layoutCatalogue: buildLayoutCatalogue(),
metricStates: {},
tiles: {
summaries,
lastSeenByType,
mood: { summary: null, entries: [] },
},
extras: null,
briefing: null,
briefingState: "preparing",
briefingUpdatedAt: null,
generatedAt: now,
};
}
/**
* Register a `page.route` interceptor for `GET /api/dashboard/snapshot`
* returning the API envelope `{ data: DashboardSnapshot, error: null }`.
*/
export async function mockDashboardSnapshot(
page: Page,
options: MockSnapshotOptions = {},
): Promise<void> {
const body = buildMockSnapshot(options);
await page.route(/\/api\/dashboard\/snapshot(\?|$)/, async (route) => {
if (options.delayMs) {
await new Promise((resolve) => setTimeout(resolve, options.delayMs));
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: body, error: null }),
});
});
}