mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
A spec that arms `page.route(...)` in a `beforeEach` and never drops the handler races its own teardown. The context closes, an async handler is still awaiting, its next call rejects with "Test ended.", and Playwright attributes that to the test that had just passed. With `retries: 2` plus `failOnFlakyTests`, which spec bites is a matter of timing. `e2e/setup/test.ts` re-exports Playwright's `test` extended with an auto fixture that unroutes every page and the context after each test, and clears the page event listeners a spec subscribes to; `expect` rides along so a spec needs one import. Auto means a spec author cannot forget it and does not have to know it exists. Every spec now takes `test` from there, and `e2e-route-cleanup-guard.test.ts` keeps the import boundary closed with an allowlist that carries its reason. The per-file `afterEach` on the cycle spec goes away — leaving both would read as not trusting the fixture. Not covered: a page inside a context a spec builds itself. Only `share-documents.spec.ts` does that, and it owns those contexts.
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
import { expect, test } from "./setup/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
|
|
* home dashboard and the core widgets paint without uncaught client
|
|
* errors.
|
|
*
|
|
* The dashboard self-gates each tile on (visible AND has data), so a
|
|
* fresh seed user with zero measurements would render an empty strip
|
|
* and skip all charts. Rather than seed measurements before this spec
|
|
* runs (which couples the spec to a particular signature of the
|
|
* `/api/measurements` shape), we mock `/api/analytics` and
|
|
* `/api/dashboard/widgets` to return a deterministic dataset. The
|
|
* widget layout + chart wrappers are pure enough that asserting on the
|
|
* mocked-data render exercises the same code paths as a real session.
|
|
*/
|
|
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`.
|
|
//
|
|
// v1.4.39.3 — match `/api/analytics` AND any sliced variant
|
|
// (`?slice=summaries`). The v1.4.39.2 dashboard split mounts two
|
|
// parallel `useAnalyticsQuery` calls; the slim variant carries a
|
|
// `?slice=summaries` query string that the literal string glob
|
|
// `**/api/analytics` (Playwright minimatch) refuses to match,
|
|
// dropping the request to the real route. The seeded test user has
|
|
// no measurements so the real route returns empty summaries, and
|
|
// the dashboard's slim-first merge then blanked the tile strip.
|
|
// The same trap bit `onboarding-flicker` and `mobile-viewport` in
|
|
// v1.4.37 W-CI; the regex form is the durable fix.
|
|
await page.route(/\/api\/analytics(\?|$)/, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
summaries: {
|
|
WEIGHT: {
|
|
latest: 78.5,
|
|
avg7: 78.2,
|
|
avg30: 77.9,
|
|
slope30: { slope: -0.05, direction: "down" },
|
|
count: 30,
|
|
},
|
|
BLOOD_PRESSURE_SYS: {
|
|
latest: 124,
|
|
avg7: 122,
|
|
avg30: 121,
|
|
slope30: { slope: 0.1, direction: "flat" },
|
|
count: 25,
|
|
},
|
|
BLOOD_PRESSURE_DIA: {
|
|
latest: 80,
|
|
avg7: 79,
|
|
avg30: 78,
|
|
slope30: { slope: 0.05, direction: "flat" },
|
|
count: 25,
|
|
},
|
|
PULSE: {
|
|
latest: 68,
|
|
avg7: 70,
|
|
avg30: 71,
|
|
slope30: { slope: -0.2, direction: "down" },
|
|
count: 25,
|
|
},
|
|
},
|
|
bpInTargetPct: 78,
|
|
glucoseByContext: {},
|
|
},
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/mood/analytics", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: { entries: [], summary: { count: 0 } },
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
// S2 — the Today hero reads the unified daily digest from
|
|
// `/api/daily/digest`. Mock it with a deterministic digest (a score +
|
|
// one worth-a-look item) so the hero paints without reaching the real
|
|
// route, and so a slow/absent digest can never hang the render or
|
|
// surface a console error under the significant-error gate below.
|
|
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
generatedAt: new Date().toISOString(),
|
|
phase: "final",
|
|
sleepPending: false,
|
|
score: { value: 82, band: "green", delta: 2 },
|
|
topSignal: null,
|
|
briefingLead: "Your week is trending steady.",
|
|
line: "Your week is trending steady.",
|
|
worthALook: [
|
|
{
|
|
kind: "sync_issue",
|
|
title: "Sync needs attention",
|
|
body: "Withings isn't syncing.",
|
|
status: "warning",
|
|
actions: [
|
|
{
|
|
labelKey: "daily.action.reconnect",
|
|
intent: "sync.reconnect",
|
|
href: "/settings/integrations",
|
|
},
|
|
],
|
|
},
|
|
// S3 — a coach check-in card (keep / adjust / let-go). Rendered
|
|
// only; the mutating taps PATCH the plan route, which this spec
|
|
// never clicks, so it stays green without a plan-route stub.
|
|
{
|
|
kind: "coach_checkin",
|
|
title: "Time to check in on a plan",
|
|
body: "About a week ago you set: “every morning → weigh in”.",
|
|
status: "info",
|
|
moduleKey: "coach",
|
|
actions: [
|
|
{
|
|
labelKey: "daily.action.checkinKeep",
|
|
intent: "coach.checkin.keep:p1",
|
|
},
|
|
{
|
|
labelKey: "daily.action.checkinAdjust",
|
|
intent: "coach.checkin.adjust",
|
|
href: "/coach",
|
|
},
|
|
{
|
|
labelKey: "daily.action.checkinLetGo",
|
|
intent: "coach.checkin.letGo:p1",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
// v1.18.6 — the dashboard now batches every visible chart's daily
|
|
// series into ONE `/api/measurements/series-batch` round-trip and
|
|
// hands each chart its slice via `preloadedSeries`. The route is a
|
|
// sub-path of `/api/measurements`, which the `**/api/measurements*`
|
|
// glob below does NOT match (Playwright's `*` does not cross `/`), so
|
|
// without an explicit stub the request fell through to the real
|
|
// backend. The seed user has no measurements, so the route returned
|
|
// every requested type as an EMPTY array — and an empty-but-present
|
|
// slice satisfies the chart's preloaded-coverage check, so each chart
|
|
// rendered the batched empty slice instead of self-fetching. Mock the
|
|
// endpoint with the same populated set so the charts paint.
|
|
await page.route(/\/api\/measurements\/series-batch(\?|$)/, (route) => {
|
|
const url = new URL(route.request().url());
|
|
const types = (url.searchParams.get("types") ?? "WEIGHT").split(",");
|
|
const series: Record<
|
|
string,
|
|
Array<{
|
|
type: string;
|
|
value: number;
|
|
measuredAt: string;
|
|
count: number;
|
|
}>
|
|
> = {};
|
|
for (const type of types) {
|
|
const baseValue =
|
|
type === "BLOOD_PRESSURE_SYS"
|
|
? 124
|
|
: type === "BLOOD_PRESSURE_DIA"
|
|
? 80
|
|
: type === "PULSE"
|
|
? 70
|
|
: 78.5;
|
|
series[type] = Array.from({ length: 10 }, (_, i) => ({
|
|
type,
|
|
value: baseValue + (i % 3) - 1,
|
|
measuredAt: new Date(Date.now() - i * 86_400_000).toISOString(),
|
|
count: 1,
|
|
}));
|
|
}
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ data: { series }, error: null }),
|
|
});
|
|
});
|
|
|
|
// The chart's per-type fetch reads `json.data.measurements` and
|
|
// expects a `meta.total`. Return a small dataset so the chart has
|
|
// points to plot — otherwise Recharts mounts a placeholder instead
|
|
// of the recharts-wrapper SVG and the assertion below times out.
|
|
// (Self-fetch fallback for any chart not covered by the batch above,
|
|
// e.g. a range-tab change that drops batched coverage.)
|
|
await page.route("**/api/measurements*", (route) => {
|
|
const url = new URL(route.request().url());
|
|
const type = url.searchParams.get("type") ?? "WEIGHT";
|
|
const baseValue =
|
|
type === "BLOOD_PRESSURE_SYS"
|
|
? 124
|
|
: type === "BLOOD_PRESSURE_DIA"
|
|
? 80
|
|
: type === "PULSE"
|
|
? 70
|
|
: 78.5;
|
|
const measurements = Array.from({ length: 10 }, (_, i) => ({
|
|
id: `m_${type}_${i}`,
|
|
type,
|
|
value: baseValue + (i % 3) - 1,
|
|
measuredAt: new Date(Date.now() - i * 86_400_000).toISOString(),
|
|
notes: null,
|
|
}));
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: { measurements, meta: { total: measurements.length } },
|
|
error: null,
|
|
}),
|
|
});
|
|
});
|
|
|
|
// v1.4.28 retired the InsightAdvisorCard surface. Coach drawer
|
|
// remains the conversational entry point on /insights, but the
|
|
// bottom-of-page card with its own summary container is gone. The
|
|
// dashboard render assertion below no longer touches /insights, so
|
|
// we don't need to mock /api/insights/comprehensive or
|
|
// /api/insights/generate any more (the iOS-consumed POST endpoint
|
|
// is preserved server-side; only the web mount retired).
|
|
});
|
|
|
|
test("dashboard tile strip + chart + insight card render", async ({
|
|
page,
|
|
}) => {
|
|
const consoleErrors: string[] = [];
|
|
page.on("pageerror", (err) => {
|
|
consoleErrors.push(err.message);
|
|
});
|
|
page.on("console", (msg) => {
|
|
if (msg.type() === "error") consoleErrors.push(msg.text());
|
|
});
|
|
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
|
|
// Tile strip — `data-slot="dashboard-tile-strip"` is the stable hook
|
|
// the dashboard exposes. The component sets `data-tile-count` to the
|
|
// active tile count; assert it's >0 so we know at least one tile
|
|
// cleared the visibility + data-floor gate.
|
|
const strip = page.locator('[data-slot="dashboard-tile-strip"]');
|
|
await expect(strip).toBeVisible({ timeout: 10_000 });
|
|
const tileCount = Number(await strip.getAttribute("data-tile-count"));
|
|
expect(tileCount).toBeGreaterThan(0);
|
|
|
|
// S2 — the Today hero paints above the tile strip from the mocked
|
|
// digest: its score ring and its worth-a-look rail render.
|
|
const todayHero = page.locator('[data-slot="today-hero"]');
|
|
await expect(todayHero).toBeVisible({ timeout: 10_000 });
|
|
await expect(
|
|
todayHero.locator('[data-slot="today-hero-score"]'),
|
|
).toBeVisible();
|
|
await expect(
|
|
todayHero.locator('[data-slot="today-hero-rail"]'),
|
|
).toBeVisible();
|
|
|
|
// At least one chart must render. Recharts mounts a `<svg>` with
|
|
// `class="recharts-surface"` (or a `<div class="recharts-wrapper">`
|
|
// around it). Use the recharts class so we don't accidentally match
|
|
// an aria-hidden lucide-react icon.
|
|
await expect(
|
|
page.locator(".recharts-wrapper, .recharts-surface").first(),
|
|
).toBeVisible({ timeout: 10_000 });
|
|
|
|
// v1.4.28 — the bottom-of-page InsightAdvisorCard retired; the
|
|
// dashboard spec no longer asserts on its summary slot. The hero
|
|
// strip and the Coach drawer carry the differentiator on /insights
|
|
// and are covered by their own specs.
|
|
|
|
// Filter out a small set of known-noise messages — we care about
|
|
// real React/JS uncaught errors only. 404s from optional assets
|
|
// (manifest icons, prefetched chunks for unrendered routes) are
|
|
// explicitly tolerated; they don't affect the dashboard render.
|
|
const significant = consoleErrors.filter(
|
|
(msg) =>
|
|
!/ResizeObserver loop|Download the React DevTools|Warning: |\[Fast Refresh\]|Failed to load resource|net::ERR_/i.test(
|
|
msg,
|
|
),
|
|
);
|
|
expect(significant, significant.join("\n")).toEqual([]);
|
|
});
|
|
});
|