chore: run each client's test suite once in local:gate, not twice (#2341)
`local:gate` ran every client's unit suite bare inside `validate` and again instrumented inside `coverage` — ~80s of a ~370s run re-executing exactly the files the coverage pass runs a few minutes later. CI pays nothing for that duplication because its two jobs run in parallel; the gate runs serially, so it paid all of it. The gate's first stage is now `local:validate`: the same `validate:guards` and `validate:core`, then each client's new `check` script (`format:check` + `lint` + `typecheck` + `build`) instead of its `validate`, which is now literally `check && test`. `npm run validate` and both CI jobs are unchanged; `local:validate` sits in the `local:` namespace so the workflow guard keeps it out of CI by construction. The "strict superset" claim in AGENTS.md is reworded to what is now true — every CI *check*, not every CI *invocation* — with the reasoning: `@vitest/coverage-v8` reads V8's precise-coverage profiler and rewrites no source, so the instrumented pass runs byte-identical code and is only slower, the stricter direction for the failure class this repo sees. Measured on the same worktree: 370s -> 262s wall (EXIT=0 both), the four removed legs summing to 78.5s in the baseline run's own profile. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
856729fb4b
commit
fe8b039030
2 changed files with 163 additions and 31 deletions
|
|
@ -7,9 +7,11 @@
|
|||
* two verify gates, `smoke` and the Storybook tests. `npm run smoke` covers
|
||||
* `smoke:launcher`, `smoke:cli`, `smoke:tui`, `smoke:web` and
|
||||
* `smoke:web:chromium` — all of which BELONG there.
|
||||
* - **`npm run local:gate`**, the pre-push gate, is a strict superset: it adds
|
||||
* the **Firefox** engine pass, and `smoke:tui` really runs there rather than
|
||||
* self-skipping. WebKit is on demand and belongs to neither tier.
|
||||
* - **`npm run local:gate`**, the pre-push gate, runs every check CI runs and
|
||||
* adds the **Firefox** engine pass, and `smoke:tui` really runs there rather
|
||||
* than self-skipping. WebKit is on demand and belongs to neither tier. It
|
||||
* runs each client's unit suite once (instrumented, under `coverage`) via
|
||||
* `local:validate`, where CI's two parallel jobs run it twice (#2341).
|
||||
*
|
||||
* The narrow thing that must never reach CI is a **non-Chromium engine pass**.
|
||||
* `smoke:web:firefox` was placed in the local gate on purpose (#2086,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
findWorkflowViolations,
|
||||
formatWorkflowViolations,
|
||||
} from "./workflow-gate.mjs";
|
||||
import { reachableScripts } from "./npm-scripts.mjs";
|
||||
|
||||
const repoRoot = join(import.meta.dirname, "..", "..");
|
||||
const workflowDir = join(repoRoot, ".github", "workflows");
|
||||
|
|
@ -609,6 +610,106 @@ describe("the gate's name", () => {
|
|||
assert.match(scripts["local:gate"], /local:storybook/);
|
||||
});
|
||||
|
||||
describe("runs each client's test suite once, not twice (#2341)", () => {
|
||||
// CI runs each client's unit suite bare (inside `validate`) and again
|
||||
// instrumented (inside `coverage`) on two PARALLEL runners, so the second
|
||||
// pass is free there. The gate runs serially, where the bare pass was ~80s
|
||||
// of a ~370s run re-executing exactly the files `coverage` runs a few
|
||||
// minutes later. So the gate's first stage is `local:validate` — `validate`
|
||||
// minus every client's `test` leg — and these pin the three properties
|
||||
// that keep it honest: the gate no longer reaches a client's bare `test`,
|
||||
// it still reaches every non-test check `validate` reaches, and `validate`
|
||||
// itself (CI's inner loop) is untouched.
|
||||
const clients = ["web", "cli", "tui", "launcher"];
|
||||
const clientScripts = Object.fromEntries(
|
||||
clients.map((c) => [
|
||||
c,
|
||||
JSON.parse(
|
||||
readFileSync(join(repoRoot, "clients", c, "package.json"), "utf8"),
|
||||
).scripts,
|
||||
]),
|
||||
);
|
||||
|
||||
it("starts from `local:validate`, and never from `validate`", () => {
|
||||
assert.match(scripts["local:gate"], /^npm run local:validate && /);
|
||||
assert.doesNotMatch(scripts["local:gate"], /\brun validate(?=$|[\s&;])/);
|
||||
// `local:validate` must not reach a client's `validate` either — that is
|
||||
// the one door through which the bare `test` leg would come back.
|
||||
const reached = reachableScripts(scripts, "local:validate");
|
||||
for (const c of clients)
|
||||
assert.ok(
|
||||
!reached.has(`validate:${c}`),
|
||||
`local:validate must not reach validate:${c}`,
|
||||
);
|
||||
assert.match(
|
||||
scripts["local:validate"],
|
||||
/\brun check(?=$|[\s&;])/,
|
||||
"local:validate must run each client's `check`",
|
||||
);
|
||||
for (const c of clients)
|
||||
assert.match(
|
||||
scripts["local:validate"],
|
||||
new RegExp(`(?:cd (?:\\.\\./|clients/)${c}|--prefix clients/${c})`),
|
||||
`local:validate must visit clients/${c}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("shares the guards and `validate:core` with `validate`", () => {
|
||||
// One list of durable guards, reached by both entry points — so a guard
|
||||
// added to `validate:guards` lands in CI and in the gate together.
|
||||
const gate = reachableScripts(scripts, "local:validate");
|
||||
const inner = reachableScripts(scripts, "validate");
|
||||
for (const name of ["validate:guards", "validate:core"]) {
|
||||
assert.ok(gate.has(name), `local:validate must reach ${name}`);
|
||||
assert.ok(inner.has(name), `validate must reach ${name}`);
|
||||
}
|
||||
// Everything `validate` reaches at the root that is not a client
|
||||
// delegation is reached by the gate too.
|
||||
for (const name of inner)
|
||||
if (
|
||||
name !== "validate" &&
|
||||
!/^validate:(web|cli|tui|launcher)$/.test(name)
|
||||
)
|
||||
assert.ok(gate.has(name), `local:validate must reach ${name}`);
|
||||
});
|
||||
|
||||
it("each client's `validate` is `check` plus `test`, and `check` runs no tests", () => {
|
||||
for (const c of clients) {
|
||||
const s = clientScripts[c];
|
||||
assert.equal(
|
||||
s.validate,
|
||||
"npm run check && npm run test",
|
||||
`clients/${c} validate`,
|
||||
);
|
||||
const fromCheck = reachableScripts(s, "check");
|
||||
for (const name of fromCheck)
|
||||
assert.doesNotMatch(
|
||||
name,
|
||||
/^(pre|post)?test(:|$)/,
|
||||
`clients/${c} check must not reach ${name}`,
|
||||
);
|
||||
assert.ok(
|
||||
!/\bvitest\b/.test([...fromCheck].map((n) => s[n]).join(" ")),
|
||||
`clients/${c} check must not invoke vitest`,
|
||||
);
|
||||
// And `check` is the whole of `validate` minus the test leg: what
|
||||
// `validate` reaches is exactly itself, plus what `check` reaches, plus
|
||||
// what `test` reaches (its `pretest` build preambles included).
|
||||
assert.deepEqual(
|
||||
[...reachableScripts(s, "validate")].sort(),
|
||||
[
|
||||
...new Set([
|
||||
"validate",
|
||||
...fromCheck,
|
||||
...reachableScripts(s, "test"),
|
||||
]),
|
||||
].sort(),
|
||||
`clients/${c} validate must reach exactly check + test`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("has no `ci` alias, not even a deprecated one", () => {
|
||||
// The rename is only worth anything if the old name is GONE (#2146): an
|
||||
// alias preserves exactly the association being scrubbed, and leaves
|
||||
|
|
|
|||
Loading…
Reference in a new issue