Focus the Map Source tray when it is actually visible, not 240ms later (#54)

`qa:map-source-tray` failed on a clean checkout at "Enter opens the tray and
hands focus to a Map Source tile". The gate was right: this is a real
keyboard-accessibility bug, not a flaky assertion.

Opening the tray schedules the focus hand-off to a Map Source tile 240ms
later (scheduleMapSourceFocus). The tray itself fades in behind a 180ms
`visibility` transition on .dock-popover-content, and a chip inside it cannot
take focus until that transition lands. Those two timers race. When the
machine is slow enough that the fade has not finished at the 240ms mark,
`chip.focus()` is called on a `visibility: hidden` element — which is a
SILENT no-op — and nothing retries. The tray sits open with keyboard focus
stranded on the disclosure, unreachable.

Instrumenting HTMLElement.prototype.focus during a failing run showed exactly
that: focus() called once on the photoreal chip, the chip connected and not
disabled, computed visibility `hidden`, activeElement unchanged.

Fix the hand-off rather than the delay:
- focusMapSource now reports whether focus actually landed (activeElement ===
  chip) instead of assuming .focus() worked.
- scheduleMapSourceFocus retries on a 30ms cadence until it lands, bounded to
  ~720ms past the first attempt so a permanently hidden tray cannot spin.

Also stop the QA gate racing the same timers. Its keyboard checks slept a
fixed 300ms and asserted focus had already moved; they now wait on controller
truth (aria-expanded plus where focus landed), the way the Bing-switch check
already did. That replaces four fixed sleeps, not just the long-Enter-hold
one the issue named. The wait only settles state — the check() below each one
is still the assertion, so a swallowed timeout surfaces as that check failing
with real values rather than an opaque puppeteer error.

Verified against a preview build: `qa:map-source-tray` goes from 3 failures
to 20/20 passing, twice in a row. Unit suite unchanged (2674 passing).

The #44 half of the original work is intentionally dropped: upstream's
track-regression.mjs already fixed the ground-3d sampleHeight floor, and more
thoroughly — it pins no absolute call count at all and adds a guard against a
timed-out driver falsely looking flat.

Closes #54.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q6VRsPswjNtZfv4jzgkCUf
This commit is contained in:
Tom-NeverwinterandClaude Opus 5 committed 2026-09-10 14:46:49 -05:00
1 parent 759652207f
commit 56d52e284f
2 files changed
+52 -9

No files matched your search

+29 -4
View File
@@ -190,9 +190,32 @@ try {
JSON.stringify(esriTileFailureFallback),
);
// The tray's state changes are timer-driven: opening schedules the focus
// hand-off to a Map Source tile 240ms later (scheduleMapSourceFocus in
// ui.js). A fixed sleep races that timer rather than observing it settle —
// 300ms left only 60ms of margin, which is what made these checks flake on
// a clean checkout (#54). Wait on controller truth instead, the way the
// Bing-switch check further down already does.
// The wait only settles state; the check() below it is still the assertion,
// so a swallowed timeout surfaces as that check failing with real values
// rather than as an opaque puppeteer error.
const waitTray = (wantExpanded, wantFocus) => page.waitForFunction(
(expanded, focus) => {
const toggle = document.getElementById('control-panel-toggle');
if (toggle?.getAttribute('aria-expanded') !== expanded) return false;
if (!focus) return true;
const active = document.activeElement;
return focus === 'toggle'
? active?.id === 'control-panel-toggle'
: active?.dataset?.stackId === focus;
},
{ timeout: 2000 },
wantExpanded, wantFocus,
).catch(() => {});
await page.focus('#control-panel-toggle');
await page.keyboard.press('Enter');
await new Promise((resolve) => setTimeout(resolve, 300));
await waitTray('true', 'photoreal');
const keyboardOpen = await page.evaluate(() => ({
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
activeStack: document.activeElement?.dataset?.stackId || null,
@@ -204,7 +227,7 @@ try {
);
await page.keyboard.press('Escape');
await new Promise((resolve) => setTimeout(resolve, 40));
await waitTray('false', 'toggle');
const keyboardClose = await page.evaluate(() => ({
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
activeId: document.activeElement?.id || null,
@@ -216,7 +239,7 @@ try {
);
await page.keyboard.press('Space');
await new Promise((resolve) => setTimeout(resolve, 300));
await waitTray('true', 'photoreal');
const spaceOpen = await page.evaluate(() => ({
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
activeStack: document.activeElement?.dataset?.stackId || null,
@@ -231,9 +254,11 @@ try {
await page.keyboard.down('Enter');
await new Promise((resolve) => setTimeout(resolve, 320));
await page.keyboard.up('Enter');
await waitTray('true', 'photoreal'); // let the hold's scheduled focus hand-off land
await page.keyboard.press('Escape');
await waitTray('false', 'toggle');
await page.keyboard.press('Enter');
await new Promise((resolve) => setTimeout(resolve, 300));
await waitTray('true', 'photoreal');
const longHoldRecovery = await page.evaluate(() => ({
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
activeStack: document.activeElement?.dataset?.stackId || null,