fix(scripts): make smoke:tui assert the TUI survives its first frame (#2147)
`smoke:tui` spawned the launcher with `stdio: ["ignore", …]`, waited for
"MCP Servers" in the output, printed OK, and SIGTERMed. It never looked at
the child again.
The child was already dead. Ink mounts `useInput`, `useInput` needs raw
mode, and raw mode is a property of the file descriptor — with stdin on
/dev/null the TUI painted one frame and exited 1 about 40ms later with
"Raw mode is not supported on the current process.stdin". Measured here:
first frame at 396ms, exit 1 at 433ms. The smoke won that race and
reported success, so what it asserted was *first paint*, not survival —
contradicting its own header ("boots and renders without crashing").
This is not environment-specific: `stdio: ["ignore", …]` makes the child's
stdin /dev/null whether or not the developer's terminal is a TTY, so this
smoke has never verified a running TUI on any machine. And the race cut
both ways — an `exit` processed first failed the run, which reads as flake
rather than as the standing defect.
Two pieces:
- `scripts/lib/pty.mjs` gives the child a real terminal via `script(1)`,
no dependency. The three flavors are not interchangeable (BSD takes
argv; util-linux takes one shell string plus `-e`; busybox that string
without `-e`), so the invocation is built and unit-tested rather than
written inline. A platform with no `script(1)` skips loudly instead of
running without one, which would be a guaranteed failure rather than a
weaker check.
- `scripts/lib/render-smoke.mjs` is the fix: the child must still be
running `surviveMs` (default 2s) after first paint. Without that, a
future harness change reintroduces the same false green and nothing
notices — which is why it lives in a module `test:scripts` drives
against a stub that paints the marker and immediately exits. Today's
harness passes that stub; this one fails it, with a diagnostic that
distinguishes "died after painting" from "died before painting".
The smoke additionally fails if the raw-mode error appears in a passing
run, so a future Ink that degrades to a warning instead of throwing can't
ship a TUI with no keyboard input past this gate.
Verified end to end: with a `script` shim that allocates no pty, the smoke
now fails with "it painted one frame, it did not run"; with the real one
the TUI is still running 2s after its first frame and never logs the
raw-mode error.
The `process.env.CI` skip stays, but its comment is corrected: the PTY
removes the technical blocker it used to cite, so the smoke is local-only
by decision now, not by capability. Whether it joins GitHub CI is out of
scope here (#2146 is keeping the other local-only smokes out on purpose).
Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
5c26da7433
commit
b6baa30d7b
4 changed files with 757 additions and 192 deletions
136
scripts/lib/pty.mjs
Normal file
136
scripts/lib/pty.mjs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Wrap a command so it runs attached to a pseudoterminal (#2147).
|
||||
*
|
||||
* `smoke:tui` spawns the Ink TUI with `stdio: ["ignore", …]`, which makes the
|
||||
* child's stdin `/dev/null`. Ink mounts `useInput`, `useInput` needs raw mode,
|
||||
* and raw mode is a property of the *file descriptor* — so the child threw
|
||||
* "Raw mode is not supported on the current process.stdin" and exited 1 about
|
||||
* 40ms after painting its first frame, on every machine, TTY or not. A parent
|
||||
* cannot assert raw mode on a child's behalf; the fd has to be a terminal.
|
||||
*
|
||||
* `script(1)` allocates one, with no dependency to install. Measured here on
|
||||
* darwin: without it the TUI paints at ~400ms and is dead at ~440ms; with it
|
||||
* the same TUI is still running at 6s and never logs the raw-mode error.
|
||||
*
|
||||
* The invocation differs by flavor, which is why this is a module rather than
|
||||
* a string literal at the call site:
|
||||
*
|
||||
* BSD (macOS) script -q /dev/null <cmd> <args...> argv, no shell
|
||||
* util-linux script -qec "<cmd args...>" /dev/null ONE shell string
|
||||
* busybox script -qc "<cmd args...>" /dev/null as above, no -e
|
||||
*
|
||||
* The `-c` flavors take a single command *string* that the child shell parses,
|
||||
* so every word has to be quoted on the way in — an unquoted temp path with a
|
||||
* space would otherwise split into two arguments.
|
||||
*
|
||||
* `-e` (util-linux) makes `script` exit with the child's status rather than its
|
||||
* own. busybox has no such flag, so under it a crashed child reports 0. That is
|
||||
* survivable and deliberately not worked around: the smoke's assertion is the
|
||||
* child's `exit` *event*, not its code — the code only sharpens the diagnostic.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
/** Platforms whose `script` takes the BSD argv form. */
|
||||
const BSD_PLATFORMS = new Set(["darwin", "freebsd", "openbsd", "netbsd"]);
|
||||
|
||||
/**
|
||||
* Quote one argument for a POSIX shell command string.
|
||||
*
|
||||
* Single quotes are literal for everything except a single quote itself, which
|
||||
* has to leave the quoted run, emit an escaped quote, and re-enter it.
|
||||
*
|
||||
* @param {string} arg
|
||||
* @returns {string}
|
||||
*/
|
||||
export function shellQuote(arg) {
|
||||
return `'${String(arg).replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which `script` flavor to build for.
|
||||
*
|
||||
* The version output is consulted first because it is the only *evidence*:
|
||||
* "linux" does not imply util-linux (Alpine ships busybox, whose `script`
|
||||
* rejects `-e`). Platform is the fallback for the case where `script --version`
|
||||
* tells us nothing, which is itself informative — BSD `script` has no
|
||||
* `--version` and fails the probe.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.platform `process.platform`.
|
||||
* @param {string} [opts.versionOutput] Combined stdout+stderr of `script --version`.
|
||||
* @returns {"bsd" | "util-linux" | "busybox" | null} null when unsupported.
|
||||
*/
|
||||
export function scriptFlavorFor({ platform, versionOutput = "" }) {
|
||||
if (/util-linux/i.test(versionOutput)) return "util-linux";
|
||||
if (/busybox/i.test(versionOutput)) return "busybox";
|
||||
if (BSD_PLATFORMS.has(platform)) return "bsd";
|
||||
// Linux with an unidentifiable `script` is overwhelmingly util-linux; guess
|
||||
// it rather than skip the smoke. A wrong guess fails loudly with `script`'s
|
||||
// own usage error in the captured output, which is diagnosable — silently
|
||||
// skipping is the outcome this issue exists to stop rewarding.
|
||||
if (platform === "linux") return "util-linux";
|
||||
// win32 and anything else: no `script(1)`. `node-pty` is the portable answer
|
||||
// if this smoke ever has to run there.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the PTY-wrapped spawn arguments for a command.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.command
|
||||
* @param {string[]} [opts.args]
|
||||
* @param {"bsd" | "util-linux" | "busybox"} opts.flavor
|
||||
* @returns {{ command: string, args: string[] }}
|
||||
*/
|
||||
export function ptyCommand({ command, args = [], flavor }) {
|
||||
if (flavor === "bsd") {
|
||||
return { command: "script", args: ["-q", "/dev/null", command, ...args] };
|
||||
}
|
||||
if (flavor === "util-linux" || flavor === "busybox") {
|
||||
const line = [command, ...args].map(shellQuote).join(" ");
|
||||
const flags = flavor === "util-linux" ? "-qec" : "-qc";
|
||||
return { command: "script", args: [flags, line, "/dev/null"] };
|
||||
}
|
||||
throw new Error(`unknown script(1) flavor: ${flavor}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the local `script(1)` for its flavor.
|
||||
*
|
||||
* @param {(cmd: string, args: string[]) => { stdout?: string, stderr?: string }} [runner]
|
||||
* Injected for tests; defaults to a real `spawnSync`.
|
||||
* @returns {string} Combined output, or "" if `script` could not be run at all.
|
||||
*/
|
||||
export function probeScriptVersion(
|
||||
runner = (cmd, args) =>
|
||||
spawnSync(cmd, args, { encoding: "utf8", timeout: 5000 }),
|
||||
) {
|
||||
try {
|
||||
const r = runner("script", ["--version"]) ?? {};
|
||||
return `${r.stdout ?? ""}${r.stderr ?? ""}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PTY wrapper for this machine, or null if none is available.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.platform]
|
||||
* @param {() => string} [opts.probe]
|
||||
* @returns {{ flavor: string, wrap: (spec: { command: string, args?: string[] }) => { command: string, args: string[] } } | null}
|
||||
*/
|
||||
export function resolvePtyWrapper({
|
||||
platform = process.platform,
|
||||
probe = probeScriptVersion,
|
||||
} = {}) {
|
||||
const flavor = scriptFlavorFor({ platform, versionOutput: probe() });
|
||||
if (!flavor) return null;
|
||||
return {
|
||||
flavor,
|
||||
wrap: ({ command, args = [] }) => ptyCommand({ command, args, flavor }),
|
||||
};
|
||||
}
|
||||
160
scripts/lib/pty.test.mjs
Normal file
160
scripts/lib/pty.test.mjs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
ptyCommand,
|
||||
probeScriptVersion,
|
||||
resolvePtyWrapper,
|
||||
scriptFlavorFor,
|
||||
shellQuote,
|
||||
} from "./pty.mjs";
|
||||
|
||||
// The flavors are not interchangeable: BSD `script` takes argv after the
|
||||
// typescript file, util-linux takes ONE shell string before it, and busybox
|
||||
// takes that string without `-e`. Getting it wrong is not a subtle failure —
|
||||
// `script` refuses to start, and the smoke reports it as a TUI crash.
|
||||
test("scriptFlavorFor prefers the version probe over the platform", () => {
|
||||
assert.equal(
|
||||
scriptFlavorFor({
|
||||
platform: "linux",
|
||||
versionOutput: "script from util-linux 2.39.3",
|
||||
}),
|
||||
"util-linux",
|
||||
);
|
||||
assert.equal(
|
||||
scriptFlavorFor({
|
||||
platform: "linux",
|
||||
versionOutput: "BusyBox v1.36.1 (2024-01-01) multi-call binary.",
|
||||
}),
|
||||
"busybox",
|
||||
);
|
||||
// Alpine is the reason the probe exists at all: "linux" does not imply
|
||||
// util-linux, and busybox's `script` rejects `-e`.
|
||||
assert.notEqual(
|
||||
scriptFlavorFor({ platform: "linux", versionOutput: "BusyBox v1.36.1" }),
|
||||
"util-linux",
|
||||
);
|
||||
});
|
||||
|
||||
test("scriptFlavorFor falls back to the platform when the probe says nothing", () => {
|
||||
// BSD `script` has no --version, so failing the probe is itself the signal.
|
||||
assert.equal(scriptFlavorFor({ platform: "darwin" }), "bsd");
|
||||
assert.equal(scriptFlavorFor({ platform: "freebsd" }), "bsd");
|
||||
// An unidentifiable `script` on linux is overwhelmingly util-linux. Guess it:
|
||||
// a wrong guess fails loudly with `script`'s own usage error in the captured
|
||||
// output, whereas skipping is the silence this issue exists to stop.
|
||||
assert.equal(scriptFlavorFor({ platform: "linux" }), "util-linux");
|
||||
});
|
||||
|
||||
test("scriptFlavorFor returns null where there is no script(1)", () => {
|
||||
assert.equal(scriptFlavorFor({ platform: "win32" }), null);
|
||||
assert.equal(
|
||||
scriptFlavorFor({ platform: "win32", versionOutput: "not a thing" }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("ptyCommand builds the BSD argv form", () => {
|
||||
assert.deepEqual(
|
||||
ptyCommand({
|
||||
command: "/bin/node",
|
||||
args: ["app.js", "--x"],
|
||||
flavor: "bsd",
|
||||
}),
|
||||
{
|
||||
command: "script",
|
||||
args: ["-q", "/dev/null", "/bin/node", "app.js", "--x"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("ptyCommand builds the util-linux shell-string form with -e", () => {
|
||||
// -e is what makes `script` exit with the child's status instead of its own.
|
||||
assert.deepEqual(
|
||||
ptyCommand({
|
||||
command: "/bin/node",
|
||||
args: ["app.js"],
|
||||
flavor: "util-linux",
|
||||
}),
|
||||
{
|
||||
command: "script",
|
||||
args: ["-qec", "'/bin/node' 'app.js'", "/dev/null"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("ptyCommand omits -e for busybox, which has no such flag", () => {
|
||||
const { args } = ptyCommand({
|
||||
command: "node",
|
||||
args: ["app.js"],
|
||||
flavor: "busybox",
|
||||
});
|
||||
assert.equal(args[0], "-qc");
|
||||
assert.deepEqual(args, ["-qc", "'node' 'app.js'", "/dev/null"]);
|
||||
});
|
||||
|
||||
test("ptyCommand quotes arguments for the shell-string flavors", () => {
|
||||
// The smoke passes an mkdtemp path; a space in it would otherwise split into
|
||||
// two arguments inside the shell `script -c` runs.
|
||||
const { args } = ptyCommand({
|
||||
command: "/usr/local/my node/bin/node",
|
||||
args: ["/tmp/a dir/catalog.json", "it's-fine"],
|
||||
flavor: "util-linux",
|
||||
});
|
||||
assert.equal(
|
||||
args[1],
|
||||
`'/usr/local/my node/bin/node' '/tmp/a dir/catalog.json' 'it'\\''s-fine'`,
|
||||
);
|
||||
});
|
||||
|
||||
test("shellQuote survives an embedded single quote", () => {
|
||||
assert.equal(shellQuote("it's"), `'it'\\''s'`);
|
||||
assert.equal(shellQuote("plain"), "'plain'");
|
||||
});
|
||||
|
||||
test("probeScriptVersion combines both streams and never throws", () => {
|
||||
assert.equal(
|
||||
probeScriptVersion(() => ({ stdout: "out ", stderr: "err" })),
|
||||
"out err",
|
||||
);
|
||||
// BSD `script --version` errors; spawnSync can also return a bare object.
|
||||
assert.equal(
|
||||
probeScriptVersion(() => {
|
||||
throw new Error("ENOENT");
|
||||
}),
|
||||
"",
|
||||
);
|
||||
assert.equal(
|
||||
probeScriptVersion(() => ({})),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolvePtyWrapper wraps with the resolved flavor, or reports none", () => {
|
||||
const wrapper = resolvePtyWrapper({
|
||||
platform: "linux",
|
||||
probe: () => "script from util-linux 2.39.3",
|
||||
});
|
||||
assert.equal(wrapper.flavor, "util-linux");
|
||||
assert.deepEqual(wrapper.wrap({ command: "node", args: ["a.js"] }), {
|
||||
command: "script",
|
||||
args: ["-qec", "'node' 'a.js'", "/dev/null"],
|
||||
});
|
||||
// `args` is optional at the call site.
|
||||
assert.deepEqual(wrapper.wrap({ command: "node" }).args[1], "'node'");
|
||||
|
||||
assert.equal(resolvePtyWrapper({ platform: "win32", probe: () => "" }), null);
|
||||
});
|
||||
|
||||
test("resolvePtyWrapper resolves on this machine unless it is Windows", () => {
|
||||
// Not a tautology: it runs the real probe, so a `script` that has vanished
|
||||
// from PATH, or a platform nobody has tried, surfaces here rather than as an
|
||||
// opaque smoke failure.
|
||||
const wrapper = resolvePtyWrapper();
|
||||
if (process.platform === "win32") {
|
||||
assert.equal(wrapper, null);
|
||||
} else {
|
||||
assert.ok(wrapper, `no PTY wrapper resolved on ${process.platform}`);
|
||||
assert.equal(wrapper.wrap({ command: "node" }).command, "script");
|
||||
}
|
||||
});
|
||||
225
scripts/lib/render-smoke.mjs
Normal file
225
scripts/lib/render-smoke.mjs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
/**
|
||||
* Spawn a terminal app, wait for it to paint a marker, and then wait again to
|
||||
* see whether it is still running (#2147).
|
||||
*
|
||||
* Extracted from `scripts/smoke-tui.mjs` for the same reason
|
||||
* `announced-child.mjs` was extracted from `smoke-web-app.mjs` (#2000): a smoke
|
||||
* script only ever exercises its own happy path, so the branch that matters
|
||||
* most here — a child that paints the marker and *then* dies — is unreachable
|
||||
* from the real TUI once it is fixed. Driving this from a test against a stub
|
||||
* that paints and immediately exits 1 is what keeps the assertion honest.
|
||||
*
|
||||
* The defect it encodes against: `smoke:tui` used to resolve OK the instant the
|
||||
* marker appeared and never looked at the child again. The TUI was exiting 1
|
||||
* about 40ms later, every run, on every machine, and the smoke won that race
|
||||
* and printed OK. So it asserted "painted once", while its own header claimed
|
||||
* "boots and renders without crashing". Worse, the race cut both ways: had the
|
||||
* `exit` event been processed first, the same run would have failed, which
|
||||
* reads as flake rather than as the standing defect it was.
|
||||
*
|
||||
* Hence `surviveMs`. First paint is not survival, and nothing shorter than an
|
||||
* explicit "still alive N ms later" check can tell the two apart.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Tail of a child's output, for quoting in a diagnostic. Slicing a terminal
|
||||
* stream can land mid-CSI-sequence, so drop through the first newline rather
|
||||
* than lead with an escape-code fragment that mangles the line after it.
|
||||
*
|
||||
* @param {string} output
|
||||
* @param {number} [limit]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function outputTail(output, limit = 800) {
|
||||
const tail = output.slice(-limit);
|
||||
const nl = tail.indexOf("\n");
|
||||
return output.length > limit && nl !== -1 ? tail.slice(nl + 1) : tail;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.command Executable to spawn.
|
||||
* @param {string[]} [opts.args] Arguments for it.
|
||||
* @param {object} [opts.spawnOptions] Merged into the `spawn` options
|
||||
* (`stdio` is fixed — see below).
|
||||
* @param {string} opts.marker Substring whose appearance in the
|
||||
* child's output counts as first paint.
|
||||
* @param {number} [opts.timeoutMs] Budget for first paint (default 15s).
|
||||
* @param {number} [opts.surviveMs] How long after first paint the child
|
||||
* must stay alive (default 2s).
|
||||
* @param {number} [opts.exitGraceMs] SIGTERM → SIGKILL grace (default 5s).
|
||||
* @param {number} [opts.drainMs] Post-exit pipe-drain cap (default 500ms).
|
||||
* @param {(child: import("node:child_process").ChildProcess) => void} [opts.onSpawn]
|
||||
* Called synchronously with the child, before any waiting — the caller's
|
||||
* teardown handle on every failure path.
|
||||
* @param {(message: string) => void} [opts.warn]
|
||||
* @returns {Promise<{ code: 0 | 1, message: string, output: string }>}
|
||||
* Resolves only after the child's streams have closed, so `output` is
|
||||
* complete and the caller may safely remove a directory the child was using.
|
||||
*/
|
||||
export function runRenderSmoke({
|
||||
command,
|
||||
args = [],
|
||||
spawnOptions = {},
|
||||
marker,
|
||||
timeoutMs = 15_000,
|
||||
surviveMs = 2_000,
|
||||
exitGraceMs = 5_000,
|
||||
drainMs = 500,
|
||||
onSpawn = () => {},
|
||||
warn = (m) => console.warn(m),
|
||||
}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
...spawnOptions,
|
||||
// stdin is `ignore` (i.e. /dev/null) deliberately, even under a PTY: BSD
|
||||
// `script` refuses to start when its own stdin is a pipe
|
||||
// ("tcgetattr/ioctl: Operation not supported on socket"). It reads the
|
||||
// immediate EOF and forwards one ^D into the pty before the app mounts,
|
||||
// which is harmless — verified against the real TUI, which survives it.
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
// Publish before waiting, so every path below leaves the caller holding a
|
||||
// stoppable handle rather than an orphan (the #2000 rule).
|
||||
onSpawn(child);
|
||||
|
||||
let output = "";
|
||||
let settled = false;
|
||||
let finished = false;
|
||||
let childExited = false;
|
||||
let childClosed = false;
|
||||
let markerAt = null;
|
||||
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
||||
let surviveTimer;
|
||||
|
||||
const t0 = Date.now();
|
||||
const since = () => Date.now() - t0;
|
||||
|
||||
const finish = (code, message) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
resolve({
|
||||
code,
|
||||
message: typeof message === "function" ? message() : message,
|
||||
output,
|
||||
});
|
||||
};
|
||||
|
||||
// Settle the verdict, then wait for the child to actually close before
|
||||
// resolving (#1801). The caller's work dir doubles as the app's HOME, so it
|
||||
// is still being written to when we signal; removing it while those writes
|
||||
// land fails with ENOTEMPTY. Waiting on `close` rather than `exit` also
|
||||
// covers a *spawn failure*, which emits `error` + `close` and never `exit`
|
||||
// — and guarantees the pipes are drained, which is what makes the thunked
|
||||
// messages below complete rather than truncated at the interesting line.
|
||||
//
|
||||
// Deliberately not `lib/child-cleanup.mjs`'s `stopChild()`: that resolves
|
||||
// on the FIRST of exit/close, whereas this wait must keep going for `close`
|
||||
// after `exit`, on a shorter re-armed deadline, for the drain.
|
||||
const done = (code, message) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(renderTimer);
|
||||
clearTimeout(surviveTimer);
|
||||
if (childClosed) {
|
||||
finish(code, message);
|
||||
return;
|
||||
}
|
||||
const forceKill = setTimeout(() => child.kill("SIGKILL"), exitGraceMs);
|
||||
|
||||
let deadline;
|
||||
const armDeadline = (ms, warning) => {
|
||||
clearTimeout(deadline);
|
||||
deadline = setTimeout(() => {
|
||||
warn(`render-smoke — ${warning}`);
|
||||
finish(code, message);
|
||||
}, ms);
|
||||
};
|
||||
armDeadline(
|
||||
exitGraceMs * 2,
|
||||
`child did not close its output streams within ${exitGraceMs * 2}ms; giving up on the drain`,
|
||||
);
|
||||
|
||||
// Once the child itself is gone only the pipe drain remains, so the wait
|
||||
// drops to `drainMs`. `close` is bounded by whoever holds the stdio
|
||||
// pipes, which can outlive the direct child: any descendant that
|
||||
// inherited them keeps it pending.
|
||||
const drainOnly = () => {
|
||||
clearTimeout(forceKill);
|
||||
armDeadline(
|
||||
drainMs,
|
||||
`child exited but held its output streams open for ${drainMs}ms (a descendant may have inherited them); giving up on the drain`,
|
||||
);
|
||||
};
|
||||
if (childExited) drainOnly();
|
||||
else child.once("exit", drainOnly);
|
||||
|
||||
child.once("close", () => {
|
||||
clearTimeout(forceKill);
|
||||
clearTimeout(deadline);
|
||||
finish(code, message);
|
||||
});
|
||||
|
||||
// Only signal a child that is still running: on the crash and
|
||||
// spawn-failure paths there is nothing left to signal, and we are here
|
||||
// purely to wait out the remaining `close`.
|
||||
if (
|
||||
child.exitCode === null &&
|
||||
child.signalCode === null &&
|
||||
!child.killed
|
||||
) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
};
|
||||
|
||||
const onData = (chunk) => {
|
||||
output += chunk.toString();
|
||||
if (markerAt !== null || !output.includes(marker)) return;
|
||||
markerAt = since();
|
||||
// First paint is not the verdict — it only starts the clock on the
|
||||
// question this smoke actually asks.
|
||||
surviveTimer = setTimeout(() => {
|
||||
done(
|
||||
0,
|
||||
`rendered "${marker}" at ${markerAt}ms and was still running ${surviveMs}ms later`,
|
||||
);
|
||||
}, surviveMs);
|
||||
};
|
||||
|
||||
child.stdout.on("data", onData);
|
||||
child.stderr.on("data", onData);
|
||||
|
||||
// Registered before done()'s own one-shot listener, so this always runs
|
||||
// first — done() can trust `childClosed` even when called from a handler.
|
||||
child.on("close", () => {
|
||||
childClosed = true;
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
childExited = true;
|
||||
if (settled) return;
|
||||
const how = signal ? `signal ${signal}` : `code ${code}`;
|
||||
done(1, () =>
|
||||
markerAt === null
|
||||
? `child exited (${how}) before rendering "${marker}"\n${outputTail(output)}`
|
||||
: `child rendered "${marker}" at ${markerAt}ms and then exited (${how}) ` +
|
||||
`${since() - markerAt}ms later, inside the ${surviveMs}ms survival window — ` +
|
||||
`it painted one frame, it did not run\n${outputTail(output)}`,
|
||||
);
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
done(1, `failed to spawn ${command}: ${err.message}`);
|
||||
});
|
||||
|
||||
const renderTimer = setTimeout(() => {
|
||||
done(
|
||||
1,
|
||||
() =>
|
||||
`child did not render "${marker}" within ${timeoutMs}ms\n${outputTail(output)}`,
|
||||
);
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
116
scripts/lib/render-smoke.test.mjs
Normal file
116
scripts/lib/render-smoke.test.mjs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
|
||||
import { outputTail, runRenderSmoke } from "./render-smoke.mjs";
|
||||
|
||||
const MARKER = "MCP Servers";
|
||||
|
||||
/** A stub terminal app, driven as a real child process rather than a spy. */
|
||||
function stub(body) {
|
||||
return { command: process.execPath, args: ["-e", body] };
|
||||
}
|
||||
|
||||
const paintThenExit = (code) =>
|
||||
stub(`console.log(${JSON.stringify(MARKER)}); process.exit(${code});`);
|
||||
const paintThenLive = stub(
|
||||
`console.log(${JSON.stringify(MARKER)}); setInterval(() => {}, 1000);`,
|
||||
);
|
||||
|
||||
const opts = {
|
||||
marker: MARKER,
|
||||
timeoutMs: 5000,
|
||||
surviveMs: 400,
|
||||
exitGraceMs: 500,
|
||||
drainMs: 200,
|
||||
warn: () => {},
|
||||
};
|
||||
|
||||
// The regression this module exists for. The old harness resolved OK on the
|
||||
// marker and never looked again, so this exact stub — paint, then die — passed.
|
||||
test("fails when the child paints the marker and then exits non-zero", async () => {
|
||||
const r = await runRenderSmoke({ ...opts, ...paintThenExit(1) });
|
||||
assert.equal(r.code, 1, `expected failure, got: ${r.message}`);
|
||||
assert.match(r.message, /code 1/);
|
||||
assert.match(r.message, /survival window/);
|
||||
// The diagnostic must not read as a crash *before* first paint — that was a
|
||||
// different defect, and conflating them sends the reader to the wrong place.
|
||||
assert.doesNotMatch(r.message, /before rendering/);
|
||||
});
|
||||
|
||||
// Exiting 0 right after painting is the same failure: the app did not run. It
|
||||
// is called out separately because `script(1)` under busybox reports 0 for a
|
||||
// crashed child, so the verdict cannot rest on the exit code.
|
||||
test("fails when the child paints the marker and then exits zero", async () => {
|
||||
const r = await runRenderSmoke({ ...opts, ...paintThenExit(0) });
|
||||
assert.equal(r.code, 1, `expected failure, got: ${r.message}`);
|
||||
assert.match(r.message, /it painted one frame, it did not run/);
|
||||
});
|
||||
|
||||
test("passes when the child paints the marker and keeps running", async () => {
|
||||
const r = await runRenderSmoke({ ...opts, ...paintThenLive });
|
||||
assert.equal(r.code, 0, `expected pass, got: ${r.message}`);
|
||||
assert.match(r.message, /still running 400ms later/);
|
||||
});
|
||||
|
||||
test("fails when the child exits before painting", async () => {
|
||||
const r = await runRenderSmoke({
|
||||
...opts,
|
||||
...stub(`console.error("boom"); process.exit(3);`),
|
||||
});
|
||||
assert.equal(r.code, 1);
|
||||
assert.match(r.message, /before rendering/);
|
||||
assert.match(r.message, /code 3/);
|
||||
// The tail is quoted after the drain, so the crash reason survives.
|
||||
assert.match(r.message, /boom/);
|
||||
});
|
||||
|
||||
test("fails when the child never paints, and kills it", async () => {
|
||||
let spawned;
|
||||
const r = await runRenderSmoke({
|
||||
...opts,
|
||||
...stub(`setInterval(() => {}, 1000);`),
|
||||
timeoutMs: 300,
|
||||
onSpawn: (c) => (spawned = c),
|
||||
});
|
||||
assert.equal(r.code, 1);
|
||||
assert.match(r.message, /did not render/);
|
||||
// The #2000 ownership rule: the child is published before the wait, so the
|
||||
// timeout path leaves a stoppable handle rather than an orphan.
|
||||
assert.ok(spawned, "onSpawn was never called");
|
||||
assert.notEqual(
|
||||
spawned.exitCode === null && spawned.signalCode === null,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("reports a spawn failure rather than throwing it uncaught", async () => {
|
||||
const r = await runRenderSmoke({
|
||||
...opts,
|
||||
command: "/definitely/not/a/binary-2147",
|
||||
args: [],
|
||||
});
|
||||
assert.equal(r.code, 1);
|
||||
assert.match(r.message, /failed to spawn/);
|
||||
});
|
||||
|
||||
test("scans stderr as well as stdout for the marker", async () => {
|
||||
// An Ink app writing to stderr is missed entirely by a stdout-only scan,
|
||||
// which then times out with an empty diagnostic.
|
||||
const r = await runRenderSmoke({
|
||||
...opts,
|
||||
...stub(
|
||||
`console.error(${JSON.stringify(MARKER)}); setInterval(() => {}, 1000);`,
|
||||
),
|
||||
});
|
||||
assert.equal(r.code, 0, `expected pass, got: ${r.message}`);
|
||||
});
|
||||
|
||||
test("outputTail drops a leading partial line but keeps short output whole", () => {
|
||||
assert.equal(outputTail("short", 800), "short");
|
||||
// Over the limit: the slice can land mid-escape-sequence, so it starts after
|
||||
// the first newline instead of leading with a fragment.
|
||||
const long = "x".repeat(50) + "\nkeep me";
|
||||
assert.equal(outputTail(long, 20), "keep me");
|
||||
// No newline in the tail at all: nothing to trim to, so return it as sliced.
|
||||
assert.equal(outputTail("y".repeat(50), 10), "y".repeat(10));
|
||||
});
|
||||
Loading…
Reference in a new issue