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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3ccf3aa2d9
commit
1be73b266c
@@ -0,0 +1,141 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user