inspector/.claude/skills/pre-push-gate/SKILL.md
cliffhall 58e53d5a92 fix: state every test-gate timeout instead of inheriting a library default
Three of the six Vitest projects ran on Vitest's own testTimeout: 5000 and
five on its hookTimeout/teardownTimeout: 10000, and all 788 Testing Library
waits in the web unit project ran on asyncUtilTimeout: 1000. None of those
numbers was chosen here, and all of them were sized for an idle machine rather
than for three or four concurrent worktree sessions each running a full
local:gate on eight cores. #2292, #2278, #2250, #1942 and #1742 were each that
class, diagnosed one site at a time.

This is the complement of #1596, not a retreat from it: every fix that issue
produced stands, and no race is hidden here. What changes is that a correct,
deterministic test is no longer cut off by a budget nobody picked.

- vitest.shared.mts exports frozen TIMEOUTS / INTEGRATION_TIMEOUTS; all six
  projects spread one of them, clients/launcher importing the shared module for
  the first time to do so.
- Both web projects configure Testing Library's asyncUtilTimeout. The storybook
  one needs its own setup file, deliberately outside .storybook/ and free of
  setProjectAnnotations so the automatic preview-annotation provisioning
  (#1898) is untouched.
- 28 per-suite restatements of the integration project's own budget are gone,
  so raising the shared value now moves every site under it. The handful that
  genuinely differ — cross-process lock contention, a full OAuth round trip —
  keep their budget as a named constant that says why.
- AppRenderer's waitFor no longer asks for exactly its enclosing budget, which
  it could never win (#2292's shape); cli's e2e child timer drops below the
  project budget so the bound carrying the diagnostic is the one that fires.
- The transition auto-settle's slack term is named RAF_SLACK_MS and raised;
  the term derived from HEADER_ANIM_MS is untouched. The two answer to
  different things and must not be scaled as one number.
- The 19 Playwright budgets scattered across three web smokes become four
  named constants in scripts/lib/browser-timeouts.mjs.

scripts/verify-test-timeouts.mjs resolves each project through Vitest itself
and asserts the budgets, the asyncUtilTimeout sites, and that retry stays
unset. It was observed red against the pre-change config: unit/tui/launcher at
testTimeout 5000, five projects at hookTimeout 10000, both asyncUtilTimeout
sites unmet.

Closes #2323

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-12 08:32:34 -04:00

7.5 KiB

name description disable-model-invocation
pre-push-gate Diagnose a failing stage of this repo's pre-push gate, and run it correctly. Use when npm run local:gate or npm run validate fails; when lint, coverage, a smoke, or Storybook goes red; when a stage behaves differently inside a git worktree; when the whole run times out; or when deciding what to run before pushing. false

The pre-push gate

The rule (stated in AGENTS.md): run npm run format before committing and npm run local:gate before pushing. npm run validate is the fast inner-loop check and is not a substitute — it runs no coverage gate, no smokes, and no Storybook tests.

The reference for what each stage covers, and the CI-vs-local split, is docs/quality-gate.md. This skill is how to run it and what to do when it goes red.

The two commands

cd <repo root>
npm run format      # auto-fix: core/, scripts/, the shared surface, every client
npm run local:gate  # several minutes

The stages it runs, in order, are listed in docs/quality-gate.md — deliberately in one place only. That list drifted apart across three copies while this PR was in review, which is the argument for not making a fourth. npm run local:gate prints each stage as it starts, so the running command is the other reliable answer.

It is a strict superset of GitHub CI (which additionally runs npm install, and runs coverage as a parallel job). So the direction that matters holds: passing local:gate locally means CI's gates will pass. The reverse does not.

⚠️ There is no npm run ci. The gate was renamed to local:gate (#2146) precisely because npm ci is a built-in that clean-installs from the lockfile and does not run this script. npm run ci now fails with npm's missing-script error.

Verify by exit code, not by grepping output

Prettier failures are [warn] lines that match no obvious failure pattern, so a grep-based check reports success on a red run. Capture the status:

npm run local:gate; echo "EXIT=$?"

⚠️ If you run it as a background task, the harness's "exit code 0" notification describes the wrapper, not the gate — read the EXIT= line.

Background it and then wait for that notification — do not spend turns watching it. The gate takes several minutes, and re-running tail or an echo ok once per turn until it lands tells you nothing the completion notification would not have; see Waiting on long-running work. Waiting out one run this way cost ~80 consecutive no-op turns on #2250.

Diagnosing a failing stage

verify:format-coverage / format:check

Something isn't formatted, or a tracked source file is covered by no format:check glob. Run the root npm run format (it covers core/, scripts/, the shared surface, and every client) — not a single client's.

verify:typecheck-coverage

A tracked .ts/.tsx/.mts/.cts lands in no tsconfig project. Usually a new top-level file in a client whose build config roots at ./src; add it to that client's tsconfig.test.json (or the src config's include).

verify:skills / verify:skills:cli

A .claude/skills manifest does not parse, declares no invocation mode, or a model-invoked skill is missing its eval cases. verify:skills:cli is the authoritative validator and fetches a pinned CLI over the network if you have none installed — so it is also the one stage that will fail offline.

verify:dep-lockstep

A dependency reaching one tsc program from two installs resolves to two versions. Align the versions — bump it in every install that declares it. Do not raise the heap with --max-old-space-size; that hides the class rather than fixing it.

verify:test-timeouts

A Vitest project resolves to a wall-clock budget nobody stated, a web setup file stopped configuring Testing Library's asyncUtilTimeout, or someone added a retry. The shared values live in vitest.shared.mts (TIMEOUTS / INTEGRATION_TIMEOUTS) and every project spreads one of them — so raise a budget there, not with a per-suite }, 30_000) argument, which only moves the one site and leaves every future file on the default. A per-suite raise is right only where the work is genuinely different (real cross-process lock contention, a full OAuth round trip); say so at the site. retry stays unset: it turns a load-induced red into a silent green on the only pre-push gate here.

A failing test is a different problem from a budget — read the failure before reaching for a number. An assertion that races is #1596's class and is fixed with fake timers or an awaited condition, not with headroom.

lint

There is no warning tier — every lint script runs --max-warnings 0, so a warning fails exactly as an error does. Fix the finding; don't widen a globalIgnores or drop the rule. If a rule genuinely must be waived on a line, use its inline disable comment with a one-line justification.

typecheck passes locally but CI's tsc -b fails

⚠️ The incremental cache hides type errors. Re-run with tsc -b --force, or in a fresh worktree.

coverage

The per-file gate is ≥90 on all four dimensions (lines, statements, functions, branches). A genuinely unreachable branch is annotated at the source with a justified /* v8 ignore … -- <reason> */, never waved through by lowering the gate. See /testing for the acceptable reasons.

Also check the file is inside a gated directory — the web coverage include is a whitelist, so a module placed outside it falls out of the gate silently.

smoke:web*

⚠️ An orphaned prod web server from a previous run fakes a rejection. It answers the readiness probe with its token, and the deep link comes back data-deeplink="rejected" with no error. Assert the port is free before blaming the change.

local:storybook

⚠️ In a worktree with a symlinked node_modules every story file fails on Vite's fs.allow. Do a real npm install in the worktree.

Everything times out at once

⚠️ Two concurrent npm run local:gate runs starve each other — ~326 tests time out at 5s. Run one at a time. (A pgrep -f "npm run local:gate" wait loop matches itself and never exits.)

Local-only steps

Two stages have no GitHub CI counterpart, each deliberately:

  • smoke:web:firefox — the three browser-driven web smokes again under Firefox. Trialled as a CI job and removed (#2086): across a dozen runs it never disagreed with Chromium, and playwright install --with-deps carries a real flake surface. Kept in front of a human about to push instead.
  • smoke: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.

A guard (scripts/lib/workflow-gate.mjs, run by npm run test:scripts) fails the suite if a workflow invokes a local:* script, a non-Chromium engine pass, or sets SMOKE_BROWSER to anything but a literal chromium. Don't work around it — the split is the design.

Publish-only check

npm run pack:verify builds, packs the real tarball, installs it into a clean throwaway consumer, and drives the installed mcp-inspector bin end to end. It needs network access, so it is a local/release check and is in neither local:gate nor CI. Run it when touching packaging (the files allowlist, a bundler external list, anything read from disk at runtime).