diff --git a/docs/TESTING.md b/docs/TESTING.md index fb1f0a93..81ec234b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -12,6 +12,8 @@ CI (`.github/workflows/ci-pr.yml`) runs parallel jobs — lint (zero warnings), **Local e2e before push (same blast radius as PR CI):** `pnpm test:e2e:affected` — runs `e2e-affected.mjs` against `origin/stage`, migrates/seeds, then `pnpm test:e2e` with the selected specs (or the full suite on high-risk diffs). Forward extra Playwright args after `--` for a narrowed repro. Opt into production mode (after fixing the known municipalities-list prod client bug) with `E2E_PROD=1 pnpm test:e2e:affected`. +**Navigation discipline in e2e (miss #54, 2026-07-30):** `page.goto` is for cold loads and URL contracts (deep links, redirects, legacy params); when the scenario is _continuity inside the authenticated shell_ — especially after a heavy list route — navigate in-shell instead (sidebar links, buttons, form submits), the way the user does. Never fire two `page.goto` back-to-back with nothing settling the first navigation (an `await expect(...)`, `waitForURL`, or interaction): the second aborts the first's in-flight RSC fetch (`net::ERR_ABORTED`, measured in `campaignSavedFilters`). The `e2eNavigationConventions` unit guard fails the build on unsettled goto pairs. Two sibling rules of the same family: wait for **interactive state** before clicking dynamic controls — `toBeVisible` proves SSR paint, not hydration (pre-hydration clicks no-op silently; `checkRadixWhenHydrated` is the precedent, P3-C class) — and gate WebAuthn ceremonies on `expectCampaignBiometricsReady` before the island's one-shot probe runs (miss #53). **Dev-mode caveat:** `campaignLeaderships`' chip-cell spec currently fails deterministically under `pnpm dev` (suggestion/commit step; prod build green — run it with `E2E_PROD=1` locally; ledgered in TECH-DEBT). + ## Minimal test database (`pnpm db:seed:minimal`) The integration suite and any agent/Cloud environment boot from a **minimal synthetic database**: schema from `pnpm migrate` (which seeds the 435-município catalog) plus `pnpm db:seed:minimal` ([`scripts/seed-minimal.mjs`](../scripts/seed-minimal.mjs)), an idempotent upsert of the rows the app refuses to run without: diff --git a/tests/unit/e2eNavigationConventions.unit.spec.ts b/tests/unit/e2eNavigationConventions.unit.spec.ts new file mode 100644 index 00000000..07182184 --- /dev/null +++ b/tests/unit/e2eNavigationConventions.unit.spec.ts @@ -0,0 +1,86 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Miss #54 (2026-07-30): a `page.goto` fired while the previous heavy RSC +// navigation was still in flight aborted with `net::ERR_ABORTED` +// (campaignSavedFilters, prod build). `goto` stays legal for cold loads and +// URL contracts — the anti-pattern is two gotos with NOTHING between them +// that settles the first navigation. A settle is any awaited interaction +// with the landed page (assertion, fill, click, reload, login…): it proves +// the previous route rendered before the next one starts. +// +// Miss #53 (2026-07-30): the biometrics probe is one-shot per island mount, +// so a ceremony that starts before the CDP virtual authenticator answers +// never sees the enrollment UI. Specs registering a virtual authenticator +// must gate on `expectCampaignBiometricsReady` first. + +const e2eRoot = resolve(process.cwd(), 'tests/e2e') + +const specFiles = readdirSync(e2eRoot, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.e2e.spec.ts')) + .map((entry) => resolve(entry.parentPath, entry.name)) + +const SETTLE_TOKENS = [ + 'await expect(', + 'waitForURL(', + '.click(', + '.check(', + '.fill(', + '.press(', + '.hover(', + 'clearCookies(', + '.reload(', + 'campaign.login(', + '.catch(', + 'await request.', + 'checkRadixWhenHydrated(', + 'expectCampaignBiometricsReady(', +] as const + +describe('e2e navigation discipline (miss #54)', () => { + it('never fires two page.goto without settling the first navigation', () => { + const offenders: string[] = [] + + for (const file of specFiles) { + const lines = readFileSync(file, 'utf8').split('\n') + let lastGoto: number | null = null + let settledSince = false + + for (const [index, line] of lines.entries()) { + if (SETTLE_TOKENS.some((token) => line.includes(token))) settledSince = true + if (!line.includes('page.goto(')) continue + if (lastGoto !== null && !settledSince) { + offenders.push( + `${relative(process.cwd(), file)}:${index + 1} (goto at ${lastGoto + 1} unsettled)`, + ) + } + lastGoto = index + settledSince = false + } + } + + expect( + offenders, + 'settle the first navigation (await expect/waitForURL/click…) before the next page.goto, or navigate in-shell (miss #54)', + ).toEqual([]) + }) +}) + +describe('e2e WebAuthn virtual authenticator (miss #53)', () => { + it('gates every virtual-authenticator spec on expectCampaignBiometricsReady', () => { + const offenders: string[] = [] + + for (const file of specFiles) { + const source = readFileSync(file, 'utf8') + if (!source.includes('WebAuthn.addVirtualAuthenticator')) continue + if (source.includes('expectCampaignBiometricsReady(')) continue + offenders.push(relative(process.cwd(), file)) + } + + expect( + offenders, + 'call expectCampaignBiometricsReady(page) before the surface whose island probes the authenticator', + ).toEqual([]) + }) +})