test(miss-54): deterministic guard for unsettled goto→goto + nav convention

The SavedFilters incident shape — a page.goto fired while the previous
heavy RSC navigation was still in flight (net::ERR_ABORTED) — gets a
build-level guard: two gotos with no settle between them (await expect /
waitForURL / click / reload / login / catch) fail the suite. Measured
baseline on the current tree: 58 gotos across 17 specs, 0 unsettled
pairs — the six SOFT pairs from the miss audit all settle on awaited
assertions, so the guard starts clean without churning them.

TESTING.md records the convention the miss asked for: goto for cold
loads and URL contracts; in-shell navigation for continuity scenarios;
interactive-state waits before dynamic clicks; a dev-mode caveat for the
campaignLeaderships chip spec (deterministic dev failure at the
suggestion/commit step, prod green — ledgered).
This commit is contained in:
Cursor Agent 2026-07-31 06:18:27 +00:00 committed by Francisco Ignácio Fontoura Solla
parent 5c59793e4c
commit 84fed02ee5
2 changed files with 88 additions and 0 deletions

View file

@ -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:

View file

@ -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([])
})
})