docs(quick-260921-ldf): Wackeltest als Produktfehler nachgewiesen und behoben

Zusammenfassung, Debug-Akte und Knowledge-Base-Eintrag zum Quick-Vorgang
260921-ldf.

Die zentrale Frage war, ob hinter dem Wackeltest aus CI-Lauf 395 ein
echter Nutzerfehler steckt. Sie ist gemessen beantwortet, nicht
geschaetzt: ein MutationObserver ueber jeden DOM-Commit zeigt den
Zustand "Vorschaubild sichtbar, Haekchen aus" bei JEDEM Oeffnen als
echten, festgeschriebenen DOM-Zustand, der ohne act() zwei volle
Makrotask-Runden haelt. Zwischen zwei Makrotasks darf der Browser
zeichnen - der Nutzer kann das also sehen. Was nicht erreichbar ist:
jemand schickt ab und das Bild fehlt, denn die Korrektur kommt binnen
Millisekunden.

Stabilitaet: 20 von 20 vollen Laeufen gruen, je 531 Tests. Die
Zusammenfassung ordnet das ehrlich ein - bei einer Ausgangsrate von
1:17 waeren 20 gruene Laeufe auch ohne Fix zu rund 30 Prozent zu
erwarten. Der tragende Beleg ist der deterministische: den falschen
Zwischenzustand gibt es nicht mehr, und Test 14/15 sind gegen den Stand
davor in fuenf von fuenf Laeufen rot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
2026-09-21 15:50:37 +02:00
parent 9f02fcc190
commit a6181e2751
3 changed files with 318 additions and 0 deletions
@@ -0,0 +1,150 @@
---
phase: quick-260921-ldf
plan: 01
subsystem: ui
tags: [react, vitest, flaky-test, bug-report, state-management, react-effects]
requires:
- phase: quick-260914-m97
provides: "Fehler-melden-Knopf mit Bildaufnahme VOR dem Dialog; Test 1 als Zusicherung dieser Reihenfolge"
- phase: quick-260918-gza
provides: "Vier Herkunftsfelder in der Nutzlast (Test 12/13 derselben Datei)"
provides:
- "Wackeltest aus CI-Lauf 395 ursaechlich beseitigt — als Produktfehler, nicht als Testfehler"
- "Fehler-melden-Dialog zeigt Vorschaubild und Haekchen ab dem ERSTEN Commit stimmig, statt einen Commit spaeter"
- "Erneutes Oeffnen nach einem Versand zeigt keinen alten Danke-Bildschirm und keinen alten Text mehr"
- "Test 14/15: MutationObserver-Pruefung ueber JEDEN Commit statt einer Stichprobe am Ende — deterministisch rot vor dem Fix"
- "Zwei Konstruktionsfehler im Fehler-melden-Test behoben (expect in der Attrappe, nicht zurueckgesetzte document.body-Groesse)"
affects: [web-bug-report, web-test-hygiene]
actuals:
tokens: 11800
tasks: 1
commits: 3
tech-stack:
added: []
patterns:
- "Abgeleiteten Zustand beim RENDERN ableiten, nicht per useEffect nachziehen: passive Effekte laufen nach dem Commit, also schreibt React zwangslaeufig erst den falschen und dann den richtigen Zustand in den DOM. Muster hier: `const attach = screenshot !== null && (attachChoice ?? true)` — eine eigene Zustandsvariable haelt nur noch die bewusste Wahl des Nutzers, nicht den abgeleiteten Wert."
- "Dialoge nur einhaengen, solange sie offen sind (`{open && <Dialog ... />}`), statt sie dauerhaft eingehaengt zu lassen und `null` zurueckgeben zu lassen. Sonst laufen die useState-Startwerte genau einmal — zu einem Zeitpunkt, an dem die spaeteren Daten noch nicht da sind — und jedes weitere Oeffnen braucht einen zuruecksetzenden Effekt, der genau diese Luecke aufreisst."
- "Wackeltests mit einem MutationObserver ueber JEDEN DOM-Commit untersuchen statt mit einer Stichprobe am Ende. Der Unterschied zwischen 'der Nutzer sieht das nie' und 'das steht bei jedem Oeffnen im DOM' ist genau so zu messen und nicht anders zu erraten."
- "Wackelursache einkreisen, indem man die Beobachtungszeit kuenstlich verschiebt (ein setTimeout in der Attrappenkette) statt auf einen Zufallstreffer unter Last zu warten: aus 1:17 wird 4 von 4."
- "Kein expect() innerhalb einer Attrappe, die in einem await der Komponente laeuft — ein Fehler daraus wird von einem catch in der Produktionskette verschluckt und der Test faellt an ganz anderer Stelle mit irrefuehrender Meldung durch. Beobachtung festhalten, im Testkoerper pruefen."
- "Object.defineProperty auf document.body (scrollWidth/scrollHeight) ueberlebt cleanup() und damit die ganze Testdatei — in afterEach per Reflect.deleteProperty zuruecknehmen."
key-files:
created: []
modified:
- apps/web/src/components/bug-report/bug-report-dialog.tsx
- apps/web/src/components/bug-report/bug-report-button.tsx
- apps/web/src/components/bug-report/bug-report-button.test.tsx
key-decisions:
- "Urteil Produktfehler, nicht Testfehler — und zwar gemessen, nicht geschaetzt: ein MutationObserver ueber jeden Commit zeigt den Zustand 'Vorschaubild sichtbar, Haekchen aus' bei JEDEM Oeffnen als echten, festgeschriebenen DOM-Zustand, nicht nur unter Last. Ohne act() haelt er zwei volle Makrotask-Runden."
- "Repariert wurde der Ursache-Code, nicht der Test. Test 1 prueft unveraendert dieselbe Zusicherung; kein retry, kein hoeheres Zeitlimit — die Ursache war nie blosse Zeit, sondern ein falscher Zustand, den es jetzt nicht mehr gibt."
- "Beide Teilursachen beseitigt, nicht nur eine. Die abgeleitete Ableitung allein wuerde die Abwahl des Nutzers ueber das Schliessen hinaus festhalten; das bedingte Einhaengen allein liesse den Fehler wiederkehren, falls Bild und Oeffnen je in getrennten Commits landeten. Erst zusammen ist die Zusicherung strukturell erzwungen."
- "Der zuruecksetzende useEffect entfaellt ersatzlos statt umgebaut zu werden — ein frischer Mount setzt Status, Text, Fehlerstand und Haekchen schon durch die useState-Startwerte zurueck."
- "Die geschluckte Ausnahme in captureScreenshot (catch liefert null) bleibt bewusst stehen: das Bild ist eine Beigabe, der Bericht geht auch ohne. Angepasst wurde stattdessen der Test, der sein expect in diese Kette gelegt hatte."
patterns-established:
- "Bei einem Wackeltest zuerst fragen, ob der beobachtete Zwischenzustand ueberhaupt in den DOM geschrieben wird. Wird er es, ist es ein Produktfehler und der Test hat recht behalten — auch wenn die sichtbare Wirkung nur ein kurzes Flackern ist."
requirements-completed: [Ursache, Urteil, Reparatur, Nebenbefund-1, Nebenbefund-2, Stabilitaet]
duration: ~1h (eine Sitzung)
completed: 2026-09-21
status: complete
---
# Quick-Vorgang 260921-ldf: Wackeltest Fehler-melden-Haekchen Summary
**Der Wackeltest hatte recht: der Fehler-melden-Dialog schrieb bei JEDEM Oeffnen zuerst den Zustand "Vorschaubild sichtbar, Haekchen aus" in den DOM und korrigierte ihn erst einen Commit spaeter — ein Produktfehler, kein Testfehler. Repariert ist der Ursache-Code; der Test prueft unveraendert dasselbe und wackelt nicht mehr.**
## Die Ursache in einem Satz
Der Dialog war dauerhaft eingehaengt, sodass `useState(screenshot !== null)` nur ein einziges Mal lief — beim allerersten Mount des Knopfs, als noch gar kein Bild da war — und der richtige Wert erst von einem `useEffect` nachgezogen wurde, der per Bauart NACH dem Commit laeuft.
## Das Urteil: Produktfehler
Das war die eigentliche Frage, und sie ist gemessen worden statt geschaetzt. Ein `MutationObserver` ueber `document.body` protokolliert jeden einzelnen DOM-Commit waehrend des Oeffnens. Gegen den Stand vor dem Fix, mit einer Attrappe, die rein in Mikrotasks aufloest, also ohne jede kuenstliche Verzoegerung:
```
COMMIT dialog=false img=nein box=-
COMMIT dialog=false img=nein box=-
COMMIT dialog=true img=ja box=AUS <- falsch, aber festgeschrieben
COMMIT dialog=true img=ja box=AN
```
Der falsche Zustand ist also kein Testartefakt und keine Frage der Last. Er entsteht bei **jedem** Oeffnen. Ein zweiter Versuch, diesmal mit einem rohen Klick ohne `act()` und einer Stichprobe pro Ereignisschleifen-Runde, zeigt, wie lange er haelt:
```
runde 1 bild=ja haekchen=AUS
runde 2 bild=ja haekchen=AUS
runde 3 bild=ja haekchen=AN
```
Zwei volle Makrotask-Runden. Zwischen zwei Makrotasks darf der Browser zeichnen — der Nutzer kann diesen Zustand also sehen. Damit ist die Kernfrage beantwortet: **ja, ein echter Nutzer geraet in diesen Zustand.**
Was dabei ehrlich dazugehoert: die Korrektur kommt binnen Millisekunden, lange bevor jemand "Senden" druecken kann. Der befuerchtete Fall — jemand sieht sein Bild, schickt ab, und das Bild fehlt — ist damit nicht erreichbar. Was bleibt, ist ein kurzes Flackern beim Oeffnen. Echt, sichtbar, aber ohne Datenverlust. Der Grund, es trotzdem im Produktcode zu reparieren statt im Test: der Test hat einen wirklich vorhandenen falschen Zustand gefunden, und wer ihn im Test wegberuhigt, laesst den Zustand stehen.
Nicht gemessen und deshalb hier auch nicht behauptet: ob der Browser den Zwischenschritt tatsaechlich in jedem Fall zeichnet. Belegt ist, dass er zwei Zeichengelegenheiten lang besteht.
## Wie die Ursache gefunden wurde
Drei Schritte, jeder mit einem eigenen Messergebnis:
1. **Beobachtungszeit kuenstlich verschoben.** Statt auf einen Zufallstreffer unter Last zu warten, bekam die `toPng`-Attrappe ein `setTimeout` in die Kette. Ergebnis: 4 von 4 Fehlschlaegen mit exakt der CI-Meldung, schon bei 0 ms. Damit war klar, dass die Last nur der Ausloeser ist und jede Makrotask-Grenze genuegt.
2. **Eine naheliegende Erklaerung widerlegt.** Der Verdacht lag auf `await import('html-to-image')` in `captureScreenshot`. Gemessen: der Import loest auf, bevor ein zuvor gesetzter `setTimeout(0)` feuert — er ueberschreitet keine Makrotask-Grenze und ist nicht die Ursache.
3. **Jeden Commit protokolliert.** Erst das zeigte, dass der falsche Zustand nicht gelegentlich unter Last entsteht, sondern immer.
Dabei fiel eine zweite Auspraegung derselben Ursache auf: beim erneuten Oeffnen nach einem Versand stand zwei Runden lang der **alte Danke-Bildschirm** im DOM, bevor das frische Formular erschien. Auch `status`, `description` und `failedStatus` wurden erst per Effekt zurueckgesetzt.
## Die Reparatur
Zwei Teilursachen, beide beseitigt — einzeln reicht keine:
- **`bug-report-button.tsx`:** Der Dialog wird nur noch eingehaengt, solange er offen ist. Jedes Oeffnen ist damit ein frischer Mount, und die `useState`-Startwerte gelten schon im ersten Commit. Der zuruecksetzende `useEffect` entfaellt ersatzlos — er hatte genau die Luecke aufgerissen, die er schliessen sollte.
- **`bug-report-dialog.tsx`:** Das Haekchen wird beim Rendern abgeleitet statt nachgezogen: `const attach = screenshot !== null && (attachChoice ?? true)`. `attachChoice` haelt nur noch die bewusste Abwahl des Nutzers.
Nach dem Fix zeigt dasselbe Commit-Protokoll den ersten Commit mit Dialog bereits als `img=ja box=AN`, und der rohe Klick ist schon in Runde 1 richtig. Es gibt keinen falschen Zwischenzustand mehr, den man beobachten koennte — deshalb kann der Test auch nicht mehr wackeln.
## Der Nachweis: Test 14 und 15
Der alte Test 1 fand den Fehler nur durch Zufall — er las den DOM einmal, nachdem `findByRole` den Dialog gemeldet hatte, und traf mal den falschen ersten, meist den richtigen zweiten Commit. Ein Fehlschlag auf rund siebzehn volle Laeufe.
Die beiden neuen Tests pruefen stattdessen **jeden** Commit und dulden keinen einzigen mit sichtbarem Bild und ausgeschaltetem Haekchen. Test 14 deckt das erste Oeffnen ab, Test 15 das erneute Oeffnen nach einem Versand mit abgewaehltem Haekchen und getipptem Text.
Gegen den Stand vor dem Fix (`8d604b8`, Quellcode zurueckgesetzt, Tests behalten) sind beide in **5 von 5 Laeufen rot**; mit dem Fix gruen. Kein `retry`, kein hoeheres Zeitlimit — beides waere hier auch falsch gewesen, weil die Ursache nie blosse Zeit war.
Test 1 bleibt in der Sache unveraendert und prueft weiterhin dieselbe Zusicherung. Die Pruefmenge ist gewachsen, nicht geschrumpft.
## Die zwei Nebenbefunde
Beide waren nicht die Ursache, beide haetten die Suche in die Irre fuehren koennen. Beide sind erledigt (Commit `c0ab5b5`, vor dem Fix, unabhaengig davon gruen).
**1. `expect` innerhalb der Attrappe.** Test 1 pruefte mitten in der `toPng`-Attrappe, dass noch kein Dialog im DOM steht. Wirft dieses `expect`, landet der Fehler mitten im `await` von `captureScreenshot`, und dessen `catch` liefert still `null` zurueck. Der Test waere dann nicht an der geprueften Stelle durchgefallen, sondern viel spaeter mit der Meldung, es gebe kein Vorschaubild — eine Meldung, die in die voellig falsche Richtung zeigt. Die Attrappe haelt die Beobachtung jetzt nur fest, geprueft wird im Testkoerper. Bewiesen wird dasselbe.
**2. `document.body`-Groesse ohne Ruecknahme.** `scrollWidth`/`scrollHeight` wurden per `Object.defineProperty` auf 3200x1000 gesetzt und nie zurueckgenommen. Die eigene Eigenschaft verdeckt den Getter von `Element.prototype`, und `document.body` ueberlebt `cleanup()` — alle zwoelf folgenden Tests der Datei sahen weiterhin 3200x1000. `stubBodyGroesse` merkt sich das jetzt, `afterEach` nimmt es per `Reflect.deleteProperty` zurueck.
## Commits
1. **Zwei Konstruktionsfehler im Fehler-melden-Test behoben** — `c0ab5b5` (test)
2. **Fehler-melden-Dialog stimmt ab dem ersten Commit, nicht erst einen spaeter** — `de7fdb7` (fix)
3. **Test 14/15 halten jeden Commit fest statt nur den Endzustand** — `9f02fcc` (test)
## Pruefstand
- **`pnpm lint`:** 5/5 erfolgreich, keine Fehlerstufe, **399 Warnungen** — unveraendert, nicht gewachsen
- **`pnpm type-check`:** 4/4 erfolgreich
- **`apps/web`:** 73 Dateien / **531 Tests** (529 + Test 14/15), keine Datei dazugekommen oder weggefallen
- **`apps/api`:** 72 Dateien / 1143 Tests, unveraendert — nicht beruehrt
- **Stabilitaet:** **20 von 20** vollen Laeufen `pnpm --filter @tessera/web exec vitest run` gruen, 0 Fehlschlaege, je 531 Tests (je ~31 s, gegen den committeten Endstand, nacheinander)
Dazu die ehrliche Einordnung: 20 saubere Laeufe sind fuer sich genommen **kein** starker Beleg. Bei der gemessenen Ausgangsrate von 1:17 waeren 20 gruene Laeufe auch ohne jede Reparatur noch mit rund 30 Prozent Wahrscheinlichkeit zu erwarten. Der eigentliche Beleg ist ein anderer und er ist deterministisch: der falsche Zwischenzustand existiert nicht mehr. Das Commit-Protokoll zeigt den ersten Commit mit Dialog bereits als `img=ja box=AN`, und Test 14/15 sind gegen den Stand davor in 5 von 5 Laeufen rot. Es gibt schlicht nichts mehr, was der Test zufaellig falsch antreffen koennte. Die 20 Laeufe bestaetigen das nur, sie tragen es nicht.
## Was nicht angefasst wurde
- Keine Versionsspruenge, keine neuen Abhaengigkeiten, kein repo-weites Umformatieren
- `STATE.md` und `ROADMAP.md` unberuehrt (Sache des Orchestrators)
- Nicht gepusht
- Die geschluckte Ausnahme in `captureScreenshot` bleibt bewusst stehen — das Bild ist eine Beigabe, der Bericht geht auch ohne