diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index cbdf84d14..febb55d52 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -75,6 +75,20 @@ const fixedMeasurementRows = Array.from({ length: 10 }, (_, index) => ({ notes: null, })); +// Ten daily mood scores ending yesterday, anchored for the same reason the +// measurement rows above are: the mood chart windows by calendar day and +// withholds its line below three DISTINCT days, so a fixed date left the mood +// sub-page painting its sparse placeholder. The scan's painted gate was then +// satisfied by a different chart on the page, and the mood line — the reason +// the case exists — was never waited for and might never have been scanned. +const fixedMoodEntries = Array.from({ length: 10 }, (_, index) => ({ + date: new Date(Date.now() - (index + 1) * 86_400_000) + .toISOString() + .slice(0, 10), + score: 3 + (index % 3) * 0.5, + samples: 1, +})); + const workoutListEntry = { id: A11Y_WORKOUT_ID, sportType: "running", @@ -330,8 +344,8 @@ async function installA11yMocks(page: Page) { await page.route("**/api/mood/analytics", (route) => fulfilJson(route, { - entries: [{ date: "2026-07-20", score: 4 }], - summary: { count: 1 }, + entries: fixedMoodEntries, + summary: { count: fixedMoodEntries.length }, }), ); @@ -342,11 +356,8 @@ async function installA11yMocks(page: Page) { // present-but-empty; the correlations block carries every metric key. await page.route("**/api/mood/insights", (route) => fulfilJson(route, { - summary: { totalEntries: 1, inTargetPct: null }, - heatmap: { - windowDays: 30, - cells: [{ date: "2026-07-20", score: 4, samples: 1 }], - }, + summary: { totalEntries: fixedMoodEntries.length, inTargetPct: null }, + heatmap: { windowDays: 30, cells: fixedMoodEntries }, distribution: [], weekday: [], timeOfDay: { buckets: [], reliable: false, best: null, worst: null }, @@ -712,18 +723,25 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [ }), }, { + // `.recharts-wrapper` is a class the charting library writes, not a + // contract this repository keeps, and it is shared by every chart on the + // page — so the gate could be satisfied by a chart other than the one the + // route exists to render, and it moves whenever the library's markup does. + // `chart-plot` is the measurement chart's own slot and the data branch is + // the only branch that renders it: an empty window paints `ChartEmptyState` + // instead and this scan still waits, which is what it is for. name: "/insights/weight metric subpage", path: "/insights/weight", - painted: (page) => page.locator(".recharts-wrapper").first(), + painted: (page) => page.locator('[data-slot="chart-plot"]').first(), }, { // Mood is event-driven, so its sub-page renders the heatmap and the mood // line chart from `/api/mood/insights` rather than a MeasurementType series. - // Gate on the line chart's recharts wrapper — the same painted signal the - // weight sub-page uses — so the scan waits for the heavy content to land. + // The mood chart carries the same `chart-plot` slot on the same terms, so + // the scan waits for the heavy content to land here too. name: "/insights/mood metric subpage", path: "/insights/mood", - painted: (page) => page.locator(".recharts-wrapper").first(), + painted: (page) => page.locator('[data-slot="chart-plot"]').first(), }, { // The two states this page's OWN render produces: the list, or the diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index c8dc20e21..838d66342 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -345,6 +345,16 @@ test.describe("document vault", () => { await detail.locator('[data-slot="document-chat-open"]').click(); const drawer = page.locator('[data-slot="coach-drawer"]'); await expect(drawer).toBeVisible(); + // The drawer being on screen is not the same event as the vault having + // recorded the hand-off. The page books `?doc=` as the way back only once + // the detail sheet is actually closed with the drawer owning the same + // document; maximize before that and the close path takes the other branch + // — `history.back()` — which races the push to `/coach` and lands back on + // `/documents` with nothing raised. The sheet's own close control leaves + // the tree with the sheet, so its absence is that hand-off, observed. + await expect( + page.locator('[data-slot="document-detail-close"]'), + ).toHaveCount(0); await drawer.locator('[data-slot="coach-drawer-maximize"]').click(); await expect.poll(() => new URL(page.url()).pathname).toBe("/coach"); expect(new URL(page.url()).searchParams.get("doc")).toBe(MRT_DOC_ID); @@ -392,11 +402,19 @@ test.describe("document vault", () => { await sheet.getByRole("button", { name: "Delete" }).click(); // Undo toast → restore → the card returns. - await expect( - page.getByText("Document deleted", { exact: true }), - ).toBeVisible(); + // + // Addressed to the toast's own attributes, not to its copy. The copy is + // i18n-driven and the literal here has already been wrong once — a trailing + // full stop moved and the assertion spent its whole timeout looking for a + // sentence that no longer existed, on every attempt, on both projects. The + // success type is what the delete claims and the action button is what the + // undo needs; both are structural. + const undo = page.locator( + '[data-sonner-toast][data-type="success"] [data-button]', + ); + await expect(undo).toBeVisible(); await expect(openButton(page, `${title}.pdf`)).not.toBeVisible(); - await page.getByRole("button", { name: "Undo" }).click(); + await undo.click(); await expect(openButton(page, `${title}.pdf`)).toBeVisible(); }); diff --git a/e2e/v137-record-session-fence-offline.spec.ts b/e2e/v137-record-session-fence-offline.spec.ts index d2e281d6e..13faa6b88 100644 --- a/e2e/v137-record-session-fence-offline.spec.ts +++ b/e2e/v137-record-session-fence-offline.spec.ts @@ -411,12 +411,28 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => { expect(stored!.epoch).toMatch(/^\d+$/); expect(stored!.scope).not.toBe("self"); - // The EXTERNAL switch: driven from a request context, so the app never - // runs its cache wipe and the entry above survives. - const left = await context.request.post("/api/account/switch", { - data: { accountId: null }, + // The EXTERNAL switch: nothing in the app initiates it, so + // `clearOfflineCachesForRecordSwitch` never runs and the entry above + // survives — the positive control below is what proves that. + // + // Sent with the page's own `fetch` rather than through + // `context.request`. Both are outside the app: the switch UI is what runs + // the wipe, and a bare `fetch` is not it (the service worker returns + // without touching any non-GET, so the request reaches the network + // unaltered). What differs is the socket. Every Playwright API context in + // the runner shares one process-wide keep-alive agent, so this POST could + // be handed a connection the server had already closed on its five-second + // idle timeout, and a POST is never replayed on a fresh one — twice this + // line ended the spec with `read ECONNRESET`, on all three attempts. + const left = await page.evaluate(async () => { + const res = await fetch("/api/account/switch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: null }), + }); + return res.status; }); - expect(left.status()).toBe(200); + expect(left).toBe(200); const me = await context.request.get("/api/auth/me"); const now = (await me.json()).data.recordSession as { epoch: number; diff --git a/e2e/vaccinations.spec.ts b/e2e/vaccinations.spec.ts index 42f8f5ef4..e46fc2b95 100644 --- a/e2e/vaccinations.spec.ts +++ b/e2e/vaccinations.spec.ts @@ -282,16 +282,34 @@ test.describe("vaccinations", () => { test("the list and the capture form carry no serious accessibility violations", async ({ page, - request, }) => { // Seed one dose so the list scans a populated surface rather than the empty // state, then scan both the list and the open capture form. - const seeded = await request.post("/api/vaccinations", { - data: { occurredAt: "2019-05-01T00:00:00.000Z", antigenSlug: "polio" }, - }); - expect(seeded.status()).toBe(201); - + // + // Written from the page, not from a Playwright API context. Every API + // context in the runner shares ONE process-wide keep-alive agent + // (`httpHappyEyeballsAgent`), so a request can be handed a pooled socket the + // server has already closed on its five-second idle timeout — and a POST, + // unlike a GET, is never replayed on a fresh one. This seed died that way + // twice, on this line, on all three attempts each time: `socket hang up` + // and `read ECONNRESET`. The browser opens its own connection and the row + // is still written by the real route, so nothing about the fixture changes + // except which socket carries it. await page.goto("/vaccinations"); + const seededStatus = await page.evaluate(async () => { + const res = await fetch("/api/vaccinations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + occurredAt: "2019-05-01T00:00:00.000Z", + antigenSlug: "polio", + }), + }); + return res.status; + }); + expect(seededStatus).toBe(201); + + await page.reload(); await expect( page.locator('[data-slot="vaccination-group"][data-antigen="polio"]'), ).toBeVisible({ timeout: 15_000 }); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 675dd643c..6948494cf 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1945,6 +1945,11 @@ export function HealthChart({ ) : null}