Merge the de-flaked end-to-end specs

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 01:06:58 +02:00
commit 7036b2d225
6 files changed
+105 -26

No files matched your search

+29 -11
View File
@@ -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
+22 -4
View File
@@ -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();
});
+21 -5
View File
@@ -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;
+24 -6
View File
@@ -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 });
+5
View File
@@ -1945,6 +1945,11 @@ export function HealthChart({
) : null}
<div
// Stable hook for the browser suite. `.recharts-wrapper` is a
// library-internal class and it is what the a11y scan used to
// gate on; this slot is rendered by the data branch only, so it
// says the same thing without depending on Recharts' markup.
data-slot="chart-plot"
className="relative z-10 h-full touch-pan-y"
role="img"
aria-label={chartAriaLabel}
+4
View File
@@ -767,6 +767,10 @@ export function MoodChart({
? "h-[var(--chart-height,140px)]"
: "h-[var(--chart-height,200px)] md:h-[var(--chart-height-md,220px)]"
} touch-pan-y`}
// Same stable hook as the measurement chart: rendered by the data
// branch only, so the browser suite can gate on it instead of on
// Recharts' own class names.
data-slot="chart-plot"
role="img"
aria-label={moodAriaLabel}
>