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:
cliffhall 2026-08-26 21:00:16 -04:00
commit b6baa30d7b

160
scripts/lib/pty.test.mjs Normal file
View 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");
}
});