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>
This commit is contained in:
cschlaefke 2026-11-04 19:50:03 +01:00
parent b9c41bee64
commit 81be119f4e
7 changed files with 500 additions and 19 deletions

View file

@ -70,3 +70,50 @@ Lokale Werte: `log_min_duration_statement = -1`, `log_statement = ddl`, `log_par
## 4. Mutationsprüfung
Mutationen in einer Kopie der Testdatei (Rollback), nicht am echten Schema: 103 von 107 SQL-Mutationen (RPC, `search_normalize`, Rechte, Tabelle, Check, Indizes) erkannt; Überlebende: ID-Tie-Break innen und außen (der Upstream-Plan liefert bereits ID-Reihenfolge), beide `collate "C"`-Mutationen (lokal ICU-Sortierung, für `[a-z0-9 ]` gleich). Fixtures: 4 von 5 (überlebt: Rohtitel-Vergleich, gleicher Endzustand durch die Semantik datenändernder CTEs). Deno-Werkzeug: 17 von 17.
## 5. PR 3c (E2E und vorläufige Latenzmessung), Stand 2026-11-04
Branch `feature/3c-katalog-suche-e2e`, `Refs #3`. Ergänzt § 7.2/7.3 der Spec; die Spec selbst wird nicht geändert.
### 5.1 Was gebaut ist
- **Web-E2E** `e2e/tests/web/kat01-suche.spec.ts` (17 Tests, ohne Anmeldung): Einstieg über die Schaltfläche „Search games“ auf der Startseite, Titel in anderer Schreibweise (Krit. 1), Relevanzreihenfolge, ausgeblendete/zusammengeführte Spiele (KAT-05 Krit. 4), alternative Titel de/en (Krit. 2), Verlage inkl. zweitem Verlag (Krit. 3), Filter Genre/Spielerzahl/Dauer/Alter (Krit. 4), nur Filter und UND-Verknüpfung (Krit. 8), Filter-Sheet im mittleren Layout, Nachladen über Scrollen mit Prüfung der Anfrage `p_offset 30` (Krit. 5), Leerzustände (Krit. 6, Krit. 8), URL-Zustand, Neuladen, Zurück in einem Schritt, Deep Link mit Filtern, ungültige URL-Parameter, Offline mit „Try again“.
- **API-E2E** `e2e/tests/api/kat01-suche.spec.ts` (18 Tests): `search_catalog_games` als `anon` über Kong (Vertrag, Normalisierung, Relevanzstufen 0 bis 4, alternative Titel, Verlage, Filter, Paging, `total_count`), nie sichtbar: ausgeblendete, zusammengeführte und private Spiele (auch nicht für den Besitzer), Fehlerfälle `search_query_too_long`, `search_filter_invalid`, `search_paging_invalid` (P0001, HTTP 400), 22003 bei smallint-Überlauf, 22P02 bei falscher UUID, `service_role` ohne Ausführungsrecht (42501), kein Schreiben auf `game_alt_titles`.
- **Testdaten** `e2e/support/catalog.ts`: je Spec-Datei ein eigenes Datenset mit eigenem UUID-Präfix und Kunstwort (`e2ec0001-`/`zqvk` web, `e2ec0002-`/`wlpd` api), Anlage als `postgres` im `beforeAll`, Aufräumen im `afterAll` (der API-Test prüft, dass nichts zurückbleibt); `seed` löscht zuerst und ist nach einem Abbruch idempotent. Die Dateien laufen intern seriell und untereinander parallel. Eigene Genres/Mechaniken statt des Vokabulars, damit „nur Filter“-Tests unabhängig vom Startbestand sind.
- **Latenzmessung** (Projekt `perf`, nur mit `PERF_WEB_URL`, nie in `e2e-run.sh`/CI): `e2e/tests/perf/kat01-suche-latenz.spec.ts`, Ladeskript `supabase/perf/catalog_search_perf_load.sql` (committet, mit Präfix `9e4f0000-`) und `…_unload.sql`. Bedienung: `e2e/README.md`.
- `playwright.config.ts`: `perf` bedingt registriert; mit `PERF_ONLY=1` kein Web-Server und kein Import der lokalen Auth-Konstanten. Ohne die Variablen verhält sich die Konfiguration wie vorher. Die allgemeinen Flutter-Helfer liegen jetzt in `support/flutter.ts` (`support/web.ts` exportiert sie unverändert weiter).
### 5.2 Messung KAT-01 Krit. 7 (vorläufig, Option A)
Lauf am 2026-11-04, lokaler Stack (Supabase CLI auf Docker/macOS), Web-Build `--release`, Chrome (Playwright-Chromium, Desktop), CDP-Drosselung 100 ms Latenz, 10 Mbit/s je Richtung. Daten: Startbestand-Stand der lokalen DB (1 Spiel) plus synthetisch 10 000 Katalogspiele, 20 000 alternative Titel, 2 000 Verlage, 12 951 Verlagszuordnungen, je Spiel 2 Genres und 3 Mechaniken (wie `catalog_search_perf.sql`).
| | Wert |
|---|---|
| Suchen gemessen | 116 (1 Erststart mit neuer Verbindung, 110 inhaltliche Suchen, 5 ohne Treffer) |
| p50 | 441 ms |
| p95 | 461 ms |
| Maximum | 637 ms (der Erststart; ohne ihn 535 ms) |
| Minimum | 404 ms |
| Grenze Krit. 7 | p95 ≤ 2000 ms: erfüllt |
Je Art (p50): Titel 436, Titelanfang 455, Wort 451, Wortkombination 437, Verlag 454, GROSSBUCHSTABEN 441, alternativer Titel 437, ohne Treffer 447 ms. Der Wert besteht zu rund 300 ms aus der Entprellung der App; der Rest ist eine Round-Trip-Zeit (100 ms), Verarbeitung und Rendern. Gegenprobe des Gates: mit `PERF_LATENCY_MS=1800` steigt der gemessene p95 auf 2157 ms (über der Grenze), das Gate reagiert also.
**Aussagegrenzen (wichtig):**
- **Kein Staging, keine Produktion.** Gemessen ist der lokale Stack auf demselben Rechner wie der Browser (Docker unter macOS); Rechenzeit von Datenbank, Gateway und Browser konkurriert miteinander und mit anderen Prozessen (auf dem Rechner lief gleichzeitig Backend-Arbeit mit eigenen Datenbank-Läufen). Die Staging-Messung bleibt Freigabebedingung in `docs/release-checkliste.md` (Abschnitt 2a).
- **Die Drosselung ist eine Browser-Emulation** (Chrome DevTools): sie verzögert Antworten und begrenzt den Durchsatz im Browser, bildet aber keine Funkstrecke, kein TLS-Handshake über echte Latenz, keine Paketverluste und keinen Wechsel zwischen Funkzellen ab. Die Latenz gilt je Anfrage; ob ein Verbindungsaufbau (TCP/TLS) mit 100 ms Round-Trip in der Emulation vollständig abgebildet ist, ist nicht geprüft.
- **Nur Web** (Desktop-Chrome, ungedrosselte CPU). Keine Stichprobe auf Android/iOS (Spec 7.3, Aufgabe C1, Chris), keine CPU-Drosselung für ein schwächeres Mobilgerät.
- **Synthetische Daten:** Wörter mit ungleicher Häufigkeit, aber kein echter Titelbestand; die Relevanz mit dem echten Startbestand bleibt C1. Der Verlag „zusammengesetzt“ entspricht nicht echten Verlagsnamen.
- **Suchliste:** feste Schrittweite durch die ersten 600 Spiele in alphabetischer Reihenfolge, nicht durch einen echten Nutzungsverlauf. Ein Treffer-Erwartungswert je Suche kommt aus derselben RPC (selbstprüfend, aber ohne unabhängiges Orakel).
- **Kein Ratenlimit** im lokalen Kong für die RPC (Entscheidung D1 vor dem Go-live): die Messung enthält keine Drosselung durch ein Gateway-Limit.
- **Gemessen ist bis zur ersten sichtbaren Kachel** (Textknoten im Semantics-Baum), nicht bis zum gezeichneten Frame; die Differenz ist nicht gemessen.
- Eine einzelne Messreihe (kein Vergleich über mehrere Läufe/Tageszeiten); Streuung zwischen zwei früheren Läufen mit 110 Suchen: p95 456 und 461 ms.
### 5.3 Befunde und offene Punkte
- **Trefferkacheln im Semantics-Baum:** Alle Kacheln liegen als Kinder eines einzigen Listen-Containers (`role=group`); die Texte stehen in `span`-Knoten. Der Container hat keinen eigenen Namen. Für Bildschirmleser ist das Verhalten nicht geprüft (NVDA/VoiceOver-Durchgang, manuell). Die Tests suchen die Kacheln als Textknoten.
- **Semantics-Eingabefeld:** Ein direkt geladenes `/catalog` nimmt per Playwright getippten Text nicht verlässlich an (Feld bleibt leer; Autofokus ist mal gesetzt, mal nicht). Über die Startseite funktioniert es. Nicht untersucht, ob echte Nutzer (Mausklick) betroffen sind. Der Wert des Eingabefelds ist nach einem Auswahlmenü oder Neuladen im DOM leer, obwohl die Suche den Begriff behält (Beleg: URL und Ergebnis); die Tests prüfen deshalb die URL.
- **Ausgeblendete Spiele:** Die Tabelle `game_alt_titles` ist für `anon` auch für alternative Titel ausgeblendeter Katalogspiele lesbar (die RLS-Regel folgt der Sichtbarkeit in `games`, nicht der View `catalog_games`). Über die Suche erscheinen sie nicht. Ob das gewollt ist, ist fachlich zu entscheiden (nicht geraten).
- **Gateway ohne `apikey`:** Die lokale CLI beantwortet die RPC auch ohne `apikey`-Header als `anon` (HTTP 200). Auf dem Server ist das eine Frage der Kong-Konfiguration (Release-Checkliste).
- Krit. 6: „Spiel melden“ ist erst mit #13 sichtbar; der Test prüft den Hinweis, nicht die Aktion (Spec-F4).
- Nicht automatisiert: „Weitere laden“ per Tastatur (das automatische Nachladen kommt der Schaltfläche zuvor; Widget-Tests in 3b), Ansage der Trefferzahl für Bildschirmleser, Textskalierung 200 %, Tab-Reihenfolge im Browser.

