No app screen changed this release, so the demo now shows the nearest real user-visible effect: `timmy-staging` is refused at startup with a reason that names TIMMY_BASE_PATH, `/timmy-staging` starts and serves the app, `PORT=0` announces the port it actually bound, and the gate file itself runs on screen (7 passed, 0 failed). Every value on the card is read from those real runs.
This commit is contained in:
1 parent
b2f95f8239
commit
4f81c35d48
1 file changed
+83
-1
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user