import { chromium } from 'playwright'; import { mkdir } from 'node:fs/promises'; import path from 'node:path'; const output = path.resolve(process.argv[2] || 'release-demo.webm'); const version = process.env.TIMMY_RELEASE_VERSION || 'review build'; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); await mkdir(path.dirname(output), { recursive: true }); 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 = []; page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); page.on('pageerror', error => errors.push(error.message)); 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', providerReady: true, modelSeen: true, }), })); await page.route('**/api/analyze', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ 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.', }), })); await page.route('**/api/agent/status', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: true, configured: true, authenticated: true, mode: 'hermes-agent' }), })); let hermesCalls = 0; await page.route('**/api/agent/chat', route => { hermesCalls += 1; return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ connected: true, reply: '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' }); 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%)} ` }); async function caption(text, duration = 1400) { await page.evaluate(text => { document.querySelector('#demo-caption')?.remove(); const node = document.createElement('div'); node.id = 'demo-caption'; node.textContent = text; document.body.append(node); node.animate([{ opacity: 0, transform: 'translateY(-8px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 260, fill: 'forwards' }); }, text); await sleep(duration); } 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.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); } await caption(`TIMMY ${version} • FEATURE DEMO`, 1200); 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 self-hosted vision route is ready', 1000); await page.locator('#ai-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 tap('#use-suggestion', 450); await caption('The user reviews every suggestion before saving', 1100); await tap('#close-sheet', 450); 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(); await caption('Hermes keeps credentials, tools, and session continuity server-side. Photos stay out of chat.', 1600); await page.locator('#chat-message').fill('I have rectal bleeding'); 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); 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); if (errors.length) throw new Error(`Browser errors: ${errors.join(' | ')}`); const recording = page.video(); await page.close(); await recording.saveAs(output); await context.close(); await browser.close(); console.log(output);