diff --git a/scripts/record_release_demo.mjs b/scripts/record_release_demo.mjs index 78affe5..d34ef4e 100644 --- a/scripts/record_release_demo.mjs +++ b/scripts/record_release_demo.mjs @@ -1,5 +1,5 @@ import { chromium } from 'playwright'; -import { execFile } from 'node:child_process'; +import { spawn, execFile } from 'node:child_process'; import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import os from 'node:os'; @@ -304,6 +304,88 @@ async function evidenceCardProof(id) { await caption(`TIMMY ${version} • RELEASE DEMO`, 1200); await caption(`This release: ${releaseDelta}`, 2600); await caption('Automated checks replay this synthetic path before review', 1200); + +// Issue #93 — the release gate itself, on camera. No app screen changed in this release, so the scene +// shows the nearest real user-visible effect: a misconfigured base path makes the app refuse to start +// instead of serving at a URL it cannot honour. Both probes spawn the committed server.mjs and quote +// what it actually printed; the third runs the gate file that decides the release. +async function startupProbe(basePath) { + const child = spawn(process.execPath, ['server.mjs'], { + cwd: process.cwd(), + env: { ...process.env, PORT: '0', TIMMY_BASE_PATH: basePath, TIMMY_VISION_ENABLED: '0', TIMMY_AGENT_ENABLED: 'false' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + const line = /^Timmy is listening on http:\/\/([^:]+):(\d+).*$/m; + const started = Date.now(); + let outcome = null; + while (Date.now() - started < 20_000) { + const match = line.exec(stdout); + if (match) { outcome = { state: 'listening', port: Number(match[2]), line: match[0], ms: Date.now() - started }; break; } + if (child.exitCode !== null) { outcome = { state: 'exit', code: child.exitCode, ms: Date.now() - started }; break; } + await sleep(50); + } + if (!outcome) { + child.kill('SIGKILL'); + throw new Error(`startup probe for ${JSON.stringify(basePath)} neither listened nor exited`); + } + const result = { ...outcome, stderr: stderr.trim() }; + if (outcome.state === 'listening') { + result.status = (await fetch(`http://127.0.0.1:${outcome.port}${basePath.replace(/\/$/, '')}/`)).status; + const closed = new Promise(resolve => child.once('close', resolve)); + child.kill('SIGTERM'); + await closed; + } + return result; +} +const refusedStartup = await startupProbe('timmy-staging'); +if (refusedStartup.state !== 'exit' || refusedStartup.code === 0 || !/TIMMY_BASE_PATH/.test(refusedStartup.stderr)) { + throw new Error(`a bare base path must be refused at startup: ${JSON.stringify(refusedStartup)}`); +} +const acceptedStartup = await startupProbe('/timmy-staging'); +if (acceptedStartup.state !== 'listening' || acceptedStartup.status !== 200) { + throw new Error(`a canonical base path must start and serve the app: ${JSON.stringify(acceptedStartup)}`); +} +const gateStarted = Date.now(); +const gateRun = await execFileAsync(process.execPath, ['--test', 'tests/staging-health.test.js'], { cwd: process.cwd() }) + .then(result => ({ code: 0, out: result.stdout })) + .catch(error => ({ code: typeof error.code === 'number' ? error.code : 1, out: `${error.stdout || ''}${error.stderr || ''}` })); +const gateSeconds = ((Date.now() - gateStarted) / 1000).toFixed(1); +const gatePass = /^# pass (\d+)$/m.exec(gateRun.out); +const gateFail = /^# fail (\d+)$/m.exec(gateRun.out); +if (gateRun.code !== 0 || !gatePass || !gateFail || Number(gatePass[1]) !== 7 || Number(gateFail[1]) !== 0) { + throw new Error(`the base-path gate must pass 6/6 on the committed tree: ${gateRun.out.slice(-900)}`); +} +const refusalLine = (/TIMMY_BASE_PATH[^.]*\./.exec(refusedStartup.stderr) || ['TIMMY_BASE_PATH was not named'])[0]; +await caption('No app screen changed in this release — the nearest visible effect is startup: a bad base path must refuse, not serve', 3200); +await evidenceCard({ + id: 'release-gate-basepath', + title: 'RELEASE GATE · BASE PATH', + subtitle: 'the running app and the gate file — real output, not app UI', + rows: [ + ['rejected', `"timmy-staging" · exit ${refusedStartup.code} in ${refusedStartup.ms} ms`], + ['reason', refusalLine], + ['accepted', `"/timmy-staging" · port ${acceptedStartup.port} in ${acceptedStartup.ms} ms · served ${acceptedStartup.status}`], + ['gate rule', 'accepted only if the child prints its own "Timmy is listening" line'], + ['timeout', 'neither exit nor listening → indeterminate, never "accepted"'], + ['ports', 'PORT=0 → the line names the bound port; a stolen port is retried'], + ['gate file', `node --test staging-health · ${gatePass[1]} passed · ${gateFail[1]} failed · ${gateSeconds} s`], + ], + font: '600 10px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace', + keyWidth: 66, + rowGap: '8px', +}); +await sleep(420); +const basePathGateProof = await evidenceCardProof('release-gate-basepath'); +if (!basePathGateProof.ok) throw new Error(`Base-path gate card is not fully readable on screen: ${JSON.stringify(basePathGateProof)}`); +await caption('Bare "timmy-staging" is rejected by the app itself: exit 1, and the reason names TIMMY_BASE_PATH', 3000); +await caption('The gate decides that from the server’s own startup line, never from a timer a slow host can beat', 2800); +await caption('Asked for port 0, the app now names the port it actually bound — the gate follows that instead of guessing', 3000); +await caption('And the gate takes its ports from the kernel, so a port stolen by an unrelated socket cannot fail a build that passed', 3000); +await page.evaluate(() => document.querySelector('#release-gate-basepath')?.remove()); await caption('One clear photo action. Manual logging stays one tap away.', 1500); await tap('[data-scan]', 450); await page.getByText(/Self-hosted model ready/i).waitFor();