From 7611c82ae72a3e026d44e3e38938db4058c3ab68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 06:17:34 +0200 Subject: [PATCH] Poll on real conditions in the e2e suite and un-age the expiry fixtures - account-sharing polls the offline persister's own IndexedDB store for the flushed snapshot instead of sleeping past the debounce. - The record-session-fence cases poll for the observable halves of their setups (the switch round-trip landing, the exit switch being issued); the one remaining fixed window is a documented absence-assertion grace with nothing to poll on. - The documents suite's two remaining sleeps are named for what they are: a focus-dance grace the outer document cannot observe, and the long-press gesture duration itself. - The inventory-date fixtures derive their printed expiry from now instead of a fixed 2027 date that would silently flip the fixture to expired when the calendar catches up. --- e2e/account-sharing.spec.ts | 39 ++++++++++++++++++- e2e/documents.spec.ts | 7 ++++ e2e/v137-record-session-fence.spec.ts | 26 ++++++++++++- .../inventory-section-dates.test.tsx | 25 ++++++++---- 4 files changed, 86 insertions(+), 11 deletions(-) diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts index 2ed1ab6ce..e2450b92f 100644 --- a/e2e/account-sharing.spec.ts +++ b/e2e/account-sharing.spec.ts @@ -363,9 +363,44 @@ test.describe("account sharing", () => { // Let the offline persister flush the delegate's own dashboard to // IndexedDB (1 s debounce). That snapshot is the stale-paint source this // whole assertion is aimed at — without it the check would pass against a - // disk that was empty anyway. + // disk that was empty anyway. Poll the persister's own store for the + // written snapshot instead of sleeping past the debounce (C3): the + // condition IS the flush, so a slow CI box waits exactly as long as it + // needs to and a fast one moves on immediately. await page.goto("/"); - await page.waitForTimeout(2000); + await expect + .poll( + () => + page.evaluate( + () => + new Promise((resolve) => { + const req = indexedDB.open("healthlog-query-cache", 1); + req.onerror = () => resolve(false); + req.onsuccess = () => { + const db = req.result; + try { + const get = db + .transaction("kv", "readonly") + .objectStore("kv") + .get("react-query"); + get.onsuccess = () => { + db.close(); + resolve(get.result !== undefined); + }; + get.onerror = () => { + db.close(); + resolve(false); + }; + } catch { + db.close(); + resolve(false); + } + }; + }), + ), + { timeout: 10_000 }, + ) + .toBe(true); await openSwitcher(page); await page.locator('[data-slot="account-switcher-entry"]').click(); diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index 96d537e0d..82114579e 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -559,6 +559,11 @@ test.describe("document vault", () => { // to the dialog after Chromium's PDF viewer grabs it, so Escape works // regardless of when the user presses it. await expect(dialog.locator("iframe")).toBeVisible(); + // True grace (C3): Chromium's PDF viewer steals focus from inside the + // iframe and the sheet hands it back — both moves are invisible to the + // outer document (activeElement is "the iframe" before AND after the + // grab), so there is no condition to poll on. The window only needs to + // outlive the focus dance. await page.waitForTimeout(250); await page.keyboard.press("Escape"); await expect(dialog).not.toBeVisible(); @@ -573,6 +578,8 @@ test.describe("document vault", () => { await expect(button).toBeVisible(); await button.dispatchEvent("touchstart"); + // Not a wait-for-condition sleep (C3): this IS the gesture — the finger + // stays down past the long-press threshold before lifting. await page.waitForTimeout(700); await button.dispatchEvent("touchend"); diff --git a/e2e/v137-record-session-fence.spec.ts b/e2e/v137-record-session-fence.spec.ts index 5868b39b6..91dfc229d 100644 --- a/e2e/v137-record-session-fence.spec.ts +++ b/e2e/v137-record-session-fence.spec.ts @@ -326,11 +326,19 @@ test.describe.serial("FENCE record-session fence in the browser", () => { const accountId = await entry.getAttribute("data-account-id"); expect(accountId).not.toBeNull(); + let switchCommitted = false; await initiator.route("**/api/account/switch", async (route) => { await route.fetch(); + switchCommitted = true; await route.abort(); }); await entry.click({ noWaitAfter: true }); + // Poll for the server round-trip having landed (the observable half of + // the setup), THEN hold a short fixed window — this one is a true + // absence-assertion grace (C3): "the peer never painted guessed state" + // has no completion signal to poll on, only time in which the wrong + // paint could have happened. + await expect.poll(() => switchCommitted, { timeout: 20_000 }).toBe(true); await initiator.waitForTimeout(500); // The peer must never paint owner-ready state it guessed at … @@ -386,13 +394,19 @@ test.describe.serial("FENCE record-session fence in the browser", () => { // Hold the response, close the tab while it is in flight. The server has // committed; nothing will ever broadcast the commit. const held = deferred(); + // The commit is observable: flag once the server round-trip inside the + // route handler finishes, and poll for it instead of a fixed sleep + // (C3) — the tab must close AFTER the write landed, and this waits + // exactly that long. + let switchCommitted = false; await initiator.route("**/api/account/switch", async (route) => { await route.fetch(); + switchCommitted = true; await held.promise; await route.abort(); }); await entry.click({ noWaitAfter: true }); - await initiator.waitForTimeout(300); + await expect.poll(() => switchCommitted, { timeout: 20_000 }).toBe(true); held.release(); await initiator.close(); @@ -591,10 +605,18 @@ test.describe.serial("FENCE record-session fence in the browser", () => { expect(held.scope).toBe(accountId); // Now move the context out from under it, and only then let it land. + // "Out from under it" is observable: the exit posts + // `/api/account/switch`, so poll for that request having been issued + // instead of sleeping and hoping the click got that far (C3). + let exitSwitchIssued = false; + await page.route("**/api/account/switch", async (route) => { + exitSwitchIssued = true; + await route.continue(); + }); const leaving = page .locator('[data-slot="shared-record-banner-exit"]') .click({ noWaitAfter: true }); - await page.waitForTimeout(500); + await expect.poll(() => exitSwitchIssued, { timeout: 20_000 }).toBe(true); held.release?.(); await leaving.catch(() => {}); diff --git a/src/components/medications/__tests__/inventory-section-dates.test.tsx b/src/components/medications/__tests__/inventory-section-dates.test.tsx index 2e52e26dc..adf6c3dd5 100644 --- a/src/components/medications/__tests__/inventory-section-dates.test.tsx +++ b/src/components/medications/__tests__/inventory-section-dates.test.tsx @@ -18,6 +18,17 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; +// Now-anchored future expiry (the fixed 2027-06-01 was a fuse: once the +// calendar passes it, an "expiring later" fixture silently becomes an +// expired one and the assertions rot). ~300 days out keeps it a plainly +// future date on every run; the expected strings derive from the same +// instant so they can never drift from the fixture. +const FUTURE_EXPIRY = new Date(Date.now() + 300 * 24 * 60 * 60 * 1000); +const FUTURE_EXPIRY_YMD = FUTURE_EXPIRY.toISOString().slice(0, 10); +const FUTURE_EXPIRY_ISO = `${FUTURE_EXPIRY_YMD}T00:00:00.000Z`; +const [FE_Y, FE_M, FE_D] = FUTURE_EXPIRY_YMD.split("-"); +const FUTURE_EXPIRY_DE = `${FE_D}.${FE_M}.${FE_Y}`; + const src = readFileSync( resolve( process.cwd(), @@ -135,13 +146,13 @@ describe(" — item-row dates", () => { ...BASE, state: "ACTIVE", firstUseAt: null, - printedExpiry: "2027-06-01T00:00:00.000Z", + printedExpiry: FUTURE_EXPIRY_ISO, }, ]); const html = render( , ); - expect(html).toContain("Haltbar bis 01.06.2027"); + expect(html).toContain(`Haltbar bis ${FUTURE_EXPIRY_DE}`); }); it("separates the two dates when the row carries both", () => { @@ -149,14 +160,14 @@ describe(" — item-row dates", () => { { ...BASE, firstUseAt: "2026-06-12T00:00:00.000Z", - printedExpiry: "2027-06-01T00:00:00.000Z", + printedExpiry: FUTURE_EXPIRY_ISO, }, ]); const html = render( , ); expect(html).toContain("Geöffnet 12.06.2026"); - expect(html).toContain("Haltbar bis 01.06.2027"); + expect(html).toContain(`Haltbar bis ${FUTURE_EXPIRY_DE}`); expect(html).toContain("·"); }); @@ -174,7 +185,7 @@ describe(" — editable dates", () => { const item: Row = { ...BASE, firstUseAt: "2026-06-12T00:00:00.000Z", - printedExpiry: "2027-06-01T00:00:00.000Z", + printedExpiry: FUTURE_EXPIRY_ISO, }; function renderDialog(over: Partial = {}): string { @@ -191,7 +202,7 @@ describe(" — editable dates", () => { const html = renderDialog(); expect(html).toContain('id="inventory-edit-expiry"'); expect(html).toContain("Aufgedrucktes Haltbarkeitsdatum"); - expect(html).toContain('value="2027-06-01"'); + expect(html).toContain(`value="${FUTURE_EXPIRY_YMD}"`); }); it("offers an opening-date field prefilled from the item", () => { @@ -205,7 +216,7 @@ describe(" — editable dates", () => { const html = renderDialog({ firstUseAt: null, printedExpiry: null }); expect(html).toContain('id="inventory-edit-expiry"'); expect(html).toContain('id="inventory-edit-opened"'); - expect(html).not.toContain("2027-06-01"); + expect(html).not.toContain(FUTURE_EXPIRY_YMD); expect(html).not.toContain("2026-06-12"); });