`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>
160 lines
5 KiB
JavaScript
160 lines
5 KiB
JavaScript
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");
|
|
}
|
|
});
|