diff --git a/scripts/lib/render-smoke.mjs b/scripts/lib/render-smoke.mjs index 468d89f8..e61c3f02 100644 --- a/scripts/lib/render-smoke.mjs +++ b/scripts/lib/render-smoke.mjs @@ -146,8 +146,15 @@ export function runRenderSmoke({ let childExited = false; let childClosed = false; let markerAt = null; - /** @type {ReturnType | undefined} */ + /** + * Both deadlines. Declared up front because `onData` and `done` each clear + * them from closures defined above the `setTimeout` calls that assign them. + * + * @type {ReturnType | undefined} + */ let surviveTimer; + /** @type {ReturnType | undefined} */ + let renderTimer; const t0 = Date.now(); const since = () => Date.now() - t0; @@ -233,6 +240,12 @@ export function runRenderSmoke({ output += chunk.toString(); if (markerAt !== null || !output.includes(marker)) return; markerAt = since(); + // Disarm the first-paint deadline. It is first-caller-wins with `done`, + // so leaving it armed means a paint landing near `timeoutMs` lets it fire + // *during* the survival window and settle the run as "did not render + // " — quoting an output tail that visibly contains the marker. + // First paint has happened; its deadline no longer has a question to ask. + clearTimeout(renderTimer); // First paint is not the verdict — it only starts the clock on the // question this smoke actually asks. surviveTimer = setTimeout(() => { @@ -269,7 +282,7 @@ export function runRenderSmoke({ done(1, `failed to spawn ${command}: ${err.message}`); }); - const renderTimer = setTimeout(() => { + renderTimer = setTimeout(() => { done( 1, () => diff --git a/scripts/lib/render-smoke.test.mjs b/scripts/lib/render-smoke.test.mjs index 0c335915..d631c62a 100644 --- a/scripts/lib/render-smoke.test.mjs +++ b/scripts/lib/render-smoke.test.mjs @@ -57,6 +57,28 @@ test("passes when the child paints the marker and keeps running", async () => { assert.match(r.message, /still running 400ms later/); }); +// The first-paint deadline and the survival window overlap: `done` is +// first-caller-wins, so an armed render timer firing during the survival window +// settles the run as "did not render" — while quoting an output tail that +// visibly contains the marker. A slow-but-fine TUI would fail, intermittently, +// with a diagnostic pointing at the wrong thing entirely. +test("a paint landing just under the deadline is not failed by the render timer", async () => { + const paintAt = 250; + const r = await runRenderSmoke({ + ...opts, + ...stub( + `setTimeout(() => { console.log(${JSON.stringify(MARKER)}); ` + + `setInterval(() => {}, 1000); }, ${paintAt});`, + ), + // Deliberately tight: the render deadline expires while the survival window + // is still open, which is the whole shape of the bug. + timeoutMs: paintAt + 150, + surviveMs: 400, + }); + assert.equal(r.code, 0, `expected pass, got: ${r.message}`); + assert.doesNotMatch(r.message, /did not render/); +}); + test("fails when the child exits before painting", async () => { const r = await runRenderSmoke({ ...opts,