View file

@ -1,6 +1,7 @@
# E2E-Tests (Playwright)
Auth-Tests für KTO-01 (Spec `docs/specs/1-auth-registrierung-anmeldung.md` § 7, Issue #38). Jeder Test
Auth-Tests für KTO-01 (Spec `docs/specs/1-auth-registrierung-anmeldung.md` § 7, Issue #38) und Tests der
Katalogsuche KAT-01 (Spec `docs/specs/3-katalog-suche.md` § 7, Issue #3). Jeder Auth-Test
nutzt zufällige E-Mail-Adressen und filtert Mails je Adresse (Mailpit wird nie global geleert),
die Tests laufen deshalb parallel.
@ -8,12 +9,14 @@ die Tests laufen deshalb parallel.
| Projekt | Ziel | Voraussetzung | Tests |
|---|---|---|---|
| `api` | GoTrue über Kong (`:54321`) + Mailpit (`:54324`) + Postgres (`:54322`) | `supabase start` | 52 |
| `web` | gebaute Flutter-Web-App auf `http://localhost:3000` | zusätzlich `flutter build web` | 27 |
| `api` | GoTrue und `search_catalog_games` über Kong (`:54321`) + Mailpit (`:54324`) + Postgres (`:54322`) | `supabase start` | 70 |
| `web` | gebaute Flutter-Web-App auf `http://localhost:3000` | zusätzlich `flutter build web` | 44 |
| `throttle` | Extra-GoTrue-Container mit `GOTRUE_RATE_LIMIT_HEADER` (AUTH-CFG-13) | `THROTTLE_GOTRUE_URL` | 4 |
| `spike` | GoTrue mit unerreichbarem SMTP (Issue #37), nicht blockierend | `SPIKE_GOTRUE_URL` | 3 |
`throttle` und `spike` werden nur registriert, wenn die jeweilige Variable gesetzt ist.
| `perf` | Latenzmessung der Katalogsuche (KAT-01 Krit. 7), nur lesend, nie in der CI | `PERF_WEB_URL`, `PERF_ANON_KEY` | 1 |
`throttle`, `spike` und `perf` werden nur registriert, wenn die jeweilige Variable gesetzt ist.
## Lokal ausführen
@ -39,12 +42,38 @@ Umgebungsvariablen (Standard = lokale CLI-Demo-Werte, keine Secrets): `SUPABASE_
`SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `MAILPIT_URL`, `E2E_DB_URL`, `WEB_URL`,
`TERMS_VERSION`, `E2E_ALLOWED_HOSTS` (zusätzlich erlaubte Hosts für SUPABASE_URL/MAILPIT_URL/E2E_DB_URL, kommagetrennt; Standard nur 127.0.0.1, localhost, [::1]).
## Katalogsuche (KAT-01)
- `tests/web/kat01-suche.spec.ts` (Suche per Schaltfläche „Search games“, Relevanz, alternative Titel, Verlage, Filter, Nachladen, leere Zustände, URL/Neuladen/Zurück, Offline) und `tests/api/kat01-suche.spec.ts` (`search_catalog_games` als `anon` über Kong, Fehlerfälle). Testnamen tragen „KAT-01 Kriterium n“.
- **Testdaten:** `support/catalog.ts` legt je Spec-Datei ein eigenes Datenset an (eigener UUID-Präfix, eigenes Kunstwort: `zqvk`/`e2ec0001-` für web, `wlpd`/`e2ec0002-` für api), im `beforeAll`, aufgeräumt im `afterAll` (idempotent: `seed` löscht zuerst). Die Dateien laufen intern seriell, untereinander parallel. Suchen mit dem Kunstwort treffen nie Startbestand oder die pgTAP-Fixtures (`qxz`). Nur lokal/CI (`support/db.ts`).
- **Bekannte Eigenheiten der Flutter-Semantics (beim Schreiben weiterer Tests beachten):** Die Trefferkacheln sind Textknoten (`span`) unter *einem* Listen-Container, nicht einzelne Elemente mit Rolle; die Liste ist virtualisiert. Während einer neuen Suche bleiben die alten Treffer sichtbar: nie nur auf „irgendeine Kachel“ warten, sondern auf Trefferzahl und erwartete Kachel. Hinweistexte stehen zusätzlich in der Live-Region `flt-announcement-polite`; Texte mit `locator('span', { hasText })` suchen. Das Semantics-Eingabefeld zeigt seinen Wert nur, solange es Fokus hat (nach Auswahlmenü oder Neuladen ist es leer); den Suchbegriff über die URL prüfen. Ein direkt geladenes `/catalog` nimmt Tipp-Eingaben nicht verlässlich an: erst über die Startseite öffnen (`openCatalog` im Spec).
### Latenzmessung (Projekt `perf`, KAT-01 Krit. 7, vorläufig)
Vorläufige Messung (Option A der Spec, `docs/specs/3-katalog-suche.md` Spec-F4): kein hartes Gate gegen Staging. Ergebnis und Aussagegrenzen: `docs/specs/3-katalog-suche-umsetzung.md`, Abschnitt „PR 3c“.
```bash
# 1. Synthetisches Volumen laden (10 000 Spiele, nur lokal, wird COMMITTET; danach wieder entfernen)
docker exec -i supabase_db_shuffle psql -U postgres -d postgres -X -v confirm_local=yes -v n_games=10000 \
< supabase/perf/catalog_search_perf_load.sql
# 2. Web-Build (gegen dieselbe Supabase-URL) bereitstellen, z. B. `npx serve -s ../app/build/web -l 3000`, und messen
cd e2e
PERF_WEB_URL=http://localhost:3000 PERF_SUPABASE_URL=http://127.0.0.1:54321 PERF_ANON_KEY=<anon> \
npm run test:perf # setzt PERF_ONLY=1: nur Projekt perf, kein lokaler Web-Server
# 3. Aufräumen
docker exec -i supabase_db_shuffle psql -U postgres -d postgres -X -v confirm_local=yes \
< supabase/perf/catalog_search_perf_unload.sql
```
Der Test drosselt Chrome per CDP (`Network.emulateNetworkConditions`: 100 ms Latenz, 10 Mbit/s), baut aus dem Katalog eine feste Liste von mindestens 100 Suchen (Titel, Titelanfang, Wort, Wortkombination, Verlag, Großbuchstaben, alternative Titel, 5 ohne Treffer), fragt den erwarteten ersten Treffer je Suche ungedrosselt bei der RPC ab und misst **im Browser** die Zeit vom letzten Tastendruck (inklusive 300 ms Entprellung) bis zur ersten passenden Kachel. Ausgabe p50/p95/Maximum, Anhang `kat01-latenz.json`, bestanden bei p95 ≤ 2000 ms. `PERF_SEARCHES` (Standard 110) setzt die Anzahl, `PERF_LATENCY_MS` (Standard 100) dient nur der Gegenprobe des Gates. Das Projekt greift weder auf `support/db.ts` noch auf `support/auth-config.ts` zu; es läuft nie in `e2e-run.sh`/CI.
## Aufbau
- `support/auth-config.ts` – Konstanten mit `AUTH-CFG-nn`-Bezug; `tests/api/config.spec.ts` prüft sie gegen `supabase/config.toml` und `docs/auth-konfiguration.md`.
- `support/gotrue.ts`, `support/mailpit.ts` – REST-Client und Mailfänger-Helfer.
- `support/db.ts` – direkter Postgres-Zugriff, **nur lokal/CI** (bricht bei anderem Host ab); setzt `recovery_sent_at`/`confirmation_sent_at` für Ablauftests.
- `support/web.ts` – Flutter-Web-Helfer (Semantics einschalten, Felder füllen, Uhr verschieben).
- `support/web.ts` – Flutter-Web-Helfer für Auth (Registrierung, Anmeldung, Uhr verschieben); die allgemeinen Helfer (Semantics einschalten, Felder füllen) liegen in `support/flutter.ts` (ohne Auth-Konstanten, auch für `perf`).
- `support/catalog.ts` – Testdaten der Katalogsuche (Datenset je Spec-Datei, Aufräumen über den UUID-Präfix).
- `support/start-throttle-gotrue.sh` – zweiter GoTrue-Container (Profile `throttle`, `smtp-down`).
## Hinweise zum Web-Projekt

View file

@ -8,7 +8,8 @@
"test:api": "playwright test --project=api",
"test:web": "playwright test --project=web",
"test:throttle": "playwright test --project=throttle",
"test:spike": "playwright test --project=spike"
"test:spike": "playwright test --project=spike",
"test:perf": "PERF_ONLY=1 playwright test --project=perf"
},
"devDependencies": {
"@playwright/test": "^1.55.0",

View file

@ -1,8 +1,11 @@
import { defineConfig, devices, type Project } from '@playwright/test';
import { env } from './support/auth-config';
const webUrl = env.webUrl;
// Bewusst ohne Import von support/auth-config: Ein reiner perf-Lauf (PERF_ONLY=1) darf keine lokalen
// Auth-Konstanten voraussetzen. Der Standardwert ist derselbe wie env.webUrl.
const webUrl = process.env.WEB_URL ?? 'http://localhost:3000';
const perfUrl = process.env.PERF_WEB_URL;
/** Reiner Messlauf (Krit. 7): nur Projekt `perf`, kein lokaler Web-Server, keine lokalen Konstanten. */
const perfOnly = process.env.PERF_ONLY === '1';
/**
* Projekte (Spec #1 § 7):
@ -10,8 +13,10 @@ const webUrl = env.webUrl;
* - web gebaute Flutter-Web-App (`app/build/web`, statisch auf Port 3000)
* - throttle Drosselung (Krit. 7) gegen den Extra-Container, nur mit THROTTLE_GOTRUE_URL
* - spike SMTP-Spike (Issue #37), nur mit SPIKE_GOTRUE_URL, nicht blockierend
* - perf Latenzmessung der Katalogsuche (KAT-01 Krit. 7), nur mit PERF_WEB_URL, nur lesend,
* nie in der CI (kein Teil von e2e-run.sh); PERF_ONLY=1 startet keinen lokalen Web-Server
*/
const projects: Project[] = [
const projects: Project[] = perfOnly ? [] : [
{ name: 'api', testDir: './tests/api', use: {} },
{
name: 'web',
@ -27,10 +32,20 @@ const projects: Project[] = [
},
];
if (process.env.THROTTLE_GOTRUE_URL) {
if (perfUrl) {
projects.push({
name: 'perf',
testDir: './tests/perf',
timeout: 900_000,
retries: 0,
fullyParallel: false,
use: { ...devices['Desktop Chrome'], baseURL: perfUrl, locale: 'en-US', serviceWorkers: 'block' },
});
}
if (!perfOnly && process.env.THROTTLE_GOTRUE_URL) {
projects.push({ name: 'throttle', testDir: './tests/throttle', use: {} });
}
if (process.env.SPIKE_GOTRUE_URL) {
if (!perfOnly && process.env.SPIKE_GOTRUE_URL) {
projects.push({ name: 'spike', testDir: './tests/spike', use: {}, retries: 0 });
}
@ -44,11 +59,13 @@ export default defineConfig({
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
use: { trace: 'retain-on-failure' },
projects,
// SPA-Fallback (`-s`), sonst liefert /auth/confirm?... 404.
webServer: {
command: 'npx serve -s ../app/build/web -l 3000 --no-clipboard',
url: webUrl,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
// SPA-Fallback (`-s`), sonst liefert /auth/confirm?... 404. Entfaellt beim reinen Messlauf.
webServer: perfOnly
? undefined
: {
command: 'npx serve -s ../app/build/web -l 3000 --no-clipboard',
url: webUrl,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
});

View file

@ -0,0 +1,233 @@
/**
* 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);
});
});

View file

@ -0,0 +1,116 @@
-- Synthetisches Katalogvolumen fuer die Latenzmessung KAT-01 Kriterium 7 (Playwright-Projekt `perf`,
-- Spec #3 Abschnitt 7.3). Anders als catalog_search_perf.sql (Messung in der Datenbank, Rollback) wird hier
-- COMMITTET: Die App im Browser muss die Daten ueber Kong lesen koennen. Deshalb nur lokal, nur kurz, und immer
-- mit catalog_search_perf_unload.sql wieder entfernen.
--
-- Was es tut: legt 10 000 sichtbare Katalogspiele (Standard), je zwei alternative Titel, 2 000 Verlage
-- (12 900 Zuordnungen) und je Spiel zwei Genres und drei Mechaniken aus dem VORHANDENEN Vokabular an. Alle Spiele
-- und Verlage tragen IDs mit dem Praefix 9e4f0000- , das Entfernen loescht darueber (Zuordnungen und alternative
-- Titel haengen per on delete cascade an den Spielen). Danach vacuum (analyze), damit der Planer die echten
-- Groessen kennt.
--
-- Schutz wie in catalog_search_perf.sql: -v confirm_local=yes, nur per Unix-Socket (docker exec ... psql), Abbruch
-- bei mehr als 20 privaten Spielen. (Konten werden bewusst NICHT gezaehlt: die E2E-Tests legen laufend welche an.)
-- Aufruf:
-- docker exec -i supabase_db_shuffle psql -U postgres -d postgres -X -v confirm_local=yes -v n_games=10000 \
-- < supabase/perf/catalog_search_perf_load.sql
\set ON_ERROR_STOP on
\if :{?confirm_local}
\else
\set confirm_local no
\endif
select :'confirm_local' = 'yes' as confirm_ok, coalesce(:'HOST', '') like '/%' as socket_ok \gset
\if :confirm_ok
\else
\echo 'Abbruch: mit -v confirm_local=yes aufrufen (nur lokal, siehe Kopfkommentar).'
\quit
\endif
\if :socket_ok
\else
\echo 'Abbruch: nur per Unix-Socket im lokalen Container (docker exec ... psql), nie ueber TCP gegen einen Server.'
\quit
\endif
\if :{?n_games}
\else
\set n_games 10000
\endif
do $guard$
begin
if (select count(*) from public.games where visibility = 'private') > 20 then
raise exception 'Abbruch: Die Datenbank sieht nach echten Daten aus (mehr als 20 private Spiele). Nur lokal.';
end if;
if exists (select 1 from public.games where id::text like '9e4f0000-%') then
raise exception 'Abbruch: synthetische Daten sind schon geladen (erst catalog_search_perf_unload.sql ausfuehren).';
end if;
if (select count(*) from public.genres) < 3 or (select count(*) from public.mechanics) < 3 then
raise exception 'Abbruch: Genre-/Mechanik-Vokabular fehlt (supabase db reset einspielen).';
end if;
end
$guard$;
begin;
select setseed(0.42);
create temp table perf_words on commit drop as
select i, translate(substr(md5('w' || i), 1, 4 + (i % 6)), '0123456789', 'ghijklmnop') as w
from generate_series(1, 3000) i;
alter table perf_words add primary key (i);
create function pg_temp.pick() returns text language sql volatile as
$$ select w from perf_words where i = (select 1 + floor(power(random(), 2.2) * 3000)::int) $$;
insert into public.publishers (id, name)
select ('9e4f0000-0000-4000-9000-' || lpad(to_hex(i), 12, '0'))::uuid, initcap(pg_temp.pick()) || ' Spiele ' || i
from generate_series(1, 2000) i;
insert into public.games (id, title, visibility, year, min_players, max_players, min_minutes, max_minutes, min_age, description, description_locale)
select ('9e4f0000-0000-4000-8000-' || lpad(to_hex(g), 12, '0'))::uuid,
initcap(concat_ws(' ', pg_temp.pick(), case when k >= 2 then pg_temp.pick() end, case when k >= 3 then pg_temp.pick() end,
case when k >= 4 then pg_temp.pick() end)),
'catalog', 1990 + (g % 35), mn, mn + (g % 5), 10 + (g % 12) * 10, 30 + (g % 12) * 10, 6 + (g % 4) * 3,
'Synthetischer Kurztext ' || g, 'de'
from (select g, 1 + floor(random() * 3.99)::int as k, 1 + (g % 4) as mn from generate_series(1, :n_games) g) s;
insert into public.game_alt_titles (game_id, locale, title)
select g.id, l.locale, initcap(pg_temp.pick() || ' ' || pg_temp.pick())
from public.games g, (values ('de'), ('en')) as l(locale)
where g.id::text like '9e4f0000-%'
on conflict do nothing;
insert into public.game_publishers (game_id, publisher_id)
select g.id, p.id
from public.games g
cross join lateral (select id from public.publishers where id::text like '9e4f0000-%'
order by md5(g.id::text || id::text) limit 1) p
where g.id::text like '9e4f0000-%'
on conflict do nothing;
insert into public.game_publishers (game_id, publisher_id)
select g.id, p.id
from public.games g
cross join lateral (select id from public.publishers where id::text like '9e4f0000-%'
order by md5(g.id::text || id::text || 'b') limit 1) p
where g.id::text like '9e4f0000-%' and random() < 0.3
on conflict do nothing;
insert into public.game_genres (game_id, genre_id)
select g.id, t.id from public.games g cross join lateral
(select x.id from public.genres x order by md5(g.id::text || x.id::text) limit 2) t
where g.id::text like '9e4f0000-%' on conflict do nothing;
insert into public.game_mechanics (game_id, mechanic_id)
select g.id, t.id from public.games g cross join lateral
(select x.id from public.mechanics x order by md5(g.id::text || x.id::text) limit 3) t
where g.id::text like '9e4f0000-%' on conflict do nothing;
commit;
vacuum (analyze) public.games;
vacuum (analyze) public.game_alt_titles;
vacuum (analyze) public.publishers;
vacuum (analyze) public.game_publishers;
vacuum (analyze) public.game_genres;
vacuum (analyze) public.game_mechanics;
\echo === geladen ===
select (select count(*) from public.catalog_games) as katalogspiele,
(select count(*) from public.game_alt_titles) as alt_titel,
(select count(*) from public.publishers) as verlage,
(select count(*) from public.game_publishers) as verlag_zuordnungen;

View file

@ -0,0 +1,38 @@
-- Entfernt das synthetische Volumen aus catalog_search_perf_load.sql (alle Zeilen mit ID-Praefix 9e4f0000-).
-- Alternative Titel, Verlags-, Genre- und Mechanik-Zuordnungen verschwinden per on delete cascade mit den Spielen.
-- Aufruf (gleiche Schutzstufen wie das Ladeskript):
-- docker exec -i supabase_db_shuffle psql -U postgres -d postgres -X -v confirm_local=yes \
-- < supabase/perf/catalog_search_perf_unload.sql
\set ON_ERROR_STOP on
\if :{?confirm_local}
\else
\set confirm_local no
\endif
select :'confirm_local' = 'yes' as confirm_ok, coalesce(:'HOST', '') like '/%' as socket_ok \gset
\if :confirm_ok
\else
\echo 'Abbruch: mit -v confirm_local=yes aufrufen.'
\quit
\endif
\if :socket_ok
\else
\echo 'Abbruch: nur per Unix-Socket im lokalen Container.'
\quit
\endif
begin;
delete from public.games where id::text like '9e4f0000-%';
delete from public.publishers where id::text like '9e4f0000-%';
commit;
vacuum (analyze) public.games;
vacuum (analyze) public.game_alt_titles;
vacuum (analyze) public.publishers;
vacuum (analyze) public.game_publishers;
vacuum (analyze) public.game_genres;
vacuum (analyze) public.game_mechanics;
\echo === entfernt ===
select (select count(*) from public.games where id::text like '9e4f0000-%') as spiele_uebrig,
(select count(*) from public.publishers where id::text like '9e4f0000-%') as verlage_uebrig,
(select count(*) from public.catalog_games) as katalogspiele;