chore: satisfy prettier in frontend and e2e

`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) <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-28 21:28:11 +02:00
parent 0932e2a470
commit eefa476765
5 changed files with 218 additions and 71 deletions

View File

@@ -9,12 +9,12 @@ acting as the showcase display.
**Validate the shipping config.** We run at the real production defaults — **Validate the shipping config.** We run at the real production defaults —
compression concurrency (`COMPRESSION_WORKER_CONCURRENCY`, default **2**), DB compression concurrency (`COMPRESSION_WORKER_CONCURRENCY`, default **2**), DB
pool (default **10**), quotas **on** — and answer: *does the app survive the pool (default **10**), quotas **on** — and answer: _does the app survive the
event, and how far behind real-time does the diashow fall?* event, and how far behind real-time does the diashow fall?_
The headline metric is **pipeline latency**: time from an upload succeeding to 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 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 photo appears on the diashow_. A backlog that builds is fine; a backlog that
**never drains** is a fail for a live event. **never drains** is a fail for a live event.
## Methodology: what we change vs. shipping ## Methodology: what we change vs. shipping
@@ -87,7 +87,7 @@ compression backlog to drain** before reporting.
## What the flags mean ## What the flags mean
| Flag | Meaning | | Flag | Meaning |
|------|---------| | ------------------------- | ---------------------------------------------------------------------------- |
| `✗ 5xx` | server errored under load — hard fail | | `✗ 5xx` | server errored under load — hard fail |
| `✗ 507` | quota rejected uploads — disk/quota misconfig for the event size | | `✗ 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 | | `✗ backlog did not drain` | compression can't keep up even after uploads stop — diashow never catches up |
@@ -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_TRUNCATE`, `LT_DRAIN_TIMEOUT_SEC`, `LT_KEEP_RATELIMITS`, `LT_BASE`,
`LT_APP_CONTAINER`, `LT_DB_CONTAINER`. `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 that sets `COMPRESSION_WORKER_CONCURRENCY` higher (boot-time env var in
`docker-compose.test.yml`) and compare the pipeline-latency / drain numbers. `docker-compose.test.yml`) and compare the pipeline-latency / drain numbers.

View File

