From 7927c7b58a3bd888137e2e2f3e0164b6e4f97eee Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 30 Jul 2026 15:07:34 -0700 Subject: [PATCH] docs(testing): probe the DOM before theorising about a missing element (#2850) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earned the hard way this week: three separate causes in `App.test.tsx` and `board-mobile-view-switch.test.tsx` all presented **identically** as a missing element, each with a DOM that looked healthy. | what the test said | what was actually wrong | |---|---| | `Unable to find "+ New Task"` | `ListView` rendered its workflow **skeleton**, which carries the same `list-view` class as the real body — so the preceding `waitFor(".list-view")` passed | | `Unable to find role="heading" "New Task"` | `NewTaskModal` **threw** — an incomplete `vi.mock` was missing `isShortViewport` — and an `ErrorBoundary` swallowed it | | `Unable to find [data-testid="switch-to-board"]` | an uncaught render error **unmounted the entire React root**; the DOM was already empty three lines earlier | The part worth recording is the hit rate. **Three theories were offered before any probe — "the board renders nothing", "i18n is returning keys", "the FloatingWindow rework" — and all three were wrong.** Three probes each landed the cause on the first try. Those wrong theories cost days; the probe is four lines. The doc records the snippet, what each signal means (`error-boundary` in the DOM = a swallowed throw, and its text names a missing mock export exactly; empty DOM with no boundary = unmounted root, so trust the *first* failing assertion not the reported one; container present but contents absent = a skeleton standing in), and the corollary for writing assertions: > Wait on a marker only the real thing has. A class shared with a loading or empty state turns *"the list rendered"* into *"something rendered"*, and the test then fails one line later against a DOM that looks fine. That is why `list-view-body` exists (#2834). Placed under the dashboard testing sections in `docs/testing.md`. Docs only — no changeset. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) --- docs/testing.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/testing.md b/docs/testing.md index 5887eb2a4d..bf598afbae 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -235,6 +235,46 @@ it to run — the curated-gate hole that silently skipped unenumerated files is (see "Curated-gate completeness" below). Add a file to a curated `qualityApp*`/`qualityApi` list only when you want it in a specific fast lane rather than the backfill catch-all. +## When a dashboard element is "missing", probe before theorising + +`getByText` / `getByRole` failures name the element that was not found, which is almost never where +the fault is. Three separate causes in `App.test.tsx` and `board-mobile-view-switch.test.tsx` all +presented identically as a missing element, and in each case the DOM looked healthy: + +| what the test said | what was actually wrong | +|---|---| +| `Unable to find "+ New Task"` | `ListView` rendered its workflow SKELETON, which carries the same `list-view` class as the real body, so the preceding `waitFor(".list-view")` passed | +| `Unable to find role="heading" "New Task"` | `NewTaskModal` THREW — an incomplete `vi.mock` was missing `isShortViewport` — and an `ErrorBoundary` swallowed it | +| `Unable to find [data-testid="switch-to-board"]` | an uncaught render error unmounted the entire React root; the DOM was empty three lines earlier | + +**Three theories were offered for these before any probe, and all three were wrong** ("the board +renders nothing", "i18n is returning keys", "the FloatingWindow rework"). Three probes each landed +the cause on the first try. The technique is simply to print what is really there at the failing +assertion: + +```ts +// eslint-disable-next-line no-console +console.log( + "PROBE testids:", JSON.stringify([...document.querySelectorAll("[data-testid]")] + .map((e) => e.getAttribute("data-testid")).slice(0, 12)), + "| boundary:", document.querySelector('[class*="error-boundary"]')?.textContent?.trim() ?? "none", +); +``` + +What each signal means: + +- **an `error-boundary` class in the DOM** — a component threw and the boundary ate it. Read its + text; for a mock-related throw it names the missing export exactly. +- **an empty DOM with no boundary** — an uncaught render error unmounted the root. Everything after + it fails misleadingly, so trust the FIRST failing assertion, not the reported one. +- **the element's container present but its contents absent** — a skeleton or empty state is + standing in. Check whether the selector you waited on is shared with that state; `list-view` is, + which is why `list-view-body` exists. + +Corollary for writing assertions: **wait on a marker only the real thing has.** A class shared with a +loading or empty state turns "the list rendered" into "something rendered", and the test then fails +one line later against a DOM that looks fine. + ## Curated-gate completeness and the skip-list The dashboard quality gate is a chain of curated lanes plus two backfill lanes.