diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 2bcaf737f8..d41ba98b21 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -82,3 +82,11 @@ This document turns the current process review into phased, durable repo practic - Conflict-free helper classes (`app-edge-backdrop`, `mobile-app-shell`, `mobile-popover-scroll`, `citation-link`, `animate-skeleton-shimmer`, `focus-ring-premium`, `source-capsule-hover`, `polished-scroll`) now live in `@layer components`, so utilities override them normally. Their call sites were audited for same-property utility collisions before the move. - **Remaining debt:** the chrome classes (`edge-glass-header`, `universal-header-*`, `answer-footer-search-*`, `*-composer-edge`, `desktop-home-search-*`, `document-mobile-search-*`) stay intentionally unlayered because call sites stack utilities that set the same properties and today rely on the class winning (e.g. footer input font-size/padding, pill min-height, header shadow). Layering them requires reconciling each call site so rendered output is unchanged. Until then: when adding a utility to an element carrying one of these classes, check the class body first — the class wins. - `tests/ui-overlap.spec.ts` is the standing regression guard for the visible symptom (overlapping header controls, composer clear-button geometry) across 640-1536px widths. + +## Cross-browser test robustness under client-only rendering (2026-07-02) + +- Making the dashboard and document viewer client-only via `dynamic(..., { ssr: false })` (PRs #144/#147) meant "page loaded" no longer implies "app mounted". Firefox/WebKit paint the client chunk later than Chromium, so three release-browser-matrix specs raced and failed on `main` while Chromium stayed green. All three were test-timing gaps, not product regressions — the app renders correctly in every browser. + - `tests/ui-overlap.spec.ts`: `gotoHome` waited on `networkidle` (Playwright discourages it) and then measured the header, which had not mounted yet — every failure was `header#search not found` (count 0), never a real overlap. Now waits for `header#search` to be visible before measuring. + - `tests/ui-tools.spec.ts` (forms detail → shared search): the shell re-syncs its query from the URL on mount via `requestAnimationFrame`, which on Firefox/WebKit can land just after a programmatic `fill` and wipe the value (button stays disabled) or drop the submit before the router navigates. The fill-and-submit now runs as one `toPass` unit that retries until the search routes. + - `tests/ui-stress.spec.ts` (desktop evidence panel): the evidence `
` is opened by focusing its `` and pressing Enter; in CI WebKit the key event could fire before focus landed, so it never toggled. Now asserts `toBeFocused()` before pressing Enter. +- Rule of thumb for these client-only surfaces: never gate an interaction on `networkidle` or a bare `goto`. Wait for the specific mounted element, and wrap fill→submit→navigate races in `toPass` (the same idiom `openAppModeMenu`/`openDailyActions` already use). The `verify` + `ui-smoke` PR gates run Chromium only, so Firefox/WebKit-specific races surface solely in the gated `release-browser-matrix` (main/release/dispatch/schedule) — keep that job green rather than letting these re-accumulate. diff --git a/tests/ui-overlap.spec.ts b/tests/ui-overlap.spec.ts index 89a7f20d38..192ded5010 100644 --- a/tests/ui-overlap.spec.ts +++ b/tests/ui-overlap.spec.ts @@ -31,7 +31,12 @@ async function mockSetupStatus(page: Page) { async function gotoHome(page: Page) { await page.goto("/", { waitUntil: "domcontentloaded" }); - await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined); + // The dashboard is a client-only (`ssr: false`) dynamic import, so DOM-ready + // and even network-idle can fire before the header mounts — Firefox and + // WebKit paint the client chunk later than Chromium, which is why the overlap + // assertion intermittently saw an empty shell. Wait for the real header to be + // attached before measuring instead of relying on the flaky idle heuristic. + await page.locator("header#search").waitFor({ state: "visible", timeout: 30_000 }); } type OverlapReport = { count: number; overlaps: string[] }; diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts index 2e93bdaec1..65413ab149 100644 --- a/tests/ui-stress.spec.ts +++ b/tests/ui-stress.spec.ts @@ -324,7 +324,11 @@ test.describe("Clinical KB long-content stress coverage", () => { await expect(evidenceDrawer).toBeVisible(); expect(await evidenceDrawer.evaluate((element) => element.hasAttribute("open"))).toBe(false); const evidenceSummary = evidenceDrawer.locator("summary"); + // Confirm focus has actually landed before pressing Enter: in WebKit the + // key event can otherwise fire before the summary is focused, so the + //
never toggles open and the panel stays hidden. await evidenceSummary.focus(); + await expect(evidenceSummary).toBeFocused(); await page.keyboard.press("Enter"); const evidenceReview = page.getByTestId("evidence-support-panel"); await expect(evidenceReview).toBeVisible(); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 46b726841a..9f9a886b30 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -268,12 +268,27 @@ test.describe("Clinical KB applications launcher", () => { await expect(page.getByRole("button", { name: "Current app mode: Forms" })).toBeVisible(); await expect(page.getByRole("heading", { level: 1, name: "Transport order" })).toBeVisible(); await expect(page.getByTestId("form-search-results")).toHaveCount(0); - await expect(page.locator('input[placeholder="Search forms..."]:visible').first()).toBeVisible(); - - await page.locator('input[placeholder="Search forms..."]:visible').first().fill("transport forms"); - await page.getByRole("button", { name: "Search forms" }).click(); - - await expect(page).toHaveURL(/\/forms\?/); + const formsSearchInput = page.locator('input[placeholder="Search forms..."]:visible').first(); + await expect(formsSearchInput).toBeVisible(); + + // Under client-only (ssr:false) rendering the shell re-syncs its query from + // the URL on mount via requestAnimationFrame. On Firefox/WebKit that frame + // can land right after a programmatic fill — wiping the value, disabling the + // submit, or dropping the submit before the router navigates. Drive the + // fill-and-submit as one retried unit until the search actually routes to + // the forms results URL; the assertions below still verify the result. + const formsSearchButton = page.getByRole("button", { name: "Search forms" }); + await expect(async () => { + // A previous attempt's click may have navigated late — after the inner URL + // wait timed out and triggered a retry. If we have already routed to the + // results page the detail-page input is gone, so re-filling would throw; + // treat the completed navigation as success instead. + if (/\/forms\?/.test(page.url())) return; + await formsSearchInput.fill("transport forms"); + await expect(formsSearchButton).toBeEnabled({ timeout: 1_000 }); + await formsSearchButton.click(); + await expect(page).toHaveURL(/\/forms\?/, { timeout: 2_000 }); + }).toPass({ timeout: 20_000 }); await expect(page.getByTestId("form-search-results")).toBeVisible(); await expect(page.getByTestId("form-search-result-transport-crisis-form")).toContainText("Transport order"); await expect(