From eefa47676553bca75be0cc481204b273d1ec037c Mon Sep 17 00:00:00 2001 From: fabi Date: Tue, 28 Jul 2026 21:28:11 +0200 Subject: [PATCH] chore: satisfy prettier in frontend and e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `checks.yml` runs `npm run format:check` for both projects and both were failing. - frontend/src/lib/ui-store.ts is mine, unformatted since the round-1 upload-queue badge fix — the same miss as the rustfmt one: I gated on svelte-check and eslint but never on format:check. - e2e/loadtest/* and e2e/shots.mjs have been unformatted since 7758270 and are unrelated to the audit work. Fixed here because they block the same gate and the fix is mechanical; no behaviour change in either project. Still red and deliberately NOT fixed here: `npm run lint` in the frontend reports `svelte/prefer-svelte-reactivity` on routes/diashow/+page.svelte:208 (a mutable `Set` where the rule wants `SvelteSet`), pre-existing since 5009590. That one is a real reactivity change in code I have no test coverage for, so it belongs in its own change rather than smuggled into a formatting commit. Co-Authored-By: Claude Opus 5 (1M context) --- e2e/loadtest/README.md | 22 ++--- e2e/loadtest/confirm-diashow-fix.mjs | 47 +++++++-- e2e/loadtest/driver.mjs | 62 +++++++++--- e2e/shots.mjs | 140 +++++++++++++++++++++------ frontend/src/lib/ui-store.ts | 18 ++-- 5 files changed, 218 insertions(+), 71 deletions(-) diff --git a/e2e/loadtest/README.md b/e2e/loadtest/README.md index e78c810..77f70ba 100644 --- a/e2e/loadtest/README.md +++ b/e2e/loadtest/README.md @@ -9,12 +9,12 @@ acting as the showcase display. **Validate the shipping config.** We run at the real production defaults — compression concurrency (`COMPRESSION_WORKER_CONCURRENCY`, default **2**), DB -pool (default **10**), quotas **on** — and answer: *does the app survive the -event, and how far behind real-time does the diashow fall?* +pool (default **10**), quotas **on** — and answer: _does the app survive the +event, and how far behind real-time does the diashow fall?_ The headline metric is **pipeline latency**: time from an upload succeeding to -its preview being ready (`upload-processed` SSE event) — i.e. *how long until the -photo appears on the diashow*. A backlog that builds is fine; a backlog that +its preview being ready (`upload-processed` SSE event) — i.e. _how long until the +photo appears on the diashow_. A backlog that builds is fine; a backlog that **never drains** is a fail for a live event. ## Methodology: what we change vs. shipping @@ -86,13 +86,13 @@ compression backlog to drain** before reporting. ## What the flags mean -| Flag | Meaning | -|------|---------| -| `✗ 5xx` | server errored under load — hard fail | -| `✗ 507` | quota rejected uploads — disk/quota misconfig for the event size | +| Flag | Meaning | +| ------------------------- | ---------------------------------------------------------------------------- | +| `✗ 5xx` | server errored under load — hard fail | +| `✗ 507` | quota rejected uploads — disk/quota misconfig for the event size | | `✗ backlog did not drain` | compression can't keep up even after uploads stop — diashow never catches up | -| `⚠ pipeline p95 > 60s` | photos take >1 min to appear on the diashow at peak | -| `⚠ SSE resyncs` | live consumers lagged the broadcast channel | +| `⚠ pipeline p95 > 60s` | photos take >1 min to appear on the diashow at peak | +| `⚠ SSE resyncs` | live consumers lagged the broadcast channel | ## Knobs @@ -101,7 +101,7 @@ All via env (see header of `driver.mjs`): `LT_GUESTS`, `LT_IMAGES`, `LT_TRUNCATE`, `LT_DRAIN_TIMEOUT_SEC`, `LT_KEEP_RATELIMITS`, `LT_BASE`, `LT_APP_CONTAINER`, `LT_DB_CONTAINER`. -To later answer *"what config should I deploy?"*, re-run with a rebuilt stack +To later answer _"what config should I deploy?"_, re-run with a rebuilt stack that sets `COMPRESSION_WORKER_CONCURRENCY` higher (boot-time env var in `docker-compose.test.yml`) and compare the pipeline-latency / drain numbers. diff --git a/e2e/loadtest/confirm-diashow-fix.mjs b/e2e/loadtest/confirm-diashow-fix.mjs index 522f54b..4d01184 100644 --- a/e2e/loadtest/confirm-diashow-fix.mjs +++ b/e2e/loadtest/confirm-diashow-fix.mjs @@ -10,17 +10,40 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const j = (r) => r.json(); const adminLogin = () => - fetch(`${BASE}/api/v1/admin/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: 'admin-test-pw' }) }).then(j).then((b) => b.jwt); + fetch(`${BASE}/api/v1/admin/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: 'admin-test-pw' }), + }) + .then(j) + .then((b) => b.jwt); const join = (name) => - fetch(`${BASE}/api/v1/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: name }) }).then(j); + fetch(`${BASE}/api/v1/join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ display_name: name }), + }).then(j); async function truncate(admin) { - await fetch(`${BASE}/api/v1/admin/__truncate`, { method: 'POST', headers: { Authorization: `Bearer ${admin}` } }); + await fetch(`${BASE}/api/v1/admin/__truncate`, { + method: 'POST', + headers: { Authorization: `Bearer ${admin}` }, + }); } async function upload(jwt) { const form = new FormData(); - form.append('file', new Blob([readFileSync('/tmp/eventsnap-loadtest/photos/photo_000.jpg')], { type: 'image/jpeg' }), 'live.jpg'); + form.append( + 'file', + new Blob([readFileSync('/tmp/eventsnap-loadtest/photos/photo_000.jpg')], { + type: 'image/jpeg', + }), + 'live.jpg' + ); form.append('caption', 'LIVE-PROBE'); - const r = await fetch(`${BASE}/api/v1/upload`, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` }, body: form }); + const r = await fetch(`${BASE}/api/v1/upload`, { + method: 'POST', + headers: { Authorization: `Bearer ${jwt}` }, + body: form, + }); return (await r.json()).id; } @@ -35,7 +58,9 @@ const browser = await chromium.launch(); const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); const page = await ctx.newPage(); const streamReqs = []; -page.on('request', (req) => { if (req.url().includes('/stream')) streamReqs.push(req.url().replace(BASE, '')); }); +page.on('request', (req) => { + if (req.url().includes('/stream')) streamReqs.push(req.url().replace(BASE, '')); +}); await page.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' }); await page.evaluate((g) => { @@ -53,7 +78,9 @@ const emptyBefore = (await page.locator('text=Noch keine Beiträge').count()) > const streamOpened = streamReqs.length > 0; console.log(`\n1) direct /diashow on empty event:`); console.log(` "Noch keine Beiträge" shown: ${emptyBefore} (expected: true)`); -console.log(` SSE stream opened: ${streamOpened} ${streamOpened ? '('+streamReqs.join(', ')+')' : ''} (expected: true — this is the fix)`); +console.log( + ` SSE stream opened: ${streamOpened} ${streamOpened ? '(' + streamReqs.join(', ') + ')' : ''} (expected: true — this is the fix)` +); // Now a guest uploads a photo while the display is open. console.log(`\n2) guest uploads a photo (display already open)…`); @@ -65,7 +92,11 @@ for (let i = 0; i < 15; i++) { await sleep(1000); const stillEmpty = (await page.locator('text=Noch keine Beiträge').count()) > 0; const imgs = await page.locator('img').count(); - if (!stillEmpty && imgs > 0) { appeared = true; console.log(` photo appeared live after ~${i + 1}s (img rendered, placeholder gone)`); break; } + if (!stillEmpty && imgs > 0) { + appeared = true; + console.log(` photo appeared live after ~${i + 1}s (img rendered, placeholder gone)`); + break; + } } if (!appeared) console.log(` ✗ photo did NOT appear within 15s`); diff --git a/e2e/loadtest/driver.mjs b/e2e/loadtest/driver.mjs index 2276c89..22c197e 100644 --- a/e2e/loadtest/driver.mjs +++ b/e2e/loadtest/driver.mjs @@ -132,9 +132,19 @@ const truncate = (adminJwt) => api('/admin/__truncate', { method: 'POST', token: adminJwt, expect: [204] }); const CAPTIONS = [ - 'Was für ein magischer Tag 💍', 'Der erste Tanz 🕺', 'Prost! 🥂', 'Die Torte 🍰', - 'Feuerwerk 🎆', 'Beste Freunde 💕', 'Was für eine Stimmung! 🎉', 'Details 🌸', - 'Sonnenuntergang 🌅', 'Tanzfläche brennt 🔥', null, null, null, + 'Was für ein magischer Tag 💍', + 'Der erste Tanz 🕺', + 'Prost! 🥂', + 'Die Torte 🍰', + 'Feuerwerk 🎆', + 'Beste Freunde 💕', + 'Was für eine Stimmung! 🎉', + 'Details 🌸', + 'Sonnenuntergang 🌅', + 'Tanzfläche brennt 🔥', + null, + null, + null, ]; const TAGS = ['hochzeit', 'liebe', 'party', 'tanzen', 'natur', 'feier', 'freunde', 'dessert']; @@ -238,9 +248,12 @@ async function sampleResources() { const out = { ts: now() }; try { const { stdout } = await execFileAsync('docker', [ - 'stats', '--no-stream', '--format', + 'stats', + '--no-stream', + '--format', '{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}', - cfg.appContainer, cfg.dbContainer, + cfg.appContainer, + cfg.dbContainer, ]); out.docker = stdout.trim(); } catch (e) { @@ -259,8 +272,15 @@ async function sampleResources() { function psql(sql) { return execFileAsync('docker', [ - 'exec', cfg.dbContainer, 'psql', '-U', 'eventsnap_test', '-d', 'eventsnap_test', - '-tAc', sql, + 'exec', + cfg.dbContainer, + 'psql', + '-U', + 'eventsnap_test', + '-d', + 'eventsnap_test', + '-tAc', + sql, ]); } @@ -292,8 +312,13 @@ function summarize(nums) { const s = [...nums].sort((a, b) => a - b); const sum = s.reduce((a, b) => a + b, 0); return { - n: s.length, min: s[0], max: s[s.length - 1], mean: Math.round(sum / s.length), - p50: pct(s, 50), p95: pct(s, 95), p99: pct(s, 99), + n: s.length, + min: s[0], + max: s[s.length - 1], + mean: Math.round(sum / s.length), + p50: pct(s, 50), + p95: pct(s, 95), + p99: pct(s, 99), }; } @@ -490,7 +515,9 @@ async function main() { await sleep(cfg.windowSec * 1000 + 500); await Promise.all(burstPromises); clearInterval(ticker); - console.log(`[run] all bursts issued. uploaded ${done}, ok ${uploads.filter((u) => u.status === 201).length}`); + console.log( + `[run] all bursts issued. uploaded ${done}, ok ${uploads.filter((u) => u.status === 201).length}` + ); // Drain: wait for compression backlog to clear (SSE processed ⊇ successful ids) console.log('[drain] waiting for compression backlog to clear…'); @@ -589,21 +616,28 @@ async function main() { console.log('\n' + '━'.repeat(72)); console.log('RESULTS'); console.log('━'.repeat(72)); - console.log(`uploads: ${okUploads.length}/${uploads.length} ok — byStatus ${JSON.stringify(byStatus)}`); + console.log( + `uploads: ${okUploads.length}/${uploads.length} ok — byStatus ${JSON.stringify(byStatus)}` + ); console.log(`upload latency ms: ${JSON.stringify(uploadLatency)}`); console.log(`pipeline latency ms (upload→preview ready): ${JSON.stringify(pipelineLatency)}`); console.log(`backlog drain: ${(drainMs / 1000).toFixed(1)}s, cleared=${report.drain.cleared}`); console.log(`final db compression status: ${JSON.stringify(finalCounts)}`); - console.log(`sse: ${sseClients.length} conns, ${totalReconnects} reconnects, ${totalResyncs} resyncs`); + console.log( + `sse: ${sseClients.length} conns, ${totalReconnects} reconnects, ${totalResyncs} resyncs` + ); console.log(`\nfull report → ${outPath}`); // Heuristic pass/fail flags (validate shipping config) const flags = []; - const err5xx = Object.entries(byStatus).filter(([s]) => +s >= 500).reduce((a, [, n]) => a + n, 0); + const err5xx = Object.entries(byStatus) + .filter(([s]) => +s >= 500) + .reduce((a, [, n]) => a + n, 0); if (err5xx > 0) flags.push(`✗ ${err5xx} server errors (5xx)`); if (byStatus['507']) flags.push(`✗ ${byStatus['507']} quota rejections (507)`); if (byStatus['413']) flags.push(`⚠ ${byStatus['413']} too-large (413)`); - if (byStatus['429']) flags.push(`⚠ ${byStatus['429']} rate-limited (429) — unexpected with limits off`); + if (byStatus['429']) + flags.push(`⚠ ${byStatus['429']} rate-limited (429) — unexpected with limits off`); if (!report.drain.cleared) flags.push(`✗ backlog did NOT drain within ${cfg.drainTimeoutSec}s`); if (totalResyncs > sseClients.length) flags.push(`⚠ ${totalResyncs} SSE resyncs (consumer lag)`); if (pipelineLatency && pipelineLatency.p95 > 60000) diff --git a/e2e/shots.mjs b/e2e/shots.mjs index dcf2b6d..a2a19e5 100644 --- a/e2e/shots.mjs +++ b/e2e/shots.mjs @@ -4,24 +4,44 @@ import { chromium, devices } from '@playwright/test'; import { Client } from 'pg'; import { readFileSync, mkdirSync } from 'node:fs'; - const BASE = 'http://localhost:3101'; const PHOTOS = '/tmp/eventsnap-shots/photos'; const OUT = '/tmp/eventsnap-shots'; mkdirSync(OUT, { recursive: true }); const api = (path, opts = {}) => - fetch(`${BASE}/api/v1${path}`, opts).then(async (r) => ({ status: r.status, body: await r.text().then((t) => { try { return JSON.parse(t); } catch { return t; } }) })); + fetch(`${BASE}/api/v1${path}`, opts).then(async (r) => ({ + status: r.status, + body: await r.text().then((t) => { + try { + return JSON.parse(t); + } catch { + return t; + } + }), + })); -const authHeaders = (jwt, json = true) => ({ Authorization: `Bearer ${jwt}`, ...(json ? { 'Content-Type': 'application/json' } : {}) }); +const authHeaders = (jwt, json = true) => ({ + Authorization: `Bearer ${jwt}`, + ...(json ? { 'Content-Type': 'application/json' } : {}), +}); async function adminLogin() { - const r = await api('/admin/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: 'admin-test-pw' }) }); + const r = await api('/admin/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: 'admin-test-pw' }), + }); return r.body.jwt; } async function joinGuest(name) { - const r = await api('/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: name }) }); - if (r.status !== 201) throw new Error('join failed ' + name + ' ' + r.status + ' ' + JSON.stringify(r.body)); + const r = await api('/join', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ display_name: name }), + }); + if (r.status !== 201) + throw new Error('join failed ' + name + ' ' + r.status + ' ' + JSON.stringify(r.body)); return r.body; // {jwt,pin,user_id} } async function upload(jwt, file, caption, hashtags) { @@ -29,7 +49,11 @@ async function upload(jwt, file, caption, hashtags) { form.append('file', new Blob([readFileSync(file)], { type: 'image/jpeg' }), 'photo.jpg'); if (caption) form.append('caption', caption); if (hashtags) form.append('hashtags', hashtags); - const r = await fetch(`${BASE}/api/v1/upload`, { method: 'POST', headers: authHeaders(jwt, false), body: form }); + const r = await fetch(`${BASE}/api/v1/upload`, { + method: 'POST', + headers: authHeaders(jwt, false), + body: form, + }); if (r.status !== 201) throw new Error('upload failed ' + r.status + ' ' + (await r.text())); return (await r.json()).id; } @@ -37,16 +61,34 @@ async function like(jwt, id) { await fetch(`${BASE}/api/v1/upload/${id}/like`, { method: 'POST', headers: authHeaders(jwt) }); } async function comment(jwt, id, body) { - await fetch(`${BASE}/api/v1/upload/${id}/comments`, { method: 'POST', headers: authHeaders(jwt), body: JSON.stringify({ body }) }); + await fetch(`${BASE}/api/v1/upload/${id}/comments`, { + method: 'POST', + headers: authHeaders(jwt), + body: JSON.stringify({ body }), + }); } -const pg = () => new Client({ host: 'localhost', port: 55432, user: 'eventsnap_test', password: 'eventsnap_test', database: 'eventsnap_test' }); +const pg = () => + new Client({ + host: 'localhost', + port: 55432, + user: 'eventsnap_test', + password: 'eventsnap_test', + database: 'eventsnap_test', + }); async function truncate(adminJwt) { - await fetch(`${BASE}/api/v1/admin/__truncate`, { method: 'POST', headers: authHeaders(adminJwt) }); + await fetch(`${BASE}/api/v1/admin/__truncate`, { + method: 'POST', + headers: authHeaders(adminJwt), + }); } async function patchConfig(adminJwt, patch) { - await fetch(`${BASE}/api/v1/admin/config`, { method: 'PATCH', headers: authHeaders(adminJwt), body: JSON.stringify(patch) }); + await fetch(`${BASE}/api/v1/admin/config`, { + method: 'PATCH', + headers: authHeaders(adminJwt), + body: JSON.stringify(patch), + }); } // ---- SEED ---- @@ -54,9 +96,27 @@ console.log('[seed] admin login + reset'); let admin = await adminLogin(); await truncate(admin); admin = await adminLogin(); -await patchConfig(admin, { rate_limits_enabled: 'false', upload_rate_enabled: 'false', feed_rate_enabled: 'false', export_rate_enabled: 'false', join_rate_enabled: 'false', quota_enabled: 'false', storage_quota_enabled: 'false', upload_count_quota_enabled: 'false' }); +await patchConfig(admin, { + rate_limits_enabled: 'false', + upload_rate_enabled: 'false', + feed_rate_enabled: 'false', + export_rate_enabled: 'false', + join_rate_enabled: 'false', + quota_enabled: 'false', + storage_quota_enabled: 'false', + upload_count_quota_enabled: 'false', +}); -const guests = ['Anna Bauer', 'Lukas Weber', 'Mia Schulz', 'Jonas Fischer', 'Emma Wagner', 'Ben Hoffmann', 'Sophie Klein', 'Paul Richter']; +const guests = [ + 'Anna Bauer', + 'Lukas Weber', + 'Mia Schulz', + 'Jonas Fischer', + 'Emma Wagner', + 'Ben Hoffmann', + 'Sophie Klein', + 'Paul Richter', +]; const accounts = {}; for (const g of guests) accounts[g] = await joinGuest(g); console.log('[seed] joined', guests.length, 'guests'); @@ -94,13 +154,23 @@ await comment(accounts['Ben Hoffmann'].jwt, ids[1], 'Was ein Abend!'); console.log('[seed] likes + comments done'); // Make one guest a host so /host renders populated -await fetch(`${BASE}/api/v1/host/users/${accounts['Anna Bauer'].user_id}/role`, { method: 'PATCH', headers: authHeaders(admin), body: JSON.stringify({ role: 'host' }) }); +await fetch(`${BASE}/api/v1/host/users/${accounts['Anna Bauer'].user_id}/role`, { + method: 'PATCH', + headers: authHeaders(admin), + body: JSON.stringify({ role: 'host' }), +}); // Wait for compression to finish so previews render -const c = pg(); await c.connect(); +const c = pg(); +await c.connect(); for (let t = 0; t < 40; t++) { - const r = await c.query(`SELECT COUNT(*)::int AS n FROM upload WHERE compression_status <> 'done' AND deleted_at IS NULL`); - if (r.rows[0].n === 0) { console.log('[seed] compression done'); break; } + const r = await c.query( + `SELECT COUNT(*)::int AS n FROM upload WHERE compression_status <> 'done' AND deleted_at IS NULL` + ); + if (r.rows[0].n === 0) { + console.log('[seed] compression done'); + break; + } await new Promise((res) => setTimeout(res, 500)); } await c.end(); @@ -113,17 +183,20 @@ const shot = async (label, theme, who, route, prep) => { const page = await ctx.newPage(); // Seed localStorage on the origin await page.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' }); - await page.evaluate(({ jwt, pin, uid, name, theme, mode }) => { - localStorage.setItem('eventsnap_theme', theme); - localStorage.setItem('eventsnap_data_mode', mode); - if (jwt) { - localStorage.setItem('eventsnap_jwt', jwt); - localStorage.setItem('eventsnap_pin', pin); - localStorage.setItem('eventsnap_user_id', uid); - localStorage.setItem('eventsnap_display_name', name); - } - localStorage.setItem('eventsnap_guide_seen', '1'); - }, { jwt: who?.jwt, pin: who?.pin, uid: who?.user_id, name: who?.name, theme, mode: 'saver' }); + await page.evaluate( + ({ jwt, pin, uid, name, theme, mode }) => { + localStorage.setItem('eventsnap_theme', theme); + localStorage.setItem('eventsnap_data_mode', mode); + if (jwt) { + localStorage.setItem('eventsnap_jwt', jwt); + localStorage.setItem('eventsnap_pin', pin); + localStorage.setItem('eventsnap_user_id', uid); + localStorage.setItem('eventsnap_display_name', name); + } + localStorage.setItem('eventsnap_guide_seen', '1'); + }, + { jwt: who?.jwt, pin: who?.pin, uid: who?.user_id, name: who?.name, theme, mode: 'saver' } + ); await page.goto(`${BASE}${route}`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(1200); if (prep) await prep(page); @@ -141,10 +214,17 @@ for (const theme of ['light', 'dark']) { await shot('01-join', theme, null, '/join'); await shot('02-feed-list', theme, hostWho, '/feed'); await shot('03-feed-grid', theme, hostWho, '/feed', async (p) => { - await p.getByLabel('Rasteransicht').click().catch(() => {}); + await p + .getByLabel('Rasteransicht') + .click() + .catch(() => {}); }); await shot('04-lightbox', theme, hostWho, '/feed', async (p) => { - await p.locator('img').first().click().catch(() => {}); + await p + .locator('img') + .first() + .click() + .catch(() => {}); await p.waitForTimeout(500); }); await shot('05-account', theme, hostWho, '/account'); diff --git a/frontend/src/lib/ui-store.ts b/frontend/src/lib/ui-store.ts index 08ef004..3346c80 100644 --- a/frontend/src/lib/ui-store.ts +++ b/frontend/src/lib/ui-store.ts @@ -11,12 +11,14 @@ export const uploadSheetOpen = writable(false); // states on purpose: counting only pending/uploading meant a rejected upload decremented // the badge exactly as if it had succeeded, so the failure was indistinguishable from a // completed upload. 'blocked' and 'error' stay counted until the user clears or retries them. -export const uploadBadgeCount = derived(queueItems, ($items) => - $items.filter( - (i) => - i.status === 'pending' || - i.status === 'uploading' || - i.status === 'error' || - i.status === 'blocked' - ).length +export const uploadBadgeCount = derived( + queueItems, + ($items) => + $items.filter( + (i) => + i.status === 'pending' || + i.status === 'uploading' || + i.status === 'error' || + i.status === 'blocked' + ).length );