/** * Functional smoke test: drives the real page in headless Chrome over CDP. * Assumes a server on 127.0.0.1:8765 and Chrome with --remote-debugging-port=9222. */ const PORT = 9222; const ORIGIN = '127.0.0.1:8765'; const GREY = 'rgb(139, 139, 150)'; const RED = 'rgb(255, 59, 48)'; function fail(msg) { console.error('FAIL: ' + msg); process.exit(1); } async function connect() { const res = await fetch(`http://127.0.0.1:${PORT}/json/list`); const targets = await res.json(); const page = targets.find((t) => t.type === 'page' && t.url.includes(ORIGIN)); if (!page) fail('page target not found: ' + JSON.stringify(targets.map((t) => t.url))); const ws = new WebSocket(page.webSocketDebuggerUrl); await new Promise((resolve, reject) => { ws.addEventListener('open', resolve, { once: true }); ws.addEventListener('error', reject, { once: true }); }); let nextId = 0; const pending = new Map(); ws.addEventListener('message', (ev) => { const msg = JSON.parse(ev.data); const resolve = pending.get(msg.id); if (resolve) { pending.delete(msg.id); resolve(msg); } }); const send = (method, params) => new Promise((resolve) => { const id = ++nextId; pending.set(id, resolve); ws.send(JSON.stringify({ id, method, params })); }); const evaluate = async (expression) => { const msg = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true, }); if (msg.result?.exceptionDetails) { throw new Error('page exception: ' + JSON.stringify(msg.result.exceptionDetails)); } return msg.result.result.value; }; return { evaluate, send, close: () => ws.close() }; } const SNAPSHOT = `(() => { const clock = document.getElementById('clock'); const exName = document.getElementById('exerciseName'); return { phase: document.getElementById('phaseLabel').textContent, exercise: exName.textContent, clock: clock.textContent, progress: document.getElementById('progress').textContent, next: document.getElementById('nextUp').textContent, bodyClass: document.body.className, exColor: getComputedStyle(exName).color, clockColor: getComputedStyle(clock).color, }; })()`; const SETUP = `(() => { // measure target colours, not the in-flight transition const style = document.createElement('style'); style.textContent = '* { transition: none !important; animation: none !important; }'; document.head.append(style); const setVal = (id, v) => { const el = document.getElementById(id); el.value = String(v); el.dispatchEvent(new Event('change', { bubbles: true })); }; setVal('pauseInput', 2); setVal('workInput', 1); setVal('setsInput', 2); const add = (name) => { const input = document.getElementById('exerciseInput'); input.value = name; document.getElementById('addBtn').click(); }; add(' '); // blank input must be ignored add('Push-ups'); add('Squats'); add('Plank'); document.getElementById('exerciseInput').value = 'Deadlift'; document.getElementById('addBtn').click(); document.querySelectorAll('#exerciseList li')[3].querySelector('button').click(); // remove Deadlift return { listed: Array.from(document.querySelectorAll('#exerciseList li .name')).map(n => n.textContent), clockBeforeStart: document.getElementById('clock').textContent, startLabel: document.getElementById('startBtn').textContent, }; })()`; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function expect(cond, msg) { if (!cond) fail(msg); } /** Evaluate, tolerating the brief window where a reload has no execution context yet. */ async function evaluateRetry(evaluate, expression, attempts = 15) { for (let i = 0; i < attempts; i++) { try { return await evaluate(expression); } catch { await sleep(300); } } return fail(`page never became responsive: ${expression}`); } /** "00:07" -> 7 */ function toSecs(text) { const [m, s] = text.split(':').map(Number); return m * 60 + s; } async function main() { const { evaluate, send, close } = await connect(); // the settings panel must be reachable before anything can be configured await evaluate(`document.getElementById('settingsToggle').click()`); const panel = await evaluate(`({ hidden: document.getElementById('settings').hidden, expanded: document.getElementById('settingsToggle').getAttribute('aria-expanded'), })`); expect(!panel.hidden && panel.expanded === 'true', 'settings toggle should open the panel'); const setup = await evaluate(SETUP); console.log('setup:', JSON.stringify(setup)); if (JSON.stringify(setup.listed) !== JSON.stringify(['Push-ups', 'Squats', 'Plank'])) { fail('exercise list should hold exactly the three added names'); } if (setup.startLabel !== 'Start') fail('start button should read "Start" before starting'); await evaluate(`document.getElementById('startBtn').click()`); const seen = []; const colors = { grey: 0, red: 0 }; const deadline = Date.now() + 30000; while (Date.now() < deadline) { const s = await evaluate(SNAPSHOT); const key = `${s.phase}|${s.exercise}|${s.progress}`; if (!seen.length || seen[seen.length - 1].key !== key) { seen.push({ key, at: Date.now(), clockSecs: toSecs(s.clock), ...s }); } else { const last = seen[seen.length - 1]; if (toSecs(s.clock) > last.clockSecs) fail(`clock went up during ${s.phase}: ${last.clock} -> ${s.clock}`); last.clockSecs = toSecs(s.clock); } if (s.phase === 'Done') break; await sleep(150); } console.log('\nobserved sequence:'); for (const s of seen) { console.log(` ${s.phase.padEnd(8)} | ${s.exercise.padEnd(16)} | ${s.clock} | ${s.progress} | ${s.next}`); if (s.exercise === 'Workout complete') continue; if (s.phase === 'Pause') { if (s.exColor !== GREY || s.clockColor !== GREY) fail(`pause should be grey, got ${s.exColor}`); colors.grey++; } else if (s.phase === 'Training') { if (s.exColor !== RED || s.clockColor !== RED) fail(`training should be red, got ${s.exColor}`); colors.red++; } } const expected = [ 'Pause|Push-ups', 'Training|Push-ups', 'Pause|Squats', 'Training|Squats', 'Pause|Plank', 'Training|Plank', 'Pause|Push-ups', 'Training|Push-ups', 'Pause|Squats', 'Training|Squats', 'Pause|Plank', 'Training|Plank', 'Done|Workout complete', ]; const actual = seen.map((s) => `${s.phase}|${s.exercise}`); console.log('\nexpected:', expected.join(' -> ')); console.log('actual: ', actual.join(' -> ')); if (actual.join('|') !== expected.join('|')) { fail('phase sequence mismatch'); } // each pause should last ~2s and each training block ~1s (poll lag allowed for). // phases chain off their deadline, so error must not accumulate from one to the next. for (let i = 0; i < seen.length - 1; i++) { const expectedMs = seen[i].phase === 'Pause' ? 2000 : 1000; const actualMs = seen[i + 1].at - seen[i].at; console.log(` ${seen[i].phase.padEnd(8)} ${String(actualMs).padStart(5)}ms (expected ~${expectedMs}ms)`); if (Math.abs(actualMs - expectedMs) > 350) { fail(`${seen[i].phase} of ${seen[i].exercise} lasted ${actualMs}ms, expected ~${expectedMs}ms`); } } /* ---- restart / pause / resume / reset / persistence ---- */ await evaluate(`document.getElementById('startBtn').click()`); await sleep(200); let s = await evaluate(SNAPSHOT); expect(s.phase === 'Pause' && s.progress.includes('Set 1 / 2'), `restart should begin a fresh session, got ${s.phase} / ${s.progress}`); expect(s.exercise === 'Push-ups', `restart should show the first exercise, got ${s.exercise}`); let ui = await evaluate(`({ startHidden: document.getElementById('startBtn').hidden, resetHidden: document.getElementById('resetBtn').hidden, inputsDisabled: document.getElementById('pauseInput').disabled && document.getElementById('addBtn').disabled, })`); expect(ui.startHidden && !ui.resetHidden, 'while running: Start hidden, Reset visible'); expect(ui.inputsDisabled, 'settings inputs should be locked while running'); await evaluate(`document.getElementById('pauseBtn').click()`); await sleep(150); const before = await evaluate(SNAPSHOT); await sleep(700); const after = await evaluate(SNAPSHOT); expect(before.phase === 'Paused', `expected Paused label, got ${before.phase}`); expect(before.clock === after.clock, `clock must freeze while paused: ${before.clock} -> ${after.clock}`); expect(await evaluate(`document.getElementById('pauseBtn').textContent`) === 'Resume', 'pause button should offer Resume'); await evaluate(`document.getElementById('pauseBtn').click()`); await sleep(150); s = await evaluate(SNAPSHOT); expect(s.phase === 'Pause', `resume should continue the paused phase, got ${s.phase}`); await evaluate(`document.getElementById('resetBtn').click()`); await sleep(200); s = await evaluate(SNAPSHOT); expect(s.phase === 'Ready' && s.clock === '00:02', `reset should return to Ready / 00:02, got ${s.phase} / ${s.clock}`); ui = await evaluate(`({ startLabel: document.getElementById('startBtn').textContent, startHidden: document.getElementById('startBtn').hidden, pauseHidden: document.getElementById('pauseBtn').hidden, resetHidden: document.getElementById('resetBtn').hidden, inputsDisabled: document.getElementById('pauseInput').disabled, listed: Array.from(document.querySelectorAll('#exerciseList li .name')).map((n) => n.textContent), })`); expect(ui.startLabel === 'Start' && !ui.startHidden, 'reset should restore the Start button'); expect(ui.pauseHidden && ui.resetHidden, 'reset should hide Pause and Reset'); expect(!ui.inputsDisabled, 'settings inputs should be editable again after reset'); expect(JSON.stringify(ui.listed) === JSON.stringify(['Push-ups', 'Squats', 'Plank']), 'exercises should survive a reset'); const stored = await evaluate(`localStorage.getItem('interval-timer/v1')`); const parsed = JSON.parse(stored); expect( parsed.pauseSec === 2 && parsed.workSec === 1 && parsed.sets === 2 && parsed.exercises.length === 3, 'settings should be persisted: ' + stored ); console.log('\nrestart/pause/resume/reset/persistence: ok'); /* ---- PWA: service worker, cached shell, offline boot ---- */ const sw = await evaluate(`(async () => { if (!('serviceWorker' in navigator)) return { supported: false }; const reg = await navigator.serviceWorker.ready; const keys = await caches.keys(); const cached = []; for (const key of keys) { for (const req of await (await caches.open(key)).keys()) { cached.push(new URL(req.url).pathname); } } return { supported: true, active: !!reg.active, scope: reg.scope, cached: cached.sort() }; })()`); console.log('service worker:', JSON.stringify(sw)); expect(sw.supported, 'service workers should be supported in a secure context'); expect(sw.active, 'service worker should be installed and active'); for (const asset of ['/', '/index.html', '/styles.css', '/app.js', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png', '/icons/icon-maskable-512.png']) { expect(sw.cached.includes(asset), `app shell should be cached: ${asset} (have ${sw.cached.join(', ')})`); } await send('Network.enable'); await send('Page.enable'); await send('Network.emulateNetworkConditions', { offline: true, latency: 0, downloadThroughput: 0, uploadThroughput: 0, }); await send('Page.reload', {}); const offlinePhase = await evaluateRetry(evaluate, `document.getElementById('phaseLabel').textContent`); const offlineEx = await evaluateRetry(evaluate, `document.getElementById('exerciseName').textContent`); expect(offlinePhase === 'Ready', `app should boot from cache while offline, got "${offlinePhase}"`); expect(offlineEx === 'Push-ups', `offline boot should restore saved exercises, got "${offlineEx}"`); console.log('offline reload: ok (Phase="' + offlinePhase + '", Exercise="' + offlineEx + '")'); await send('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1, }); close(); console.log(`\nPASS — pause/training sequence, grey->red colours, timing, controls, PWA offline all verified`); } main().catch((err) => fail(err.stack || String(err)));