From f334fe9d20b13638933ff52561f7f0a4155dc5dd Mon Sep 17 00:00:00 2001 From: Timmy Date: Thu, 5 Nov 2026 10:59:23 +0000 Subject: [PATCH] fix(test): decide base-path rejection from the server's own startup line (#93) The old gate raced child exit against a fixed 800 ms timer. Under full-suite CPU contention the child was still importing modules, so it was killed and the timeout was asserted as acceptance: 2/2 full-suite runs on main failed with '"timmy-staging" was accepted' while the file alone passed 3/3 and the server rejected the value directly. Acceptance is now decided by the child printing 'Timmy is listening on http://'; a child that neither exits nor listens inside 20 s is reported as indeterminate. Rejection assertions (non-zero exit, TIMMY_BASE_PATH in stderr) still run. Mutation check: with the leading-slash validation disabled the repaired test fails in 398 ms with the acceptance message. --- tests/staging-health.test.js | 45 ++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/tests/staging-health.test.js b/tests/staging-health.test.js index 6258f99..bec1a7f 100644 --- a/tests/staging-health.test.js +++ b/tests/staging-health.test.js @@ -125,6 +125,30 @@ test('agent cookie is constrained to the normalized base path', async t => { assert.match(response.headers.get('set-cookie'), /; Path=\/timmy-staging\//); }); +// The child's own startup line is the only trustworthy evidence that a base path was accepted. +// A fixed timer cannot tell "still importing modules" from "server is up": on a loaded host the +// old 800 ms race killed a correctly-rejecting child and reported it as accepted. +function startupOutcome(child, deadlineMs = 20_000) { + return new Promise(resolve => { + let stdout = ''; + let stderr = ''; + let settled = false; + const finish = outcome => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ ...outcome, stdout, stderr }); + }; + const timer = setTimeout(() => finish({ timedOut: true }), deadlineMs); + child.stdout.on('data', chunk => { + stdout += chunk; + if (/^Timmy is listening on http:\/\//m.test(stdout)) finish({ listening: true }); + }); + child.stderr.on('data', chunk => { stderr += chunk; }); + child.once('exit', code => finish({ code })); + }); +} + test('malformed and escaping base paths are rejected at startup', async () => { const invalid = [ 'timmy-staging', @@ -152,16 +176,17 @@ test('malformed and escaping base paths are rejected at startup', async () => { const child = spawn(process.execPath, ['server.mjs'], { cwd: root, env: { ...process.env, PORT: '0', TIMMY_BASE_PATH: value }, - stdio: ['ignore', 'ignore', 'pipe'], + stdio: ['ignore', 'pipe', 'pipe'], }); - let stderr = ''; - child.stderr.on('data', chunk => { stderr += chunk; }); - const exitCode = await Promise.race([ - new Promise(resolve => child.once('exit', resolve)), - new Promise(resolve => setTimeout(() => { child.kill('SIGTERM'); resolve('timeout'); }, 800)), - ]); - assert.notEqual(exitCode, 'timeout', `${JSON.stringify(value)} was accepted`); - assert.notEqual(exitCode, 0, `${JSON.stringify(value)} exited successfully`); - assert.match(stderr, /TIMMY_BASE_PATH/); + const outcome = await startupOutcome(child); + if (outcome.listening) { + const closed = new Promise(resolve => child.once('close', resolve)); + child.kill('SIGKILL'); + await closed; + } + assert.equal(outcome.listening, undefined, `${JSON.stringify(value)} was accepted: the server announced it was listening`); + assert.notEqual(outcome.timedOut, true, `${JSON.stringify(value)} neither exited nor announced listening within the startup deadline`); + assert.notEqual(outcome.code, 0, `${JSON.stringify(value)} exited successfully`); + assert.match(outcome.stderr, /TIMMY_BASE_PATH/); } });