Files
timer/tools/smoke_test.mjs
T
2026-09-19 07:28:18 -03:00

320 lines
12 KiB
JavaScript

/**
* 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)));