From cee6c706383ad68b4d594a98c6152ee81ca13ef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:02:46 +0200 Subject: [PATCH 1/4] test(a11y): gate the metric scans on the chart's own slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two metric sub-page cases waited for `.recharts-wrapper`, a class the charting library writes and every chart on the page carries, so the gate was satisfied by whichever chart rendered first rather than by the one the route exists to show. On `/insights/mood` that is exactly what happened: the mock carried a single mood entry on a fixed date, the chart withholds its line below three distinct days, and the sparse placeholder painted while some other chart answered the gate. The mood line was never waited for and may never have been scanned. Both charts now carry `data-slot="chart-plot"` on the wrapper their data branch renders — an empty window still paints the empty state and the scan still waits — and the mood fixture is anchored on now with ten daily scores, the same way the measurement rows above it are. --- e2e/a11y.spec.ts | 40 +++++++++++++++++++------- src/components/charts/health-chart.tsx | 5 ++++ src/components/charts/mood-chart.tsx | 4 +++ 3 files changed, 38 insertions(+), 11 deletions(-) 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/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}
From 99991cd4f3596d42468374068078c0a147735bf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:02:53 +0200 Subject: [PATCH 2/4] test(documents): wait for the vault to book the Coach hand-off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maximize case clicked the drawer's maximize control as soon as the drawer was on screen. Those are not the same event: the vault records `?doc=` as the way back only once the detail sheet has actually closed with the drawer owning the same document, and maximizing before that sends the close down the other branch, which calls `history.back()` and races the push to `/coach`. The URL then stays on `/documents` and nothing is raised. The sheet's own close control leaves the tree with the sheet, so its absence is that hand-off, observed. The delete case is addressed to the toast's attributes rather than its copy. The literal there has already been wrong once — a trailing full stop moved and the assertion spent its whole timeout hunting a sentence that no longer existed, on every attempt, on both projects. --- e2e/documents.spec.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) 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(); }); From 0bf54b48eb10cce584b14d655ca954af7ae7163c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:02:59 +0200 Subject: [PATCH 3/4] test(vaccinations): seed the a11y dose over the page's own connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan's fixture was written with a Playwright API context. Every API context in a runner shares one process-wide keep-alive agent, so a request can be handed a 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. That is what ended this spec twice, on this line, on all three attempts each time: `socket hang up` and `read ECONNRESET`. The dose is written from the page instead. The browser opens its own connection, the service worker is blocked in this project, and the row still goes through the real route, so nothing about the fixture changes except which socket carries it. --- e2e/vaccinations.spec.ts | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) 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 }); From 2b3c01738e5f7839b7865b0e01272b2aebb85225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:03:05 +0200 Subject: [PATCH 4/4] test(fence): switch the record over the page's own connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FENCE-AC-07 drove its external switch through the browser context's API request context, which shares one process-wide keep-alive agent with every other API context in the runner. A POST there can be handed a connection the server has 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. The switch is sent with the page's own `fetch`. It stays external in the sense the case needs: the switch UI is what runs the cache wipe and a bare `fetch` is not it, and the service worker returns without touching any non-GET, so the request reaches the network unaltered. The positive control below still proves the cached entry survived. --- e2e/v137-record-session-fence-offline.spec.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) 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;