Files
HealthLog/e2e/v1427-coach-launch.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

332 lines
12 KiB
TypeScript

import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Coach launch surfaces on `/insights/{slug}` sub-pages.
*
* Contracts under test (v1.18.6, CCH-04):
*
* 1. The floating Coach FAB (`data-slot="coach-fab"`) is the single
* Coach launcher on every authenticated page. The per-metric inline
* launch icon the sub-page header used to mount was retired in
* CCH-04 — the FAB is now the only entry. An unread Coach-initiated
* nudge (`GET /api/insights/coach/nudge-status`) paints an unread
* dot (`data-slot="coach-fab-unread"`) on its corner; with nothing
* unread the FAB renders without the dot.
*
* 2. Clicking the FAB opens the Coach drawer in place — as a
* bottom-sheet (`data-variant="bottom-sheet"`) on `<sm`, as a
* right-side sheet (`data-variant="side-sheet"`) on `>=sm`.
*
* 3. With an unread nudge the FAB carries the dot; clicking the FAB
* opens the Coach drawer in place (v1.16.11 — no navigation, the
* page underneath stays) and the dot clears.
*
* 4. The drawer's "Conversations" button no longer opens an in-panel
* tray; it hands off to the full-page route `/coach`.
*
* The three sub-pages covered (blutdruck, gewicht, schlaf) span the
* empty-state branch (no data → CTA) and the populated branch (with
* data → full chart). The FAB launcher is present on both.
*/
test.describe("Coach launch surfaces on insights sub-pages", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
// Populated analytics so each sub-page paints the data branch
// (`hasMetricData` true). The Coach FAB is the launcher on every
// authenticated page regardless of the data branch.
// Regex form matches the slim slice the 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: 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: 30,
},
BLOOD_PRESSURE_DIA: {
latest: 80,
avg7: 79,
avg30: 78,
slope30: { slope: 0.05, direction: "flat" },
count: 30,
},
SLEEP_DURATION: {
latest: 7.5,
avg7: 7.2,
avg30: 7.1,
slope30: { slope: 0.0, direction: "flat" },
count: 30,
},
},
bpInTargetPct: 78,
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,
}),
}),
);
// Default nudge state: nothing unread. Registered AFTER the
// generic `**-status*` mock so it wins for the nudge endpoint
// (Playwright matches routes in reverse registration order).
await page.route("**/api/insights/coach/nudge-status*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { nudgedAt: null, unread: false },
error: null,
}),
}),
);
});
for (const slug of ["blutdruck", "gewicht", "schlaf"] as const) {
test(`/insights/${slug} mounts the dot-free Coach FAB on Pixel 5`, async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-mobile",
"mobile FAB-launcher branch",
);
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
// CCH-04 — the per-metric inline launch icon is retired; no header
// launcher renders on the sub-page.
await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
0,
);
// The FAB is the permanent launcher; with no unread nudge it
// renders without the unread dot.
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
0,
);
});
test(`/insights/${slug} mounts the dot-free Coach FAB on Desktop Chrome`, async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"desktop FAB-launcher branch",
);
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
0,
);
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
0,
);
});
}
test("clicking the Coach FAB opens the drawer as a bottom-sheet on Pixel 5", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-mobile",
"drawer bottom-sheet branch is mobile-only",
);
// Hydration gate — the nudge-status query is fired by the FAB
// component itself, so its response proves the FAB's click handler
// is attached. Clicking the SSR-painted FAB any earlier is silently
// lost (CI flake).
const fabHydrated = page.waitForResponse(
/\/api\/insights\/coach\/nudge-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await fabHydrated;
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await fab.click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
await expect(drawer).toHaveAttribute("data-variant", "bottom-sheet");
});
test("clicking the Coach FAB opens the drawer as a side-sheet on Desktop Chrome", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"drawer side-sheet branch is desktop-only",
);
// Hydration gate — see the bottom-sheet branch above.
const fabHydrated = page.waitForResponse(
/\/api\/insights\/coach\/nudge-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await fabHydrated;
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await fab.click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
await expect(drawer).toHaveAttribute("data-variant", "side-sheet");
});
test("an unread Coach nudge paints the FAB dot and clicking the FAB opens the Coach drawer in place", async ({
page,
}, testInfo) => {
// The FAB contract is viewport-independent — the test runs on
// both projects. Override the default nudge mock with an unread
// one (later registration wins).
await page.route("**/api/insights/coach/nudge-status*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { nudgedAt: "2026-06-09T06:00:00.000Z", unread: true },
error: null,
}),
}),
);
// Hydration gate — the nudge-status query is fired by the FAB
// component itself, so its response proves the FAB's click handler
// is attached (the button paints from SSR HTML long before that).
const fabHydrated = page.waitForResponse(
/\/api\/insights\/coach\/nudge-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await fabHydrated;
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await expect(fab).toHaveAttribute("data-unread", "true", {
timeout: 10_000,
});
await expect(page.locator('[data-slot="coach-fab-unread"]')).toBeVisible();
await fab.click();
// v1.16.11 — the FAB opens the side drawer in place instead of
// navigating to `/coach`; the page underneath stays.
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
await expect(drawer).toHaveAttribute(
"data-variant",
testInfo.project.name === "chromium-mobile"
? "bottom-sheet"
: "side-sheet",
);
expect(new URL(page.url()).pathname).not.toContain("/coach");
// Opening the Coach counts as reading the nudge on this device —
// the dot clears and the unread flag drops off the FAB.
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(0);
await expect(fab).not.toHaveAttribute("data-unread", "true");
});
test("the drawer's Conversations button hands off to the full-page chat on Desktop Chrome", async ({
page,
}, testInfo) => {
// The in-panel history tray is gone from the drawer; the button
// navigates to the dedicated `/coach/conversations` page, which renders
// the search + recency-grouped conversation list.
test.skip(
testInfo.project.name !== "chromium-desktop",
"exercises the desktop drawer layout",
);
// Hydration gate — see the bottom-sheet test above.
const fabHydrated = page.waitForResponse(
/\/api\/insights\/coach\/nudge-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
await fabHydrated;
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
await fab.click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
// The drawer renders no inline history column.
await expect(
page.locator('[data-slot="coach-drawer-history"]'),
).toHaveCount(0);
const historyButton = page.locator(
'[data-slot="coach-drawer-history-tray-trigger"]',
);
await expect(historyButton).toBeVisible({ timeout: 10_000 });
await historyButton.click();
// No in-panel tray opens — the click navigates to the dedicated
// conversation-history page, which renders the search field + list.
await page.waitForURL(/\/coach\/conversations(\?|$)/, { timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-conversations-page"]'),
).toBeVisible({ timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-conversations-search"]'),
).toBeVisible({ timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-conversations-list"]'),
).toBeVisible({ timeout: 10_000 });
await expect(
page.locator('[data-slot="coach-drawer-history-tray"]'),
).toHaveCount(0);
});
});