Files
HealthLog/e2e/v1427-insights-empty-state.spec.ts
T
Marc-André Bombeck a23ee61184 test(e2e): route cleanup happens by construction, not per spec
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.
2026-07-25 23:02:03 +02:00

156 lines
5.2 KiB
TypeScript

import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.27 F17 — insights metric-availability gating.
*
* When a metric has zero observations the routed sub-page early-
* returns an empty-state component with TWO mandatory affordances:
*
* 1. A CTA `<Link>` pointing at `/measurements?add=<TYPE>` so the
* user lands on the matching add-measurement form.
* 2. A `<CoachLaunchButton>` so the same metric is reachable from
* the Coach without first creating data.
*
* The spec mocks `/api/analytics` to return zero counts for the
* relevant metric, navigates to each sub-page, and asserts both
* affordances render. Three sub-pages cover the BP / weight / sleep
* branches.
*/
test.describe("v1.4.27 — insights empty-state with metric gating", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
// Zero-count summaries — every gated sub-page short-circuits to
// its EmptyState branch.
// v1.4.39.3 — regex form matches the slim slice the v1.4.39.2
// dashboard split fires alongside the thick request.
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
summaries: {
WEIGHT: {
latest: null,
avg7: null,
avg30: null,
slope30: null,
count: 0,
},
BLOOD_PRESSURE_SYS: {
latest: null,
avg7: null,
avg30: null,
slope30: null,
count: 0,
},
BLOOD_PRESSURE_DIA: {
latest: null,
avg7: null,
avg30: null,
slope30: null,
count: 0,
},
SLEEP_DURATION: {
latest: null,
avg7: null,
avg30: null,
slope30: null,
count: 0,
},
},
bpInTargetPct: null,
glucoseByContext: {},
},
error: null,
}),
}),
);
await page.route("**/api/measurements*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { measurements: [], meta: { total: 0 } },
error: null,
}),
}),
);
await page.route("**/api/insights/**-status*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
text: null,
hasProvider: false,
cached: false,
updatedAt: null,
},
error: null,
}),
}),
);
});
for (const { slug, addType, headingMatch } of [
{
slug: "blutdruck",
addType: "BLOOD_PRESSURE",
headingMatch: /blood pressure|blutdruck/i,
},
{ slug: "gewicht", addType: "WEIGHT", headingMatch: /weight|gewicht/i },
{ slug: "schlaf", addType: "SLEEP", headingMatch: /sleep|schlaf/i },
] as const) {
test(`/insights/${slug} renders the empty state + Coach launch when count = 0`, async ({
page,
}) => {
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
// EmptyState primitive carries `data-slot="empty-state"` (see
// `src/components/ui/empty-state.tsx`).
const emptyState = page.locator('[data-slot="empty-state"]').first();
await expect(emptyState).toBeVisible({ timeout: 10_000 });
// The CTA link points at /measurements?add=<TYPE>. For the
// sleep page the wiring may still be wired through a non-
// measurement entry-point (sleep ingest happens via the iOS
// app), so the assertion is split: BP + weight pin on the
// exact `?add=` href; sleep just asserts the empty state and
// Coach launch render. The brief lists this as a known gap.
if (slug !== "schlaf") {
const cta = emptyState.getByRole("link").first();
await expect(cta).toBeVisible();
await expect(cta).toHaveAttribute(
"href",
new RegExp(`/measurements\\?add=${addType}`),
);
}
// The Coach launch button is rendered alongside the empty state.
// The DOM carries both the FAB + inline branches; CSS picks the
// visible one. Pin on whichever branch is the visible one for
// the current project.
const coachAffordance = page
.locator(
'[data-slot="coach-launch-fab"], [data-slot="coach-launch-inline"]',
)
.first();
await expect(coachAffordance).toBeAttached({ timeout: 10_000 });
// The page heading proves we're on the right sub-page (the
// empty state shell renders a SubPageShell with the metric
// title). Pin on the sub-page slot's h1 so we don't accidentally
// match a sidebar / nav element.
await expect(
page.locator('[data-slot="insights-subpage"] #insights-subpage-title'),
).toContainText(headingMatch);
});
}
});