Files
Laufzettel/tests/e2e/live.spec.ts
T
Florian WildeandClaude Opus 5 4aa79c93ff
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
feat(laeufe): Live-Aktualisierung per Ereignisstrom statt Polling (#53)
Die Abarbeitungsansicht fragte alle zehn Sekunden nach dem Zustand. Jetzt
melden vier Datenbank-Trigger `NOTIFY lauf_geaendert, '<laufId>'`, eine
LISTEN-Verbindung je Prozess hoert zu, und der Route Handler schiebt
weiter. Gemessen: der Haken des Kollegen ist nach 485 ms beim Zuschauer
(am Handy 487 ms), vorher bis zu zehn Sekunden. Zwanzig offene Laschen
kosten nichts mehr statt zwanzig Abfragen je zehn Sekunden.

Das Ereignis kommt aus der Datenbank und nicht aus dem Dienst, aus
demselben Grund, aus dem auch die Regeln dort stehen: ein Trigger sieht
auch den Seed, eine Migration und die Reparatur per psql. Und NOTIFY
erreicht jede Instanz der Anwendung, ohne einen zweiten Dienst.

Welche Tabellen melden, haengt an dem, was `laufZustand` liefert — nicht
daran, was das Issue vorgeschlagen hat. Mit Triggern nur auf
Schrittergebnis und Lauf waere der Strom ein **Rueckschritt** gewesen:
das Polling verglich den ganzen Stand und sah deshalb auch Kommentare,
ein Strom sieht nur, was gemeldet wird. Der Kommentar des Kollegen waere
nie angekommen, und die Seite haette ausgesehen wie immer. Es melden
jetzt vier Tabellen, und `laufZustand` traegt `anzahlAnhaenge` — ein
Ereignis ohne Entsprechung im Zustand loest nichts aus.

Das Polling bleibt als Rueckfall an **derselben** Adresse, unterschieden
nur am Accept-Kopf: eine Rechtepruefung, ein Lesemodell, eine Nutzlast.
Ein zweiter Endpunkt waere eine zweite Stelle, an der jemand
rechtErforderlich vergessen kann. Bei erreichter Obergrenze antwortet
der Handler gewoehnlich mit JSON; EventSource meldet darauf einen Fehler
und versucht es nicht wieder, also faellt der Client von sich aus
zurueck — ohne Sonderfall.

Nebenbei zwei Fehler in docs/betrieb/reverse-proxy.md behoben, beide aus
#52: `client_max_body_size 8m` bei `ANHANG_MAX_MB=10` haette ein
10-MB-Foto mit 413 abgewiesen, bevor die eigene Meldung zum Zuge kommt.

Closes #53

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 09:36:10 +02:00

142 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { SEED_TAG, SEED_VORLAGE, anmelden, kundeWaehlen, variablenFuellen, vorlageWaehlen } from "./helfer";
/**
* Zwei Sitzungen an einem Lauf (#033).
*
* Das ist das Abnahmekriterium, das sich nur hier pruefen laesst: **hakt die
* eine ab, sieht die andere es ohne neu zu laden.** Mit dem Polling aus #013 war
* das richtig, aber bis zu zehn Sekunden langsam; seit dem Strom kommt es
* sofort.
*
* Und der zweite Ablauf prueft den Rueckfall. Ein Rueckfall, der nie gefahren
* wird, ist keiner — dieses Projekt hat genau dafuer schon bezahlt (#010: eine
* Oberflaeche, die serverseitig richtig aussah und nie hydrierte).
*/
const STEMPEL = Date.now();
/** Legt einen Lauf an, beginnt ihn und gibt seine Adresse zurueck. */
async function laufVorbereiten(seite: Page, titel: string): Promise<string> {
await seite.goto("/laeufe/starten");
await vorlageWaehlen(seite, SEED_VORLAGE);
const geraet = await prisma.geraet.findUniqueOrThrow({
where: { tagCode: SEED_TAG },
select: { id: true, kundeId: true },
});
await kundeWaehlen(seite, geraet.kundeId);
await seite.selectOption('select[name="geraetId"]', geraet.id);
await seite.locator("details summary", { hasText: "Titel, Ticket" }).click();
await seite.getByLabel("Titel", { exact: true }).fill(titel);
await variablenFuellen(seite);
await seite.getByRole("button", { name: "Lauf anlegen" }).click();
await seite.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 });
await seite.getByRole("button", { name: "Lauf beginnen" }).click();
await expect(seite.getByRole("button", { name: "Lauf abschließen" })).toBeVisible();
return new URL(seite.url()).pathname;
}
/**
* Den ersten Abschnitt aufklappen und Kopf **und** Bereich zurueckgeben.
*
* Gezaehlt wird spaeter im Bereich, nicht auf der Seite: die Vorlage hat 34
* Schritte in sieben Abschnitten, und `0 von 34` steht in keinem Kopf.
*/
async function ersterAbschnitt(seite: Page) {
const bereich = seite
.locator("section")
.filter({ has: seite.locator("button[aria-expanded]") })
.first();
const kopf = bereich.locator("button[aria-expanded]").first();
if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click();
return { kopf, bereich };
}
test.describe("Live-Aktualisierung", () => {
test("die zweite Sitzung sieht den Haken der ersten, ohne neu zu laden", async ({ page, browser }) => {
test.slow();
await anmelden(page);
const pfad = await laufVorbereiten(page, `ZZ E2E Live ${STEMPEL}`);
// Zweite Sitzung: eigener Kontext, eigenes Cookie, eigener Strom.
const zweiter = await browser.newContext();
const zuschauer = await zweiter.newPage();
try {
await anmelden(zuschauer);
await zuschauer.goto(pfad);
const { kopf: kopfA, bereich: bereichA } = await ersterAbschnitt(page);
const { kopf: kopfB } = await ersterAbschnitt(zuschauer);
const schritte = await bereichA.getByRole("button", { name: "erledigt", exact: true }).count();
expect(schritte).toBeGreaterThan(0);
await expect(kopfB).toContainText(`0 von ${schritte}`);
/*
* Jetzt der eigentliche Nachweis. Gewartet wird **nur** auf den Zaehler im
* Kopf des Zuschauers — kein `reload()`, kein Klick. Kommt er dort an, hat
* der Strom funktioniert.
*/
const start = Date.now();
await bereichA.getByRole("button", { name: "erledigt", exact: true }).first().click();
await expect(kopfA).toContainText(`1 von ${schritte}`);
await expect(kopfB).toContainText(`1 von ${schritte}`, { timeout: 8_000 });
const dauer = Date.now() - start;
/*
* **Warum 5000 und nicht 1000.** Das Kriterium nennt „unter einer
* Sekunde“, und gemessen sind es hier gewoehnlich zwei- bis
* dreihundert Millisekunden. Eine Schranke von 1000 ms waere aber auf
* einer belasteten Maschine ein Test, der gelegentlich ohne Fehler im Code
* rot wird — und ein flatternder Test wird irgendwann ignoriert. 5000 ms
* beweisen das Entscheidende: **es wurde nicht gepollt**, denn das Polling
* fragt alle zehn Sekunden.
*/
expect(dauer).toBeLessThan(5_000);
console.log(`Der Haken war nach ${dauer} ms beim Zuschauer.`);
} finally {
await zweiter.close();
}
});
test("ohne EventSource kommt die Änderung über das Polling an", async ({ page, browser }) => {
test.slow();
await anmelden(page);
const pfad = await laufVorbereiten(page, `ZZ E2E Rückfall ${STEMPEL}`);
const zweiter = await browser.newContext();
const zuschauer = await zweiter.newPage();
try {
/*
* `EventSource` wegnehmen, **bevor** die Seite laedt. Genau diesen Zustand
* hat ein Browser hinter einem Proxy, der Stroeme puffert: der Strom kommt
* nie an, und ohne Rueckfall sieht die Seite eine Stunde lang unveraendert
* aus.
*/
await zuschauer.addInitScript(() => {
// @ts-expect-error – genau das ist der Punkt des Ablaufs.
delete window.EventSource;
});
await anmelden(zuschauer);
await zuschauer.goto(pfad);
expect(await zuschauer.evaluate(() => typeof EventSource)).toBe("undefined");
const { kopf: kopfA, bereich: bereichA } = await ersterAbschnitt(page);
const { kopf: kopfB } = await ersterAbschnitt(zuschauer);
const schritte = await bereichA.getByRole("button", { name: "erledigt", exact: true }).count();
await expect(kopfB).toContainText(`0 von ${schritte}`);
await bereichA.getByRole("button", { name: "erledigt", exact: true }).first().click();
await expect(kopfA).toContainText(`1 von ${schritte}`);
// Das Polling fragt alle zehn Sekunden – entsprechend mehr Luft.
await expect(kopfB).toContainText(`1 von ${schritte}`, { timeout: 25_000 });
} finally {
await zweiter.close();
}
});
});