diff --git a/scripts/build_release.py b/scripts/build_release.py index 2d9dfc8..5c829e7 100755 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -163,11 +163,17 @@ def main() -> int: raise SystemExit("Release demo produced no on-camera caption receipt") recorded = json.loads(caption_receipt.read_text(encoding="utf-8")) onscreen = [str(line) for line in (recorded.get("captions") or [])] - if recorded.get("releaseDelta") != release_delta or not any(release_delta in line for line in onscreen): + # A pushed string is not proof of pixels. Only captions the demo measured as a painted, + # on-top, in-frame bar (style + elementFromPoint + band pixel change) count as on camera. + verified = [str(item.get("text")) for item in (recorded.get("captionsVerified") or []) + if item.get("visible") is True and item.get("bandChanged") is True] + if recorded.get("releaseDelta") != release_delta or not any(release_delta in line for line in verified): raise SystemExit("Release demo did not state the declared release delta on camera") for required in DEMO_REQUIRED_CAPTIONS: if not any(required in line for line in onscreen): raise SystemExit(f"Release demo is missing a required on-camera caption: {required}") + if not any(required in line for line in verified): + raise SystemExit(f"Release demo caption never painted on screen: {required}") finally: server.terminate() try: diff --git a/scripts/record_release_demo.mjs b/scripts/record_release_demo.mjs index 4fc778a..233e350 100644 --- a/scripts/record_release_demo.mjs +++ b/scripts/record_release_demo.mjs @@ -113,21 +113,51 @@ await page.route('**/api/agent/chat', route => { await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' }); await page.evaluate(() => localStorage.clear()); await page.reload({ waitUntil: 'networkidle' }); -await page.addStyleTag({ content: ` - #demo-caption{position:fixed;left:16px;right:16px;top:14px;z-index:20000;background:rgba(40,33,29,.94);color:#fff;padding:11px 14px;border-radius:14px;font:800 12px/1.25 system-ui;letter-spacing:.02em;text-align:center;box-shadow:0 8px 24px rgba(0,0,0,.2)} - #demo-touch{position:fixed;z-index:20001;width:48px;height:48px;border:4px solid #fff;background:rgba(245,201,91,.55);box-shadow:0 0 0 7px rgba(21,125,120,.28);border-radius:50%;pointer-events:none;transform:translate(-50%,-50%)} -` }); +const captionEvidence = []; +// Pixel proof that a caption actually painted: if the caption band looks identical with and without +// the node, the caption did not reach the screen even though the string was pushed. +async function captionBand() { + return page.screenshot({ clip: { x: 0, y: 0, width: 405, height: 68 } }); +} async function caption(text, duration = 1400) { captionsShown.push(text); + const blankBand = await captionBand().catch(() => null); await page.evaluate(text => { document.querySelector('#demo-caption')?.remove(); const node = document.createElement('div'); node.id = 'demo-caption'; node.textContent = text; + // Inline styles, not a stylesheet: a stylesheet belongs to one document and the demo reloads the + // page, which silently stripped caption styling and left every later caption clipped body text. + node.style.cssText = 'position:fixed;left:16px;right:16px;top:14px;z-index:20000;' + + 'background:rgba(40,33,29,.94);color:#fff;padding:11px 14px;border-radius:14px;' + + 'font:800 12px/1.25 system-ui;letter-spacing:.02em;text-align:center;' + + 'box-shadow:0 8px 24px rgba(0,0,0,.2)'; document.body.append(node); node.animate([{ opacity: 0, transform: 'translateY(-8px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 260, fill: 'forwards' }); }, text); + await sleep(320); + // Structural proof: a fixed bar at the top of the viewport, on top of whatever is beneath it, fully + // inside the frame. A caption that lost its stylesheet sits unstyled at the end of the body instead. + const painted = await page.evaluate(() => { + const node = document.querySelector('#demo-caption'); + if (!node) return { ok: false, reason: 'caption node missing' }; + const rect = node.getBoundingClientRect(); + const style = getComputedStyle(node); + const topmost = document.elementFromPoint(Math.round(rect.left + rect.width / 2), Math.round(rect.top + rect.height / 2)); + return { + ok: style.position === 'fixed' && style.zIndex === '20000' && rect.top <= 40 && rect.height >= 24 + && rect.width > 200 && rect.bottom <= window.innerHeight && (topmost === node || (topmost && node.contains(topmost))), + top: Math.round(rect.top), height: Math.round(rect.height), position: style.position, + zIndex: style.zIndex, background: style.backgroundColor, topmost: topmost === node, + }; + }); + const shownBand = await captionBand().catch(() => null); + const bandChanged = Boolean(blankBand && shownBand && !blankBand.equals(shownBand)); + if (!painted.ok) throw new Error(`Caption is not an on-screen bar: ${text} ${JSON.stringify(painted)}`); + if (!bandChanged) throw new Error(`Caption painted nothing into the caption band: ${text}`); + captionEvidence.push({ text, visible: true, position: painted.position, top: painted.top, height: painted.height, bandChanged }); await sleep(duration); // A caption must not outlive its stated duration. It used to stay on screen until the next one // replaced it, so a view change left the previous screen's claim hanging over a different screen — @@ -150,6 +180,9 @@ async function tap(selector, after = 650) { document.querySelector('#demo-touch')?.remove(); const ring = document.createElement('div'); ring.id = 'demo-touch'; + ring.style.cssText = 'position:fixed;z-index:20001;width:48px;height:48px;border:4px solid #fff;' + + 'background:rgba(245,201,91,.55);box-shadow:0 0 0 7px rgba(21,125,120,.28);border-radius:50%;' + + 'pointer-events:none;transform:translate(-50%,-50%)'; ring.style.left = `${x}px`; ring.style.top = `${y}px`; document.body.append(ring); @@ -170,6 +203,9 @@ async function indicate(selector) { document.querySelector('#demo-touch')?.remove(); const ring = document.createElement('div'); ring.id = 'demo-touch'; + ring.style.cssText = 'position:fixed;z-index:20001;width:48px;height:48px;border:4px solid #fff;' + + 'background:rgba(245,201,91,.55);box-shadow:0 0 0 7px rgba(21,125,120,.28);border-radius:50%;' + + 'pointer-events:none;transform:translate(-50%,-50%)'; ring.style.left = `${x}px`; ring.style.top = `${y}px`; document.body.append(ring); @@ -379,7 +415,9 @@ await page.reload({ waitUntil: 'networkidle' }); // states that a photo exists without rendering it, the image appears only when asked for, removing // it keeps every confirmed field, and delete-all takes the ledger and every photo together. The // only image here is a synthetic 1x1 PNG constant written by this script; no real photo is used. -const SYNTHETIC_PHOTO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII='; +// A 160x120 synthetic test card (checkerboard + 'SYNTHETIC DEMO PHOTO'), so the reveal is legibly +// an image on camera. It carries no photograph of any person or specimen. +const SYNTHETIC_PHOTO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAAG0ElEQVR4nO2deVATZxTAXxKSIldVZqyiTEWucMgpyCGggEK96gFYgSKVenCJoYAy1DpaBYpBnEqt1WoVb2071fHCqi2IiFo70gJaT7COWKdYBZExCW7/2HFJm8OFok5f3m/yx+6X9739Jr98m+zb+RKB1MYaCLwIX/UAiBcLCUaOEbfVEh/33OjGtAT+qYeWlvEPpsy9m9ly+w52g2YwckgwckgwckgwckgwckgwckgwckgwckgwckgwckgwcgTc7cILNeXPjUZTqkWf2dsvgt2gGYwcEowcEowcEowcEowcEowcEowcEowcEowcEowcEowcEowcEowcEoycrqUrfG5Robmbhj6z5bMNmsHIIcHIIcHIIcHIIcHIIcHIIcHIIcHIIcHIIcHIIcHIIcHIIcHI6Vp8xv0Q2r3lBa9uPMR/YsBHuewG90NoWlYXmgxwevkjI3qFx/cusRu0utBQIMHIIcHIIcHIIcHIIcHIMXp+CAAANNTVFRcVqFQqkUiUXyRvf9QuS0/57nC5UCgEgKjJEwrkq6Pfnuju6bV15x62i6+7y7na+uPlR8u2bAaAn3867zXCBwDiEhKXLM4+V1vPJWcjAcDT2WG4uwfbGBo+dsgQaz199+3euWvHNlNTMxMT02Ur8wcOsuqdlwQZUhtr9tH+RwP7YLQxZXxEc/MdhmHKjxySpSUzDJO5IPXQgf0Mw/x48gTb4uPmHBc97eyZaraLj5uzegb1XV1P/atdT9/Tpypnxc7o6OhgGKbih5OJsTO0djQ0OImcVr6n6JaWPxVPngBAaPi4uIREAEhJX7j+s7VPnz7dsK40dYGMDUuTfbB2zeoX81b8B5s3fiHLyjE2NgaA4NFjrN8cqlKpXsJx/3fwFSzLWhQXM/3DRdkXzp/z9vEFAFs7O0ep09K83EFWg23t7dkwP/8AADh7pvoFDZfj2tUrTi6u3O7y/EIjI74fNwYF3xdlalR02Nhxx78vL/h4WXhEZFqGDABSFmRMHBd28NgJ9cj0hZmflhSP9A/Qk02pVCbMjFHf1WzPzF7k4eWtK0NnZyfPkRs4vATfv9/S1Njo6eU9LSpmdGjY5MixrGCbYbampqY2w2zVg339/EUiUY3eSSwWi8t27e3q4u6itV0PQ22GXW6od/PwBACGYXKzMwvlJXw6Ghq8TtECgUCWlny3+Q4APPjrgZXVYP3x6QszS0uKe2F0uomNT1hTvEqhUADA4YMH2A1CE14zuF+//svzCzNSk42NjYVC4coiuf74Eb4jxWJxD1509VO0h6dXZs5iXZFvTZzU2Hhz+qTx/S0tLS0tlyxf0d1jGQh0PxgVmveDqZKFHBKMHBKMHL7XwWyVWCAQKBWK6Hdip0ZFg0bpODFpjoeT/Ziw8JLSz9nGHFnGsaOHL166CgDffr13R9kWsViiVCrenTV7yvQozeQqpXLxkqXD3dy56jQLt6uZRGutu739ka5jGRp8BXNXqB2PH6fMmd3HxCRy/ATNy1aJRHLzxo3Ozk6RSMQwzK1bTRKJBACqKiu+2bN7y/bd5hYWba2t85MS3xg40D9w1L+SX/ntcl5O1r79B7WOQWuS8IjI8IhIAPB1d2GTVFVWlG3+UtexDI1un6L7mJhk5eZt+2qTrgBnV9e6X2oB4HJDg6NUyjZu2rA+OzfP3MICAMwtLLJy8zauX6fZ18FRevv277oy80zCM8xA6MlnsKPUqampUdezo4JCqiorAKDqVMWo4BC28cb1a+qlY2cX1+vXrmr2rak+LXVy1pWZZxKeYQZCTwr0KpVKbCQGHaXjwKDgndu2pmbIaqpPz4zX/msxDMMIBAJul83DMIy5ufmKwlWgu1itJ4kueIZhpSeCf6296CCVgo7S8et9+wqEQrauaWZmxjba2tk31Nd5Prt50FBfZ2fvwHXRzKO1WK0/CQfPMAOh26fo1ocP5Z8UJM2drycmKGR0ibwoQO17TdLc+fKClW1tbQDQ1tpaXJj//rzk7h6aZ5JeORYa+M5g9pzJXsnMmZfs6+cPukvHIWNC18iL9h85xnUPDAq+e7d5VmyMRPKaUqmIT3jPLyCwu2PlmaRXjoUGqkWjgmrRBgcJRg6tLkQFrS40OLR8i+beBZqg+bVdw8lMMxg5JBg5JBg5JBg5JBg5JBg5JBg5JBg5JBg5JBg5JBg5JBg59N+FODPTfxcaCiQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOVpWF+oBzd009JlpdaGhQIKRQ4KRQ4KRQ4KRQ4KRQ4KRQ4KRQ4KRQ4KRQ4KR01WLJlBCMxg5JBg5fwMX/GJFpYaDtAAAAABJRU5ErkJggg=='; const photoDay = (id, daysAgo, bristolType = 4) => ({ ...sampleDay(id, daysAgo, bristolType), photoDataUrl: SYNTHETIC_PHOTO }); const storedLedger = async () => { const key = await page.evaluate(() => `timmy:${document.querySelector('meta[name="timmy-base-path"]')?.content || '/'}:ledger-v1`); @@ -568,7 +606,8 @@ if (expectedFailClosed.length !== 2 || errors.length !== expectedFailClosed.leng // Machine-readable receipt of exactly what was rendered on camera, so the release gate can prove // the recorded delta statement reached the screen instead of trusting the script source. if (captionsOut) { - await writeFile(captionsOut, JSON.stringify({ version, releaseDelta, captions: captionsShown }, null, 2) + '\n', 'utf8'); + await writeFile(captionsOut, JSON.stringify({ version, releaseDelta, captions: captionsShown, + captionsVerified: captionEvidence }, null, 2) + '\n', 'utf8'); } const recording = page.video(); await page.close(); diff --git a/tests/release-demo.test.js b/tests/release-demo.test.js index 9bf5cae..c403ba3 100644 --- a/tests/release-demo.test.js +++ b/tests/release-demo.test.js @@ -168,9 +168,13 @@ test('release demo states the declared release delta on camera and proves it wit assert.match(demo, /TIMMY_RELEASE_DEMO_DELTA/); assert.match(demo, /This release: \$\{releaseDelta\}/); assert.match(demo, /no new feature is claimed/); - // A caption alone is not evidence; the demo must emit a machine-readable receipt of what rendered. + // A caption string alone is not evidence; each caption must be measured as a painted bar, and the + // receipt must carry that per-caption measurement. assert.match(demo, /TIMMY_DEMO_CAPTIONS_OUT/); assert.match(demo, /captionsShown\.push\(text\)/); + assert.match(demo, /captionsVerified: captionEvidence/); + assert.match(demo, /Caption is not an on-screen bar/); + assert.match(demo, /Caption painted nothing into the caption band/); assert.match(demo, /writeFile\(captionsOut/); }); @@ -207,8 +211,10 @@ test('release demo exercises the photo lifecycle on camera without showing an im // Delete-all is actually performed, not just pointed at. assert.match(demo, /tap\('#delete-all'/); assert.match(demo, /no photo bytes may survive delete-all|JSON\.stringify\(localStorage\)\.includes\('data:image'\)/); - // Synthetic bytes only: a committed 1x1 PNG constant, never a real photo. + // Synthetic bytes only: a committed test-card PNG constant, never a real photo. A blank 1x1 pixel + // rendered as an empty white box on camera, which read as a broken image instead of a saved photo. assert.match(demo, /const SYNTHETIC_PHOTO = 'data:image\/png;base64,/); + assert.match(demo, /synthetic test card/); }); test('release builder refuses to publish without the on-camera delta receipt', async () => { @@ -219,10 +225,24 @@ test('release builder refuses to publish without the on-camera delta receipt', a assert.match(builder, /Release demo produced no on-camera caption receipt/); assert.match(builder, /Release demo did not state the declared release delta on camera/); assert.match(builder, /Release demo is missing a required on-camera caption/); + assert.match(builder, /Release demo caption never painted on screen/); + assert.match(builder, /recorded\.get\("captionsVerified"\)/); assert.match(builder, /"release_delta_visible_on_camera": "passed"/); assert.match(builder, /"release_delta": release_delta/); }); +test('caption styling survives the demo reloads instead of dying with the first document', async () => { + const demo = await readFile(demoPath, 'utf8'); + + // A stylesheet belongs to one document; the demo reloads the page, which stripped caption and touch + // styling and left later captions as clipped body text. Both now carry inline styles per node. + assert.doesNotMatch(demo, /page\.addStyleTag/); + assert.doesNotMatch(demo, /addInitScript/); + assert.match(demo, /In a stylesheet belongs to one document|a stylesheet belongs to one document/); + assert.match(demo, /node\.style\.cssText = 'position:fixed;left:16px;right:16px;top:14px;z-index:20000;'/); + assert.match(demo, /ring\.style\.cssText = 'position:fixed;z-index:20001/); +}); + test('release demo captions expire and the contact sheet spans the whole recording', async () => { const demo = await readFile(demoPath, 'utf8'); const builder = await readFile(builderPath, 'utf8');