hallo knut

This commit is contained in:
oliver committed 2026-09-19 07:28:18 -03:00
commit 33a8b18147
79 files changed
+2267

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
"""Generates the PWA icons. Run: python3 tools/make_icons.py"""
import os
from PIL import Image, ImageDraw
BG = (14, 14, 17, 255)
GREY = (139, 139, 150, 255)
RED = (255, 59, 48, 255)
WHITE = (242, 242, 245, 255)
OUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "icons")
SS = 4 # supersampling factor for smooth edges
def make_icon(size, content_scale):
s = size * SS
img = Image.new("RGBA", (s, s), BG)
d = ImageDraw.Draw(img)
cx = cy = s / 2
r = s * content_scale / 2
width = int(s * 0.072)
box = [cx - r, cy - r, cx + r, cy + r]
# grey arc = pause (270 deg), red arc = training (90 deg)
d.arc(box, start=-90, end=180, fill=GREY, width=width)
d.arc(box, start=180, end=270, fill=RED, width=width)
# clock hand pointing up
hand_w = int(s * 0.05)
d.line([(cx, cy), (cx, cy - r * 0.58)], fill=WHITE, width=hand_w)
dot = hand_w * 0.9
d.ellipse([cx - dot, cy - dot, cx + dot, cy + dot], fill=WHITE)
return img.resize((size, size), Image.Resampling.LANCZOS)
def main():
os.makedirs(OUT_DIR, exist_ok=True)
targets = [
("icon-192.png", 192, 0.78),
("icon-512.png", 512, 0.78),
("icon-maskable-512.png", 512, 0.60),
]
for name, size, scale in targets:
path = os.path.join(OUT_DIR, name)
make_icon(size, scale).save(path, "PNG")
print("wrote", path)
if __name__ == "__main__":
main()
+117
View File
@@ -0,0 +1,117 @@
"""Probes Firefox's web-platform support by loading a page that reports back.
Usage: python3 tools/probe_firefox.py
Launches headless Firefox against a local page whose script POSTs its feature
detection results back to this server, then prints them.
"""
import http.server
import json
import os
import shutil
import subprocess
import threading
PORT = 8766
# Snap-confined Firefox cannot see /tmp, so the profile lives next to the repo.
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROFILE = os.path.join(ROOT, 'tmp-ffprof-probe')
USER_JS = '''
user_pref("browser.shell.checkDefaultBrowser", false);
user_pref("browser.startup.homepage_override.mstone", "ignore");
user_pref("datareporting.policy.dataSubmissionEnabled", false);
user_pref("toolkit.telemetry.enabled", false);
user_pref("app.update.auto", false);
'''
PAGE = """<!doctype html>
<html><head><link rel="manifest" href="/manifest.webmanifest"></head>
<body><script>
const info = {
userAgent: navigator.userAgent,
beforeinstallprompt: 'onbeforeinstallprompt' in window,
serviceWorker: 'serviceWorker' in navigator,
wakeLock: 'wakeLock' in navigator,
vibrate: typeof navigator.vibrate === 'function',
audioContext: !!(window.AudioContext || window.webkitAudioContext),
localStorage: (() => { try { localStorage.setItem('t', '1'); return true; } catch { return false; } })(),
standaloneDisplayMode: matchMedia('(display-mode: standalone)').matches,
secureContext: window.isSecureContext,
manifestParsed: (() => {
const m = document.querySelector('link[rel=manifest]');
return m ? m.href : null;
})(),
};
fetch('/report', { method: 'POST', body: JSON.stringify(info) });
</script></body></html>"""
report = {}
done = threading.Event()
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/manifest.webmanifest':
body = json.dumps({
"name": "Probe", "short_name": "Probe", "start_url": "./",
"display": "standalone",
"icons": [{"src": "/icon.png", "sizes": "192x192", "type": "image/png"}],
}).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/manifest+json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
return
body = PAGE.encode()
self.send_response(200)
self.send_header('Content-Type', 'text/html')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
length = int(self.headers.get('Content-Length', 0))
report.update(json.loads(self.rfile.read(length) or b'{}'))
self.send_response(204)
self.end_headers()
done.set()
def log_message(self, *args):
pass
def main():
server = http.server.ThreadingHTTPServer(('127.0.0.1', PORT), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
shutil.rmtree(PROFILE, ignore_errors=True)
os.makedirs(PROFILE, exist_ok=True)
with open(os.path.join(PROFILE, 'user.js'), 'w') as fh:
fh.write(USER_JS)
proc = subprocess.Popen(
['firefox', '--headless', '--no-remote', '--new-instance',
'--profile', PROFILE, f'http://127.0.0.1:{PORT}/'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
ok = done.wait(timeout=30)
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
server.shutdown()
shutil.rmtree(PROFILE, ignore_errors=True)
if not ok:
print('probe: no report received (Firefox did not run the page)')
return
print(json.dumps(report, indent=2, sort_keys=True))
if __name__ == '__main__':
main()
+320
View File
@@ -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)));