From 7445b698425bbacfc17dfd1cb38053c26f22783d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 12:58:36 +0200 Subject: [PATCH] test(e2e): add a weight through the dashboard against the real server The add-measurement flow answered its own POST and list read, so it proved that the list renders a row the test typed in. It now runs on the measurement journey's account, emptied first, and checks that the reading the server stored is the one entered and that the dashboard tile, the weight chart's data table and the day view all show it. --- e2e/measurement-flow.spec.ts | 259 ++++++++---------- .../e2e-critical-no-api-stub-guard.test.ts | 2 +- 2 files changed, 112 insertions(+), 149 deletions(-) diff --git a/e2e/measurement-flow.spec.ts b/e2e/measurement-flow.spec.ts index 414dba6f6..7cc228024 100644 --- a/e2e/measurement-flow.spec.ts +++ b/e2e/measurement-flow.spec.ts @@ -1,140 +1,92 @@ +import type { Page } from "@playwright/test"; + import { expect, test } from "./setup/test"; -import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + criticalAccount, + criticalStorageState, + resetCriticalAccount, +} from "./setup/critical-fixture"; import { openMenu } from "./open-menu"; /** - * Add-measurement flow — exercises the dashboard's quick-entry dropdown, - * the dialog-mounted MeasurementForm, the POST to /api/measurements, and - * the resulting query invalidation that surfaces the row on - * `/measurements`. + * Add-measurement flow, against the real server: the dashboard's quick-entry + * menu, the dialog-mounted MeasurementForm, the real POST to + * `/api/measurements`, and then every surface that has to agree about the + * reading afterwards: the dashboard tile, the weight chart (through the data + * table that lists exactly the points it drew) and the day view. * - * Weight is the simplest type (single numeric input + datetime), so it - * keeps the spec focused on the round-trip rather than the BP three-field - * batched submit. + * Until v1.46 this spec answered its own POST and its own list read with a + * route stub, so it proved that the list renders a row the test typed in. + * Nothing here is stubbed now. The account is the measurement journey's own, + * one per project (`critical-fixture.ts`), and is emptied of readings before + * the test, so "the tile shows the weight" can only mean the weight this test + * entered. + * + * Weight is the simplest type (single numeric input + datetime), so it keeps + * the spec on the round trip rather than on the blood-pressure batch. */ + +/** Distinct from anything a fixture seeds, so a match is this reading. */ +const WEIGHT_KG = "81.7"; + +/** Today in the browser's zone (`timezoneId` in `playwright.config.ts`). */ +function todayInBerlin(): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date()); +} + +/** The account's weight readings, as the list route answers them. */ +async function storedWeights( + page: Page, +): Promise> { + const res = await page.request.get("/api/measurements?type=WEIGHT"); + expect(res.status()).toBe(200); + const { data } = (await res.json()) as { + data: { measurements: Array<{ value: number; unit: string }> }; + }; + return data.measurements.map(({ value, unit }) => ({ value, unit })); +} + test.describe("add measurement flow", () => { - test.use({ storageState: STORAGE_STATE_PATH }); + test.use({ storageState: criticalStorageState("measurement") }); // This journey lands on the dashboard, which is the most expensive page in - // the product to render cold: the route segment suspends behind the server - // render and shows `loading.tsx` until it resolves. On a shared runner with - // the whole suite in flight that has overrun the 30 s default, and the test - // then died on the quick-add trigger rather than on anything it is about. - // The subject here is the add-then-appears round trip, not the dashboard's - // cold-start latency, so the budget is the page's and the assertions stay - // exactly as tight as they were. + // the product to render cold, and then reads three more surfaces. The + // assertions stay as tight as they are; the budget is the pages'. test.setTimeout(90_000); - test("creating a weight reading surfaces it in the list", async ({ + test.beforeEach(async ({}, testInfo) => { + // Fixture hygiene: what a previous run or a retry left on the account. + await resetCriticalAccount(criticalAccount("measurement", testInfo), [ + "DELETE FROM measurement_rollups WHERE user_id = $1", + "DELETE FROM measurements WHERE user_id = $1", + ]); + }); + + test("a weight entered on the dashboard reaches the tile, the chart and the day view @critical", async ({ page, }) => { - // Capture the POST payload so we can assert against it independently - // of any list-fetch response Playwright might cache. Match both - // `/api/measurements` (POST creates a reading) and the list query - // `/api/measurements?type=...&limit=...&offset=...` (GET). - let postedBody: unknown = null; - await page.route("**/api/measurements*", async (route) => { - const req = route.request(); - if (req.method() === "POST") { - postedBody = JSON.parse(req.postData() ?? "null"); - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { id: "m_e2e_1", success: true }, - error: null, - }), - }); - return; - } - // GET — return the just-posted reading so the list view shows it. - // The list expects `data.measurements: Measurement[]` plus - // `data.meta.total`; chart fetches use the same envelope. - const body = postedBody as { - type?: string; - value?: number; - measuredAt?: string; - } | null; - // The list-page `Measurement` shape requires `unit` and `source` - // — without them the row crashes the render and the e2e poll - // never sees "78.4". Mock with the same fields a real reading - // carries. - const measurements = body - ? [ - { - id: "m_e2e_1", - type: body.type, - value: body.value, - unit: body.type === "WEIGHT" ? "kg" : "", - source: "MANUAL", - measuredAt: body.measuredAt, - notes: null, - }, - ] - : []; - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { measurements, meta: { total: measurements.length } }, - error: null, - }), - }); - }); - - // Dashboard's analytics fetch — empty summaries are fine; we only - // need the page to render so we can click "Add". - // - // v1.4.39.3 — match `/api/analytics` AND any sliced variant - // (`?slice=summaries`). The v1.4.39.2 dashboard split fires both - // requests in parallel; the previous string glob missed the slim - // variant which then fell through to the real route and slowed - // the page paint enough that the post-mutation list query never - // surfaced inside the 10 s window. - await page.route(/\/api\/analytics(\?|$)/, (route) => - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - data: { summaries: {}, bpInTargetPct: null, 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, - }), - }), - ); - await page.goto("/", { waitUntil: "domcontentloaded" }); - // Open the "Add" dropdown — the dashboard's quick-entry trigger sits - // at the top-right of `
`. Scope the locator there so we don't - // accidentally match an "Add" button on the sidebar. - const main = page.locator("main"); - const addTrigger = main + // The quick-entry trigger sits at the top of `
`. Waiting for it, + // rather than for the route skeleton to go, is waiting for the thing the + // next line clicks. + const addTrigger = page + .locator("main") .getByRole("button", { name: /^add$|hinzufügen|hinzufuegen/i }) .first(); - // Wait for the trigger itself rather than for the route-level skeleton to - // go. The skeleton is also absent while the protected shell holds the - // whole page behind its hydration gate, so its disappearance is not a - // statement about the suspended segment having resolved; the control the - // next line clicks is. `networkidle` says nothing about either. await expect(addTrigger).toBeVisible({ timeout: 60_000 }); - await page.waitForLoadState("networkidle"); + // The account holds no weight yet, so whatever the surfaces below show + // can only come from this reading. + expect(await storedWeights(page)).toEqual([]); await openMenu(page, addTrigger); - // v1.5: the menu items now have distinct labels — the - // measurement entry says "Measurement" / "Messung" instead of "Add", - // so we can target it directly. // The desktop rail opens a dropdown, the phone shell the capture sheet // the bottom nav's plus opens; the measurement entry is in either. await page @@ -143,52 +95,63 @@ test.describe("add measurement flow", () => { .first() .click(); - // Form should be visible inside the dialog. Switch type to WEIGHT - // (combo-box defaults to BLOOD_PRESSURE). const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); - // The Select trigger renders the current label — click to open and - // pick the weight option. + // The type select defaults to blood pressure. await dialog.getByRole("combobox").first().click(); await page .getByRole("option", { name: /weight|gewicht/i }) .first() .click(); + await dialog.locator("#value").fill(WEIGHT_KG); - // Weight is single-value mode — fill the value field with 78.4 kg. - const valueInput = dialog.locator("#value"); - await valueInput.fill("78.4"); - - // Save + const created = page.waitForResponse( + (res) => + new URL(res.url()).pathname === "/api/measurements" && + res.request().method() === "POST", + ); await dialog.getByRole("button", { name: /save|speichern/i }).click(); + const response = await created; + expect(response.status()).toBe(201); + await expect(dialog).toBeHidden(); - // Dialog closes when the mutation succeeds - await expect(dialog).toBeHidden({ timeout: 5_000 }); - - // Assert the POST went through with the expected shape - expect(postedBody).toMatchObject({ - type: "WEIGHT", - value: 78.4, + await test.step("the server stored exactly that reading", async () => { + expect(await storedWeights(page)).toEqual([ + { value: Number(WEIGHT_KG), unit: "kg" }, + ]); }); - // Navigate to the list and confirm the entry surfaces. The list - // page renders entries from /api/measurements which we've mocked - // to echo the just-posted row. - await page.goto("/measurements", { waitUntil: "domcontentloaded" }); + await test.step("the dashboard tile", async () => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + const tile = page.locator( + '[data-slot="dashboard-tile-link"][data-tile-id="weight"]', + ); + await expect(tile).toBeVisible({ timeout: 60_000 }); + await expect(tile.locator('[data-slot="trend-card-value"]')).toHaveText( + WEIGHT_KG, + ); + }); - // The list renders a desktop table AND a mobile card grid (one is - // hidden via `md:hidden`/`hidden md:block`). At least one match - // means our row landed in the DOM regardless of viewport. - await expect - .poll( - async () => - await page - .locator("main") - .getByText(/78[.,]4/) - .count(), - { timeout: 10_000 }, - ) - .toBeGreaterThanOrEqual(1); + await test.step("the weight chart", async () => { + await page.goto("/insights/weight", { waitUntil: "domcontentloaded" }); + const table = page.locator('[data-slot="chart-data-table"]').first(); + await expect(table).toBeVisible({ timeout: 30_000 }); + await table.locator('[data-slot="chart-data-table-toggle"]').click(); + const rows = table.locator('[data-slot="chart-data-table-row"]'); + await expect(rows).toHaveCount(1); + await expect(rows.first()).toContainText(WEIGHT_KG); + }); + + await test.step("the day view", async () => { + await page.goto(`/measurements?day=${todayInBerlin()}`, { + waitUntil: "domcontentloaded", + }); + const value = page.locator( + '[data-slot="day-view"] [data-slot="day-value"][data-type="WEIGHT"]', + ); + await expect(value).toBeVisible({ timeout: 30_000 }); + await expect(value).toContainText(WEIGHT_KG); + }); }); }); diff --git a/src/__tests__/e2e-critical-no-api-stub-guard.test.ts b/src/__tests__/e2e-critical-no-api-stub-guard.test.ts index 418b18e58..856884d96 100644 --- a/src/__tests__/e2e-critical-no-api-stub-guard.test.ts +++ b/src/__tests__/e2e-critical-no-api-stub-guard.test.ts @@ -49,7 +49,7 @@ const E2E = join(process.cwd(), "e2e"); * the set is the release gate, so a journey that loses its tag is a change * somebody should see here. */ -const CRITICAL_TEST_FLOOR = 10; +const CRITICAL_TEST_FLOOR = 11; /** A test whose title literal carries the tag, however the call is spelled. */ const CRITICAL_TEST =