@@ -10,17 +10,40 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const j = (r) => r.json(); const j = (r) => r.json();
const adminLogin = () => 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) => 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) { 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) { async function upload(jwt) {
const form = new FormData(); 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'); 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; 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 ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await ctx.newPage(); const page = await ctx.newPage();
const streamReqs = []; 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.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' });
await page.evaluate((g) => { await page.evaluate((g) => {
@@ -53,7 +78,9 @@ const emptyBefore = (await page.locator('text=Noch keine Beiträge').count()) >
const streamOpened = streamReqs.length > 0; const streamOpened = streamReqs.length > 0;
console.log(`\n1) direct /diashow on empty event:`); console.log(`\n1) direct /diashow on empty event:`);
console.log(` "Noch keine Beiträge" shown: ${emptyBefore} (expected: true)`); 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. // Now a guest uploads a photo while the display is open.
console.log(`\n2) guest uploads a photo (display already 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); await sleep(1000);
const stillEmpty = (await page.locator('text=Noch keine Beiträge').count()) > 0; const stillEmpty = (await page.locator('text=Noch keine Beiträge').count()) > 0;
const imgs = await page.locator('img').count(); 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`); if (!appeared) console.log(` ✗ photo did NOT appear within 15s`);

View File

@@ -132,9 +132,19 @@ const truncate = (adminJwt) =>
api('/admin/__truncate', { method: 'POST', token: adminJwt, expect: [204] }); api('/admin/__truncate', { method: 'POST', token: adminJwt, expect: [204] });
const CAPTIONS = [ const CAPTIONS = [
'Was für ein magischer Tag 💍', 'Der erste Tanz 🕺', 'Prost! 🥂', 'Die Torte 🍰', 'Was für ein magischer Tag 💍',
'Feuerwerk 🎆', 'Beste Freunde 💕', 'Was für eine Stimmung! 🎉', 'Details 🌸', 'Der erste Tanz 🕺',
'Sonnenuntergang 🌅', 'Tanzfläche brennt 🔥', null, null, null, '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']; const TAGS = ['hochzeit', 'liebe', 'party', 'tanzen', 'natur', 'feier', 'freunde', 'dessert'];
@@ -238,9 +248,12 @@ async function sampleResources() {
const out = { ts: now() }; const out = { ts: now() };
try { try {
const { stdout } = await execFileAsync('docker', [ const { stdout } = await execFileAsync('docker', [
'stats', '--no-stream', '--format', 'stats',
'--no-stream',
'--format',
'{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}', '{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}',
cfg.appContainer, cfg.dbContainer, cfg.appContainer,
cfg.dbContainer,
]); ]);
out.docker = stdout.trim(); out.docker = stdout.trim();
} catch (e) { } catch (e) {
@@ -259,8 +272,15 @@ async function sampleResources() {
function psql(sql) { function psql(sql) {
return execFileAsync('docker', [ return execFileAsync('docker', [
'exec', cfg.dbContainer, 'psql', '-U', 'eventsnap_test', '-d', 'eventsnap_test', 'exec',
'-tAc', sql, 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 s = [...nums].sort((a, b) => a - b);
const sum = s.reduce((a, b) => a + b, 0); const sum = s.reduce((a, b) => a + b, 0);
return { return {
n: s.length, min: s[0], max: s[s.length - 1], mean: Math.round(sum / s.length), n: s.length,
p50: pct(s, 50), p95: pct(s, 95), p99: pct(s, 99), 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 sleep(cfg.windowSec * 1000 + 500);
await Promise.all(burstPromises); await Promise.all(burstPromises);
clearInterval(ticker); 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) // Drain: wait for compression backlog to clear (SSE processed ⊇ successful ids)
console.log('[drain] waiting for compression backlog to clear…'); console.log('[drain] waiting for compression backlog to clear…');
@@ -589,21 +616,28 @@ async function main() {
console.log('\n' + '━'.repeat(72)); console.log('\n' + '━'.repeat(72));
console.log('RESULTS'); console.log('RESULTS');
console.log('━'.repeat(72)); 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(`upload latency ms: ${JSON.stringify(uploadLatency)}`);
console.log(`pipeline latency ms (upload→preview ready): ${JSON.stringify(pipelineLatency)}`); 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(`backlog drain: ${(drainMs / 1000).toFixed(1)}s, cleared=${report.drain.cleared}`);
console.log(`final db compression status: ${JSON.stringify(finalCounts)}`); 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}`); console.log(`\nfull report → ${outPath}`);
// Heuristic pass/fail flags (validate shipping config) // Heuristic pass/fail flags (validate shipping config)
const flags = []; 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 (err5xx > 0) flags.push(`${err5xx} server errors (5xx)`);
if (byStatus['507']) flags.push(`${byStatus['507']} quota rejections (507)`); if (byStatus['507']) flags.push(`${byStatus['507']} quota rejections (507)`);
if (byStatus['413']) flags.push(`${byStatus['413']} too-large (413)`); 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 (!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 (totalResyncs > sseClients.length) flags.push(`${totalResyncs} SSE resyncs (consumer lag)`);
if (pipelineLatency && pipelineLatency.p95 > 60000) if (pipelineLatency && pipelineLatency.p95 > 60000)

View File

@@ -4,24 +4,44 @@ import { chromium, devices } from '@playwright/test';
import { Client } from 'pg'; import { Client } from 'pg';
import { readFileSync, mkdirSync } from 'node:fs'; import { readFileSync, mkdirSync } from 'node:fs';
const BASE = 'http://localhost:3101'; const BASE = 'http://localhost:3101';
const PHOTOS = '/tmp/eventsnap-shots/photos'; const PHOTOS = '/tmp/eventsnap-shots/photos';
const OUT = '/tmp/eventsnap-shots'; const OUT = '/tmp/eventsnap-shots';
mkdirSync(OUT, { recursive: true }); mkdirSync(OUT, { recursive: true });
const api = (path, opts = {}) => 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() { 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; return r.body.jwt;
} }
async function joinGuest(name) { async function joinGuest(name) {
const r = await api('/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: name }) }); const r = await api('/join', {
if (r.status !== 201) throw new Error('join failed ' + name + ' ' + r.status + ' ' + JSON.stringify(r.body)); 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} return r.body; // {jwt,pin,user_id}
} }
async function upload(jwt, file, caption, hashtags) { 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'); form.append('file', new Blob([readFileSync(file)], { type: 'image/jpeg' }), 'photo.jpg');
if (caption) form.append('caption', caption); if (caption) form.append('caption', caption);
if (hashtags) form.append('hashtags', hashtags); 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())); if (r.status !== 201) throw new Error('upload failed ' + r.status + ' ' + (await r.text()));
return (await r.json()).id; 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) }); await fetch(`${BASE}/api/v1/upload/${id}/like`, { method: 'POST', headers: authHeaders(jwt) });
} }
async function comment(jwt, id, body) { 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) { 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) { 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 ---- // ---- SEED ----
@@ -54,9 +96,27 @@ console.log('[seed] admin login + reset');
let admin = await adminLogin(); let admin = await adminLogin();
await truncate(admin); await truncate(admin);
admin = await adminLogin(); 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 = {}; const accounts = {};
for (const g of guests) accounts[g] = await joinGuest(g); for (const g of guests) accounts[g] = await joinGuest(g);
console.log('[seed] joined', guests.length, 'guests'); 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'); console.log('[seed] likes + comments done');
// Make one guest a host so /host renders populated // 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 // 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++) { 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`); const r = await c.query(
if (r.rows[0].n === 0) { console.log('[seed] compression done'); break; } `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 new Promise((res) => setTimeout(res, 500));
} }
await c.end(); await c.end();
@@ -113,7 +183,8 @@ const shot = async (label, theme, who, route, prep) => {
const page = await ctx.newPage(); const page = await ctx.newPage();
// Seed localStorage on the origin // Seed localStorage on the origin
await page.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' }); await page.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' });
await page.evaluate(({ jwt, pin, uid, name, theme, mode }) => { await page.evaluate(
({ jwt, pin, uid, name, theme, mode }) => {
localStorage.setItem('eventsnap_theme', theme); localStorage.setItem('eventsnap_theme', theme);
localStorage.setItem('eventsnap_data_mode', mode); localStorage.setItem('eventsnap_data_mode', mode);
if (jwt) { if (jwt) {
@@ -123,7 +194,9 @@ const shot = async (label, theme, who, route, prep) => {
localStorage.setItem('eventsnap_display_name', name); localStorage.setItem('eventsnap_display_name', name);
} }
localStorage.setItem('eventsnap_guide_seen', '1'); localStorage.setItem('eventsnap_guide_seen', '1');
}, { jwt: who?.jwt, pin: who?.pin, uid: who?.user_id, name: who?.name, theme, mode: 'saver' }); },
{ 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.goto(`${BASE}${route}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1200); await page.waitForTimeout(1200);
if (prep) await prep(page); if (prep) await prep(page);
@@ -141,10 +214,17 @@ for (const theme of ['light', 'dark']) {
await shot('01-join', theme, null, '/join'); await shot('01-join', theme, null, '/join');
await shot('02-feed-list', theme, hostWho, '/feed'); await shot('02-feed-list', theme, hostWho, '/feed');
await shot('03-feed-grid', theme, hostWho, '/feed', async (p) => { 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 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 p.waitForTimeout(500);
}); });
await shot('05-account', theme, hostWho, '/account'); await shot('05-account', theme, hostWho, '/account');

View File

@@ -11,7 +11,9 @@ export const uploadSheetOpen = writable(false);
// states on purpose: counting only pending/uploading meant a rejected upload decremented // 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 // 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. // completed upload. 'blocked' and 'error' stay counted until the user clears or retries them.
export const uploadBadgeCount = derived(queueItems, ($items) => export const uploadBadgeCount = derived(
queueItems,
($items) =>
$items.filter( $items.filter(
(i) => (i) =>
i.status === 'pending' || i.status === 'pending' ||