Files
tessera-ctl/.planning/debug/resolved/wackeltest-bugreport-haekchen.md
T
schalli a6181e2751 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
2026-09-21 15:50:37 +02:00

11 KiB

status, trigger, created, updated, symptoms_prefilled, goal
status trigger created updated symptoms_prefilled goal
resolved 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. 2026-09-21T00:00:00Z 2026-09-21T00:00:00Z true 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: 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