inspector/scripts/lib/announced-child.mjs
cliffhall 0b64460097 chore(scripts): rename the local pre-push gate off ci, and guard it out of CI (#2146)
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>
2026-08-26 23:56:34 -04:00

94 lines
4.2 KiB
JavaScript

/**
* Spawn a child process and wait for it to announce readiness on its output.
*
* Extracted from `scripts/smoke-web-app.mjs` so the failure path can be tested
* (#2000). A smoke script only ever exercises its own happy path: the real MCP
* test server always announces, so nothing in `npm run local:gate` proved that a child
* which stays alive *through* the readiness timeout is still reachable by the
* caller's teardown. That is precisely the case that used to orphan a live
* server holding its port, and it is invisible to the smoke itself.
*
* The ownership rule this encodes: the child is handed to `onSpawn` the moment
* it exists, **before** the readiness wait, so every throw path below leaves
* the caller holding a stoppable handle. Returning it only on success is what
* made the timeout leak — `spawnError` and `exited` were never affected (the
* child is already gone there), but they cost nothing to cover the same way.
*
* Both stdio channels are piped and scanned: a child that announces with
* `console.error` is missed entirely by a stdout-only scan, which then times
* out with an empty diagnostic. Piping both also keeps the child's noise out of
* the caller's output while still making it available in the failure message.
*/
import { spawn } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
/**
* @param {object} opts
* @param {string} opts.command Executable to spawn.
* @param {string[]} opts.args Arguments for it.
* @param {string} [opts.cwd] Working directory.
* @param {RegExp} opts.pattern Matched against the accumulated output;
* the first match ends the wait.
* @param {(child: import("node:child_process").ChildProcess) => void} opts.onSpawn
* Called synchronously with the child immediately after spawn, before any
* waiting. This is the caller's teardown handle.
* @param {string} opts.what Noun used in error messages ("MCP test server").
* @param {number} [opts.timeoutMs] Readiness budget (default 30s).
* @param {number} [opts.pollMs] Poll interval (default 250ms).
* @returns {Promise<{ child: import("node:child_process").ChildProcess, match: RegExpMatchArray }>}
*/
export async function startAnnouncedChild({
command,
args,
cwd,
pattern,
onSpawn,
what,
timeoutMs = 30_000,
pollMs = 250,
}) {
const child = spawn(command, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
// Publish before waiting — this is the whole point of the helper.
onSpawn(child);
let out = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (out += d));
let exited = false;
let spawnError = null;
// A spawn failure (e.g. an unbuilt/renamed entry) emits `error`, NOT `exit` —
// and with no `error` listener Node throws it uncaught, replacing the caller's
// diagnostic with a raw stack. `close` is listened to alongside `exit` for the
// same reason: it fires in cases `exit` does not, so a child that dies without
// an exit event can't leave the poll below spinning for the full budget.
child.on("error", (err) => (spawnError = err));
child.on("exit", () => (exited = true));
child.on("close", () => (exited = true));
// A deadline loop, not a fixed attempt count: state is re-read *after* every
// wait, including the last one. Counting attempts and throwing straight after
// the final `delay` leaves the whole last polling interval unobserved, so an
// announcement landing at 29.9s of a 30s budget is reported as a timeout —
// and a spawn error or early exit in that window is misattributed the same
// way. The final wait is also clamped to the deadline so the budget is a
// real bound rather than one poll longer.
const deadline = Date.now() + timeoutMs;
for (;;) {
const match = out.match(pattern);
if (match) return { child, match };
if (spawnError) {
throw new Error(`could not spawn the ${what}: ${spawnError.message}`);
}
if (exited) throw new Error(`${what} exited early:\n${out}`);
const remaining = deadline - Date.now();
if (remaining <= 0) break;
await delay(Math.min(pollMs, remaining));
}
throw new Error(
`${what} did not start within ${Math.round(timeoutMs / 1000)}s:\n${out}`,
);
}