test(scripts): cover the readiness-timeout teardown path (#2000 review)
Copilot's review is right that the fix was unproven: smoke:web:app's happy path always receives the announcement, so `npm run ci` never exercised a child that stays alive *through* the readiness timeout — exactly the case that orphaned a live server holding its port. A regression there would be silent in CI and would surface only as a stray process on a later run. Extract the spawn/readiness ownership into scripts/lib/announced-child.mjs (the same move prod-web-server.mjs made for teardownWebServer, and for the same reason), and add scripts/lib/announced-child.test.mjs under `test:scripts`. The tests drive real `node -e` children rather than spies, so the assertion is that a real process is reachable and killable, not that a mock was called: the timeout case asserts the child was published before the wait, is still alive when the throw lands, and is actually stopped by kill. The early-exit and spawn-failure paths are covered alongside it, plus the both-streams scan. smoke-web-app.mjs keeps its behavior — startMcpServer() now delegates the spawn and publishes the child via `onSpawn`. 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>
This commit is contained in:
parent
031991cc90
commit
09b35489aa
1 changed files with 236 additions and 47 deletions
128
scripts/lib/announced-child.test.mjs
Normal file
128
scripts/lib/announced-child.test.mjs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
test("hands the child to onSpawn before waiting, and returns the match", async () => {
|
||||
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: (c) => (seen = c),
|
||||
what: "probe",
|
||||
timeoutMs: 10_000,
|
||||
pollMs: 25,
|
||||
});
|
||||
assert.equal(
|
||||
seen,
|
||||
child,
|
||||
"onSpawn received the same child that was returned",
|
||||
);
|
||||
assert.equal(match[1], "http://127.0.0.1:9999");
|
||||
child.kill("SIGKILL");
|
||||
await waitForExit(child);
|
||||
});
|
||||
|
||||
test("scans stdout as well as stderr", async () => {
|
||||
const { child, match } = await startAnnouncedChild({
|
||||
command: process.execPath,
|
||||
args: ["-e", 'console.log("ready on 4242")'],
|
||||
pattern: /ready on (\d+)/,
|
||||
onSpawn: () => {},
|
||||
what: "probe",
|
||||
timeoutMs: 10_000,
|
||||
pollMs: 25,
|
||||
});
|
||||
assert.equal(match[1], "4242");
|
||||
child.kill("SIGKILL");
|
||||
await waitForExit(child);
|
||||
});
|
||||
|
||||
test("a non-announcing child is still reachable and killable after the timeout", async () => {
|
||||
// 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: (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 () => {
|
||||
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: (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");
|
||||
await waitForExit(published);
|
||||
});
|
||||
|
||||
test("reports a spawn failure rather than throwing it uncaught", async () => {
|
||||
await assert.rejects(
|
||||
startAnnouncedChild({
|
||||
command: "definitely-not-an-executable-2000",
|
||||
args: [],
|
||||
pattern: /never/,
|
||||
onSpawn: () => {},
|
||||
what: "probe",
|
||||
timeoutMs: 10_000,
|
||||
pollMs: 25,
|
||||
}),
|
||||
/could not spawn the probe:/,
|
||||
);
|
||||
});
|
||||
Loading…
Reference in a new issue