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
|
|
@ -15,8 +15,17 @@ 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.exit(${code});`);
|
||||
stub(`console.log(${JSON.stringify(MARKER)}); process.exitCode = ${code};`);
|
||||
const paintThenLive = stub(
|
||||
`console.log(${JSON.stringify(MARKER)}); setInterval(() => {}, 1000);`,
|
||||
);
|
||||
|
|
@ -96,7 +105,9 @@ test("a paint landing just under the deadline is not failed by the render timer"
|
|||
test("fails when the child exits before painting", async () => {
|
||||
const r = await runRenderSmoke({
|
||||
...opts,
|
||||
...stub(`console.error("boom"); process.exit(3);`),
|
||||
// `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/);
|
||||
|
|
@ -181,6 +192,61 @@ test("a zero survival window falls back rather than passing instantly", async ()
|
|||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue