Skip to content

Latest commit

 

History

History
115 lines (73 loc) · 55 KB

File metadata and controls

115 lines (73 loc) · 55 KB

Testing and the quality gate

The rules — the ≥90% per-file gate, test placement, the v8 ignore policy — are in AGENTS.md. This page is the reference for what each script covers.

Each client self-validates from its own folder; the root scripts chain them. There is no aggregate root test script — use validate (fast) or coverage (the gate).

Two tiers: GitHub CI and the local gate

This is the canonical description of the CI-vs-local split — everything else in the repo that mentions it points here.

Tier How it runs What it covers
GitHub CI (.github/workflows/main.yml) Automatically, on every push npm install, then validate, verify:skills:cli, verify:build-gate, verify:bundle-externals, smoke (which includes smoke:web:chromium), test:storybook — plus coverage in a parallel job (#2159)
The local gate (npm run local:gate) By hand, before you push Every check above (the install is yours to run; local:validate stands in for validate, see below), plus the Firefox engine pass (smoke:web:firefox), and smoke:tui for real rather than self-skipped

The local gate runs every check CI runs, and is not a mirror. Two of its steps have no GitHub CI counterpart, each for its own reason:

Local-only step Why it is local-only
smoke:web:firefox Trialled as a CI job and removed (#2086): across a dozen runs it never once disagreed with Chromium, and playwright install --with-deps carries a real flake surface. Kept in front of a human about to push instead. smoke:web:webkit is worse still — it fails two of the three smokes for reasons nobody has identified — so it is in neither tier. See Supported browsers.
smoke:tui The Ink TUI needs a real TTY. It is invoked in CI via npm run smoke and self-skips there on process.env.CI, so it needs no guarding — it handles itself.

And one CI invocation the gate deliberately does not repeat: CI runs each client's unit suite twice — bare inside validate in the build job, instrumented inside coverage in the parallel job — at no wall-clock cost, because the two jobs run on separate runners. Serially in one process the bare pass was ~81s of a ~358s quiet gate — the baseline measured on #2338 — running exactly the test files the coverage pass runs again a few minutes later (#2341). So the gate's first stage is local:validate: the same guards and validate:core, then each client's check — format:check + lint + typecheck, plus build for web, tui and launcher, which name one explicitly; cli's validate built only through test's pretest hook, so dropping the test leg drops that build too, and coverage:cli builds the binary once, later — instead of its validate (check + test). The suites still run once, under coverage. That subsumes the bare pass because @vitest/coverage-v8 reads V8's own precise-coverage profiler and rewrites nothing — the code under test is byte-identical, and the instrumented run is only slower, which is the stricter direction for the failures this repo sees. npm run validate and both CI jobs are unchanged. With the duplicate gone a quiet gate is ~260s (268s in #2341's own before/after, 257s where #2339 measured it) — a reader who remembers six minutes is remembering the gate before #2341.

So the direction that matters holds: passing npm run local:gate means every check CI applies has already passed on your machine — the strongest predictor of a green CI this repo has, though not a proof: CI runs on a different OS, and the bare pass above is the one invocation it has that the gate does not, so a test that passes only when instrumentation slows it down would surface there first. That test is a race (#1596) to fix, not a reason to put the second pass back. The reverse direction does not hold at all: CI green says nothing about the Firefox pass or the real smoke:tui.

Every CI job carries timeout-minutes (#2333, PR #2349): build 25, coverage 20, publish 10, publish-github-container-registry 35. Each is roughly twice the slowest observed run, rounded up to the next five minutes — build was read from 7.6–11.8 min and coverage from 5.9–8.5 min across the 40 push runs before it landed, the two publish jobs from 2.3–2.8 and 14.5–15.4 min across the last three releases — and the range each was read from is stated beside the job in the workflow, so the next raise carries a new range rather than a guess. It is a hung-job guard, not a flake remedy: GitHub runners are not the contended machine the local gate runs on, nothing measured implicates them, and no value there is sized for load. A release-run build before the coverage split (#2159) reached 15.6 min with the coverage gate still inside it; that shape no longer exists, so it is not in the range.

The gate is deliberately not named ci (#2146). That name collided with npm ci — a built-in that clean-installs from the lockfile, does not run this script, and is one keystroke away — and it invited the cross-engine passes into GitHub CI by looking like the thing a workflow ought to call. There is no back-compat alias: npm run ci now fails with npm's missing-script error, which prints the available scripts.

That is the readable half, and prose rots. The enforced half is scripts/lib/workflow-gate.mjs, run by npm run test:scripts over every file under .github/workflows/. It fails the suite if a workflow invokes a local:* script, a non-Chromium engine pass (smoke:web:firefox, smoke:web:webkit), or smoke:web:engine (whose engine comes from the environment and so cannot be read off the workflow at all), or sets SMOKE_BROWSER to anything but a literal chromium (including setting it to nothing, which is present-and-empty rather than unset, and is rejected rather than defaulted). That last rule respects where it is written — an env: entry whose key is SMOKE_BROWSER (case-insensitively, since Windows environment variables are), or an assignment in a run: script (POSIX, cmd, a >> $GITHUB_ENV append, or PowerShell — the default shell on a Windows runner) — so a script that merely prints the variable's name is not read as setting it. It treats any name it cannot read the same way — a workflow expression (smoke:web:${{ matrix.browser }}) or a shell variable the script expands itself (smoke:web:$ENGINE, %ENGINE%) — since a value the workflow can vary may well be firefox. It deliberately does not forbid npm run smoke, smoke:web:chromium, or smoke:tui — those belong in CI and are there today. It parses the workflow and descends the schema's executable paths — workflow env/defaults.run.shell, job env/container.env/with/defaults.run.shell, step run/shell/env/with — a custom shell: counts, since Actions runs it around the script — with aliases resolved — rather than matching text or key names, so it reads only what actually executes — so a step name explaining the rule is not mistaken for an invocation of it, and the workflow can still document itself.

Script What it does
npm run validate Runs the five durable guards first — verify:format-coverage (every tracked source file is format-gated), verify:skills (every .claude/skills manifest parses and declares its invocation mode; the model-invoked ones also carry eval cases — the name-only skills intentionally have none), verify:typecheck-coverage (every tracked TS file lands in a tsconfig project), verify:dep-lockstep (two tiers: no dependency reaching one tsc program from two installs skews across them, and — since #2226 — no declared package holds two different top-level versions across installs at all), verify:test-timeouts (every Vitest project resolves to a budget somebody chose, both web projects configure Testing Library's asyncUtilTimeout, and every project loads the runtime no-retry setup) — then test:scripts (the guards' own parser unit tests), then validate:core (the shared core/ format:check + lint gate — including the core/react/** React-hooks block added by #2192, whose plugin is a root devDependency because a client-local copy is unreachable from the root: Node resolution walks up, not down), then per client: format:check + lint + typecheck (cli/tui/launcher; web typechecks via tsc -b inside its build) + build + fast unit tests. The quick inner-loop check.
npm run coverage The per-file ≥90% gate (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared core/ runtime (including core/json and core/client). maxWorkers is deliberately unset, here and in every Vitest project (#2336, PR #2345): the forks-pool projects inherit Vitest's max(availableParallelism() - 1, 1) — 7 on the eight-logical-core machine this team works on — and keeping it is a choice with a measurement, recorded beside TIMEOUTS in vitest.shared.mts: at the leased baseline, arms interleaved over three rounds, maxWorkers: 4 cost +14% wall on web unit and +39% on coverage:web for zero fewer failures (0 across all 18 runs). See Multi-agent testing for why a cap was the wrong lever for the contention it was proposed against.
npm run smoke End-to-end smokes through the built launcher (--help dispatch + prod cli/tui/web — the TUI one runs under a pseudoterminal and asserts the Ink app is still running two seconds after its first frame, not merely that it painted one, #2147), plus three headless-browser smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an MCP Apps smoke (smoke:web:app) that drives connect → open app → data-app-status="ready" against a composable App server, covering the sandbox proxy and UI-protocol bridge, and an app-rendered elicitation smoke (smoke:web:elicit) that drives one end to end — call the tool, answer inside the sandboxed app, see the app's ElicitResult reach the server — and then the same tool against a server that never advertised the capability, which must fall back to the native elicitation form. Those three take their engine from SMOKE_BROWSER — see Supported browsers. A fourth, smoke:web:tabs, drives the core tabs against one server on one browser launch — Tools runs a tool and asserts its structuredContent section, Resources reads a resource with templates listed, Prompts fetches one (#2148) — asserting the data-* contract in the web README rather than visible copy. It is Chromium-only: those tabs are ordinary React and Mantine, so unlike the sandbox they are not engine-sensitive. The Playwright budgets the browser smokes wait under are named once, in scripts/lib/browser-timeouts.mjs (BROWSER_TIMEOUTS: ui 30s, roundTrip 45s, nested 15s, bestEffort 5s — #2323); since #2333 (PR #2349) the shared flow helpers deep-link-connect.mjs and mcp-app-flow.mjs default to those constants instead of carrying literals of their own, and the two helper budgets that stay put are stated at the site with the measurement behind them: announced-child.mjs's DEFAULTS (a 30s readiness ceiling on a 250ms poll — boot-to-announce measured 135ms median through it at load average 15, so a passing start pays one poll interval, not the budget) and pty.mjs's SCRIPT_PROBE_TIMEOUT_MS (5s for the script --version probe, measured at 1.3ms median). That probe's expiry is now a failure, not a skip: a timed-out spawnSync reports in the same error slot as ENOENT, and reading both as "no script(1) on PATH" had turned smoke:tui into an exit-0 skip on exactly the loaded machine the gate is meant to be believable on.
npm run verify:build-gate Runs a real vite build with a Node built-in forced into the browser graph and asserts the build fails via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of npm run local:gate and of GitHub CI.
npm run verify:bundle-externals Guards the must-not-bundle invariant (#2067): for each tsup-bundled client it reads the built build/ output and fails if any package that must stay external was inlined anyway. Candidates are the union of the client's own external array and the root manifest's dependencies — the latter because #2067 was a missing external entry, which a self-referential check would have passed. Detection is via esbuild's // <path> module banners, so it covers both shapes — a separate <pkg>-HASH.js chunk (what a dynamically import()ed CommonJS package produces) and a statically-imported package folded straight into index.js, which emits no chunk at all. A build with no banners fails as such rather than passing clean, so enabling minify cannot silently retire the check. undici was declared only in the root and clients/cli manifests, and tsup auto-externalizes only what the nearest manifest declares, so the web and TUI bundles inlined 1.05MB of it — and CommonJS inlined into an ESM bundle throws Dynamic require of "assert" is not supported on first use, from a specifier no user-side install can satisfy. Reads the output rather than the config because those two disagreed for four releases. Part of npm run local:gate and of GitHub CI.
npm run verify:format-coverage Parses the format:check globs out of every package.json (only those reachable from validate), enumerates all tracked source files, and fails listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in validate.
npm run verify:skills Guards .claude/skills (#2163). A skill's body loads only when it is invoked, so one that stops being reachable loses behavior silently — and the way that happens is frontmatter Claude Code cannot parse, which loads the body with an empty description: /skill-name still works, so a manual spot check passes, while nothing is left for the model to match against. This parses each SKILL.md the same way, requires an explicit invocation mode on every skill and committed eval cases (positives and negatives) on every model-invoked one, and fails when the skill listing exceeds its recorded character budget. Also runs claude plugin validate — the authoritative schema — when the installed CLI is exactly the pinned version, and otherwise says so and moves on. It runs inside validate:guards, which both validate (CI) and local:validate (the gate's first stage) execute before the pinned step, so accepting any other local CLI would let it reject skills that CI accepts and exit the gate before the reproducible check ever ran. Runs in validate; because that hand-off is best-effort, the authoritative validator also gets a guaranteed step of its own — see verify:skills:cli below.
npm run verify:skills:cli Runs claude plugin validate — the authoritative skill schema — as a guaranteed step, in local:gate and in CI. verify:skills reimplements the parse and skips when the CLI is absent, which is right for validate (fast, offline, no Claude Code required) but adds up to "never runs". This one resolves the CLI instead: an installed one only when it matches the pin exactly, otherwise the pinned package via npx -y @anthropic-ai/claude-code@…. Exact, not a floor: accepting a newer local CLI would validate against a different schema than CI's, so the same local:gate could disagree across machines — which is the failure a pin exists to prevent. Needs no authentication. Not in validate — it can reach the network.
npm run test:scripts Table-driven unit tests (node --test) for the guard's own pure parsers (scripts/lib/npm-scripts.mjs, scripts/lib/tsc-program.mjs + the exported helpers of verify-typecheck-coverage.mjs and verify-dep-lockstep.mjs), one case per rule they encode, plus two suites over shared scripts/lib helpers that no smoke can check itself: resolve-node-bin.test.mjs — the cross-platform bin resolver (#1939), pinned against the real bin/exports shapes of the packages the scripts actually spawn — and announced-child.test.mjs — the spawn/readiness ownership helper (#2000), which drives real node -e children to prove a child that never announces is still published to the caller before the timeout throws, and so is reachable by teardown rather than orphaned. Four more do the same: mcp-app-flow.test.mjs covers the shared MCP Apps flow (#2003) — the deep link's two CSRF gates and appArgs encoding, plus driveAppFlow's failure branches against a stand-in page, all of which are dead code from the happy-path smokes' point of view and would otherwise surface only as opaque timeouts; render-smoke.test.mjs and pty.test.mjs cover the TUI boot harness (#2147) — the former driving real node -e stubs to prove that a child which paints the marker and then dies is a failure, which the old harness reported as OK and which no fixed TUI can reproduce; the latter pinning the three mutually-incompatible script(1) invocations, where a wrong guess stops the pseudoterminal from being allocated at all; and ensure-test-servers.test.mjs pins the #2111 invariant — that test-servers/build is rebuilt even when it already exists — which no smoke can assert about itself, since one driving a stale fixture reports a product failure rather than a staleness one. workflow-gate.test.mjs is a different shape again (#2146): besides the table of parser cases it runs the parser over the repo's real .github/workflows/**, so a workflow that invokes the local-only gate or a non-Chromium engine pass fails here rather than in a CI run nobody expected to be red. Five more arrive with the skills (#2163). skill-manifest.test.mjs pins the frontmatter contract one case per rule — most importantly that a fence which is not a whole line is not a terminator, and that malformed YAML is an error rather than an empty description. verify-skills.main.test.mjs drives that guard's main() over fixture directories, the sibling guards' *.main.test.mjs pattern, so it cannot stop enforcing while the parser tests stay green. skill-eval.test.mjs covers the trigger eval's stream parsing and its run classification, which an eval run's happy path never reaches and which would otherwise report a plausible hit rate for a CLI that failed to run. verify-skills-cli.test.mjs covers which validator gets run — a local CLI only at the exact pin — and the orchestration around it, neither of which an ordinary run on a working machine exercises. claude-cli.test.mjs covers the one place that decides how the CLI is spawned — the Windows .cmd shim needs a shell, and its arguments then need quoting — with the platform injected, since a win32-only bug that no runner here executes is exactly the kind that ships. gate-lease.test.mjs (#2339, PR #2344) covers the gate's lease wrapper the way announced-child and render-smoke are covered — against a real proper-lockfile lock and real child processes: two runs serialize; a stale lock is taken over while one that cannot be removed is waited on; the give-up names the holder; the bypass variable and an unusable lease directory each degrade to an unleased run; and a real SIGTERM to the wrapper stops the grandchild, releases the lease and exits 143. Every case uses a throwaway lease directory, so the suite can neither queue behind nor block a developer's actual gate, and an after hook removes them (#2346, PR #2351: 14 directories left behind per run → 0). workflow-gate.test.mjs also pins local:gate to being exactly the wrapper over local:gate:stages — a stage placed beside the wrapper would run outside the lease — and every assertion about the gate's shape reads the stages script directly. Runs in validate — and verify:typecheck-coverage guards this gate in turn (reachable from validate, non-empty test set, every test file matched by the test:scripts glob), since node --test silently skips a file its glob misses and still exits 0.
npm run verify:typecheck-coverage The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its typecheck script's projects, or for a tsc -b client like clients/web via its tsconfig.json references) it runs those projects with tsc --listFilesOnly, unions them, and fails listing any tracked .ts/.tsx/.mts/.cts under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (test-servers/src, the root vitest.shared.mts, all of core/, and any new top-level location) to land in some client project's tsc pass — so a core *.tsx web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its typecheck script, or web's tsc -b — is reachable from its validate, and the root chain runs each client's validate). Runs in validate.
npm run verify:dep-lockstep Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — core/, test-servers/src, and the root-owned vitest.shared.mts, all of which resolve their dependencies from the root install — alongside the client's own sources, putting the same package in one tsc program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod 4.3.6 vs 4.4.3 exhausted the 4GB tsc heap in clients/web). Derives its candidate set from what actually enters each program (#1965) — every client tsconfig project listed with tsc --listFilesOnly via the shared scripts/lib/tsc-program.mjs, each resolved node_modules file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's .d.ts, as @modelcontextprotocol/sdk's do, is invisible to a scan of first-party imports). Prices each copy from the lockfile entry for the exact install path the program resolved, compares only the installs that met in one program, and fails deny-by-default on any disagreement not in the annotated TOLERATED_SKEW allowlist — empty today — with an allowlisted package tolerated only within a major version. A second tier (#2226) runs alongside it, asking the weaker but broader question the AGENTS.md rule actually states: does a package this repo declares anywhere resolve to two versions across our installs at all? Its candidate set is every name in any install's dependencies/devDependencies/optionalDependencies — unioned across the root and all four clients, so a copy declared by only one of them still counts — that more than one install holds a top-level copy of (17 packages today). Nested copies are excluded: one exists because some dependency asked for a different version, so it is that dependency's range to govern, not ours. Neither tier subsumes the other — the program tier sees a copy no manifest names (@modelcontextprotocol/sdk, arriving through another package's .d.ts), while the declared tier sees a transitive copy no program loads (cli's @types/node, hoisted via @types/express — the case that motivated it), two clients disagreeing with no root copy involved (@types/react, web against tui), and the peer shadows eslint/typescript/vitest that never enter a program. Same deny-by-default and same within-a-major rule, against its own TOLERATED_DECLARED_SKEW — also empty. Two limits: it reads lockfiles, so an uncommitted hand-installed copy is invisible, and it compares only declared names, so a purely transitive package no manifest names stays the first tier's business. Runs in validate.
npm run verify:test-timeouts Guards the wall-clock budgets the test gates run under (#2323). The class it encodes against is a budget nobody chose: three of the six Vitest projects ran on Vitest's own testTimeout: 5000 and five on its hookTimeout/teardownTimeout: 10000, sized for an idle machine rather than the one this team works on — three or four concurrent agent sessions in separate worktrees, each free to run a full local:gate, on eight logical cores. A correct, deterministic test cut off by such a budget fails a gate its diff did not break, which is the same "channel nobody trusts" failure Lint has no warning tier describes from the other direction; #2292, #1942 and #1742 were each an instance, found one site at a time. Deliberately not cited: #2278 (a missing condition wait around a geometry read) and #2250 (a real race in a test's own timing) were fixed by making the test wait for the right thing, and presenting a race fix as evidence for a larger ceiling would argue against #1596. It asks Vitest itself to resolve each of the four configs and reads the number a test actually gets, rather than checking that a key is absent from a config block — which would pass just as happily on a config that had stopped being loaded. A seventh project with no row, or a config file it does not discover (it knows all twelve filenames Vitest accepts, not just the two this repo uses), is an error rather than a silent skip. It also checks that every project loads vitest.setup.shared.mts. ⚠️ Everything it reads comes from a resolved project, never from source text — the two rules no config can report are asserted at runtime instead: retry by vitest.setup.shared.mts (which reads the value Vitest resolved, so a per-test option, a describe option, a project setting and a --retry flag are one check) and Testing Library's asyncUtilTimeout by clients/web/src/test/asyncUtilTimeout.test.ts (which reads what the project's own waitFors use). Both began as source scanning in #2334 and the review found a new valid spelling missed in five consecutive rounds, so the boundary is deliberate: if a rule cannot be answered by asking the tool, assert it at runtime rather than reading the source for it. Observed red against the pre-#2323 config before it was trusted. Runs in validate; its decision logic has a sibling verify-test-timeouts.test.mjs under test:scripts.
npm run local:gate Mandatory pre-push command. local:validate → verify:skills:cli → coverage → verify:build-gate → verify:bundle-externals → smoke → smoke:web:firefox → local:storybook. Every GitHub CI check plus two local-only ones — see Two tiers. Named local: rather than ci on purpose (#2146); there is no npm run ci alias. Runs under a machine-wide lease (scripts/gate-lease.mjs, #2339): a second local:gate started in another worktree waits for the first to finish instead of running alongside it. Measured on aa56551b, a quiet gate took 257s; two started together had one fail at 279s on a smoke port collision (smoke:web:chromium binds 6298, and so did the other gate's) and the survivor take 338s — so overlapping gates are red by construction, not merely slow, and back to back both finish green in ~2x257s. The wait prints the holder's pid and worktree; a holder that dies without releasing is taken over after 30s (proper-lockfile stale detection) — unless its lock directory cannot be removed, in which case the wait runs to its 45-minute cap and names the path; INSPECTOR_SKIP_GATE_LEASE=1 bypasses it. The stages themselves are local:gate:stages, which the wrapper runs verbatim. Why it is a queue rather than a load wait or a worker cap, and what it does and does not change about capacity, is Multi-agent testing.
npm run local:validate The gate's first stage: validate with each client's test leg removed (#2341) — the same validate:guards and validate:core, then every client's check — format:check + lint + typecheck, plus build for web, tui and launcher, which name one explicitly (cli's only validate-time build was test's pretest hook, so it goes with the test leg and coverage:cli builds the binary once, later) — each client's validate being check && test. The suites run once, under coverage, instead of bare here and instrumented there. In the local: namespace so the workflow guard keeps it out of CI, where the bare pass is free.
npm run local:storybook The gate's last stage: from clients/web, npx playwright install chromium and then test:storybook — the Storybook play functions, run headless by Vitest's browser project (CI runs test:storybook directly after its own Playwright install step, which is why this wrapper is in the local: namespace). Since #2340 (PR #2342) the project's optimizeDeps carries force: true (getStorybookOptimizeDeps in clients/web/server/vite-base-config.ts), so Vite discards its dep pre-bundle cache and re-scans every story entry before the browser opens — the cold path CI takes on every PR. Without it, Vite keys that cache on the lockfile and the config, never on what the story graph imports, so after a story gains an import of an already-installed package the "valid" cache re-runs the optimizer mid-run and sends a full-reload the Vitest tester iframes do not act on; every story an already-loaded iframe renders from then on fails with Failed to fetch dynamically imported module, and the next run is green because the cache has caught up. Measured: a stale cache went 3/3 red → 3/3 green with force → 3/3 red reverted, load refuted as a cause (0 of 6 under load 8–12), cost ~0.8s per run against a ~25s stage.
npm run pack:verify Publish smoke — see Publishing.

Per-client scripts exist too (validate:web, coverage:cli, smoke:tui, …), plus root validate:core / format:core for the shared core/ package, format:scripts for the root scripts/ tooling, and format:shared / lint:shared for the root "shared" surface (test-servers/src/**, vitest.shared.mts, the root eslint.config.js). Run npm run format before committing — the root format fixes core/, the root scripts/, the shared surface, and every client; validate runs the non-fixing format:check and fails CI on any unformatted file.

Linting is type-aware. All five ESLint scopes (clients/{web,cli,tui,launcher} plus the root core/ + shared gate) enable @typescript-eslint/no-floating-promises at error, so a promise that is neither awaited, returned, .catch(…)-terminated, nor explicitly discarded with void fails lint — and therefore validate (#1959). The rule needs type information, so each scope's config names a parser project; the root scope's is tsconfig.lint.json, a lint-only project covering core/**, test-servers/src/**, and vitest.shared.mts, which have no tsconfig of their own. It emits nothing and changes no typecheck — but a new first-party TS location added to the root lint scope must be added to its include. See TypeScript instructions in AGENTS.md for when void is acceptable.

The React hook rules cover core/react/ too. Every copy of eslint-plugin-react-hooks used to be client-local — a devDependency of clients/web and of clients/tui, each reachable only from that client's own eslint . — so the whole hook rule set, set-state-in-effect included, had never looked at core/react/, a directory of nothing but React hooks (#2192). "Neither surface has JSX" was the stated reason and it was never a good one: the rules judge hooks, not JSX. The plugin is now a root devDependency as well, applied by a core/react/** block in the root eslint.config.js — scoped there rather than to all of core/**, since the rules key off the use-prefix convention and a non-React useFoo helper elsewhere would be judged as a hook it is not.

And lint has no warning tier. Every lint script runs with --max-warnings 0, so a warning fails validate exactly as an error does (#2085) — a warn-level react-hooks/exhaustive-deps finding had otherwise let a stale-closure bug pass the mandatory pre-push gate and reach review. Fix the finding rather than silencing it; if a rule genuinely must be waived, use its inline disable comment with a one-line justification.

For the full testing rules — the ≥90% per-file gate, where test files live, the unit vs. integration vs. storybook projects, and the v8 ignore policy — see AGENTS.md.

Supported browsers

The three headless web smokes — smoke:web:browser, smoke:web:app, smoke:web:elicit — take their browser engine from SMOKE_BROWSER, which accepts chromium, firefox and webkit (#2086):

SMOKE_BROWSER=webkit npm run smoke:web:app     # one smoke, one engine
SMOKE_BROWSER=firefox npm run smoke:web:engine # all three smokes, one engine

Unset, the engine is chromium, so npm run smoke is unchanged. npm run local:gate — the mandatory pre-push gate — additionally runs all three smokes under Firefox via smoke:web:firefox; GitHub CI does not. An unrecognized SMOKE_BROWSER is an error, not a fallback: a silent fallback would report a green Chromium run for a command that asked for webkit. A missing browser binary fails naming the engine and a remedy that works from where you are — npm run smoke:web:<engine>, or npx playwright install --with-deps <engine> run from clients/web, where Playwright is pinned.

Firefox passes all three smokes. WebKit fails the two App smokes, for reasons nobody has identified. Two things are known: it does not reproduce in real Safari (an MCP App opens there normally), and an isolated repro of the mechanism it was first blamed on did not reproduce it under Playwright's WebKit either. So it reads as a property of that particular build rather than a bug users hit, and chasing it further was judged not worth the effort — treat a WebKit failure as unexplained rather than as a defect until someone has looked.

Why Firefox is in the pre-push gate rather than in CI. A GitHub Actions job was trialled and was cheap — about two minutes, in parallel with the then-15-minute build job (it still carried the coverage gate; see the CI budgets above), so no added wall-clock. It was dropped anyway, on the honest count: across a dozen runs it never once disagreed with Chromium, so it spent runner minutes on every push from every branch to re-confirm a result already in hand, and carried a real flake surface (playwright install --with-deps runs apt-get update, which fails whenever a third-party repo in the runner image breaks).

Putting it in npm run local:gate instead keeps the check where it is worth most — in front of a human about to push a change they can still reason about — and pays for it once per push rather than once per CI run. If a cross-engine regression ever reaches v2/main because someone skipped the gate, that is the evidence for restoring the CI job; it is a one-job diff.

Why these smokes specifically. Most of the web client's behavior is React and Mantine, where a second engine buys little. The MCP Apps sandbox is the exception — it is built out of the primitives that genuinely diverge between engines: a CSP <meta> injected as the first <head> child of a srcdoc document, a nested sandboxed iframe, a Permissions-Policy allow attribute, and postMessage origin discipline across those two frames. Nothing else covers that: sandbox-csp.test.ts asserts which policy string is built, which passes identically on an engine that ignores <meta> CSP entirely, and no Storybook story reaches the sandbox at all (all three App stories point the iframe at a data: placeholder and hand the renderer a mock bridge). Storybook itself remains Chromium-only — broadening it covers a much larger and differently-shaped surface, and is a separate decision.

⚠️ Playwright's WebKit is a WebKit build, not Safari. It is close enough to catch engine-level CSP and iframe divergence, and not close enough to certify Safari specifically. A green run here is not a Safari guarantee.

pack:verify stays Chromium-only on purpose — it is a packaging check, and the engine question belongs where the sandbox is under test.

Multi-agent testing: the gate lease

The machine this repo is worked on runs three or four agent sessions at once, each in its own worktree and each free to run npm run local:gate. #2338 is the effort that made the gate believable there; #2339 is its coordination aspect, shipped as PR #2344. This section is what that coordination is, why it is a queue, what it changed about capacity, and what it leaves alone. The diagnosis side — what to do when a gate is waiting, or when everything times out at once — is the pre-push-gate skill.

What the lease does

npm run local:gate is node scripts/gate-lease.mjs npm run local:gate:stages: the stage chain runs as the child of a wrapper that holds one machine-wide lease for the whole run. The lock lives under os.tmpdir() — or $XDG_RUNTIME_DIR where a desktop session sets it — and never inside a worktree, because a lock in the repo would be one per worktree, which is one per session, which coordinates nothing. A second gate started in another worktree while one holds the lease waits: it prints one line naming the holder's pid, worktree and elapsed time, re-asks every 2s, says still waiting once a minute, and starts the moment the holder releases.

The lease is released on every way out — a normal exit, a spawn failure, or a signal. On POSIX the wrapper runs the chain in its own process group, so Ctrl-C, a plain kill, a closed terminal or Ctrl-\ stop the whole tree (npm run nests four deep and sh forwards nothing), release, and exit 128 + signal; on Windows there is no process group (spawnSpec spawns through cmd.exe, not detached), the signal is delivered to the immediate child only, and the lease is released just the same — what is not guaranteed there is that every descendant of that child has stopped. A holder that dies without releasing — a killed terminal, an OOM'd session — stops refreshing its lock and is taken over after 30s (proper-lockfile's stale detection, at 30s rather than the library's 10s default: a refresh timer firing late under the very load this exists to manage would otherwise cause a false takeover, which is two gates overlapping — the thing being prevented — whereas the cost of the longer window is 20 more seconds after a crash, against a gate that takes minutes). A waiter gives up after 45 minutes, naming the holder and the lock path. That budget is a total queueing budget: it counts from the waiter's first attempt and is not reset when the holder ahead releases and another queued gate takes the lease, so it expires against a live gate that has hung, a dead holder's lock directory that cannot be removed, or a queue deeper than the budget covers — about ten gates at ~4.5 minutes each (see capacity). A give-up behind a queue of healthy gates is that third case, not a hung holder; the line it prints names whichever gate holds the lease at that moment. INSPECTOR_SKIP_GATE_LEASE=1 bypasses the lease — for a measurement that needs contention, never for a sooner result, since the queued run finishes before an overlapped one would. And the lease adds only one way for the wrapper itself to exit red — that 45-minute give-up, which happens before any stage is spawned. A lock that cannot be created (an unwritable tmpdir) degrades to an unleased run with a warning rather than failing, and every handled exit attempts the release; if the release itself fails, the wrapper says so and leaves the lock for the stale takeover, or for a hand removal if whatever blocked it persists.

Why a queue, and not a load-based wait or a worker cap

Each alternative was ruled out by a measurement rather than an argument:

  • Overlapping gates are red by construction, so no budget or worker cap could make two of them green. The web smokes bind fixed ports — 6295–6299 by default (smoke:web:tabs 6295, smoke:web:elicit 6296, smoke:web:app 6297, smoke:web:browser 6298, smoke:web 6299), plus the sandbox and app-origin ports. Measured for #2339 on aa56551b: a quiet gate took 257s and passed; two started together in separate worktrees had one fail at 279s — smoke:web:chromium found port 6298 held by the other gate's copy of the same smoke — while the survivor took 338s, ~1.3x, with zero test timeouts. That is a red run on a diff that cannot have caused it, the exact failure #2338 exists to stop, and only not overlapping fixes it. (The corollary corrected the effort's own framing: at two sessions the load inflation is ~1.3x; the 2.5x recorded on #2323, with ~326 tests timing out at 5s, was the four-worktree number.)
  • A load-threshold wait deadlocks. Two sessions each polling "is the machine quiet yet?" never clear, because each is the reason the other is waiting — the trap #2339 recorded before any code existed. A lease grants instead: exactly one waiter wins the mkdir, runs, and releases; the rest keep asking until they acquire it or exhaust their own 45-minute budget. There is nothing to wait for except the holder's release — by exit, by signal, or by the stale takeover above — and the wait for it is bounded, which is what a load threshold cannot promise. The one case where no release ever comes is the unremovable stale lock above, and there the waiter still exits at its budget naming the path rather than waiting forever. A pgrep -f "npm run local:gate" loop is the same trap from another side — it matches itself and never exits.
  • A worker cap is a permanent solo-run cost bought against a failure that does not occur at the leased baseline. Measured for #2336 (PR #2345), arms interleaved over three rounds, nothing else running: maxWorkers: 4 cost +14% wall on web unit and +39% on coverage:web — ~35s on every coverage:web, a tenth of the whole gate — for zero fewer failures (0 across all 18 runs). With gates serialized, the oversubscription a cap would mitigate comes only from non-gate work in a sibling session, and that arm was measured separately against a concurrent bare unit run: total throughput was the same with both sides capped as with both at the default, because eight cores are saturated either way, and the one thing a cap moved was failures — 2 of 10 uncapped 7-vs-7 runs lost AppRenderer.test.tsx's theme-flip case to its 5s inner waitFor at 14 workers, against 0 of 12 with a cap on either side. That is a single test's inner budget starving under two sessions' worth of workers, fixable at that site, not a case for taxing every solo run. So maxWorkers stays unset, stated as a choice beside TIMEOUTS in vitest.shared.mts.

Two further calls, recorded on PR #2344: the lease covers the whole gate, not only its parallel stages, because the serial stages are not free (type-aware lint, a full Vite build) and per-stage leases would let two gates interleave into the same contention; and it is on by default, because the sessions that need it are the ones that did not think to opt in.

What it means for capacity

One gate runs at a time — before and after. What changed is what happens when a second one is started. Before, a second start was a wasted run: one of the two went red on the port collision regardless of budgets, so the honest capacity was one green gate at a time. After, as many as fit inside the wait budget — about ten, see below — can be started from separate worktrees, and each runs back to back with no other gate contending for the machine or its ports. What the lease removes is gate-on-gate contention, not any other reason a check can fail; measured, three started within seconds of each other all completed green with zero timeouts. Each gate is also shorter (a quiet run went ~358s → ~260s with #2341), so a queue drains sooner: three queued gates finish in roughly 13 minutes in total, against one green, one red and a re-run before. The ceiling on queue depth is the 45-minute wait budget — at ~4.5 minutes per gate, about ten can be queued before the last gives up and names the holder, so a deeper queue is one where the last starts give up rather than wait. Whether truly parallel gates could ever be green was not measured: the port collision makes that a different project (per-gate port allocation), not a tuning problem, and nothing in #2338's goal needs it. The measurements are collected in #2338's epilogue.

What it does not do

  • No per-stage locks. The whole gate is one lease, and nothing finer.
  • No degraded mode. A queued gate runs at full concurrency against the same quiet baseline every other #2338 number was tuned to; it never runs smaller to fit beside another.
  • No coordination with non-gate work. Only local:gate takes the lease, so a bare vitest run, an npm run coverage or a test:storybook in another session still contends with a running gate — and so does Spotlight indexing a fresh node_modules. When everything times out at once, look for one of those rather than for a second gate.
  • Nothing in CI. The wrapper is reached only through local:gate, which scripts/lib/workflow-gate.mjs already keeps out of every workflow; GitHub runners are separate machines and need no lease.