hallo knut
This commit is contained in:
commit
33a8b18147
79 files changed
+2267
No files matched your search
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* 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)));
|
||||
Reference in new issue
Block a user