import { chromium } from 'playwright'; import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { analyzePhoto, visionFailureResponse } from '../src/vision-service.js'; import { loadVisionCorpus } from '../src/vision-corpus.js'; const output = path.resolve(process.argv[2] || 'release-demo.webm'); const version = process.env.TIMMY_RELEASE_VERSION || 'review build'; // The release delta is supplied by the builder so the recording states, on camera, what actually // changed in this release instead of implying a feature that did not ship. const releaseDelta = process.env.TIMMY_RELEASE_DEMO_DELTA || 'Verified rebuild of the current user-visible feature path — no new feature is claimed.'; const captionsOut = process.env.TIMMY_DEMO_CAPTIONS_OUT || ''; const captionsShown = []; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); await mkdir(path.dirname(output), { recursive: true }); // The refusal scene replays committed synthetic corpus cases through the real analysis code path, // so the recording shows the status and copy the server would actually send rather than copy // written for the video. No real photo is used anywhere in the corpus. const corpus = await loadVisionCorpus(fileURLToPath(new URL('../tests/fixtures/vision-corpus/', import.meta.url))); const corpusCase = id => corpus.cases.find(entry => entry.id === id); async function corpusAnalyzeResult(entry) { const fetchImpl = async () => ({ ok: true, json: async () => ({ choices: [{ message: { content: entry.providerContent } }] }) }); try { const result = await analyzePhoto({ payload: { imageDataUrl: 'data:image/png;base64,YQ==', consent: true }, fetchImpl, config: { baseUrl: 'http://127.0.0.1:9/v1', apiKey: 'corpus-replay', model: 'corpus-model' }, }); return { status: 200, body: result }; } catch (error) { const failure = visionFailureResponse(error); return { status: failure.status, body: failure.body }; } } const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 405, height: 720 }, deviceScaleFactor: 1, serviceWorkers: 'block', recordVideo: { dir: path.dirname(output), size: { width: 405, height: 720 } }, colorScheme: 'light', }); const page = await context.newPage(); const errors = []; // Delete-all asks for confirmation; the recording accepts it so the resulting state is shown, not implied. page.on('dialog', dialog => dialog.accept()); page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); page.on('pageerror', error => errors.push(error.message)); let visionOffline = false; // Reachable is not loaded: the worker can answer /v1/models before its pinned model is resident, // and the recording shows the app withholding AI in exactly that state (issue #15). let modelLoaded = true; await page.route('**/api/vision-status', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: true, profile: 'selfhost', processor: 'self-hosted', model: 'SmolVLM2-2.2B-Instruct', // The second pass flips the app's own readiness contract so no AI control is offered at all. providerReady: !visionOffline, modelSeen: !visionOffline && modelLoaded, }), })); // The happy path keeps one deterministic suggestion. The refusal scene swaps this for a real // corpus replay, so both the usable and the refused answers come from the same code path. const suggestionBody = { status: 'suggestion', isStool: true, bristolType: 4, color: 'brown', confidence: 0.83, imageQuality: 'good', observations: 'Smooth, formed appearance.', warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.', }; let analyzeOverride = null; await page.route('**/api/analyze', route => { const response = analyzeOverride || { status: 200, body: suggestionBody }; return route.fulfill({ status: response.status, contentType: 'application/json', body: JSON.stringify(response.body) }); }); let hermesCalls = 0; // Issue #37 turns the agent on and off on camera: local Timmy mode is a real app state, and the // grounded local answer has to be shown as its own surface rather than implied. let agentConnected = true; await page.route('**/api/agent/status', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: true, // Local Timmy mode is the no-backend state the app really uses: nothing configured, nothing sent. configured: agentConnected, authenticated: agentConnected, mode: agentConnected ? 'hermes-agent' : 'local-fallback', }), })); await page.route('**/api/agent/chat', route => { hermesCalls += 1; return route.fulfill({ status: 200, contentType: 'application/json', // The refs are the positional handles the server gives the agent; the browser resolves them // against the same ledger it sent, so this reply is a real citation contract, not a prop. body: JSON.stringify({ connected: true, reply: 'You have 4 confirmed logs in the last 28 days [entry:L1] [entry:L2] [entry:L3] [entry:L4]. Your confirmed logs are mostly Type 4. I can explain that pattern, but I cannot diagnose a cause.' }), }); }); await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' }); await page.evaluate(() => localStorage.clear()); await page.reload({ waitUntil: 'networkidle' }); 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 — // e.g. a delete-data caption sitting on top of the home view. await page.evaluate(() => { const node = document.querySelector('#demo-caption'); if (!node) return; node.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 180, fill: 'forwards' }); setTimeout(() => node.remove(), 220); }); await sleep(220); } async function tap(selector, after = 650) { const target = page.locator(selector).first(); await target.scrollIntoViewIfNeeded(); const box = await target.boundingBox(); if (!box) throw new Error(`Missing demo target: ${selector}`); await page.evaluate(({ x, y }) => { 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); ring.animate([{ opacity: .2, transform: 'translate(-50%,-50%) scale(.55)' }, { opacity: 1, transform: 'translate(-50%,-50%) scale(1)' }], { duration: 400 }); setTimeout(() => ring.remove(), 550); }, { x: box.x + box.width / 2, y: box.y + box.height / 2 }); await sleep(220); await target.click(); await sleep(after); } async function indicate(selector) { const target = page.locator(selector).first(); await target.scrollIntoViewIfNeeded(); const box = await target.boundingBox(); if (!box) throw new Error(`Missing demo target: ${selector}`); await page.evaluate(({ x, y }) => { 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); ring.animate([{ opacity: .2, transform: 'translate(-50%,-50%) scale(.55)' }, { opacity: 1, transform: 'translate(-50%,-50%) scale(1)' }], { duration: 400 }); setTimeout(() => ring.remove(), 550); }, { x: box.x + box.width / 2, y: box.y + box.height / 2 }); await sleep(650); } await caption(`TIMMY ${version} • RELEASE DEMO`, 1200); await caption(`This release: ${releaseDelta}`, 2600); await caption('Automated checks replay this synthetic path before review', 1200); 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(); await caption('The pinned bootstrap verifies both model files before starting on private loopback', 1500); await indicate('label[for="camera-photo"]'); await page.locator('#camera-photo').dispatchEvent('cancel'); await page.getByText(/Camera or photo picker closed/i).waitFor(); await caption('Camera closed cleanly — gallery and manual logging are still available', 1600); await indicate('label[for="gallery-photo"]'); await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg'); await caption('Nothing uploads until explicit consent', 1100); await page.locator('#ai-consent').check(); await tap('#analyze-photo', 450); await page.getByText(/83% confidence/i).waitFor(); await caption('AI may suggest visible form, broad color, and image quality — never symptoms or diagnosis', 1700); await indicate('.scan-result[data-provenance="pending"]'); await caption('The suggestion stays provisional — nothing is applied or saved until you confirm it', 1700); await tap('#use-suggestion', 450); await page.locator('.ai-provenance[data-provenance="confirmed"]').waitFor(); await caption('Confirming is explicit, and every suggested value is still yours to overrule', 1600); await tap('[data-type="2"]', 500); await page.locator('[data-provenance="corrected"]').waitFor(); await caption('Change a suggested value and Timmy clears the AI-confirmed state', 1700); await indicate('.ai-provenance[data-provenance="corrected"] .provenance-note'); await tap('#next', 450); await page.getByText(/You changed it to Type 2/).waitFor(); await caption('Only visible form and color were ever suggested — urgency, discomfort, and symptoms stay yours', 1900); await tap('#next', 450); await page.locator('[data-followup-block]').waitFor(); await caption('Type 2 is hard or lumpy, so Timmy asks about soreness, hydration, blood, and black-or-tarry — never guessed from the photo', 2600); await page.locator('[data-question="bloodVisible"]').scrollIntoViewIfNeeded(); await indicate('[data-question="bloodVisible"]'); await tap('[data-followup="bloodVisible"][data-answer="no"]', 400); await caption('Every answer is yours to give, and a skipped question stays unanswered', 1900); await tap('[data-followup="soreness"][data-answer="yes"]', 350); await tap('[data-followup="dehydration"][data-answer="yes"]', 350); await page.locator('#followup-guidance').waitFor(); await caption('Mild report: steady water, no straining, gentle care — general self-care, not a diagnosis', 2600); await indicate('#followup-guidance'); await caption('And it never claims the photo ruled out bleeding', 1900); await tap('[data-followup="tarryBlack"][data-answer="yes"]', 600); await page.locator('#urgent-box .alert').waitFor(); await caption('Say it was truly black and tarry and the same deterministic red-flag path fires instead', 2600); await tap('[data-followup="tarryBlack"][data-answer="no"]', 500); await page.locator('#urgent-box .alert').waitFor({ state: 'detached' }); await caption('Dark brown is not silently escalated, and the flag clears the moment the answer changes', 2200); await tap('#close-sheet', 450); await caption('Closing the log keeps the draft in this session instead of throwing it away', 1600); await caption('Now the same photo flow while the vision worker is offline', 1500); visionOffline = true; await tap('[data-scan]', 550); await page.getByText(/Vision worker offline/i).waitFor(); await caption('Vision worker offline — the AI and consent steps disappear instead of failing after upload', 1900); await indicate('label[for="gallery-photo"]'); await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg'); await page.getByText(/Ready for your review/i).waitFor(); await caption('No consent prompt and no upload when analysis cannot run', 1600); await tap('#manual-from-scan', 500); const keptDraft = await page.locator('.draft-resume').innerText({ timeout: 5000 }); if (!/Type 2/.test(keptDraft)) throw new Error('The retained draft must show the value the user corrected to'); await caption('One tap still reaches manual Bristol entry, and the draft keeps your corrected value', 1800); await tap('#discard-draft', 500); await caption('Start over clears the kept draft — nothing lingers silently', 1500); await tap('#close-sheet', 450); // Post-load readiness pass (issue #15): the worker answers, but its pinned model is not resident // yet. The app must withhold AI instead of offering a control that fails after the upload. Every // claim below is asserted in the browser, so the recording cannot show a state the app does not have. modelLoaded = false; visionOffline = false; await caption('Now the same worker while its pinned model is still loading', 1700); await tap('[data-scan]', 550); await page.getByText(/model is not loaded yet/i).first().waitFor(); if (await page.getByText(/Self-hosted model ready/i).count() !== 0) { throw new Error('A reachable worker without its loaded model must never be reported ready'); } const loadingAmber = await page.locator('.model-status.loading').first().evaluate(el => getComputedStyle(el).backgroundColor); if (loadingAmber !== 'rgb(253, 243, 216)') throw new Error('The not-loaded state must be visually distinct from ready and offline'); await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg'); await page.getByText(/Ready for your review/i).waitFor(); if (await page.locator('#analyze-photo').count() !== 0) throw new Error('No AI control may exist before the model is loaded'); if (await page.locator('.consent-card').count() !== 0) throw new Error('No consent may be requested before the model is loaded'); await caption('Reachable is not loaded: no AI control, no consent, and no upload until the pinned model is resident', 2900); await tap('#manual-from-scan', 500); await page.getByText('Pick the closest form').waitFor(); await caption('One tap still reaches manual Bristol entry while the model finishes loading', 2000); await tap('#close-sheet', 450); modelLoaded = true; await caption('Once the pinned model is loaded, the same screen offers the AI analysis again', 2200); await tap('[data-scan]', 550); await page.getByText(/Self-hosted model ready/i).waitFor(); await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg'); await page.getByText(/Ready for your review/i).waitFor(); if (await page.locator('.consent-card').isVisible() !== true) throw new Error('The AI path must return once the model is loaded'); if (await page.locator('#analyze-photo').count() !== 1) throw new Error('The analyze control must return once the model is loaded'); await tap('#close-sheet', 450); // Corpus refusal pass (issue #18): the same photo flow, fed the answers Timmy has to refuse. visionOffline = false; await caption('The corpus also replays the answers Timmy has to refuse', 1700); analyzeOverride = await corpusAnalyzeResult(corpusCase('non-stool-scene')); await tap('[data-scan]', 500); await page.getByText(/Self-hosted model ready/i).waitFor(); await page.locator('#gallery-photo').setInputFiles('tests/fixtures/vision-corpus/non-stool-scene.png'); await page.locator('#ai-consent').check(); await tap('#analyze-photo', 600); await page.getByText(/No confident match/i).waitFor(); await caption('A non-stool photo gets a refusal, never a guess — manual entry stays one tap away', 2600); await indicate('.scan-result'); analyzeOverride = await corpusAnalyzeResult(corpusCase('glare-overexposed')); await tap('#retake-photo', 400); await page.locator('#gallery-photo').setInputFiles('tests/fixtures/vision-corpus/glare-overexposed.png'); await page.locator('#ai-consent').check(); await tap('#analyze-photo', 600); await page.getByText(/does not show a form Timmy can read safely/i).waitFor(); await caption('Unreadable visual fields abstain as well: a valid reply with nothing to prefill is not an error', 2700); await indicate('.scan-result'); analyzeOverride = await corpusAnalyzeResult(corpusCase('out-of-range-fields')); await tap('#retake-photo', 400); await page.locator('#gallery-photo').setInputFiles('tests/fixtures/vision-corpus/out-of-range-fields.png'); await page.locator('#ai-consent').check(); await tap('#analyze-photo', 600); await page.getByText(/couldn’t analyze that safely/i).waitFor(); await caption('An out-of-range answer fails closed: nothing is applied and no provider detail reaches you', 2700); await indicate('.scan-result'); // Issue #73's escape class: one field blank, the other filled in and impossible. The old blank-first // ordering abstained here, which told the user the photo was unreadable while the model had returned a // value Timmy must never apply. The scene is asserted in the browser, not just captioned. analyzeOverride = await corpusAnalyzeResult(corpusCase('blank-plus-out-of-range')); await tap('#retake-photo', 400); await page.locator('#gallery-photo').setInputFiles('tests/fixtures/vision-corpus/blank-plus-out-of-range.png'); await page.locator('#ai-consent').check(); await tap('#analyze-photo', 600); await page.getByText(/couldn’t analyze that safely/i).waitFor(); if (await page.getByText(/No confident match/i).count() !== 0) { throw new Error('A filled-in out-of-range field must not be shown as an unreadable-photo abstention'); } await caption('A filled-in field that is out of range still fails closed, even when the other field came back blank', 2900); await indicate('.scan-result'); await caption('You are no longer told the photo was unreadable when the model returned an impossible value', 2400); await indicate('.scan-result'); await caption('Every refusal replays a committed synthetic fixture — there are no real photos in the corpus', 2500); analyzeOverride = null; await tap('#close-sheet', 450); // Explainable pattern summary (issue #34). The recording seeds fictional multi-day sample logs into // the app's own local ledger contract, then follows one summary line to the exact records behind it. // Every claim below is checked in the browser, and no real health data is involved anywhere. const seedSyntheticLedger = async entries => { const key = await page.evaluate(() => `timmy:${document.querySelector('meta[name="timmy-base-path"]')?.content || '/'}:ledger-v1`); await page.evaluate(({ storeKey, list }) => localStorage.setItem(storeKey, JSON.stringify(list)), { storeKey: key, list: entries }); await page.reload({ waitUntil: 'networkidle' }); }; const sampleDay = (id, daysAgo, bristolType) => ({ id, occurredAt: new Date(Date.now() - daysAgo * 86400000).toISOString(), bristolType, color: 'brown', urgency: 1, discomfort: 1, note: '', photoDataUrl: '', symptoms: {}, }); await caption('The journal explains its own pattern lines instead of just asserting a trend', 2200); await seedSyntheticLedger([ sampleDay('demo-firm', 2, 2), sampleDay('demo-typical-1', 5, 3), sampleDay('demo-typical-2', 9, 4), sampleDay('demo-typical-3', 12, 4), sampleDay('demo-old', 40, 6), ]); await caption("Fictional sample logs are seeded into this browser's own storage — no real health data appears here", 2600); await page.locator('[data-pattern-summary]').waitFor(); await indicate('[data-pattern-summary]'); await caption('The summary says which confirmed logs it read, and a citation opens exactly those records', 2400); await indicate('[data-statement="typical"]'); await tap('[data-statement-link="typical"]', 650); const citedRecords = await page.locator('[data-summary-record]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-entry-id')).sort()); if (JSON.stringify(citedRecords) !== JSON.stringify(['demo-typical-1', 'demo-typical-2', 'demo-typical-3'])) { throw new Error('A summary citation must list exactly the records it cites'); } await caption('Every pattern line names the confirmed logs behind it, and following one shows exactly those records', 2900); await indicate('[data-summary-records]'); await tap('#pattern-records-close', 450); await seedSyntheticLedger([sampleDay('demo-sparse-1', 1, 4), sampleDay('demo-sparse-2', 4, 6)]); await page.locator('[data-summary-insufficient]').waitFor(); await indicate('[data-summary-insufficient]'); await caption('Two logs is not a pattern: the summary refuses to describe a trend it cannot support', 2900); // Restore the empty ledger the earlier scenes started from, so the rest of the recording is unchanged. await page.evaluate(() => localStorage.clear()); await page.reload({ waitUntil: 'networkidle' }); // Issue #36 — a saved photo and its confirmed log are separate things a person owns. The journal // 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. // 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,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'; 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`); const raw = await page.evaluate(storeKey => localStorage.getItem(storeKey), key); return { key, raw }; }; await caption('A confirmed log can hold a private photo — the journal says so without showing it', 2300); await seedSyntheticLedger([photoDay('demo-photo', 1), sampleDay('demo-nophoto', 4, 6)]); await tap('[data-view="calendar"]', 550); await page.locator('.entries-card .entry').first().waitFor(); if (await page.locator('.entries-card [data-photo-indicator="present"]').count() !== 1) { throw new Error('exactly one log must be marked as holding a photo'); } if (await page.locator('.entries-card img').count() !== 0) { throw new Error('the journal must not show a thumbnail by default'); } await caption('Photo presence is a label, not a thumbnail — nothing sensitive is on screen until you ask', 2700); await indicate('[data-photo-indicator="present"]'); await tap('[data-entry-open="demo-photo"]', 550); await page.locator('[data-photo-lifecycle="present"]').waitFor(); if (await page.locator('#entry-photo').count() !== 0) { throw new Error('opening a log must not reveal its photo'); } await caption('Opening the log states the photo is saved here — and still does not render it', 2500); await indicate('[data-photo-lifecycle="present"]'); await tap('#show-entry-photo', 600); await page.locator('#entry-photo').waitFor(); const revealedPhoto = await page.locator('#entry-photo').getAttribute('src'); if (!/^data:image\/png;base64,/.test(revealedPhoto || '')) throw new Error('the revealed image must be the stored bytes'); await caption('Showing the photo is your explicit request, from this browser’s own storage', 2300); await indicate('#entry-photo'); await tap('#remove-entry-photo', 650); await page.locator('[data-photo-lifecycle="absent"]').waitFor(); const afterRemoval = JSON.parse((await storedLedger()).raw || '[]'); const keptLog = afterRemoval.find(entry => entry.id === 'demo-photo'); if (afterRemoval.length !== 2 || !keptLog || keptLog.photoDataUrl !== '') { throw new Error('removing a photo must keep the confirmed log and clear only its image bytes'); } if (keptLog.bristolType !== 4 || keptLog.color !== 'brown') throw new Error('removing a photo must not alter the confirmed fields'); await caption('Remove just the photo: the confirmed log, form, and note are all still here', 2900); await indicate('[data-photo-lifecycle="absent"]'); await tap('#entry-sheet-close', 450); await page.locator('.entries-card .entry').first().waitFor(); if (await page.locator('.entries-card [data-photo-indicator="present"]').count() !== 0) { throw new Error('the journal must drop the photo label once the bytes are gone'); } await caption('The label follows the bytes: the log stays, the photo is gone', 2300); // One photo-bearing log remains for the deletion pass below, so the wipe shows photos leaving with // the ledger rather than an empty promise. await seedSyntheticLedger([photoDay('demo-wipe', 2)]); await caption('The threat model documents this surface, so the recording shows it live rather than describing it', 2200); await tap('[data-view="calendar"]', 550); await page.getByText(/Your private journal/i).waitFor(); await tap('[data-view="privacy"]', 550); await page.getByText(/Your poop\. Your phone\./i).waitFor(); await caption('Every byte-bearing control the model names, in the running app: local ledger, consent-gated upload, portable export, full deletion', 2800); await indicate('#export'); await caption('Export writes a plain JSON file you hold — the model records it as a data-lifetime boundary, not a server sync', 2600); await indicate('#delete-all'); await tap('#delete-all', 650); await page.getByText(/Local ledger and photos deleted/).waitFor(); const wiped = await storedLedger(); if (wiped.raw !== null) throw new Error('delete-all must remove the stored ledger'); if (await page.evaluate(() => JSON.stringify(localStorage).includes('data:image'))) { throw new Error('no photo bytes may survive delete-all'); } await caption('Delete all local data clears the ledger and every saved photo in this browser; nothing is kept on a server', 2900); await tap('[data-view="home"]', 500); // Issue #37 — a chat answer about the journal must point at the confirmed records behind it. The // scenes below run against the app's own local ledger contract with fictional logs, and every claim // is asserted in the browser before the caption is shown. agentConnected = false; await seedSyntheticLedger([ sampleDay('demo-chat-1', 2, 4), sampleDay('demo-chat-2', 5, 3), sampleDay('demo-chat-3', 9, 4), sampleDay('demo-chat-4', 12, 4), ]); await tap('[data-view="timmy"]', 550); await page.getByText(/Local Timmy mode/i).waitFor(); await caption('Fictional logs are seeded into this browser only — the chat answers from them, not from model memory', 2600); await page.locator('#chat-message').fill('How many times have I logged?'); await tap('#send-chat', 500); const groundedAnswer = page.locator('.bubble.timmy', { hasText: '4 confirmed logs' }).last(); await groundedAnswer.locator('[data-chat-citation]').first().waitFor(); if (await groundedAnswer.locator('[data-chat-citation]').count() !== 4) { throw new Error('A grounded chat answer must cite every record it counted'); } await caption('The answer states the count and cites the four confirmed logs it counted', 2600); await indicate('[data-chat-citations]'); await tap('[data-chat-citation]', 700); await page.locator('[data-chat-record]').waitFor(); if (await page.locator('[data-chat-record]').count() !== 1) { throw new Error('A chat citation must open exactly the one record it names'); } await caption('Following a citation opens exactly the confirmed log behind that answer', 2600); await indicate('[data-chat-record-sheet]'); await tap('#chat-record-close', 450); await page.locator('#chat-message').fill('Is it safe to eat tacos?'); await tap('#send-chat', 500); await page.getByText(/cannot clear a food/i).waitFor(); if (await page.locator('.bubble.timmy').last().locator('[data-chat-citation]').count() !== 0) { throw new Error('A refusal must not borrow journal citations'); } await caption('Ask something the journal cannot answer and Timmy refuses instead of reassuring', 2800); agentConnected = true; await page.reload({ waitUntil: 'networkidle' }); await tap('[data-view="timmy"]', 550); await page.getByText(/Hermes Agent connected/i).waitFor(); await caption('Hermes Agent connected — free-text, contextual, and server-side', 1400); await page.locator('#chat-message').fill('What pattern do you see?'); await tap('#send-chat', 500); await page.getByText(/mostly Type 4/i).waitFor(); if (await page.locator('.bubble.timmy').last().locator('[data-chat-citation]').count() !== 4) { throw new Error('A Hermes answer must render the records it cites as sources'); } await caption('Hermes answers carry the same ledger citations, rendered as sources in the transcript', 2400); await caption('Hermes keeps credentials, tools, and session continuity server-side. Photos stay out of chat.', 1600); await page.locator('#chat-message').fill('I barfed'); await tap('#send-chat', 450); await page.getByText(/Pause and get medical help/i).waitFor(); if (hermesCalls !== 1) throw new Error('Urgent chat must be intercepted before Hermes'); await caption('Urgent language is intercepted deterministically before Hermes', 1800); // Issue #21 — the escalation has to fire for the phrasing people actually use, not only for the one // word the previous scene typed. Each line below is asserted in the browser: exactly one new Timmy // bubble, carrying the app's own fixed copy, with hermesCalls unmoved. const escalationBubbles = () => page.locator('.bubble.timmy', { hasText: 'Pause and get medical help' }).count(); const expectEscalation = async (text, label) => { const before = await escalationBubbles(); await page.locator('#chat-message').fill(text); await tap('#send-chat', 450); const bubble = page.locator('.bubble.timmy', { hasText: 'Pause and get medical help' }).last(); await bubble.waitFor(); if (await escalationBubbles() !== before + 1) throw new Error(`${label}: expected exactly one escalation bubble`); if (!/^Pause and get medical help\. These reported symptoms can need prompt medical care\./.test(await bubble.innerText())) { throw new Error(`${label}: the escalation copy must be the app's own fixed string`); } if (hermesCalls !== 1) throw new Error(`${label}: an escalated turn must not reach Hermes`); }; await expectEscalation('There is blood when I wipe', 'bleeding reported as wiping'); await caption('Bleeding reported as wiping still escalates, in the app’s own fixed words', 2600); await expectEscalation('I have tarry stools', 'tarry stool'); await caption('Tarry and black stool wording escalates too: one red flag, one unchanging message', 2600); // The same detector runs over the confirmed ledger, so a symptom already written down as a note // escalates even when the question itself is innocuous. The seeded entry is fictional and stays in // this browser, exactly like the summary scene above. await page.evaluate(() => localStorage.clear()); await seedSyntheticLedger([{ ...sampleDay('demo-tarry-note', 1, 4), note: 'stool was tarry yesterday' }]); await tap('[data-view="timmy"]', 550); const ledgerEscalations = await escalationBubbles(); await page.locator('#chat-message').fill('What do my recent logs show?'); await tap('#send-chat', 450); await page.locator('.bubble.timmy', { hasText: 'Pause and get medical help' }).last().waitFor(); if (await escalationBubbles() !== ledgerEscalations + 1) throw new Error('a tarry note in the journal must escalate the next answer'); if (hermesCalls !== 1) throw new Error('a ledger red flag must be intercepted before Hermes'); await caption('A tarry note already saved in the journal escalates the same way, before any provider call', 2800); await page.evaluate(() => localStorage.clear()); await page.reload({ waitUntil: 'networkidle' }); await tap('[data-view="timmy"]', 500); await sleep(400); await page.evaluate(() => { document.querySelector('#demo-caption')?.remove(); const outro = document.createElement('div'); outro.id = 'release-outro'; outro.innerHTML = 'SLEEK. SIMPLE.
HERMES-POWERED.
User-confirmed guidance — never a diagnosis.'; Object.assign(outro.style, { position:'fixed', inset:'0', zIndex:'30000', background:'linear-gradient(145deg,#f7f3ea,#f5c95b)', display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', textAlign:'center', color:'#28211d', fontFamily:'system-ui', opacity:'0' }); outro.querySelector('img').style.cssText = 'width:150px;height:150px;filter:drop-shadow(0 14px 16px rgba(63,37,30,.18))'; outro.querySelector('strong').style.cssText = 'font-size:31px;line-height:1.05;margin:18px 0 12px;letter-spacing:-.04em'; outro.querySelector('span').style.cssText = 'font-size:13px;font-weight:800;color:#176957'; document.body.append(outro); outro.animate([{opacity:0},{opacity:1}], {duration:500,fill:'forwards'}); }); await sleep(2200); // The refusal pass deliberately produces two fail-closed 503s (an out-of-range reply, and one blank field // beside an out-of-range one), which the browser logs as failed resources. Requiring exactly those two // keeps the scene honest: if the boundary stops refusing, this fails instead of quietly recording a happy // path, and a dropped scene is caught rather than silently shrinking the evidence. const expectedFailClosed = errors.filter(message => /Failed to load resource: the server responded with a status of 503 \(Service Unavailable\)/.test(message)); if (expectedFailClosed.length !== 2 || errors.length !== expectedFailClosed.length) { throw new Error(`Browser errors: ${errors.join(' | ') || 'unexpected error count'}`); } // 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, captionsVerified: captionEvidence }, null, 2) + '\n', 'utf8'); } const recording = page.video(); await page.close(); await recording.saveAs(output); await context.close(); await browser.close(); console.log(output);