Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/process-hardening.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<details>` is opened by focusing its `<summary>` 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.
7 changes: 6 additions & 1 deletion tests/ui-overlap.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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[] };
Expand Down
4 changes: 4 additions & 0 deletions tests/ui-stress.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
// <details> 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();
Expand Down
27 changes: 21 additions & 6 deletions tests/ui-tools.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 });
Comment thread
BigSimmo marked this conversation as resolved.
}).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(
Expand Down
Loading