inspector/scripts/lib/announced-child.test.mjs
cliffhall 6b4bf68b8b fix(scripts): observe the final polling interval before declaring a timeout
Copilot's suppressed round-3 comment is right. The attempt-counted loop read
the output and then slept, so nothing ever observed the last interval: an
announcement arriving at 29.9s of a 30s budget was reported as a timeout, and
a spawn error or early exit landing in that window was misattributed the same
way. Inherited from the original smoke, but wrong either way.

Replace it with a deadline loop that re-reads state after every wait, the last
one included, and clamp the final wait to the deadline so the budget is a real
bound rather than one poll longer.

The regression test sets pollMs === timeoutMs, which makes the distinction
deterministic rather than a race: the attempt-counted loop gets exactly one
check, at t≈0 before the child has finished starting, then throws; the deadline
loop re-reads after the wait and sees the announcement. The child announces
immediately, ~2s inside the window, so a loaded runner can't flip it. Verified
both directions — the test fails against the old loop and passes against the
new one — and `npm run test:scripts` was repeated to confirm it is stable under
the full 116-test parallel run (an earlier margin-based version of this test
was not).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 13:02:38 -04:00

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 ci` 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:/,
);
});