Skip to content

test(e2e): verify in a real browser that a dialog hides the live regions (#501) - #504

Merged
mforce merged 17 commits into
mainfrom
feat/501-inert-announcer-e2e
Aug 11, 2026
Merged

mforce merged 17 commits into
mainfrom
feat/501-inert-announcer-e2e

Conversation

@mforce

@mforce mforce commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Part of #501. That issue stays open — this lands the half a machine can check; the screen-reader pass is still owed.

Why

#485's premise — that #483's inert sweep takes the offscreen announcers out of the accessibility tree, so an announcement made then is never heard — was only ever inferred from the source. jsdom implements neither live regions nor the inert IDL, so nothing in web/ could observe it.

What it proves, in real Chromium

  1. Both announcers are mounted, empty, exposed and in the accessibility tree on a healthy screen, with no live role.
  2. Opening a dialog removes them from the tree while leaving the DOM and their attributes untouched. Control in the same snapshot: the dialog's own controls stay.
  3. Closing it returns them.
  4. A standing farm warning is carried by the visible role="alert" banner, and the announcer is never written to across two dialog cycles.

CDP is not a style choice

Playwright's own accessibility surface does not model inert — measured, not assumed: ariaSnapshot() still lists an inert node, isVisible()/isEnabled() still return true, only click() notices. The same spec written with getByRole would pass whether or not the app inerts anything. src/ax.ts is the sanctioned reader.

Three traps, each of which produced a false result first

  • The sweep runs in a passive effect, so waiting for the dialog to appear does not settle it. Every transition now waits on the sweep, which is also an assertion.
  • A target and its control must share one tree snapshot — a control read several round trips later cannot rule out a tree that moved. That is what ax.nodes() is for.
  • "Stayed empty" is a non-event, so a snapshot read only says empty when I looked. A MutationObserver records every write for the whole test.

Also recorded, so the manual pass has less to establish

Both of #501's deferred designs rest on a browser assumption nobody had checked. aria-live="off" does suppress the implicit politeness of role="alert" (paired with a control, because a non-live element reports the same null), and un-inerting one subtree while a dialog is open does return it to the tree. The Chromium build is annotated — the CDP accessibility domain is experimental.

Mutation

The harness's first DOM-level mutants; src/mutants.ts records what that instrument is and is not faithful to. The three announcer mutants differ only in when they write, and each is the only one that reaches its assertion — the first version had one mutant covering a third of the test it named, and the harness reported a clean kill and said nothing.

Full run: baseline GREEN, 15 killed, 0 survivors, restore GREEN.

Not covered, stated in the spec header

  • No screen reader is involved. Tree presence is the precondition, not the utterance.
  • The missed-then-delivered path — the core of fix(web): announce the update and farm warnings a dialog made inert #499's fix — has no product affordance to drive it: the update banner needs a second service worker to park in waiting (Playwright cannot provoke it), and the farm warning needs /account to fail on a re-read, whose only callers are the banner's own retry and the settings save, neither of which runs with a dialog open. A test-only trigger was considered and rejected.

Both are docs/runbooks/screen-reader-verification.md's job.

Review

Four local passes before pushing (pi + three agents: adversarial, conventions, flake/CI). They found the passive-effect race, the uncovered anti-nag loop, a false claim of mine in mutation-check.sh, an undelivered "same snapshot" invariant, and the snapshot-vs-cumulative weakness. One reported finding — aria-hidden slipping past inTree — did not reproduce: Chromium 151 drops those nodes outright, same as inert.

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant