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
+19
View File
@@ -0,0 +1,19 @@
# GSD Debug Knowledge Base
Geloeste Debug-Sitzungen. Wird von `gsd-debugger` zu Beginn einer neuen
Untersuchung gelesen, um bekannte Muster als Hypothesen-Kandidaten
vorzuschlagen.
---
## wackeltest-bugreport-haekchen — Vorschaubild sichtbar, Haekchen "Bildschirmfoto anhaengen" aus (Wackeltest CI 395)
- **Date:** 2026-09-21
- **Error patterns:** toBeChecked, Received element is not checked, flaky, Wackeltest, nur unter Last, isoliert nie, Zustand erst einen Commit spaeter richtig
- **Root cause(s):** Dialog dauerhaft eingehaengt, sodass `useState(abgeleiteterWert)` nur beim allerersten Mount lief (Daten noch nicht da); zusammen damit: Anfangszustand per `useEffect` nachgezogen, und passive Effekte laufen NACH dem Commit — React schreibt deshalb bei jedem Oeffnen erst den falschen, dann den richtigen Zustand in den DOM
- **Fix:** Dialog nur einhaengen, solange offen (`{open && <Dialog/>}`) — frischer Mount je Oeffnen; abgeleiteten Wert beim Rendern ableiten statt per Effekt nachziehen (`const attach = screenshot !== null && (attachChoice ?? true)`); zuruecksetzender Effekt entfaellt ersatzlos
- **Files changed:** 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
- **Why not caught:** Es gab ein Tor, aber ein stumpfes — Test 1 las den DOM EINMAL nach `findByRole` und traf damit mal den falschen ersten, meist den richtigen zweiten Commit (1:17). Eine Stichprobe am Ende kann einen falschen Zwischen-Commit grundsaetzlich nicht zuverlaessig sehen. Lint und Typpruefung koennen diese Klasse gar nicht sehen.
- **Recurrence guard:** Regressionstest apps/web/src/components/bug-report/bug-report-button.test.tsx:"Test 14 (quick-260921-ldf): kein falscher Zwischenzustand" und ":"Test 15 (quick-260921-ldf): erneutes Oeffnen startet frisch" — beide beobachten per MutationObserver JEDEN Commit statt einer Stichprobe und sind gegen den Stand davor deterministisch rot (5/5)
- **Merksatz fuer aehnliche Faelle:** Bei einem Wackeltest zuerst per MutationObserver pruefen, ob der beobachtete Zwischenzustand ueberhaupt in den DOM geschrieben wird. Wird er es, ist es ein Produktfehler und der Test hat recht — dann nicht den Test beruhigen (kein retry, kein hoeheres Zeitlimit), sondern den Zustand beseitigen.
---
@@ -0,0 +1,149 @@
---
status: resolved
trigger: "CI 395 rot: apps/web/src/components/bug-report/bug-report-button.test.tsx Test 1 -- Vorschaubild da, Haekchen 'Bildschirmfoto anhaengen' aus. 1 Fehlschlag in ~17 vollen Laeufen, isoliert nie."
created: 2026-09-21T00:00:00Z
updated: 2026-09-21T00:00:00Z
symptoms_prefilled: true
goal: find_and_fix
---
## Current Focus
reasoning_checkpoint:
hypothesis: "attach ist abgeleiteter Zustand, der per passivem useEffect nachgezogen wird. Da BugReportDialog dauerhaft eingehaengt ist, laeuft useState(screenshot !== null) nur beim ersten Mount (screenshot noch null) -> attach startet immer false. Deshalb committet React BEI JEDEM Oeffnen zuerst einen DOM-Zustand 'Dialog offen + Bild da + Haekchen AUS' und korrigiert ihn erst im naechsten Commit."
confirming_evidence:
- "MutationObserver-Protokoll (H2): COMMIT dialog=true img=ja box=AUS, danach COMMIT dialog=true img=ja box=AN -- der falsche Zustand ist ein echter, committeter DOM-Zustand, kein Testartefakt."
- "H3 (roher Klick ohne act): der falsche Zustand haelt ZWEI volle Makrotask-Runden. Zwei Makrotask-Grenzen = zwei Gelegenheiten des Browsers zu zeichnen."
- "H1 (kuenstlicher Makrotask im toPng-Mock): Fehlschlag 4 von 4, exakt dieselbe Meldung wie in CI 395 -- die Wackelbedingung ist reine Beobachtungszeit."
- "H3b: dasselbe Muster beim erneuten Oeffnen -- der alte Danke-Bildschirm steht zwei Runden lang im DOM, bevor das frische Formular erscheint."
falsification_test: "Waere es ein reines Testartefakt, duerfte im MutationObserver-Protokoll kein Commit mit img=ja/box=AUS auftauchen. Er taucht auf, ausnahmslos, bei jedem Oeffnen."
fix_rationale: "Ursache ist die Konstruktion: Zustand wird per Effekt nachgezogen statt beim Rendern abgeleitet, und der Dialog bleibt ueber das Schliessen hinaus eingehaengt. Beides beseitigen: (1) attach waehrend des Renderns aus screenshot ableiten, (2) den Dialog nur einhaengen, solange er offen ist -> jeder Oeffnungsvorgang startet mit frischem Zustand, schon im ersten Commit."
blind_spots: "Ob der Browser den Zwischen-Frame tatsaechlich zeichnet, ist hier nicht im echten Browser gemessen -- belegt ist, dass der falsche Zustand zwei Makrotask-Grenzen ueberdauert, also mindestens zwei Zeichengelegenheiten offenstehen."
candidate_causes:
- "code: abgeleiteter Zustand per passivem Effekt statt beim Rendern (bestaetigt)"
- "code: Dialog dauerhaft eingehaengt -> useState-Startwert veraltet (bestaetigt, zweite Teilursache)"
- "environment: Ereignisschleifen-Last im vollen Vitest-Lauf (nur Ausloeser der Beobachtung, nicht Ursache)"
- "data: Datenform des Bildes -- ausgeschlossen, img src ist im Fehlerfall korrekt"
and_gate: "ja -- zwei Bedingungen zusammen: (a) attach wird per Effekt nachgezogen UND (b) der Dialog bleibt eingehaengt, sodass der useState-Startwert aus der Zeit vor dem ersten Bild stammt. Ohne (b) waere (a) beim ersten Oeffnen unauffaellig; ohne (a) waere (b) folgenlos."
test: Fix anwenden, danach H2/H3-Instrumentierung erneut laufen lassen
expecting: Erster Commit mit Dialog traegt bereits Haekchen AN
next_action: bug-report-dialog.tsx und bug-report-button.tsx anpassen
## Symptoms
expected: Nach Klick auf den Fehler-melden-Knopf oeffnet der Dialog mit Vorschaubild UND gesetztem Haekchen "Bildschirmfoto anhaengen".
actual: Vorschaubild ist da (img src == DATA_URL, Checkbox nicht disabled), aber die Checkbox ist nicht checked.
errors: |
Error: expect(element).toBeChecked()
Received element is not checked:
<input class="h-4 w-4" id="bug-report-attach" type="checkbox" />
bug-report-button.test.tsx:126:72
reproduction: pnpm --filter @tessera/web exec vitest run (voller Lauf), ~1 von 17. Isoliert (nur die Datei) in 6 Laeufen nie.
started: CI-Lauf 395 (2026-09-21), Test existiert seit quick-260914-m97
## Eliminated
- hypothesis: "Reines Testartefakt -- die Pruefung misst einen Zustand, den der Nutzer nie sieht"
evidence: "MutationObserver-Protokoll zeigt den Zustand als echten DOM-Commit, der zwei Makrotask-Runden ueberdauert. Der Browser hat in dieser Zeit mindestens zwei Zeichengelegenheiten."
timestamp: T2
- hypothesis: "Der dynamische Import von html-to-image kostet einen Makrotask und kippt dadurch die Reihenfolge"
evidence: "Gemessen: await import('html-to-image') loest ohne Makrotask-Grenze auf (Timer 0, davor gesetzt, feuert NACH dem Import). Der Import ist nicht die Ursache -- die falsche Reihenfolge besteht auch ohne ihn."
timestamp: T2
## Evidence
- timestamp: T0
checked: apps/web/src/components/bug-report/bug-report-button.tsx
found: "handleClick: setCapturing(true); const shot = await captureScreenshot(); setScreenshot(shot); setOpen(true); setCapturing(false). BugReportDialog wird IMMER gerendert (kein bedingtes Mounten) -- die Instanz bleibt ueber open-Wechsel hinweg bestehen."
implication: "useState(screenshot !== null) im Dialog laeuft nur EINMAL, beim ersten Mount des Knopfs, da ist screenshot noch null -> attach startet IMMER false. Das Haekchen wird ausschliesslich durch den useEffect gesetzt."
- timestamp: T0
checked: apps/web/src/components/bug-report/bug-report-dialog.tsx
found: "const [attach, setAttach] = useState(screenshot !== null); useEffect(() => { if (open) { ... setAttach(screenshot !== null); ... } }, [open, screenshot]);"
implication: "attach ist abgeleiteter Zustand, synchronisiert per passivem Effekt. Zwischen dem Commit (Bild im DOM) und dem Lauf des passiven Effekts (Haekchen an) existiert zwangslaeufig ein Zustand 'Bild da, Haekchen aus'."
- timestamp: T0
checked: apps/web/src/lib/bug-report-api.ts captureScreenshot
found: "await import('html-to-image') -- dynamischer Import VOR dem toPng-Aufruf; Fehler werden geschluckt (catch -> null)."
implication: "Zwei await-Stufen vor setScreenshot/setOpen. Unter Last kann die Aufloesung nach dem Ende des act()-Bereichs von user.click() landen."
- timestamp: T1
checked: "Kuenstlicher Makrotask im toPng-Mock (zz-repro.test.tsx, Verzoegerung 0/1/5/20 ms)"
found: "4 von 4 Fehlschlaegen mit exakt der CI-Meldung 'Received element is not checked'."
implication: "Jede Makrotask-Grenze in der Aufnahmekette genuegt, damit die Pruefung den falschen Zwischenzustand sieht. Die Last im vollen Lauf ist nur der Ausloeser."
- timestamp: T2
checked: "MutationObserver ueber document.body waehrend des Oeffnens (zz-repro3.test.tsx), toPng rein mikrotask wie im echten Test"
found: |
COMMIT dialog=false img=nein box=-
COMMIT dialog=false img=nein box=-
COMMIT dialog=true img=ja box=AUS <- falscher Zustand, committet
COMMIT dialog=true img=ja box=AN
implication: "Der Zustand 'Bild da, Haekchen aus' ist ein echter, committeter DOM-Zustand -- bei JEDEM Oeffnen, nicht nur unter Last. Der Test faellt nur dann durch, wenn er zufaellig den ersten statt den zweiten Commit sieht."
- timestamp: T2
checked: "Roher Klick ohne act(), Sampling pro Makrotask-Runde (zz-repro4.test.tsx)"
found: "runde 1: bild=ja haekchen=AUS | runde 2: bild=ja haekchen=AUS | runde 3: bild=ja haekchen=AN"
implication: "Der falsche Zustand ueberdauert zwei volle Ereignisschleifen-Runden. Im Browser liegen damit mindestens zwei Zeichengelegenheiten in diesem Zustand -> fuer den Nutzer sichtbar."
- timestamp: T2
checked: "Erneutes Oeffnen nach Versand (zz-repro4.test.tsx, H3b)"
found: "runde 2 und 3 zeigen den ALTEN Danke-Bildschirm (danke=true), erst runde 4 das frische Formular."
implication: "Zweite Auspraegung derselben Ursache: auch status/description werden erst per Effekt zurueckgesetzt. Der Fix muss beide Teilursachen beseitigen."
- timestamp: T2
checked: "await import('html-to-image') gegen setTimeout(0) (zz-repro2.test.tsx)"
found: "[IMPORT-1] timerFired=false 1.13ms, [IMPORT-2] timerFired=false 0.18ms"
implication: "Der dynamische Import ueberschreitet keine Makrotask-Grenze -- er ist nicht die Ursache."
## Resolution
root_cause: |
Produktfehler, zwei Teilursachen im UND-Verbund (bestaetigt per MutationObserver
ueber jeden DOM-Commit):
(a) BugReportDialog war dauerhaft eingehaengt und gab bei geschlossenem Zustand
nur `null` zurueck. `useState(screenshot !== null)` lief damit genau einmal,
beim allerersten Mount des Knopfs -- da war `screenshot` noch `null`, also
startete `attach` immer als `false`.
(b) Der Anfangszustand wurde per `useEffect` nachgezogen. Passive Effekte laufen
NACH dem Commit. React schrieb deshalb bei JEDEM Oeffnen zuerst den Zustand
"Dialog offen + Vorschaubild sichtbar + Haekchen AUS" in den DOM und
korrigierte ihn erst im naechsten Commit.
Der falsche Zustand ueberdauerte gemessen zwei volle Makrotask-Runden -- der
Browser hat in dieser Zeit mindestens zwei Gelegenheiten, ihn zu zeichnen.
Die Last im vollen Vitest-Lauf war nur der Ausloeser dafuer, dass die Pruefung
den ersten statt den zweiten Commit sah; sie war nie die Ursache.
fix: |
(a) Der Dialog wird nur noch eingehaengt, solange er offen ist
(`{open && <BugReportDialog ... />}`) -- jedes Oeffnen ist ein frischer
Mount, der Anfangszustand gilt schon im ersten Commit. Der zuruecksetzende
Effekt entfaellt ersatzlos.
(b) Das Haekchen wird beim Rendern aus `screenshot` abgeleitet statt per Effekt
nachgezogen: `const attach = screenshot !== null && (attachChoice ?? true)`.
`attachChoice` haelt allein die bewusste Abwahl des Nutzers.
verification: |
signal_reproduktion: bestaetigt -- kuenstlicher Makrotask in der Aufnahmekette
erzwang den Fehlschlag 4/4 vor dem Fix, 4/4 gruen danach.
signal_regressionstest: Test 14/15 sind gegen den Stand vor dem Fix in 5 von 5
Laeufen rot, danach gruen. Deterministisch, kein retry, kein Zeitlimit.
signal_umkehrprobe: Quellcode auf 8d604b8 zurueckgesetzt, neue Tests bleiben --
der Fehler kehrt zurueck. Fix und Fehler haengen nachweislich zusammen.
signal_zwischenzustand: MutationObserver-Protokoll nach dem Fix zeigt den
ersten Commit mit Dialog bereits als "img=ja box=AN". Kein falscher Commit mehr.
signal_kein_loeschfix: der Diff ist kein Wegnehmen einer Pruefung -- Test 1
prueft unveraendert dieselbe Zusicherung, zwei Tests kamen hinzu.
gates: lint 5/5 ohne Fehlerstufe, 399 Warnungen (unveraendert); type-check 4/4;
apps/web 73 Dateien / 531 Tests; apps/api 72 Dateien / 1143 Tests.
signal_stabilitaet: 20/20 volle Laeufe von `pnpm --filter @tessera/web exec vitest run`
gruen, 0 Fehlschlaege, je 531 Tests. Fuer sich genommen schwach (bei 1:17 waeren
20 gruene Laeufe auch ohne Fix zu ~30 % zu erwarten) -- der tragende Beleg ist
der deterministische: den falschen Zustand gibt es nicht mehr.
guardrail_verdict: accepted
files_changed:
- 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