The root `ci` script was never what runs in GitHub CI, and the name worked
against us three ways: it collides with the built-in `npm ci` (which does
something else entirely and is one keystroke away), it invites the
cross-engine passes into a workflow by looking like the thing a workflow
should call, and it blurs "GitHub CI" and "the local gate" in every doc
that names it.
- Rename `ci` → `local:gate` and `ci:storybook` → `local:storybook`, with
no back-compat alias: `npm run ci` now fails with npm's missing-script
error, which prints the available scripts.
- Add `scripts/lib/workflow-gate.mjs` + its `test:scripts` suite, which
runs over the repo's real `.github/workflows/**` and fails on an
invocation of a `local:*` script, of a non-Chromium engine pass (derived
from SUPPORTED_BROWSERS), or of `smoke:web:engine`, or on `SMOKE_BROWSER`
set to anything but a literal `chromium`. It does not forbid
`npm run smoke`, `smoke:web:chromium` or `smoke:tui` — those belong in CI
and are there today. Comments are not scanned, so the workflow can still
explain itself. This is the durable half; the rest is prose.
- Document the two-tier model once, in the root README ("Two tiers: GitHub
CI and the local gate"), and point the other docs at it rather than
restating the split.
- Sweep the ~40 stale references across README, AGENTS.md,
.github/copilot-instructions.md, the client READMEs, the roadmap doc and
the script headers. AGENTS.md and copilot-instructions.md change
together, per the mirror rule.
No gate changes what it actually runs.
Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
163 lines
5.8 KiB
JavaScript
163 lines
5.8 KiB
JavaScript
/**
|
|
* Tests for the spawn/readiness ownership helper (#2000).
|
|
*
|
|
* The invariant under test is the one a smoke script can never check itself:
|
|
* a child that stays alive *through* the readiness timeout must still be
|
|
* reachable by the caller's teardown. `smoke:web:app`'s happy path always gets
|
|
* the announcement, so a regression here would be silent in `npm run local:gate` and
|
|
* would surface only as a stray process holding a port on a later run.
|
|
*
|
|
* Every case drives a real `node -e` child rather than a spy, so the assertion
|
|
* is about a real process being killable, not about a mock's call log.
|
|
*/
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { startAnnouncedChild } from "./announced-child.mjs";
|
|
|
|
/** Wait for a real child to exit (or resolve immediately if it already has). */
|
|
function waitForExit(child) {
|
|
if (child.exitCode !== null || child.signalCode !== null)
|
|
return Promise.resolve();
|
|
return new Promise((resolve) => child.once("close", resolve));
|
|
}
|
|
|
|
/**
|
|
* Build an `onSpawn` that registers teardown the moment the child exists, so it
|
|
* is killed on every path out of the test — a failed assertion included.
|
|
*
|
|
* This is the helper's own ownership rule applied to the suite: killing only on
|
|
* the success path would leak a 60-second child into the runner precisely when
|
|
* the regression being guarded has come back, which is the one run where a leak
|
|
* matters most. Killing an already-exited child is a no-op, so the early-exit
|
|
* cases pay nothing for it.
|
|
*/
|
|
function killOnTeardown(t, capture = () => {}) {
|
|
return (child) => {
|
|
capture(child);
|
|
t.after(async () => {
|
|
child.kill("SIGKILL");
|
|
await waitForExit(child);
|
|
});
|
|
};
|
|
}
|
|
|
|
test("hands the child to onSpawn before waiting, and returns the match", async (t) => {
|
|
let seen = null;
|
|
const { child, match } = await startAnnouncedChild({
|
|
command: process.execPath,
|
|
args: ["-e", 'console.error("listening at http://127.0.0.1:9999")'],
|
|
pattern: /listening at (http:\/\/\S+)/i,
|
|
onSpawn: killOnTeardown(t, (c) => (seen = c)),
|
|
what: "probe",
|
|
timeoutMs: 10_000,
|
|
pollMs: 25,
|
|
});
|
|
assert.equal(seen, child, "onSpawn got the child that was returned");
|
|
assert.equal(match[1], "http://127.0.0.1:9999");
|
|
});
|
|
|
|
test("scans stdout as well as stderr", async (t) => {
|
|
const { match } = await startAnnouncedChild({
|
|
command: process.execPath,
|
|
args: ["-e", 'console.log("ready on 4242")'],
|
|
pattern: /ready on (\d+)/,
|
|
onSpawn: killOnTeardown(t),
|
|
what: "probe",
|
|
timeoutMs: 10_000,
|
|
pollMs: 25,
|
|
});
|
|
assert.equal(match[1], "4242");
|
|
});
|
|
|
|
test("sees an announcement that lands inside the final polling interval", async (t) => {
|
|
// The off-by-one this guards: a fixed attempt count checks the output and
|
|
// then sleeps, so nothing ever observes the last interval — an announcement
|
|
// at 29.9s of a 30s budget reads as a timeout.
|
|
//
|
|
// Setting pollMs === timeoutMs makes that deterministic rather than a race:
|
|
// the attempt-counted loop gets exactly one check, at t≈0 before the child
|
|
// has even finished starting, and then throws. The deadline loop re-reads
|
|
// after the wait, so *any* announcement before the deadline is seen. The
|
|
// child announces immediately, ~2s inside the window on either side, so a
|
|
// loaded runner cannot flip the outcome. (Verified: this fails against the
|
|
// attempt-counted loop and passes against the deadline loop.)
|
|
const { match } = await startAnnouncedChild({
|
|
command: process.execPath,
|
|
args: ["-e", 'console.error("listening at http://127.0.0.1:8080")'],
|
|
pattern: /listening at (http:\/\/\S+)/i,
|
|
onSpawn: killOnTeardown(t),
|
|
what: "probe",
|
|
timeoutMs: 2000,
|
|
pollMs: 2000,
|
|
});
|
|
assert.equal(match[1], "http://127.0.0.1:8080");
|
|
});
|
|
|
|
test("a non-announcing child is still reachable and killable after the timeout", async (t) => {
|
|
// The regression: a child that is alive but never announces. The throw must
|
|
// not be the only thing that happens — the caller must already hold the
|
|
// handle, or `process.exit(1)` leaves this running.
|
|
let published = null;
|
|
await assert.rejects(
|
|
startAnnouncedChild({
|
|
// Sleeps well past the budget and prints nothing matching.
|
|
command: process.execPath,
|
|
args: ["-e", "setTimeout(() => {}, 60000)"],
|
|
pattern: /never going to match/,
|
|
onSpawn: killOnTeardown(t, (c) => (published = c)),
|
|
what: "probe",
|
|
timeoutMs: 300,
|
|
pollMs: 25,
|
|
}),
|
|
/probe did not start within/,
|
|
);
|
|
|
|
assert.ok(published, "the child was published before the readiness wait");
|
|
assert.equal(
|
|
published.exitCode,
|
|
null,
|
|
"and it is still alive, as in the bug",
|
|
);
|
|
|
|
published.kill("SIGKILL");
|
|
await waitForExit(published);
|
|
assert.ok(
|
|
published.exitCode !== null || published.signalCode !== null,
|
|
"teardown could actually stop it",
|
|
);
|
|
});
|
|
|
|
test("reports a child that exits before announcing, with its output", async (t) => {
|
|
let published = null;
|
|
await assert.rejects(
|
|
startAnnouncedChild({
|
|
command: process.execPath,
|
|
args: ["-e", 'console.error("boom: bad config"); process.exit(3)'],
|
|
pattern: /never going to match/,
|
|
onSpawn: killOnTeardown(t, (c) => (published = c)),
|
|
what: "probe",
|
|
timeoutMs: 10_000,
|
|
pollMs: 25,
|
|
}),
|
|
(err) =>
|
|
/probe exited early/.test(err.message) &&
|
|
/boom: bad config/.test(err.message),
|
|
);
|
|
assert.ok(published, "published even on the early-exit path");
|
|
});
|
|
|
|
test("reports a spawn failure rather than throwing it uncaught", async (t) => {
|
|
await assert.rejects(
|
|
startAnnouncedChild({
|
|
command: "definitely-not-an-executable-2000",
|
|
args: [],
|
|
pattern: /never/,
|
|
onSpawn: killOnTeardown(t),
|
|
what: "probe",
|
|
timeoutMs: 10_000,
|
|
pollMs: 25,
|
|
}),
|
|
/could not spawn the probe:/,
|
|
);
|
|
});
|