inspector/scripts/lib/render-smoke.test.mjs
cliffhall e15dd8abee fix(scripts): make the test stubs drain, and the raw-mode guard testable (#2147)
Copilot review round 5 — four findings, all in the suppressed block.

1+4. The regression stubs did `console.log(MARKER); process.exit(1)`.
     Writing to a PIPE is asynchronous and `process.exit()` tears the
     process down without draining, so the marker can be lost outright —
     and then the stub that pins the paint-then-die path fails as "exited
     before rendering", the neighbouring branch and the wrong diagnostic
     for the thing it guards.

     Worth recording that I met this mechanism last round and did not join
     the dots: probing the drain race, a 4MB write followed by
     `process.exit()` reached the parent with the marker missing
     entirely. That was noted as a reason the repro failed, while the same
     construct stayed in the stubs. Both now set `process.exitCode` and
     let the loop drain — still exiting immediately after the write — with
     a comment, since `process.exit()` is the more obvious spelling and
     will read as a simplification to the next person. Same fix for the
     `console.error("boom")` stub, whose assertion had the same exposure.

2.   DEFAULTS claimed "every one is a hard floor as well as a default",
     which contradicts `normalizeMs` (any finite positive value passes)
     and the suite two files over, which passes 400ms against a 2000ms
     default. It rejects the shape a bad env var produces, not smallness.

3.   The raw-mode forbidden-output check was unreachable from any test:
     the real PTY happy path only exercises the pattern NOT matching, so
     a regression breaking it would stay green forever — an
     acceptance-criterion guard with nothing guarding it, which is the
     recursive form of this PR's own complaint.

     Now a `runRenderSmoke` option (`forbidOutput`), so it runs inside the
     tested state machine and against the fully drained output.
     `smoke:tui` passes its pattern in and drops the post-hoc check. It
     can only turn a pass into a failure, never rewrite an existing
     failure's message: a crash reason is more useful than "and it also
     printed X", which is usually a symptom of that crash. Three tests —
     matching, not matching, and that asymmetry.

Verified: test:scripts 213/213; `smoke:tui OK — rendered "MCP Servers" at
360ms and was still running 2000ms later`; and the no-PTY shim still
fails through the refactored path.

Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-26 22:13:35 -04:00

258 lines
11 KiB
JavaScript

import { strict as assert } from "node:assert";
import test from "node:test";
import {
DEFAULTS,
normalizeMs,
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] };
}
// `process.exitCode`, never `process.exit()`. Writing to a *pipe* is
// asynchronous, and `process.exit()` tears the process down without draining —
// so `console.log(MARKER); process.exit(1)` can lose the marker outright. That
// is not hypothetical: probing the drain race for this PR, a 4MB write followed
// by `process.exit()` arrived at the parent with the marker missing entirely.
// A stub that intermittently never paints would fail the very assertion meant
// to pin the paint-then-die path, and would fail it as "exited before
// rendering" — the neighbouring branch. Setting `exitCode` and letting the loop
// drain still exits immediately after the write; nothing holds it open.
const paintThenExit = (code) =>
stub(`console.log(${JSON.stringify(MARKER)}); process.exitCode = ${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, /it painted one frame, it did not run/);
// 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/);
});
// `exit` races the child's last `data` event, so this stub can be observed
// either way round on the same machine. Both orders must produce the same
// verdict AND the same wording — a message phrased off observation order would
// flip run to run, which is how a stable failure gets written off as flake.
// Repeated because one pass proves nothing about a race.
test("the verdict does not depend on whether exit beats the final output", async () => {
for (let i = 0; i < 12; i++) {
const r = await runRenderSmoke({ ...opts, ...paintThenExit(1) });
assert.equal(r.code, 1, `run ${i}: expected failure, got: ${r.message}`);
assert.match(r.message, /it painted one frame, it did not run/, `run ${i}`);
assert.doesNotMatch(r.message, /before rendering/, `run ${i}`);
}
});
// 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/);
});
// The first-paint deadline and the survival window overlap: `done` is
// first-caller-wins, so an armed render timer firing during the survival window
// settles the run as "did not render" — while quoting an output tail that
// visibly contains the marker. A slow-but-fine TUI would fail, intermittently,
// with a diagnostic pointing at the wrong thing entirely.
test("a paint landing just under the deadline is not failed by the render timer", async () => {
const paintAt = 250;
const r = await runRenderSmoke({
...opts,
...stub(
`setTimeout(() => { console.log(${JSON.stringify(MARKER)}); ` +
`setInterval(() => {}, 1000); }, ${paintAt});`,
),
// Deliberately tight: the render deadline expires while the survival window
// is still open, which is the whole shape of the bug.
timeoutMs: paintAt + 150,
surviveMs: 400,
});
assert.equal(r.code, 0, `expected pass, got: ${r.message}`);
assert.doesNotMatch(r.message, /did not render/);
});
test("fails when the child exits before painting", async () => {
const r = await runRenderSmoke({
...opts,
// `exitCode`, not `exit()` — see `paintThenExit`. A forced exit can
// truncate the stderr write, making the `boom` assertion below flaky.
...stub(`console.error("boom"); process.exitCode = 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}`);
});
// `Number("")` is 0 and `Number("typo")` is NaN, and `??` catches neither
// because both are non-nullish. `setTimeout` fires immediately on either, so an
// un-normalized `surviveMs` resolves the survival check before the child could
// possibly have died — restoring the exact first-paint false green, on a run
// that still prints OK. This is the guard on the guard.
test("normalizeMs rejects the values a bad env var actually produces", () => {
assert.equal(normalizeMs(Number(""), 2000), 2000);
assert.equal(normalizeMs(Number("typo"), 2000), 2000);
assert.equal(normalizeMs(undefined, 2000), 2000);
assert.equal(normalizeMs(-1, 2000), 2000);
assert.equal(normalizeMs(Infinity, 2000), 2000);
assert.equal(normalizeMs("3000", 2000), 2000, "a string is not a duration");
// 0 is a typo, never "skip the check" — there is no caller who wants that.
assert.equal(normalizeMs(0, 2000), 2000);
// A real value passes through untouched.
assert.equal(normalizeMs(1234, 2000), 1234);
});
test("a zero survival window falls back rather than passing instantly", async () => {
// End to end through the real state machine: with surviveMs honored as 0 the
// paint-then-exit stub would settle OK before the child's exit was seen.
const r = await runRenderSmoke({
...opts,
...paintThenExit(1),
surviveMs: Number(""),
});
assert.equal(r.code, 1, `expected failure, got: ${r.message}`);
// The window named in the message is the normalized one, not the 0 passed in.
assert.match(
r.message,
new RegExp(`${DEFAULTS.surviveMs}ms survival window`),
);
assert.doesNotMatch(r.message, /\b0ms survival window/);
});
// `forbidOutput` is `smoke:tui`'s guard for the acceptance criterion that the
// raw-mode error must not appear in a passing run. It lives here rather than at
// the call site precisely so it can be driven both ways: the real PTY happy
// path only ever exercises the pattern NOT matching, so a regression that broke
// this check would stay green forever.
test("forbidOutput fails a run that would otherwise have passed", async () => {
const r = await runRenderSmoke({
...opts,
...stub(
`console.log(${JSON.stringify(MARKER)}); ` +
`console.error("Raw mode is not supported on the current process.stdin"); ` +
`setInterval(() => {}, 1000);`,
),
forbidOutput: {
pattern: /Raw mode is not supported/,
reason: "survived but logged the raw-mode error",
},
});
assert.equal(r.code, 1, `expected failure, got: ${r.message}`);
assert.match(r.message, /survived but logged the raw-mode error/);
// The offending output is quoted, so the reader sees the evidence.
assert.match(r.message, /Raw mode is not supported/);
});
test("forbidOutput leaves a passing run alone when it does not match", async () => {
const r = await runRenderSmoke({
...opts,
...paintThenLive,
forbidOutput: {
pattern: /Raw mode is not supported/,
reason: "should not be reached",
},
});
assert.equal(r.code, 0, `expected pass, got: ${r.message}`);
assert.doesNotMatch(r.message, /should not be reached/);
});
// A crash reason is more useful than "and it also printed X", which is usually
// a symptom of the same crash rather than a separate finding.
test("forbidOutput does not overwrite an existing failure's diagnostic", async () => {
const r = await runRenderSmoke({
...opts,
...stub(
`console.error("Raw mode is not supported"); process.exitCode = 1;`,
),
forbidOutput: {
pattern: /Raw mode is not supported/,
reason: "should not replace the crash reason",
},
});
assert.equal(r.code, 1);
assert.match(r.message, /before rendering/);
assert.doesNotMatch(r.message, /should not replace the crash reason/);
});
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));
});