`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>
116 lines
4.2 KiB
JavaScript
116 lines
4.2 KiB
JavaScript
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));
|
|
});
|