Shuffle/e2e/tests/perf/kat01-suche-latenz.spec.ts
cschlaefke 81be119f4e test(catalog): Latenzmessung der Katalogsuche (Projekt perf, vorlaeufig) und Doku
Playwright-Projekt perf (nur mit PERF_WEB_URL, nur lesend, nie in der CI):
CDP-Drosselung 100 ms / 10 Mbit/s, mind. 100 Suchen, Messung im Browser vom
letzten Tastendruck bis zur ersten Kachel. Ladeskript fuer synthetisches
Volumen unter supabase/perf/. Messung und Aussagegrenzen in der Umsetzungsdoku.

Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 19:50:03 +01:00

233 lines
12 KiB
TypeScript

/**
* KAT-01 Kriterium 7 (Latenz der Suche), Projekt `perf`. Spec docs/specs/3-katalog-suche.md § 7.3.
*
* Misst in der gebauten Web-App mit einem mobilen 4G-Profil (Chrome-DevTools-Drosselung: 100 ms
* Round-Trip, 10 Mbit/s), wie lange es vom LETZTEN Tastendruck (inklusive Entprellung 300 ms) bis zur
* ersten sichtbaren Trefferkachel (bzw. zum Leerzustand bei absichtlich leeren Suchen) dauert.
*
* NUR LESEND: kein Datenbankzugriff, keine Anmeldung, nur der oeffentliche Anon-Schluessel. Importiert
* bewusst nichts aus support/auth-config.ts, damit der Lauf auch gegen eine fremde Umgebung moeglich
* ist (spaeter Staging). Die Suchliste entsteht aus dem tatsaechlichen Katalog:
* - Fuer jede Suche wird der erwartete erste Treffer ZUERST ungedrosselt bei der RPC erfragt (nur
* lesend, gleicher Aufruf wie die App) und danach in der App abgewartet. So ist die Liste
* selbstpruefend und unabhaengig von Datenstand und Relevanzregeln.
* - Zwischen zwei Messungen wird das Feld geleert; gemessen wird erst, wenn der ganze Katalog wieder
* sichtbar ist (die alten Treffer koennen die Messung so nie vorzeitig beenden).
*
* Aufruf (lokal; Daten siehe supabase/perf/catalog_search_perf_load.sql):
* PERF_WEB_URL=http://localhost:3000 PERF_SUPABASE_URL=http://127.0.0.1:54321 PERF_ANON_KEY=<anon> \
* PERF_ONLY=1 npx playwright test --project=perf
* Das Web-Bundle muss gegen dieselbe Supabase-URL gebaut sein (--dart-define-from-file).
*/
import { expect, test, type CDPSession, type Page } from '@playwright/test';
import { enableSemantics, fillField } from '../../support/flutter';
const supabaseUrl = process.env.PERF_SUPABASE_URL ?? 'http://127.0.0.1:54321';
const anonKey = process.env.PERF_ANON_KEY ?? '';
const SEARCHES = Number(process.env.PERF_SEARCHES ?? 110);
/** Grenze aus KAT-01 Kriterium 7: 95 % der Suchen liefern die ersten Treffer in hoechstens 2 Sekunden. */
const P95_LIMIT_MS = 2000;
/** Mobiles 4G-Profil (Spec 7.3): 100 ms Round-Trip, 10 Mbit/s = 1 250 000 Byte/s. PERF_LATENCY_MS nur zur Gegenprobe des Gates. */
const NETWORK = { offline: false, latency: Number(process.env.PERF_LATENCY_MS ?? 100), downloadThroughput: 1_250_000, uploadThroughput: 1_250_000 };
const MEASURE_TIMEOUT_MS = 15_000;
interface Row {
title: string;
publisher_names: string[];
total_count: number;
}
async function rpc(params: Record<string, unknown>): Promise<Row[]> {
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/search_catalog_games`, {
method: 'POST',
headers: { 'content-type': 'application/json', apikey: anonKey, authorization: `Bearer ${anonKey}` },
body: JSON.stringify(params),
});
if (res.status !== 200) throw new Error(`RPC ${res.status}: ${await res.text()}`);
return (await res.json()) as Row[];
}
interface Sample {
kind: string;
query: string;
/** Erwarteter Titel der ersten Kachel; null = Leerzustand erwartet. */
expectedFirst: string | null;
ms: number;
total: number;
}
type Plan = { kind: string; query: string };
const words = (title: string) => title.split(/\s+/).filter((w) => /^[\p{L}\p{N}]{3,}$/u.test(w));
/** Deterministische Suchliste aus dem Katalogbestand (feste Schrittweite, keine Zufallszahlen). */
async function buildPlan(count: number): Promise<{ plan: Plan[]; first: string }> {
const rows: Row[] = [];
for (let offset = 0; offset < 600; offset += 50) {
const page = await rpc({ p_limit: 50, p_offset: offset });
rows.push(...page);
if (page.length < 50) break;
}
expect(rows.length, 'der Katalog muss mindestens 50 Spiele enthalten').toBeGreaterThanOrEqual(50);
// Reserve von 6 fuer Suchen, die uebersprungen werden (erster Treffer = Katalogbeginn); 5 leere, bis 12 alternative Titel.
const want = Math.max(count, 100) + 6;
const plan: Plan[] = [];
const stride = Math.max(1, Math.floor(rows.length / count));
const kinds = ['Titel', 'Titelanfang', 'Wort', 'Wortkombination', 'Verlag', 'GROSSBUCHSTABEN', 'alternativer Titel'];
for (let i = 0, k = 0; plan.length < want - 5 - 12 && i < rows.length * 4; i += stride, k++) {
const row = rows[(i * 7 + k) % rows.length];
const w = words(row.title);
const kind = kinds[k % kinds.length];
let query: string | null = null;
switch (kind) {
case 'Titel':
query = row.title;
break;
case 'Titelanfang':
query = row.title.slice(0, Math.max(2, Math.min(5, row.title.length)));
break;
case 'Wort':
query = w[w.length - 1] ?? null;
break;
case 'Wortkombination':
query = w.length >= 2 ? `${w[0]} ${w[1]}` : null;
break;
case 'Verlag':
query = row.publisher_names[0] ? (words(row.publisher_names[0])[0] ?? null) : null;
break;
case 'GROSSBUCHSTABEN':
query = (w[0] ?? row.title).toUpperCase();
break;
case 'alternativer Titel':
query = null; // unten aus der Tabelle der alternativen Titel
break;
}
if (query && !plan.some((p) => p.query === query)) plan.push({ kind, query });
}
// Alternative Titel (lesbar fuer anon): jeder 7. Eintrag.
const alt = await fetch(`${supabaseUrl}/rest/v1/game_alt_titles?select=title&order=title&limit=400`, {
headers: { apikey: anonKey, authorization: `Bearer ${anonKey}` },
});
const altTitles = alt.status === 200 ? ((await alt.json()) as { title: string }[]).map((r) => r.title) : [];
for (let i = 0; i < altTitles.length && plan.filter((p) => p.kind === 'alternativer Titel').length < 12; i += 7) {
if (!plan.some((p) => p.query === altTitles[i])) plan.push({ kind: 'alternativer Titel', query: altTitles[i] });
}
// Absichtlich leere Suchen (Leerzustand), 5 Stueck, bleiben nach dem Kuerzen erhalten.
const empty: Plan[] = ['qqzzxx', 'zzyyxk', 'kkqqww', 'xxvvzz', 'wwqqkk'].map((query) => ({ kind: 'ohne Treffer', query }));
expect(plan.length + empty.length, 'Suchliste zu kurz').toBeGreaterThanOrEqual(want);
return { plan: [...plan.slice(0, want - empty.length), ...empty], first: rows[0].title };
}
/**
* Installiert (einmal je Seite) den letzten-Tastendruck-Zeitstempel und die Messfunktion im Browser.
* Im Browser gemessen, damit weder Playwright-Abfrageintervalle noch die Protokolllatenz in die Zahl
* eingehen. `performance.now()` ist in Browser und Beobachter dieselbe Uhr.
*/
async function installProbe(page: Page): Promise<void> {
await page.addInitScript(() => {
const w = window as any;
w.__lastKey = 0;
window.addEventListener('keydown', () => (w.__lastKey = performance.now()), true);
// Wartet auf die erste Kachel mit diesem Titel (Textknoten beginnt mit "Titel,") oder, bei expected=null,
// auf den Leerzustand ("No games found for"). Liefert den Zeitstempel zurueck.
w.__arm = (expected: string | null, mode: 'key' | 'now') =>
new Promise<number>((resolve, reject) => {
const startedAt = performance.now();
const found = () => {
const spans = document.querySelectorAll('flt-semantics span');
for (const s of Array.from(spans)) {
const text = s.textContent ?? '';
if (expected === null ? text.startsWith('No games found for') : text === expected || text.startsWith(expected + ',')) {
return true;
}
}
return false;
};
const done = () => {
const t = performance.now();
obs.disconnect();
resolve(mode === 'key' ? t - w.__lastKey : t - startedAt);
};
const obs = new MutationObserver(() => {
if (found()) done();
});
obs.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true });
setTimeout(() => {
obs.disconnect();
reject(new Error(`Zeitueberschreitung beim Warten auf ${expected ?? 'Leerzustand'}`));
}, 15_000);
if (found()) done();
});
});
}
const percentile = (sorted: number[], p: number) => sorted[Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)];
test.describe('KAT-01 Kriterium 7: Latenz der Suche im mobilen 4G-Profil (perf)', () => {
test.skip(!anonKey, 'PERF_ANON_KEY nicht gesetzt (oeffentlicher Anon-Schluessel der Zielumgebung)');
test(`KAT-01 Kriterium 7: p95 der ersten Treffer <= ${P95_LIMIT_MS} ms bei ${SEARCHES} Suchen (vorlaeufig, ohne Staging)`, async ({ page, context }, testInfo) => {
const { plan, first } = await buildPlan(SEARCHES);
await installProbe(page);
// 1. App laden (ungedrosselt: Das Bundle zaehlt nicht), Semantics einschalten.
await page.goto('/');
await enableSemantics(page);
const cdp: CDPSession = await context.newCDPSession(page);
await cdp.send('Network.enable');
// 2. Drosseln und die Suche oeffnen: Die erste Suche der App (ganzer Katalog, neue Verbindung zum
// Gateway) zaehlt mit; gemessen ab dem Klick.
await cdp.send('Network.emulateNetworkConditions', NETWORK);
const samples: Sample[] = [];
const armFirst = page.evaluate(([t]) => (window as any).__arm(t, 'now'), [first] as const);
await page.getByRole('button', { name: 'Search games' }).click();
const firstMs = await armFirst;
samples.push({ kind: 'Erststart (Katalog)', query: '', expectedFirst: first, ms: firstMs, total: 0 });
const field = page.getByRole('textbox', { name: /Search games/ });
await expect(field).toBeVisible();
// 3. Suchen. Zwischen den Messungen: Feld leeren (der ganze Katalog erscheint wieder), dann tippen.
for (const { kind, query } of plan) {
const hits = await rpc({ p_query: query, p_limit: 30 });
const expectedFirst = hits.length > 0 ? hits[0].title : null;
// Zustand zuruecksetzen: ungemessen warten, bis der Katalogbeginn wieder sichtbar ist.
if (expectedFirst === first) continue; // sonst koennten die alten Treffer die Messung vorzeitig beenden
await fillField(field, '');
await page.evaluate(([t]) => (window as any).__arm(t, 'now'), [first] as const);
// Messen: erst scharf schalten, dann tippen; Ende der Zeit = erste passende Kachel.
const armed = page.evaluate(([t]) => (window as any).__arm(t, 'key'), [expectedFirst] as const);
await field.pressSequentially(query, { delay: 15 });
const ms = await armed;
samples.push({ kind, query, expectedFirst, ms, total: hits[0]?.total_count ?? 0 });
}
// 4. Auswertung.
const sorted = samples.map((s) => s.ms).sort((a, b) => a - b);
const result = {
profile: NETWORK,
searches: samples.length,
p50_ms: Math.round(percentile(sorted, 50)),
p95_ms: Math.round(percentile(sorted, 95)),
max_ms: Math.round(sorted[sorted.length - 1]),
min_ms: Math.round(sorted[0]),
erststart_ms: Math.round(firstMs),
catalog_size: (await rpc({ p_limit: 1 }))[0]?.total_count,
by_kind: Object.fromEntries(
[...new Set(samples.map((s) => s.kind))].map((k) => {
const v = samples.filter((s) => s.kind === k).map((s) => s.ms).sort((a, b) => a - b);
return [k, { n: v.length, p50_ms: Math.round(percentile(v, 50)), max_ms: Math.round(v[v.length - 1]) }];
}),
),
samples: samples.map((s) => ({ ...s, ms: Math.round(s.ms) })),
};
await testInfo.attach('kat01-latenz.json', { body: JSON.stringify(result, null, 2), contentType: 'application/json' });
const { samples: _omit, ...summary } = result;
// eslint-disable-next-line no-console
console.log('KAT-01 Kriterium 7, Messung:\n' + JSON.stringify(summary, null, 2));
expect(samples.length, 'mindestens 100 Suchen').toBeGreaterThanOrEqual(100);
expect(result.p95_ms, `p95 ${result.p95_ms} ms`).toBeLessThanOrEqual(P95_LIMIT_MS);
});
});