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>
This commit is contained in:
parent
4af38c719c
commit
e15dd8abee
1 changed files with 103 additions and 19 deletions
|
|
@ -23,7 +23,11 @@
|
|||
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
/** Durations, in ms. Every one is a hard floor as well as a default. */
|
||||
/**
|
||||
* Fallback durations, in ms. These are defaults, NOT minimums: `normalizeMs`
|
||||
* accepts any finite positive value, and the tests deliberately pass shorter
|
||||
* ones. What it rejects is the shape a bad env var produces, not smallness.
|
||||
*/
|
||||
export const DEFAULTS = Object.freeze({
|
||||
timeoutMs: 15_000,
|
||||
surviveMs: 2_000,
|
||||
|
|
@ -93,6 +97,14 @@ export function outputTail(output, limit = 800) {
|
|||
* @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 {{ pattern: RegExp, reason: string }} [opts.forbidOutput]
|
||||
* Text that must NOT appear in an otherwise-passing run. `smoke:tui` uses it
|
||||
* for Ink's raw-mode error: survival already implies its absence *today*,
|
||||
* because that throw is fatal — but if Ink ever degraded it to a warning, a
|
||||
* TUI with no keyboard input would survive happily and pass. Checked here
|
||||
* rather than at the call site so it runs against the fully drained output
|
||||
* and is reachable from a test. Use a non-global RegExp: `test()` on a `/g`
|
||||
* pattern advances `lastIndex` and alternates.
|
||||
* @param {(message: string) => void} [opts.warn]
|
||||
* @returns {Promise<{ code: 0 | 1, message: string, output: string }>}
|
||||
* On the normal path, resolves only after the child's streams have `close`d —
|
||||
|
|
@ -115,6 +127,7 @@ export function runRenderSmoke({
|
|||
surviveMs: requestedSurviveMs,
|
||||
exitGraceMs: requestedExitGraceMs,
|
||||
drainMs: requestedDrainMs,
|
||||
forbidOutput,
|
||||
onSpawn = () => {},
|
||||
warn = (m) => console.warn(m),
|
||||
}) {
|
||||
|
|
@ -162,11 +175,17 @@ export function runRenderSmoke({
|
|||
const finish = (code, message) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
resolve({
|
||||
code,
|
||||
message: typeof message === "function" ? message() : message,
|
||||
output,
|
||||
});
|
||||
let verdict = code;
|
||||
let text = typeof message === "function" ? message() : message;
|
||||
// Only ever turns a pass into a failure. A run that already failed keeps
|
||||
// its own diagnostic: the crash reason is the more useful of the two, and
|
||||
// forbidden output is usually a *symptom* of it rather than a separate
|
||||
// finding.
|
||||
if (verdict === 0 && forbidOutput?.pattern.test(output)) {
|
||||
verdict = 1;
|
||||
text = `${forbidOutput.reason}\n${outputTail(output)}`;
|
||||
}
|
||||
resolve({ code: verdict, message: text, output });
|
||||
};
|
||||
|
||||
// Settle the verdict, then wait for the child to actually close before
|
||||
|
|
|
|||
Loading…
Reference in a new issue