Files
gods-eye-view/scripts/qa-map-source-tray.mjs
Sameh Khamis 10bb2c6a36 test(qa): open the phone tab sheets in phone-width harness checks
At 720px and narrower the panels live in the phone shell's tab sheets, so the
camera, scene and map-source tray harnesses open the matching tab (from the
keyboard where they check focus rings) before measuring, wait for the shell to
follow a viewport change, measure the container that actually scrolls, and run
the pin and compact-wing checks at 800px where the narrow desktop dock still
exists.
2026-10-22 22:09:37 -07:00

1744 lines
87 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/** Focused browser proof for the responsive, accessible Map Source tray. */
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const shotsDir = process.env.QA_SHOTS_DIR || path.join(repoRoot, 'qa-shots', 'map-source-tray');
const appUrl = process.env.QA_BASE_URL || 'http://localhost:4173';
const headful = process.argv.includes('--headful');
// The no-ion-token contract is a real shipped state that a normal keyed
// `dev-fresh.sh` run can never reach, so it went untested on every machine that
// has a token. `--keyless` (or QA_MAP_SOURCE_TRAY_KEYLESS=1) clears the
// controller's token in-page before the key-required assertions, so one server
// can prove both branches. It forces the keyless EXPECTATIONS as well: a seam
// that fails to take effect is a failure, not a quiet fall-through to the keyed
// branch.
const forceKeyless = process.argv.includes('--keyless')
|| process.env.QA_MAP_SOURCE_TRAY_KEYLESS === '1';
const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH
|| await puppeteer.executablePath().catch(() => null);
if (!executablePath || !fs.existsSync(executablePath)) {
throw new Error('Puppeteer Chrome for Testing is unavailable');
}
fs.mkdirSync(shotsDir, { recursive: true });
const browser = await puppeteer.launch({
headless: headful ? false : 'new',
executablePath,
// Metal is a macOS-only ANGLE backend: passing it on Windows or Linux makes
// WebGL initialization fail outright, so Cesium never constructs and this
// harness dies at the boot wait before a single assertion runs.
args: [
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
'--no-sandbox',
],
});
const page = await browser.newPage();
const failures = [];
const consoleErrors = [];
page.on('console', (message) => {
if (message.type() === 'error' && !/Failed to load resource.*404/i.test(message.text())) {
const source = message.location()?.url;
consoleErrors.push(source ? `${message.text()} [${source}]` : message.text());
}
});
page.on('pageerror', (error) => consoleErrors.push(error.message));
const check = (name, passed, detail = '') => {
console.log(` [${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`);
if (!passed) failures.push(name);
};
const trayMetrics = () => page.evaluate(() => {
const panel = document.getElementById('control-panel');
const popover = document.getElementById('control-panel-popover');
const row = document.getElementById('map-stack-chips');
const popoverRect = popover.getBoundingClientRect();
const chips = [...row.children].map((chip) => {
const rect = chip.getBoundingClientRect();
return {
id: chip.dataset.stackId,
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
pressed: chip.getAttribute('aria-pressed'),
ariaDisabled: chip.getAttribute('aria-disabled'),
ariaLabel: chip.getAttribute('aria-label'),
};
});
return {
viewport: { width: innerWidth, height: innerHeight },
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
pinned: panel.classList.contains('dock-pinned'),
popover: {
left: popoverRect.left,
right: popoverRect.right,
top: popoverRect.top,
bottom: popoverRect.bottom,
width: popoverRect.width,
},
columns: getComputedStyle(row).gridTemplateColumns,
rows: new Set(chips.map((chip) => chip.top)).size,
chips,
};
});
// Observe rendered focus, including clipping by the scrolling style row. A
// focus-visible match alone does not prove that the keyboard user sees a ring.
const focusMetrics = (selector = '.style-btn') => page.evaluate((match) => {
const button = document.activeElement;
if (!button?.matches(match)) return { focusedStyle: null, focusedId: null };
const rect = button.getBoundingClientRect();
const css = getComputedStyle(button);
const outlineWidth = parseFloat(css.outlineWidth) || 0;
const outlineOffset = parseFloat(css.outlineOffset) || 0;
const outset = Math.max(0, outlineWidth + outlineOffset);
const ring = {
left: rect.left - outset, right: rect.right + outset,
top: rect.top - outset, bottom: rect.bottom + outset,
};
const clips = [];
for (let parent = button.parentElement; parent; parent = parent.parentElement) {
const parentCss = getComputedStyle(parent);
const parentRect = parent.getBoundingClientRect();
const left = parentRect.left + parent.clientLeft;
const top = parentRect.top + parent.clientTop;
if (/auto|scroll|hidden|clip/.test(parentCss.overflowX)
&& (ring.left < left - 1 || ring.right > left + parent.clientWidth + 1)) {
clips.push(`${parent.id || parent.className}:horizontal`);
}
if (/auto|scroll|hidden|clip/.test(parentCss.overflowY)
&& (ring.top < top - 1 || ring.bottom > top + parent.clientHeight + 1)) {
clips.push(`${parent.id || parent.className}:vertical`);
}
}
const center = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
return {
focusedStyle: button.dataset.style, focusedId: button.id || null, tag: button.tagName, type: button.type,
layerId: button.closest('[data-layer-id]')?.dataset.layerId || null,
text: button.textContent.trim(), ariaLabel: button.getAttribute('aria-label'),
controls: button.getAttribute('aria-controls'), expanded: button.getAttribute('aria-expanded'),
selectedStyle: window.__godsEyeView.styleManager.activeStyle,
selectedMap: window.__godsEyeView.styleManager.mapStackController.getActiveId(),
focusVisible: button.matches(':focus-visible'),
selected: button.classList.contains('active'),
outlineStyle: css.outlineStyle, outlineWidth, outlineOffset, outlineColor: css.outlineColor,
visible: rect.width > 0 && rect.height > 0 && css.visibility === 'visible'
&& (center === button || button.contains(center)),
insideViewport: ring.left >= 0 && ring.right <= innerWidth
&& ring.top >= 0 && ring.bottom <= innerHeight,
clips,
ring,
viewport: { width: innerWidth, height: innerHeight },
rowScrollLeft: document.getElementById('style-buttons').scrollLeft,
};
}, selector);
const styleFocusMetrics = () => focusMetrics('.style-btn');
const hasVisibleControlFocus = (state) => state.focusVisible && state.visible && state.insideViewport && state.clips.length === 0
&& state.outlineStyle !== 'none' && state.outlineStyle !== 'hidden' && state.outlineWidth >= 2
&& state.outlineColor !== 'transparent' && state.outlineColor !== 'rgba(0, 0, 0, 0)';
const hasVisibleStyleFocus = (state, style) => state.focusedStyle === style && hasVisibleControlFocus(state);
const pressTabs = async (count, backwards = false) => {
if (backwards) await page.keyboard.down('Shift');
try {
for (let index = 0; index < count; index += 1) await page.keyboard.press('Tab');
} finally {
if (backwards) await page.keyboard.up('Shift');
}
// The row scrolls smoothly when native Tab brings an edge button into view.
await new Promise((resolve) => setTimeout(resolve, 250));
};
// Phones (≤720px) present the dock's Presets inline in the More tab's sheet.
// It opens from the keyboard, as a keyboard user would, so the focus it moves
// into the sheet shows a ring.
const openPhoneMoreSheet = async () => {
const tab = '#mobile-tab-bar [data-mobile-sheet="more"]';
if (await page.evaluate(() => document.body.dataset.mobileSheet === 'more')) {
await page.focus(tab);
await page.keyboard.press('Enter');
}
await page.focus(tab);
await page.keyboard.press('Enter');
await page.evaluate(() => new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
}));
};
// Media-query changes reach the page on a rendering step; wait until the
// phone shell agrees with the viewport before measuring.
const waitForShellSettled = () => page.waitForFunction(() => (
document.body.classList.contains('mobile-shell')
=== matchMedia('(max-width: 720px)').matches
), { timeout: 10_000, polling: 100 });
// The dock slides back into place when the layout leaves the phone shell.
const waitForDockSettled = () => page.waitForFunction(() => (
!document.getElementById('command-dock').getAnimations({ subtree: true })
.some((animation) => animation.playState === 'running')
), { timeout: 5_000 });
const checkResponsiveStyleFocus = async (width) => {
// The disclosure is only the starting boundary. Every style target below is
// reached by actual Tab events, never by focusing the style under test.
const selectedMapBefore = await page.evaluate(() => (
window.__godsEyeView.styleManager.mapStackController.getActiveId()
));
if (width <= 720) {
// Phones show Presets in the More tab's sheet, which focuses its first
// visible control (Normal) when it opens; the tray toggle and pin are
// desktop chrome there.
await openPhoneMoreSheet();
} else {
await page.focus('#control-panel-toggle');
await pressTabs(2); // disclosure -> pin -> Normal
}
for (const [index, style] of ['normal', 'thermal', 'snow'].entries()) {
if (index) await pressTabs(3);
const state = await styleFocusMetrics();
check(`${width} px ${style} keyboard ring is visible and unclipped`,
hasVisibleStyleFocus(state, style) && state.selectedStyle === 'normal'
&& state.selectedMap === selectedMapBefore, JSON.stringify(state));
await page.screenshot({ path: path.join(shotsDir, `${width}-style-${style}-focus.png`) });
}
};
// Start from an explicit boundary, then use real Tab events for every target.
// No layer or location action is activated merely to establish keyboard focus.
const tabTo = async (selector, { backwards = false, limit = 160 } = {}) => {
if (backwards) await page.keyboard.down('Shift');
try {
for (let step = 1; step <= limit; step += 1) {
await page.keyboard.press('Tab');
if (await page.evaluate((match) => document.activeElement?.matches(match), selector)) {
await new Promise((resolve) => setTimeout(resolve, 250));
return { reached: true, steps: step };
}
}
return { reached: false, steps: limit };
} finally {
if (backwards) await page.keyboard.up('Shift');
}
};
const locationState = async () => ({
...await focusMetrics('#location-bar-toggle'),
...await page.evaluate(() => {
const panel = document.getElementById('location-bar');
const toggle = document.getElementById('location-bar-toggle');
const voice = window.__godsEyeView.voiceCommands;
return {
locationExpanded: toggle?.getAttribute('aria-expanded'),
locationLabel: toggle?.getAttribute('aria-label'),
popoverId: panel.querySelector('.dock-popover-content')?.id,
pinned: panel.classList.contains('dock-pinned'),
searchExpanded: document.getElementById('location-search').classList.contains('expanded'),
searchValue: document.getElementById('location-search').value,
transitions: [...(window.__qaLocationFocus?.transitions || [])],
voice: { status: voice.status, epoch: voice.startEpoch, held: voice.spaceKeyHeld, pushToTalk: voice.pushToTalkKeyHeld },
};
}),
});
const resetLocationTransitions = () => page.evaluate(() => { window.__qaLocationFocus.transitions = []; });
const layerFocusState = async (id) => ({
...await focusMetrics('.data-toggle-btn'),
...await page.evaluate((layerId) => {
const manager = window.__godsEyeView.dataManager;
const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`);
// The list scrolls on its own on desktop; in a phone sheet the sheet
// scrolls instead. Measure whichever container actually scrolls.
let list = document.getElementById('data-toggles');
for (let node = list; node; node = node.parentElement) {
const overflow = getComputedStyle(node).overflowY;
if ((overflow === 'auto' || overflow === 'scroll') && node.scrollHeight > node.clientHeight) {
list = node;
break;
}
}
return {
lifecycle: manager.getLayerLifecycleState(layerId),
feedState: button?.dataset.feedState, label: button?.textContent.trim(),
buttonActive: button?.classList.contains('active'),
disabled: button?.disabled,
ariaDisabled: button?.getAttribute('aria-disabled'),
ariaBusy: button?.getAttribute('aria-busy'),
listScrollTop: list.scrollTop, listClientHeight: list.clientHeight, listScrollHeight: list.scrollHeight,
};
}, id),
});
try {
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
await page.setRequestInterception(true);
page.on('request', (request) => {
const url = new URL(request.url());
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/openai/hud-summary') {
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ summary: 'Map Source tray QA' }),
});
return;
}
// Share-link navigation asks for optional Google place context. This
// harness is about the map-source tray, so keep that unrelated keyed proxy
// hermetic and quiet just as the HUD summary is above.
if (url.origin === new URL(appUrl).origin && url.pathname === '/api/google/nearby-places') {
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ places: [] }),
});
return;
}
request.continue();
});
// This harness owns the Map Source keyboard. Suppress the separate first-run
// launcher on every navigation so its Escape/Space handlers cannot turn a
// tray assertion into a mission or voice action in a pristine browser.
await page.goto(`${appUrl}/?welcome=0`, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 });
await page.waitForFunction(
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60_000 },
);
const presentation = await page.evaluate(() => ({
ids: [...document.querySelectorAll('.map-stack-chip')].map((chip) => chip.dataset.stackId),
retiredPanel: Boolean(document.getElementById('stack-panel')),
toggleTag: document.getElementById('control-panel-toggle')?.tagName,
controls: document.getElementById('control-panel-toggle')?.getAttribute('aria-controls'),
}));
check(
'exact five-source presentation; the retired left Map Stack panel is gone',
JSON.stringify(presentation.ids) === JSON.stringify([
'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm',
]) && !presentation.retiredPanel,
JSON.stringify(presentation),
);
check(
'compact wing is a semantic disclosure',
presentation.toggleTag === 'BUTTON' && presentation.controls === 'control-panel-popover',
JSON.stringify(presentation),
);
const esriTileFailureFallback = await page.evaluate(async () => {
const styleManager = window.__godsEyeView.styleManager;
const controller = styleManager.mapStackController;
await styleManager._setMapStack('esri-imagery', { syncShare: false });
// Cesium updates on-screen credits on a rendered frame, after setStack
// resolves. Observe that boundary before checking the visible source.
const creditDeadline = performance.now() + 5000;
while (!document.body.innerText.includes('Powered by Esri') && performance.now() < creditDeadline) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
const provider = controller._activeImageryProvider;
const before = {
activeId: controller.getActiveId(),
creditVisible: document.body.innerText.includes('Powered by Esri'),
globeShown: styleManager.viewer.scene.globe.show,
hasLayer: Boolean(controller._imageryLayer),
};
provider?.errorEvent?.raiseEvent?.({ timesRetried: 0 });
await new Promise((resolve) => setTimeout(resolve, 50));
const afterOne = controller.getActiveId();
provider?.errorEvent?.raiseEvent?.({ timesRetried: 1 });
const deadline = performance.now() + 5000;
while (controller.getActiveId() !== 'osm' && performance.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
// The controller commits `activeId` before its fallback promise callback
// emits the terminal error state that re-syncs the chips, and Cesium drops
// the Esri credit on a later render frame again. A fixed delay races both:
// 50ms was enough most runs and not enough on a slow one, which is the same
// flake as the tray timers above (#54). Poll the observable truth instead.
const domDeadline = performance.now() + 3000;
const settled = () => !document.body.innerText.includes('Powered by Esri')
&& JSON.stringify([...document.querySelectorAll('.map-stack-chip[aria-pressed="true"]')]
.map((chip) => chip.dataset.stackId)) === JSON.stringify(['osm']);
while (!settled() && performance.now() < domDeadline) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
const afterTwo = {
activeId: controller.getActiveId(),
lastError: controller.getState().lastError,
creditVisible: document.body.innerText.includes('Powered by Esri'),
globeShown: styleManager.viewer.scene.globe.show,
hasLayer: Boolean(controller._imageryLayer),
active: [...document.querySelectorAll('.map-stack-chip')]
.filter((chip) => chip.getAttribute('aria-pressed') === 'true')
.map((chip) => chip.dataset.stackId),
};
await styleManager._setMapStack('esri-imagery', { syncShare: false });
return { before, afterOne, afterTwo };
});
check(
'two active Esri tile failures fall back to a rendered, truthful OSM stack',
esriTileFailureFallback.before.activeId === 'esri-imagery'
&& esriTileFailureFallback.before.creditVisible
&& esriTileFailureFallback.before.globeShown
&& esriTileFailureFallback.before.hasLayer
&& esriTileFailureFallback.afterOne === 'esri-imagery'
&& esriTileFailureFallback.afterTwo.activeId === 'osm'
&& /tile requests failed; using OSM/i.test(esriTileFailureFallback.afterTwo.lastError)
&& esriTileFailureFallback.afterTwo.creditVisible === false
&& esriTileFailureFallback.afterTwo.globeShown
&& esriTileFailureFallback.afterTwo.hasLayer
&& JSON.stringify(esriTileFailureFallback.afterTwo.active) === JSON.stringify(['osm']),
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.evaluate(() => window.__godsEyeView.styleManager._setMapStack('osm', { syncShare: false }));
const keyboardSource = await page.evaluate(() => (
window.__godsEyeView.styleManager.mapStackController.getActiveId()
));
check('keyboard checks start on selected OSM as the last tile',
keyboardSource === 'osm' && await page.evaluate(() => (
document.querySelector('#map-stack-chips .map-stack-chip:last-child')?.dataset.stackId === 'osm'
)));
await page.focus('#control-panel-toggle');
await page.keyboard.press('Enter');
await waitTray('true', keyboardSource);
const keyboardOpen = await page.evaluate(() => ({
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
activeStack: document.activeElement?.dataset?.stackId || null,
}));
check(
'Enter opens the tray and hands focus to the selected Map Source tile',
keyboardOpen.expanded === 'true' && keyboardOpen.activeStack === keyboardSource,
JSON.stringify(keyboardOpen),
);
await page.keyboard.press('Escape');
await waitTray('false', 'toggle');
const keyboardClose = await page.evaluate(() => ({
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
activeId: document.activeElement?.id || null,
}));
check(
'Escape closes the tray and restores disclosure focus',
keyboardClose.expanded === 'false' && keyboardClose.activeId === 'control-panel-toggle',
JSON.stringify(keyboardClose),
);
await page.keyboard.press('Space');
await waitTray('true', keyboardSource);
const spaceOpen = await page.evaluate(() => ({
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
activeStack: document.activeElement?.dataset?.stackId || null,
}));
check(
'Space opens the tray through the same keyboard path',
spaceOpen.expanded === 'true' && spaceOpen.activeStack === keyboardSource,
JSON.stringify(spaceOpen),
);
await page.keyboard.press('Escape');
await page.keyboard.down('Enter');
await new Promise((resolve) => setTimeout(resolve, 320));
await page.keyboard.up('Enter');
await waitTray('true', keyboardSource); // let the hold's scheduled focus hand-off land
await page.keyboard.press('Escape');
await waitTray('false', 'toggle');
await page.keyboard.press('Enter');
await waitTray('true', keyboardSource);
const longHoldRecovery = await page.evaluate(() => ({
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
activeStack: document.activeElement?.dataset?.stackId || null,
}));
check(
'long Enter hold cannot strand the disclosure keyboard path',
longHoldRecovery.expanded === 'true' && longHoldRecovery.activeStack === keyboardSource,
JSON.stringify(longHoldRecovery),
);
// Force a delayed visible state while using the real controller and keyboard routes.
const hideTray = () => page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('control-panel', true);
window.__qaTrayStyles = [...document.querySelectorAll('.map-stack-chip')]
.map((chip) => [chip, chip.style.cssText]);
for (const [chip] of window.__qaTrayStyles) chip.style.setProperty('visibility', 'hidden', 'important');
document.getElementById('control-panel-toggle').focus();
});
const showTray = () => page.evaluate(() => {
for (const [chip, cssText] of window.__qaTrayStyles) chip.style.cssText = cssText;
delete window.__qaTrayStyles;
});
await hideTray();
await page.keyboard.press('Enter');
await new Promise((resolve) => setTimeout(resolve, 350));
const delayedBefore = await page.evaluate(() => document.activeElement?.id);
await showTray();
await waitTray('true', keyboardSource);
const delayedAfter = await page.evaluate(() => document.activeElement?.dataset?.stackId);
check('a delayed visible tray receives selected-source focus after the first attempt',
delayedBefore === 'control-panel-toggle' && delayedAfter === keyboardSource,
JSON.stringify({ delayedBefore, delayedAfter, keyboardSource }));
await hideTray();
await page.keyboard.press('Enter');
await new Promise((resolve) => setTimeout(resolve, 350));
await page.keyboard.press('Tab');
const departure = await page.evaluate(() => {
window.__qaDepartedFocus = document.activeElement;
return document.activeElement !== document.getElementById('control-panel-toggle');
});
await showTray();
await new Promise((resolve) => setTimeout(resolve, 1000));
const focusRetained = await page.evaluate(() => {
const same = document.activeElement === window.__qaDepartedFocus;
delete window.__qaDepartedFocus;
return same;
});
check('Tab away during the opening transition revokes delayed focus', departure && focusRetained,
JSON.stringify({ departure, focusRetained }));
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('control-panel', true, { explicit: true }));
await page.focus('#control-panel-toggle');
await page.keyboard.press('Enter');
await page.keyboard.press('Escape');
await new Promise((resolve) => setTimeout(resolve, 1100));
check('Escape cancels the pending opening handoff', await page.evaluate(() => (
document.activeElement === document.body
&& document.getElementById('control-panel').classList.contains('collapsed')
)));
await page.focus('#control-panel-toggle');
await page.keyboard.press('Enter');
await waitTray('true', keyboardSource);
await page.keyboard.press('Escape');
await page.focus('#control-panel-toggle');
await page.keyboard.press('Enter');
await page.keyboard.down('Shift');
await page.keyboard.press('Tab');
await page.keyboard.up('Shift');
const leftBeforeReturn = await page.evaluate(() => document.activeElement?.id !== 'control-panel-toggle');
await page.focus('#control-panel-toggle');
await new Promise((resolve) => setTimeout(resolve, 1100));
check('returning to the disclosure does not revive a cancelled keyboard opening',
leftBeforeReturn && await page.evaluate(() => document.activeElement?.id === 'control-panel-toggle'));
await page.keyboard.press('Escape');
await page.focus('#control-panel-toggle');
await page.keyboard.press('Enter');
await page.keyboard.press('Escape');
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('control-panel', false));
await new Promise((resolve) => setTimeout(resolve, 1100));
check('programmatic reopening cannot inherit a cancelled keyboard handoff', await page.evaluate(() => (
document.activeElement === document.body
&& document.getElementById('control-panel-toggle').getAttribute('aria-expanded') === 'true'
)));
await page.focus('#control-panel-toggle');
await page.keyboard.press('Escape');
await page.focus('#control-panel-toggle');
await page.keyboard.press('Enter');
await waitTray('true', keyboardSource);
// Visual Styles precede the five Map Source tiles. Keep the existing opening
// handoff to selected OSM, then use the user's real Shift+Tab path into styles.
await pressTabs(5, true);
const snowFocus = await styleFocusMetrics();
check('Shift+Tab reaches Snow with a visible ring while Normal and OSM stay selected',
hasVisibleStyleFocus(snowFocus, 'snow') && !snowFocus.selected
&& snowFocus.selectedStyle === 'normal' && snowFocus.selectedMap === 'osm',
JSON.stringify(snowFocus));
await page.screenshot({ path: path.join(shotsDir, 'keyboard-style-focus.png') });
await pressTabs(3, true);
const middleFocus = await styleFocusMetrics();
check('Shift+Tab traverses the middle FLIR style without activating it',
hasVisibleStyleFocus(middleFocus, 'thermal') && !middleFocus.selected
&& middleFocus.selectedStyle === 'normal' && middleFocus.selectedMap === 'osm',
JSON.stringify(middleFocus));
await pressTabs(2, true);
await page.keyboard.press('Enter');
const enterStyle = await styleFocusMetrics();
check('Enter activates the focused CRT style and preserves selected OSM',
enterStyle.focusedStyle === 'retro' && enterStyle.selectedStyle === 'retro'
&& enterStyle.selected && enterStyle.selectedMap === 'osm', JSON.stringify(enterStyle));
await pressTabs(1, true);
const normalFocus = await styleFocusMetrics();
check('the first Normal style has a focus ring independent from selected CRT',
hasVisibleStyleFocus(normalFocus, 'normal') && !normalFocus.selected
&& normalFocus.selectedStyle === 'retro' && normalFocus.selectedMap === 'osm',
JSON.stringify(normalFocus));
// Count real native clicks and calls into the unchanged style implementation.
// Stub only the provider start seam so timing and focus arbitration can be
// exercised without opening a live microphone session during browser QA.
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
const voice = window.__godsEyeView.voiceCommands;
const grid = document.getElementById('style-buttons');
const root = document.getElementById('gev-voice-control');
const originalSetStyle = manager.setStyle;
const originalVoiceStart = voice.start;
const probe = { events: [], activations: [], voiceMutations: [], voiceStarts: [] };
const voiceState = () => ({
present: Boolean(voice && root), status: voice?.status, startEpoch: voice?.startEpoch,
spaceKeyHeld: voice?.spaceKeyHeld, pushToTalkKeyHeld: voice?.pushToTalkKeyHeld,
pushToTalkMode: voice?.pushToTalkMode, radioVoiceDucked: voice?.radioVoiceDucked,
visibleStatus: root?.dataset.status, visibleHold: root?.dataset.pushToTalk || null,
});
const record = (event) => probe.events.push({
type: event.type, code: event.code || null, repeat: event.repeat || false,
trusted: event.isTrusted, detail: event.detail ?? null,
style: event.target.closest('.style-btn')?.dataset.style || null,
});
// A claimed hold deliberately blurs the button. Repeats and release then
// target the document body, so observe Space beyond the original grid.
const recordSpace = (event) => {
if (event.code === 'Space') record(event);
};
for (const type of ['keydown', 'keyup']) document.addEventListener(type, recordSpace, true);
grid.addEventListener('click', record, true);
manager.setStyle = function (...args) {
probe.activations.push(args[0]);
return originalSetStyle.apply(this, args);
};
voice.start = async function (options) {
probe.voiceStarts.push({
options: { ...options },
focusedStyle: document.activeElement?.dataset.style || null,
activeTag: document.activeElement?.tagName || null,
});
};
const observer = new MutationObserver((records) => {
for (const record of records) probe.voiceMutations.push({
attribute: record.attributeName, oldValue: record.oldValue,
value: root.getAttribute(record.attributeName),
});
});
if (root) observer.observe(root, {
attributes: true, attributeOldValue: true, attributeFilter: ['data-status', 'data-push-to-talk'],
});
probe.voiceBefore = voiceState();
probe.snapshot = () => ({
events: [...probe.events], activations: [...probe.activations], voiceMutations: [...probe.voiceMutations],
voiceStarts: [...probe.voiceStarts],
voiceBefore: probe.voiceBefore, voiceNow: voiceState(),
selectedStyle: manager.activeStyle, selectedMap: manager.mapStackController.getActiveId(),
focusedStyle: document.activeElement?.dataset.style || null,
holdObservation: {
pageFocused: document.hasFocus(), visibility: document.visibilityState,
timerPending: Boolean(voice.pushToTalkHoldTimer),
focusOwnerMatches: document.activeElement === voice.pushToTalkHoldFocusOwner,
},
});
probe.reset = () => {
probe.events.length = 0;
probe.activations.length = 0;
probe.voiceMutations.length = 0;
probe.voiceStarts.length = 0;
probe.voiceBefore = voiceState();
};
probe.restore = () => {
manager.setStyle = originalSetStyle;
voice.start = originalVoiceStart;
observer.disconnect();
for (const type of ['keydown', 'keyup']) document.removeEventListener(type, recordSpace, true);
grid.removeEventListener('click', record, true);
};
window.__qaStyleKeyProbe = probe;
});
let shortSpaceDown;
let shortSpaceReleased;
let longSpaceDown;
let longSpaceHeld;
let longSpaceReleased;
let styleSpaceIsDown = false;
try {
await page.keyboard.down('Space');
styleSpaceIsDown = true;
shortSpaceDown = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
await new Promise((resolve) => setTimeout(resolve, 150));
await page.keyboard.up('Space');
styleSpaceIsDown = false;
await new Promise((resolve) => setTimeout(resolve, 100));
shortSpaceReleased = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setStyle('retro');
window.__qaStyleKeyProbe.reset();
document.querySelector('.style-btn[data-style="normal"]')?.focus();
});
await page.keyboard.down('Space');
styleSpaceIsDown = true;
longSpaceDown = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
// Use the browser's clock, like the production hold timer. Runner-side
// delays can finish while Chromium's timer is still pending under load.
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 250)));
await page.keyboard.down('Space'); // exercise repeat without resetting the hold deadline
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 400)));
longSpaceHeld = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
await page.keyboard.up('Space');
styleSpaceIsDown = false;
await new Promise((resolve) => setTimeout(resolve, 100));
longSpaceReleased = await page.evaluate(() => window.__qaStyleKeyProbe.snapshot());
} finally {
if (styleSpaceIsDown) await page.keyboard.up('Space');
await page.evaluate(() => {
window.__qaStyleKeyProbe?.restore();
delete window.__qaStyleKeyProbe;
window.__godsEyeView.styleManager.setStyle('normal');
});
}
const voiceUntouched = (state) => state.voiceBefore.present
&& state.voiceStarts.length === 0 && JSON.stringify(state.voiceBefore) === JSON.stringify(state.voiceNow)
&& state.voiceMutations.length === 0;
check('Space down on focused Normal does not activate the style or start voice',
shortSpaceDown.focusedStyle === 'normal' && shortSpaceDown.selectedStyle === 'retro'
&& shortSpaceDown.selectedMap === 'osm' && shortSpaceDown.activations.length === 0
&& shortSpaceDown.events.every((event) => event.type !== 'click')
&& shortSpaceDown.voiceStarts.length === 0 && shortSpaceDown.voiceMutations.length === 0
&& shortSpaceDown.voiceNow.status === shortSpaceDown.voiceBefore.status
&& shortSpaceDown.voiceNow.startEpoch === shortSpaceDown.voiceBefore.startEpoch
&& shortSpaceDown.voiceNow.spaceKeyHeld === true
&& shortSpaceDown.voiceNow.pushToTalkKeyHeld === false,
JSON.stringify(shortSpaceDown));
const releasedClicks = shortSpaceReleased.events.filter((event) => event.type === 'click');
check('short Space activates Normal once on trusted key release and preserves OSM',
shortSpaceReleased.focusedStyle === 'normal' && shortSpaceReleased.selectedStyle === 'normal'
&& shortSpaceReleased.selectedMap === 'osm'
&& JSON.stringify(shortSpaceReleased.activations) === JSON.stringify(['normal'])
&& releasedClicks.length === 1 && releasedClicks[0].trusted && releasedClicks[0].detail === 0
&& shortSpaceReleased.events.some((event) => event.type === 'keyup' && event.trusted)
&& voiceUntouched(shortSpaceReleased), JSON.stringify(shortSpaceReleased));
check('long Space blurs the focused style before requesting push-to-talk',
longSpaceDown.focusedStyle === 'normal' && longSpaceDown.selectedStyle === 'retro'
&& longSpaceDown.activations.length === 0 && longSpaceDown.voiceStarts.length === 0
&& longSpaceHeld.focusedStyle === null && longSpaceHeld.selectedStyle === 'retro'
&& longSpaceHeld.activations.length === 0
&& longSpaceHeld.events.some((event) => event.type === 'keydown' && event.repeat && event.trusted)
&& longSpaceHeld.events.every((event) => event.type !== 'click')
&& longSpaceHeld.voiceStarts.length === 1
&& longSpaceHeld.voiceStarts[0].options.pushToTalk === true
&& longSpaceHeld.voiceStarts[0].focusedStyle === null,
JSON.stringify({ down: longSpaceDown, held: longSpaceHeld }));
check('releasing a claimed long Space hold does not activate the blurred style',
longSpaceReleased.focusedStyle === null && longSpaceReleased.selectedStyle === 'retro'
&& longSpaceReleased.selectedMap === 'osm' && longSpaceReleased.activations.length === 0
&& longSpaceReleased.voiceStarts.length === 1
&& longSpaceReleased.events.every((event) => event.type !== 'click'),
JSON.stringify(longSpaceReleased));
if (forceKeyless) {
await page.evaluate(async () => {
const styleManager = window.__godsEyeView.styleManager;
const controller = styleManager.mapStackController;
if (controller.googleTileset) controller.googleTileset.show = false;
controller.googleTileset = null;
controller.cesiumToken = '';
// Availability is now composed in the source registry. Override only
// this fixture's choices, as the previous token-field seam did.
for (const source of controller._sources.values()) {
if (source.descriptor.requiresIon || source.descriptor.kind === 'photoreal') {
source.available = false;
}
}
controller._registry.state.hasCesiumIonToken = false;
await styleManager._setMapStack('osm', { syncShare: false });
styleManager._initMapStackControl();
});
const keylessState = await page.evaluate(() => {
const controller = window.__godsEyeView.styleManager.mapStackController;
return {
activeId: controller.getActiveId(),
hasGoogleTileset: Boolean(controller.googleTileset),
hasCesiumIonToken: Boolean(controller.cesiumToken),
};
});
check(
'forced-keyless seam removes direct Google and ion sources before restore checks',
keylessState.activeId === 'osm'
&& keylessState.hasGoogleTileset === false
&& keylessState.hasCesiumIonToken === false,
JSON.stringify(keylessState),
);
}
const activeBeforeIonAttempt = await page.evaluate(() => (
window.__godsEyeView.styleManager.mapStackController.getActiveId()
));
// The long-Space test above deliberately blurs its style button. On a slower
// keyless rebuild that can give the tray's pending auto-close enough time to
// hide its chips before page.focus() runs. Focus the disclosure first to
// clear that close timer, then ensure the tray is visibly open so this check
// exercises the unavailable tile rather than a hidden element.
await page.focus('#control-panel-toggle');
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed(
'control-panel', false, { persist: false, syncShare: false },
));
await waitTray('true', null);
await page.focus('[data-stack-id="bing-aerial"]');
const ionAvailable = await page.$eval(
'[data-stack-id="bing-aerial"]',
(chip) => chip.getAttribute('aria-disabled') !== 'true',
);
const ionFocusedBeforeActivation = await page.$eval(
'[data-stack-id="bing-aerial"]',
(chip) => document.activeElement === chip,
);
// Exercise the keyboard path. Unavailable tiles intentionally reject pointer
// hit testing while remaining focusable so their missing-key explanation is
// available to keyboard and assistive-technology users.
await page.keyboard.press('Space');
if (ionAvailable) {
// Cesium creates the imagery provider asynchronously. Wait for controller
// truth instead of assuming a keyed switch can settle in one animation.
await page.waitForFunction(
() => window.__godsEyeView.styleManager.mapStackController.getActiveId() === 'bing-aerial'
|| Boolean(window.__godsEyeView.styleManager.mapStackController.getState()?.lastError),
{ timeout: 20_000 },
).catch(() => {});
} else {
// A disabled chip must remain inert after the event loop has settled, not
// just at the synchronous DOM sample immediately following the click.
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 300)));
}
const ionSource = await page.evaluate(() => {
const chip = document.querySelector('[data-stack-id="bing-aerial"]');
return {
focused: document.activeElement === chip,
ariaDisabled: chip.getAttribute('aria-disabled'),
ariaLabel: chip.getAttribute('aria-label'),
activeId: window.__godsEyeView.styleManager.mapStackController.getActiveId(),
active: [...document.querySelectorAll('.map-stack-chip')]
.filter((candidate) => candidate.getAttribute('aria-pressed') === 'true')
.map((candidate) => candidate.dataset.stackId),
};
});
ionSource.focusedBeforeActivation = ionFocusedBeforeActivation;
if (forceKeyless || ionSource.ariaDisabled === 'true') {
check(
'key-required sources stay focusable, explained, and inert when no ion token is configured',
ionSource.ariaDisabled === 'true'
&& ionSource.focusedBeforeActivation
// #143 names the missing key: "Needs CESIUM_ION_TOKEN — add it in Provider Settings".
&& /needs [A-Z_]+.*provider settings/i.test(ionSource.ariaLabel)
&& ionSource.activeId === activeBeforeIonAttempt
&& JSON.stringify(ionSource.active) === JSON.stringify([activeBeforeIonAttempt]),
JSON.stringify(ionSource),
);
} else {
check(
'key-required sources switch normally when the ion token is configured',
ionSource.focused
&& ionSource.ariaDisabled === 'false'
&& ionSource.activeId === 'bing-aerial'
&& JSON.stringify(ionSource.active) === JSON.stringify(['bing-aerial']),
JSON.stringify(ionSource),
);
}
const switching = await page.evaluate(async () => {
const styleManager = window.__godsEyeView.styleManager;
const controller = styleManager.mapStackController;
const originalSetStack = controller.setStack.bind(controller);
const before = controller.getActiveId();
let release;
const gate = new Promise((resolve) => { release = resolve; });
controller.setStack = async (stackId) => {
await gate;
return originalSetStack(stackId);
};
const switchPromise = styleManager._setMapStack('osm', { syncShare: false });
const during = {
status: document.getElementById('map-stack-status').textContent,
active: [...document.querySelectorAll('.map-stack-chip')]
.filter((chip) => chip.getAttribute('aria-pressed') === 'true')
.map((chip) => chip.dataset.stackId),
};
release();
await switchPromise;
const after = {
status: document.getElementById('map-stack-status').textContent,
active: [...document.querySelectorAll('.map-stack-chip')]
.filter((chip) => chip.getAttribute('aria-pressed') === 'true')
.map((chip) => chip.dataset.stackId),
};
controller.setStack = originalSetStack;
await styleManager._setMapStack(before, { syncShare: false });
return { before, during, after };
});
check(
'switching feedback is truthful and active state moves only after commit',
switching.during.status === '...'
&& JSON.stringify(switching.during.active) === JSON.stringify([switching.before])
&& JSON.stringify(switching.after.active) === JSON.stringify(['osm']),
JSON.stringify(switching),
);
const acquiringLifecycle = await page.evaluate(async () => {
const styleManager = window.__godsEyeView.styleManager;
const status = document.getElementById('global-loading-status');
const snapshot = () => ({
hidden: status.hidden,
state: status.dataset.state || null,
label: document.getElementById('global-loading-label').textContent.trim(),
detail: document.getElementById('global-loading-detail').textContent.trim(),
});
styleManager._handleShareTrackingRestoreStatus({
classification: 'pending',
layerId: 'flights',
targetId: 'qa-flight',
label: 'flight',
});
await new Promise((resolve) => setTimeout(resolve, 40));
const pending = snapshot();
styleManager._handleShareTrackingRestoreStatus({
classification: 'followed',
layerId: 'flights',
targetId: 'qa-flight',
label: 'flight',
});
const followed = snapshot();
styleManager._handleShareTrackingRestoreStatus({
classification: 'pending',
layerId: 'military',
targetId: 'qa-military',
label: 'military flight',
});
styleManager._handleShareTrackingRestoreStatus({
classification: 'source-unavailable',
layerId: 'flights',
targetId: 'qa-flight',
label: 'flight',
});
await new Promise((resolve) => setTimeout(resolve, 40));
const staleTerminal = snapshot();
styleManager._handleShareTrackingRestoreStatus({
classification: 'cancelled',
layerId: 'military',
targetId: 'qa-military',
label: 'military flight',
});
const cancelled = snapshot();
return { pending, followed, staleTerminal, cancelled };
});
check(
'ACQUIRING DOM notice persists, ignores stale terminals, and clears on ownership completion',
acquiringLifecycle.pending.hidden === false
&& acquiringLifecycle.pending.state === 'acquiring'
&& acquiringLifecycle.pending.label === 'ACQUIRING'
&& acquiringLifecycle.pending.detail === 'SHARED FLIGHT'
&& acquiringLifecycle.followed.hidden === true
&& acquiringLifecycle.staleTerminal.hidden === false
&& acquiringLifecycle.staleTerminal.state === 'acquiring'
&& acquiringLifecycle.staleTerminal.detail === 'SHARED MILITARY FLIGHT'
&& acquiringLifecycle.cancelled.hidden === true,
JSON.stringify(acquiringLifecycle),
);
const acquiringFailureArbitration = await page.evaluate(async () => {
const styleManager = window.__godsEyeView.styleManager;
const dataManager = styleManager._dataManager;
const status = document.getElementById('global-loading-status');
const originalGetAll = dataManager.getAll;
const snapshot = () => ({
hidden: status.hidden,
state: status.dataset.state || null,
label: document.getElementById('global-loading-label').textContent.trim(),
detail: document.getElementById('global-loading-detail').textContent.trim(),
});
const waitForQueuedNotice = async (label, timeoutMs = 1000) => {
const deadline = performance.now() + timeoutMs;
while (styleManager._feedback._globalStatusNotice?.label !== label
&& performance.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 16));
}
return styleManager._feedback._globalStatusNotice?.label === label;
};
const baseNow = performance.now();
try {
styleManager._feedback._loadingFeedbackState = {
phase: 'idle', visible: false, startedAt: 0, showAt: 0, hideAt: 0,
activeIds: [], batchOutcome: null, terminal: null, operation: null,
};
styleManager._handleShareTrackingRestoreStatus({
classification: 'pending',
layerId: 'flights',
targetId: 'qa-failure-flight',
label: 'flight',
});
dataManager.getAll = () => [{
id: 'qa-unrelated-layer',
name: 'QA unrelated layer',
lifecycleState: 'enabling',
enabled: false,
stats: {},
}];
styleManager._updateGlobalLoadingFeedback(baseNow);
styleManager._updateGlobalLoadingFeedback(baseNow + 200);
dataManager.getAll = () => [];
styleManager._feedback._loadingFeedbackEvent = {
type: 'visibility-failed',
layerId: 'qa-unrelated-layer',
error: new Error('QA offline'),
};
styleManager._updateGlobalLoadingFeedback(baseNow + 300);
const failureStart = snapshot();
styleManager._handleShareTrackingRestoreStatus({
classification: 'source-unavailable',
layerId: 'flights',
targetId: 'qa-failure-flight',
label: 'flight',
});
const queuedNoticeReady = await waitForQueuedNotice(
'Shared flight could not be restored — feed unavailable',
);
const shareFailureQueued = snapshot();
styleManager._updateGlobalLoadingFeedback(baseNow + 5299);
const failureEnd = snapshot();
styleManager._updateGlobalLoadingFeedback(baseNow + 5300);
const shareFailureStart = snapshot();
styleManager._updateGlobalLoadingFeedback(baseNow + 10299);
const shareFailureEnd = snapshot();
styleManager._updateGlobalLoadingFeedback(baseNow + 10300);
const settled = snapshot();
return {
failureStart,
queuedNoticeReady,
shareFailureQueued,
failureEnd,
shareFailureStart,
shareFailureEnd,
settled,
};
} finally {
dataManager.getAll = originalGetAll;
styleManager._handleShareTrackingRestoreStatus({
classification: 'cancelled',
layerId: 'flights',
targetId: 'qa-failure-flight',
label: 'flight',
});
}
});
check(
'manager failure then queued share failure each receives its full visible dwell',
acquiringFailureArbitration.failureStart.hidden === false
&& acquiringFailureArbitration.failureStart.state === 'error'
&& acquiringFailureArbitration.failureStart.label === 'LOAD FAILED'
&& acquiringFailureArbitration.queuedNoticeReady === true
&& acquiringFailureArbitration.shareFailureQueued.state === 'error'
&& acquiringFailureArbitration.shareFailureQueued.label === 'LOAD FAILED'
&& acquiringFailureArbitration.failureEnd.state === 'error'
&& acquiringFailureArbitration.failureEnd.label === 'LOAD FAILED'
&& acquiringFailureArbitration.shareFailureStart.state === 'error'
&& acquiringFailureArbitration.shareFailureStart.label === 'Shared flight could not be restored — feed unavailable'
&& acquiringFailureArbitration.shareFailureEnd.label === 'Shared flight could not be restored — feed unavailable'
&& acquiringFailureArbitration.settled.hidden === true,
JSON.stringify(acquiringFailureArbitration),
);
// Unpinned mouse-away dismissal AFTER a tile click. Chromium focuses a
// <button> on mouse press, so a close-guard reading plain
// `document.activeElement` left the tray permanently open once Map Source
// moved into it — switch a basemap and the popover never went away again
// (owner field report). The pin samples the exact mechanism: focus IS parked
// inside the panel and is NOT `:focus-visible`, and the tray closes anyway.
const setControlPanelPinned = (wanted) => page.evaluate((want) => {
const panel = document.getElementById('control-panel');
if (panel.classList.contains('dock-pinned') !== want) {
document.querySelector('.dock-pin-btn[data-pin-target="control-panel"]').click();
}
return panel.classList.contains('dock-pinned');
}, wanted);
const readTrayState = () => page.evaluate(() => {
const panel = document.getElementById('control-panel');
const active = document.activeElement;
let focusVisible = null;
try { focusVisible = active?.matches?.(':focus-visible') ?? null; } catch { focusVisible = null; }
return {
collapsed: panel.classList.contains('collapsed'),
expanded: document.getElementById('control-panel-toggle').getAttribute('aria-expanded'),
focusInside: panel.contains(active),
focusVisible,
};
});
const clickTileThenLeave = async (stackId) => {
await page.evaluate(() => window.__godsEyeView.styleManager
.setPanelCollapsed('control-panel', false, { explicit: true }));
await new Promise((resolve) => setTimeout(resolve, 240));
await page.click(`[data-stack-id="${stackId}"]`);
await new Promise((resolve) => setTimeout(resolve, 120));
const afterClick = await readTrayState();
await page.mouse.move(20, 20); // leave the dock entirely → pointerleave
// Past the 420 ms unpinned close delay with room for a slow frame.
await new Promise((resolve) => setTimeout(resolve, 1000));
return { afterClick, afterLeave: await readTrayState() };
};
// The OTHER half of the same rule, asserted positively: a KEYBOARD user who
// tabbed to a tile and pressed Enter must keep the tray, because closing it
// out from under them would strand the caret in a hidden surface. Same
// mouse-away that dismisses after a click, opposite outcome — so a fix that
// simply deleted the focus guard would fail here.
await setControlPanelPinned(false);
await page.evaluate(() => window.__godsEyeView.styleManager
.setPanelCollapsed('control-panel', true, { explicit: true }));
await new Promise((resolve) => setTimeout(resolve, 200));
await page.focus('#control-panel-toggle');
await page.keyboard.press('Enter'); // opens and hands focus to the active tile
const selectedForHold = await page.evaluate(() => window.__godsEyeView.styleManager.mapStackController.getActiveId());
await waitTray('true', selectedForHold);
// Keyless starts on OSM, the last tile. Tab forward there correctly leaves
// the tray, so navigate to a neighbouring tile in the available direction.
const activeIsLastTile = await page.evaluate(() => {
const chips = [...document.querySelectorAll('#map-stack-chips .map-stack-chip')];
return document.activeElement === chips.at(-1);
});
if (activeIsLastTile) await page.keyboard.down('Shift');
await page.keyboard.press('Tab');
if (activeIsLastTile) await page.keyboard.up('Shift');
await page.keyboard.press('Enter'); // activate it from the keyboard
await new Promise((resolve) => setTimeout(resolve, 200));
const keyboardAfterActivate = await page.evaluate(() => ({
focusedStack: document.activeElement?.dataset?.stackId || null,
isChip: !!document.activeElement?.classList?.contains('map-stack-chip'),
}));
// Enter the tray with the pointer and leave again, so a real pointerleave
// schedules the close this pin expects to be declined.
const chipPoint = await page.$eval('#map-stack-chips .map-stack-chip', (chip) => {
const rect = chip.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
});
await page.mouse.move(chipPoint.x, chipPoint.y);
await new Promise((resolve) => setTimeout(resolve, 120));
await page.mouse.move(20, 20);
await new Promise((resolve) => setTimeout(resolve, 1000));
const keyboardHold = await readTrayState();
check(
'keyboard activation of a tile HOLDS the tray open through the same mouse-away',
keyboardAfterActivate.isChip === true
&& keyboardHold.collapsed === false
&& keyboardHold.expanded === 'true'
&& keyboardHold.focusInside === true
&& keyboardHold.focusVisible === true,
JSON.stringify({ keyboardAfterActivate, keyboardHold }),
);
await setControlPanelPinned(false);
const dismissAfterTileClick = await clickTileThenLeave('osm');
const pinnedForHold = await setControlPanelPinned(true);
const pinnedHold = await clickTileThenLeave('photoreal');
await setControlPanelPinned(false);
await page.evaluate(() => window.__godsEyeView.styleManager
._setMapStack('photoreal', { syncShare: false }));
// Approach the disclosure before opening: the previous mouse-away case
// intentionally leaves a pending close, so an unattended programmatic reopen
// can disappear while the next real pointer click is being dispatched.
const pinApproach = await page.$eval('#control-panel-toggle', (toggle) => {
const rect = toggle.getBoundingClientRect();
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
});
await page.mouse.move(pinApproach.x, pinApproach.y);
await page.evaluate(() => window.__godsEyeView.styleManager
.setPanelCollapsed('control-panel', false, { explicit: true }));
await new Promise((resolve) => setTimeout(resolve, 240));
check(
'a tile click does not exempt the unpinned tray from mouse-away auto-dismiss',
dismissAfterTileClick.afterClick.collapsed === false
&& dismissAfterTileClick.afterClick.focusInside === true
&& dismissAfterTileClick.afterClick.focusVisible === false
&& dismissAfterTileClick.afterLeave.collapsed === true
&& dismissAfterTileClick.afterLeave.expanded === 'false'
&& pinnedForHold === true
&& pinnedHold.afterLeave.collapsed === false,
JSON.stringify({ dismissAfterTileClick, pinnedForHold, pinnedHold }),
);
await page.click('.dock-pin-btn[data-pin-target="control-panel"]');
const desktop = await trayMetrics();
check(
'desktop tray is one row and fully inside the viewport',
desktop.rows === 1
&& desktop.popover.left >= 0
&& desktop.popover.right <= desktop.viewport.width,
JSON.stringify(desktop),
);
await checkResponsiveStyleFocus(1000);
await page.setViewport({ width: 620, height: 900, deviceScaleFactor: 1 });
await new Promise((resolve) => setTimeout(resolve, 180));
await waitForShellSettled();
await openPhoneMoreSheet();
const at620 = await trayMetrics();
check(
'620 px tray uses two rows without clipping',
at620.expanded === 'true'
&& at620.pinned
&& at620.rows === 2
&& at620.popover.left >= 0
&& at620.popover.right <= at620.viewport.width,
JSON.stringify(at620),
);
await checkResponsiveStyleFocus(620);
await page.setViewport({ width: 480, height: 900, deviceScaleFactor: 1 });
await new Promise((resolve) => setTimeout(resolve, 180));
const at480 = await trayMetrics();
const allRectsInside = at480.chips.every((chip) => (
chip.left >= 0 && chip.right <= at480.viewport.width
&& chip.top >= 0 && chip.bottom <= at480.viewport.height
));
check(
'480 px live resize keeps the open tray and every tile in bounds',
at480.expanded === 'true'
&& at480.pinned
&& at480.rows === 2
&& at480.popover.left >= 0
&& at480.popover.right <= at480.viewport.width
&& allRectsInside,
JSON.stringify(at480),
);
await page.screenshot({ path: path.join(shotsDir, '480-open.png') });
await checkResponsiveStyleFocus(480);
// Pinning and the compact wing are the narrow desktop dock (721–900 px);
// phones use the tab bar instead.
await page.setViewport({ width: 800, height: 900, deviceScaleFactor: 1 });
await waitForShellSettled();
await waitForDockSettled();
await page.click('.dock-pin-btn[data-pin-target="control-panel"]');
await waitForDockSettled();
await page.click('#control-panel-toggle');
const cdp = await page.createCDPSession();
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
const toggleRect = await page.$eval('#control-panel-toggle', (toggle) => {
const rect = toggle.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
});
await page.touchscreen.tap(toggleRect.x, toggleRect.y);
await new Promise((resolve) => setTimeout(resolve, 100));
const touchOpen = await page.$eval('#control-panel-toggle', (toggle) => toggle.getAttribute('aria-expanded'));
check('coarse-pointer tap opens the compact wing', touchOpen === 'true', `aria-expanded=${touchOpen}`);
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: false });
// Retired and unknown stack ids take the SAME path. `bing-road` was the fifth
// source behind the retired left Map Stack panel; deleting it from
// `MAP_STACKS` (no build carrying it ever shipped publicly, so no link is
// owed anything) means an old `map=bing-road` link is now simply an
// unrecognized id, and `setStack()`'s `getStack(id) || getStack('photoreal')`
// fallback requests Google 3D. A keyed run lands there; a keyless run keeps
// its truthful OSM recovery. Either way, the active tile must reflect the
// rendered source — never a hidden fifth source with a ROAD status.
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
const photorealAvailable = await page.$eval(
'[data-stack-id="photoreal"]',
(chip) => chip.getAttribute('aria-disabled') !== 'true',
);
const expectedLegacyActive = photorealAvailable ? 'photoreal' : 'osm';
for (const legacyId of ['bing-road', 'garbage']) {
if (forceKeyless) {
// Keep the forced-keyless seam alive. A full reload would rebuild the
// controller from the keyed server before this in-page override exists,
// so drive the same parse/apply startup contract on the current keyless
// controller instead.
await page.evaluate(async (id) => {
const styleManager = window.__godsEyeView.styleManager;
history.replaceState(null, '', `?welcome=0#v=2&lat=30.27&lon=-97.74&map=${id}`);
const state = styleManager.shareLinkManager.parseInitialHash();
await styleManager.shareLinkManager.applyState(state, { applyCamera: false });
styleManager.shareLinkManager.completeInitialRestore();
}, legacyId);
} else {
await page.goto(`${appUrl}/?welcome=0#v=2&lat=30.27&lon=-97.74&map=${legacyId}`, {
waitUntil: 'domcontentloaded',
timeout: 60_000,
});
await page.waitForFunction(() => window.__godsEyeView?.styleManager, { timeout: 60_000 });
await page.waitForFunction(
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60_000 },
);
}
await page.waitForFunction(
() => window.__godsEyeView.styleManager.mapStackController.getState()?.status !== 'switching',
{ timeout: 20_000 },
).catch(() => {});
const restored = await page.evaluate(() => ({
activeId: window.__godsEyeView.styleManager.mapStackController.getActiveId(),
lastError: window.__godsEyeView.styleManager.mapStackController.getState()?.lastError || null,
status: document.getElementById('map-stack-status').textContent.trim(),
pressed: [...document.querySelectorAll('.map-stack-chip')]
.filter((chip) => chip.getAttribute('aria-pressed') === 'true')
.map((chip) => chip.dataset.stackId),
}));
check(
`a map=${legacyId} link restores to the best available fallback with its tile lit`,
restored.activeId === expectedLegacyActive
// Keyless photoreal now explains itself as "Needs GOOGLE_MAPS_API_KEY — add it in
// Provider Settings — or a Cesium ion token …"; a keyed-but-failing route still says "unavailable".
&& (photorealAvailable ? restored.lastError === null : /needs [A-Z_]+|unavailable/i.test(restored.lastError || ''))
&& JSON.stringify(restored.pressed) === JSON.stringify([expectedLegacyActive]),
JSON.stringify(restored),
);
await page.screenshot({ path: path.join(shotsDir, `legacy-${legacyId}.png`) });
}
// Location is a native disclosure: Enter is immediate, while Space waits for
// key release. A repeated keydown must not toggle or claim voice early.
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
await page.mouse.move(20, 20);
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
for (const id of ['control-panel', 'location-bar']) {
manager._setCommandDockPanelPinState(id, false, { persist: false, syncShare: false });
manager.setPanelCollapsed(id, true, { persist: false, syncShare: false });
}
const chrome = manager._panelChrome;
const original = chrome.setPanelCollapsed;
const probe = { transitions: [], restore: () => { chrome.setPanelCollapsed = original; } };
chrome.setPanelCollapsed = function (id, ...args) {
const before = document.getElementById(id)?.classList.contains('collapsed');
const result = original.call(this, id, ...args);
const after = document.getElementById(id)?.classList.contains('collapsed');
if (id === 'location-bar' && before !== after) probe.transitions.push({ before, after });
return result;
};
window.__qaLocationFocus = probe;
});
try {
await page.focus('#gev-voice-button');
const locationTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 8 });
const locationClosed = await locationState();
check('Location: native disclosure semantics and closed Tab focus without opening',
locationTab.reached && hasVisibleControlFocus(locationClosed)
&& locationClosed.tag === 'BUTTON' && locationClosed.type === 'button'
&& locationClosed.controls === 'location-bar-popover' && locationClosed.popoverId === 'location-bar-popover'
&& locationClosed.locationExpanded === 'false' && /^Expand Location$/i.test(locationClosed.locationLabel)
&& locationClosed.transitions.length === 0, JSON.stringify({ locationTab, locationClosed }));
await resetLocationTransitions();
await page.keyboard.press('Enter');
const locationEnter = await locationState();
check('Location: Enter opens once with visible focus and accurate name',
hasVisibleControlFocus(locationEnter) && locationEnter.locationExpanded === 'true'
&& /^Collapse Location$/i.test(locationEnter.locationLabel) && locationEnter.transitions.length === 1,
JSON.stringify(locationEnter));
await page.keyboard.press('Escape');
const locationEscape = await locationState();
check('Location: Escape on the disclosure closes without retaining focus',
locationEscape.focusedId === null && locationEscape.locationExpanded === 'false'
&& /^Expand Location$/i.test(locationEscape.locationLabel), JSON.stringify(locationEscape));
await page.focus('#location-bar-toggle');
await resetLocationTransitions();
const voiceBeforeLocationSpace = locationEscape.voice;
let locationSpaceHeld = false;
let locationSpaceDown;
let locationSpaceRepeat;
try {
await page.keyboard.down('Space');
locationSpaceHeld = true;
locationSpaceDown = await locationState();
await new Promise((resolve) => setTimeout(resolve, 320));
await page.keyboard.down('Space');
await page.keyboard.down('Space');
locationSpaceRepeat = await locationState();
await page.keyboard.up('Space');
locationSpaceHeld = false;
} finally {
if (locationSpaceHeld) await page.keyboard.up('Space');
}
const locationSpaceUp = await locationState();
check('Location: short repeated Space toggles once on release without starting voice',
[locationSpaceDown, locationSpaceRepeat].every((state) => (
state.focusedId === 'location-bar-toggle' && state.locationExpanded === 'false'
&& state.transitions.length === 0 && state.voice.status === voiceBeforeLocationSpace.status
&& state.voice.epoch === voiceBeforeLocationSpace.epoch
&& state.voice.held === true && state.voice.pushToTalk === false
))
&& locationSpaceUp.focusedId === 'location-bar-toggle'
&& locationSpaceUp.locationExpanded === 'true' && locationSpaceUp.transitions.length === 1
&& JSON.stringify(locationSpaceUp.voice) === JSON.stringify(voiceBeforeLocationSpace),
JSON.stringify({ down: locationSpaceDown, repeat: locationSpaceRepeat, up: locationSpaceUp }));
const searchTab = await tabTo('#search-toggle', { limit: 30 });
await page.keyboard.press('Enter');
await page.keyboard.type('focus cleanup'); // no submission or navigation
const typedSearch = await page.$eval('#location-search', (input) => ({
focused: document.activeElement === input, expanded: input.classList.contains('expanded'), value: input.value,
}));
await page.keyboard.press('Escape');
const searchEscape = await locationState();
check('Location: Escape clears typed search and restores disclosure focus',
searchTab.reached && typedSearch.focused && typedSearch.expanded && typedSearch.value === 'focus cleanup'
&& searchEscape.focusedId === 'location-bar-toggle' && searchEscape.locationExpanded === 'false'
&& !searchEscape.searchExpanded && searchEscape.searchValue === '', JSON.stringify({ typedSearch, searchEscape }));
await page.focus('#control-panel-toggle');
await page.keyboard.press('Enter'); // begin the Map Source handoff
const siblingTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 30 });
await page.keyboard.press('Enter');
await new Promise((resolve) => setTimeout(resolve, 1100));
const siblingState = await locationState();
check('Location: sibling opening cancels pending Map Source focus',
siblingTab.reached && siblingState.focusedId === 'location-bar-toggle'
&& siblingState.locationExpanded === 'true'
&& await page.$eval('#control-panel-toggle', (toggle) => toggle.getAttribute('aria-expanded') === 'false'),
JSON.stringify(siblingState));
await tabTo('.dock-pin-btn[data-pin-target="location-bar"]', { limit: 3 });
await page.keyboard.press('Enter');
await page.focus('#control-panel-toggle');
await page.keyboard.press('Enter');
await new Promise((resolve) => setTimeout(resolve, 400));
const pinnedSibling = await locationState();
check('Location: pinned tray survives sibling opening',
pinnedSibling.pinned && pinnedSibling.locationExpanded === 'true'
&& await page.$eval('#control-panel-toggle', (toggle) => toggle.getAttribute('aria-expanded') === 'true'),
JSON.stringify(pinnedSibling));
await page.focus('#location-bar-toggle');
await tabTo('.dock-pin-btn[data-pin-target="location-bar"]', { limit: 3 });
await page.keyboard.press('Enter');
check('Location: pin can be removed by keyboard', !(await locationState()).pinned);
for (const width of [1000, 620, 480]) {
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
await waitForShellSettled();
if (width <= 720) {
// Phones present Location as the Search tab's sheet: the keyboard opens
// it onto the field, and Escape returns focus to the tab.
const searchTab = '#mobile-tab-bar [data-mobile-sheet="search"]';
await page.focus(searchTab);
await page.keyboard.press('Enter');
await new Promise((resolve) => setTimeout(resolve, 250));
const field = await focusMetrics('#location-search');
check(`Search: ${width} px keyboard opens the sheet on the location field`,
field.focusedId === 'location-search' && field.visible && field.insideViewport,
JSON.stringify(field));
await page.screenshot({ path: path.join(shotsDir, `${width}-search-open.png`) });
await page.keyboard.press('Escape');
await new Promise((resolve) => setTimeout(resolve, 250));
const tab = await focusMetrics(searchTab);
const closedSheet = await page.evaluate(() => !document.body.dataset.mobileSheet);
check(`Search: ${width} px Escape closes the sheet with a visible ring on its tab`,
closedSheet && hasVisibleControlFocus(tab), JSON.stringify({ closedSheet, tab }));
continue;
}
await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager;
manager.setPanelCollapsed('control-panel', true, { persist: false, syncShare: false });
manager.setPanelCollapsed('location-bar', true, { persist: false, syncShare: false });
});
await page.focus('#gev-voice-button');
const closedTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 8 });
const closed = await locationState();
check(`Location: ${width} px closed Tab ring is visible without opening`,
closedTab.reached && hasVisibleControlFocus(closed) && closed.locationExpanded === 'false', JSON.stringify(closed));
await page.screenshot({ path: path.join(shotsDir, `${width}-location-closed-focus.png`) });
await page.keyboard.press('Enter');
await pressTabs(1); // disclosure -> pin
const openTab = await tabTo('#location-bar-toggle', { backwards: true, limit: 3 });
const open = await locationState();
check(`Location: ${width} px open ShiftTab ring is visible`,
openTab.reached && hasVisibleControlFocus(open) && open.locationExpanded === 'true', JSON.stringify(open));
await page.screenshot({ path: path.join(shotsDir, `${width}-location-open-focus.png`) });
await page.keyboard.press('Escape');
}
} finally {
await page.evaluate(() => {
window.__qaLocationFocus?.restore();
delete window.__qaLocationFocus;
const manager = window.__godsEyeView.styleManager;
for (const id of ['control-panel', 'location-bar']) {
manager._setCommandDockPanelPinState(id, false, { persist: false, syncShare: false });
manager.setPanelCollapsed(id, true, { persist: false, syncShare: false });
}
});
}
// The existing dev-only QA registration seam supplies stable ON/STALE rows
// through the real manager/renderer. These fixtures fetch nothing, render no
// entities and have no periodic update; production layers are never toggled.
let dataSetup;
try {
dataSetup = await page.evaluate(async () => {
const manager = window.__godsEyeView.dataManager;
const production = () => manager.getAll().filter((layer) => !layer.id.startsWith('qa-keyboard-focus-'))
.map((layer) => ({
id: layer.id, enabled: layer.enabled, phase: layer.lifecycleState, uncertain: layer.lifecycleUncertain,
visibilityIntentEpoch: manager.layers.get(layer.id).visibilityIntentEpoch,
paramsIntentEpoch: manager.layers.get(layer.id).paramsIntentEpoch,
}));
// Inspect this known layer-state key only. Keep its value inside the page;
// evidence receives equality booleans, never stored contents.
const durableBefore = localStorage.getItem('gev:layer-state:v2');
const state = { ids: [], before: production(), production, durableBefore, collapsed: document.getElementById('data-panel').classList.contains('collapsed') };
window.__qaDataFocus = state;
if (typeof window.__gevQaRegisterLayer !== 'function' || typeof window.__gevQaUnregisterLayer !== 'function') {
return { ready: false, reason: 'Existing dev-only QA registration seam is unavailable' };
}
for (const [suffix, stale] of [['on', false], ['status', true]]) {
const id = `qa-keyboard-focus-${suffix}`;
window.__gevQaRegisterLayer(manager, {
id, name: `QA focus ${stale ? 'STALE' : 'ON'}`, icon: '◌', source: 'Local focus fixture',
init() {}, enable() {}, disable() {}, destroy() {}, update() {},
getStats: () => ({ count: 1, lastUpdate: Date.now() - (stale ? 60_000 : 0), stale, source: 'Local focus fixture' }),
});
state.ids.push(id);
await manager.setEnabled(id, true, { origin: 'programmatic' });
}
const transitionId = 'qa-keyboard-focus-transition';
const transition = {
id: transitionId,
enableCalls: 0,
disableCalls: 0,
releaseEnable: null,
releaseDisable: null,
};
state.transition = transition;
window.__gevQaRegisterLayer(manager, {
id: transitionId,
name: 'QA focus transition',
icon: '◌',
source: 'Local focus fixture',
init() {},
enable() {
transition.enableCalls += 1;
return new Promise((resolve) => {
transition.releaseEnable = () => {
transition.releaseEnable = null;
resolve();
};
});
},
disable() {
transition.disableCalls += 1;
return new Promise((resolve) => {
transition.releaseDisable = () => {
transition.releaseDisable = null;
resolve();
};
});
},
destroy() {},
update() {},
getStats: () => ({ count: 1, lastUpdate: Date.now(), source: 'Local focus fixture' }),
});
state.ids.push(transitionId);
const { application } = await import('/src/main.js');
application.getComponents().data.presentation.panel._renderToggles();
const offId = manager.getAll().find((layer) => !state.ids.includes(layer.id) && layer.showInTogglePanel && !layer.enabled)?.id;
return {
ready: Boolean(offId), offId, fixtureIds: state.ids.slice(0, 2), transitionId,
productionUnchanged: JSON.stringify(state.before) === JSON.stringify(production()),
durableUnchanged: durableBefore === localStorage.getItem('gev:layer-state:v2'),
};
});
check('Data Layers: explicit dev fixtures provide status coverage without changing production layers',
dataSetup.ready && dataSetup.productionUnchanged && dataSetup.durableUnchanged, JSON.stringify(dataSetup));
if (dataSetup.ready) {
for (const width of [1000, 620, 480]) {
await page.setViewport({ width, height: 900, deviceScaleFactor: 1 });
await waitForShellSettled();
if (width <= 720 && !(await page.evaluate(() => document.body.dataset.mobileSheet === 'layers'))) {
// Phones present Data Layers in the Layers tab's sheet.
await page.focus('#mobile-tab-bar [data-mobile-sheet="layers"]');
await page.keyboard.press('Enter');
}
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }));
// Resizing schedules rail placement on animation frames. Wait for that
// pass and its CSS transitions before comparing a focus rectangle with
// hit testing; a fixed Tab delay can observe two different positions.
await page.evaluate(() => new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
}));
await page.waitForFunction((expectedWidth) => {
const stack = document.getElementById('left-panel-stack');
if (innerWidth !== expectedWidth || !stack) return false;
if ((stack.dataset.layoutMode === 'mobile') !== (expectedWidth <= 720)) return false;
return !stack.getAnimations({ subtree: true }).some((animation) => (
animation instanceof CSSTransition && animation.playState === 'running'
));
}, { timeout: 5_000 }, width);
await page.focus('#data-panel .panel-collapse-btn');
const targets = [[dataSetup.offId, 'OFF', false], [dataSetup.fixtureIds[0], 'ON', true], [dataSetup.fixtureIds[1], 'STALE', true]];
for (const [id, label, enabled] of targets) {
const navigation = await tabTo(`[data-layer-id="${id}"] .data-toggle-btn`);
const state = await layerFocusState(id);
check(`Data Layers: ${width} px ${label} Tab focus ring is visible`,
navigation.reached && state.layerId === id && hasVisibleControlFocus(state)
&& state.label === label && state.buttonActive === enabled && state.lifecycle?.enabled === enabled
&& state.lifecycle?.lifecycleState === (enabled ? 'enabled' : 'disabled') && !state.lifecycle?.uncertain,
JSON.stringify({ setup: enabled ? 'dev QA fixture' : 'real production OFF row; not activated', navigation, state }));
await page.screenshot({ path: path.join(shotsDir, `${width}-data-${label.toLowerCase()}-focus.png`) });
if (label === 'OFF') {
const passive = await page.evaluate(async (layerId) => {
const probe = window.__qaDataFocus;
const focusBefore = document.activeElement;
const productionBefore = JSON.stringify(probe.production());
const durableBefore = localStorage.getItem('gev:layer-state:v2');
const { application } = await import('/src/main.js');
application.getComponents().data.presentation.refresh();
return {
focusRetained: document.activeElement === focusBefore
&& focusBefore.matches('.data-toggle-btn')
&& focusBefore.closest('[data-layer-id]')?.dataset.layerId === layerId,
productionUnchanged: productionBefore === JSON.stringify(probe.production())
&& productionBefore === JSON.stringify(probe.before),
durableUnchanged: durableBefore === localStorage.getItem('gev:layer-state:v2')
&& durableBefore === probe.durableBefore,
};
}, id);
const refreshed = await layerFocusState(id);
check(`Data Layers: ${width} px passive refresh preserves OFF focus and state`,
passive.focusRetained && passive.productionUnchanged && passive.durableUnchanged
&& hasVisibleControlFocus(refreshed) && refreshed.label === 'OFF' && !refreshed.lifecycle?.enabled,
JSON.stringify({ passive, refreshed }));
}
if (label === 'STALE') check(`Data Layers: ${width} px native Tab scrolls to the lower status row`,
state.listScrollHeight > state.listClientHeight && state.listScrollTop > 0 && hasVisibleControlFocus(state), JSON.stringify(state));
}
}
check('Data Layers: focus traversal leaves production visibility unchanged', await page.evaluate(() => (
JSON.stringify(window.__qaDataFocus.before) === JSON.stringify(window.__qaDataFocus.production())
&& window.__qaDataFocus.durableBefore === localStorage.getItem('gev:layer-state:v2')
)));
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
await waitForShellSettled();
await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false }));
await page.focus('#data-panel .panel-collapse-btn');
const transitionTab = await tabTo(`[data-layer-id="${dataSetup.transitionId}"] .data-toggle-btn`);
const transitionBefore = await layerFocusState(dataSetup.transitionId);
check('Data Layers: transition fixture receives native Tab focus',
transitionTab.reached && transitionBefore.label === 'OFF' && hasVisibleControlFocus(transitionBefore),
JSON.stringify({ transitionTab, transitionBefore }));
await page.keyboard.press('Space');
await page.waitForFunction((layerId) => {
const state = window.__qaDataFocus?.transition;
const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`);
return state?.enableCalls === 1 && typeof state.releaseEnable === 'function'
&& button?.textContent.trim() === 'ENABLING';
}, { timeout: 5_000 }, dataSetup.transitionId);
const enabling = await layerFocusState(dataSetup.transitionId);
check('Data Layers: focused Space keeps a visible ring through ENABLING',
hasVisibleControlFocus(enabling) && enabling.label === 'ENABLING' && !enabling.disabled
&& enabling.ariaDisabled === 'true' && enabling.ariaBusy === 'true', JSON.stringify(enabling));
const enablingEpoch = await page.evaluate((layerId) => (
window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch
), dataSetup.transitionId);
await page.keyboard.press('Space');
const enablingRepeat = await page.evaluate((layerId) => ({
calls: window.__qaDataFocus.transition.enableCalls,
epoch: window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch,
}), dataSetup.transitionId);
check('Data Layers: repeated Space is inert while ENABLING',
enablingRepeat.calls === 1 && enablingRepeat.epoch === enablingEpoch, JSON.stringify(enablingRepeat));
await page.evaluate(() => window.__qaDataFocus.transition.releaseEnable());
await page.waitForFunction((layerId) => {
const state = window.__godsEyeView.dataManager.getLayerLifecycleState(layerId);
return state.enabled && state.lifecycleState === 'enabled';
}, { timeout: 5_000 }, dataSetup.transitionId);
const enabled = await layerFocusState(dataSetup.transitionId);
check('Data Layers: settled ON keeps the same visible keyboard focus',
hasVisibleControlFocus(enabled) && !enabled.disabled && enabled.ariaDisabled === 'false'
&& enabled.ariaBusy === 'false' && enabled.lifecycle?.enabled, JSON.stringify(enabled));
await page.keyboard.press('Space');
await page.waitForFunction((layerId) => {
const state = window.__qaDataFocus?.transition;
const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`);
return state?.disableCalls === 1 && typeof state.releaseDisable === 'function'
&& button?.textContent.trim() === 'DISABLING';
}, { timeout: 5_000 }, dataSetup.transitionId);
const disabling = await layerFocusState(dataSetup.transitionId);
check('Data Layers: focused Space keeps a visible ring through DISABLING',
hasVisibleControlFocus(disabling) && disabling.label === 'DISABLING' && !disabling.disabled
&& disabling.ariaDisabled === 'true' && disabling.ariaBusy === 'true', JSON.stringify(disabling));
const disablingEpoch = await page.evaluate((layerId) => (
window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch
), dataSetup.transitionId);
await page.keyboard.press('Space');
const disablingRepeat = await page.evaluate((layerId) => ({
calls: window.__qaDataFocus.transition.disableCalls,
epoch: window.__godsEyeView.dataManager.layers.get(layerId).visibilityIntentEpoch,
}), dataSetup.transitionId);
check('Data Layers: repeated Space is inert while DISABLING',
disablingRepeat.calls === 1 && disablingRepeat.epoch === disablingEpoch, JSON.stringify(disablingRepeat));
await page.evaluate(() => window.__qaDataFocus.transition.releaseDisable());
await page.waitForFunction((layerId) => {
const state = window.__godsEyeView.dataManager.getLayerLifecycleState(layerId);
return !state.enabled && state.lifecycleState === 'disabled';
}, { timeout: 5_000 }, dataSetup.transitionId);
const disabled = await layerFocusState(dataSetup.transitionId);
check('Data Layers: settled OFF keeps the same visible keyboard focus',
hasVisibleControlFocus(disabled) && !disabled.disabled && disabled.ariaDisabled === 'false'
&& disabled.ariaBusy === 'false' && !disabled.lifecycle?.enabled, JSON.stringify(disabled));
}
} finally {
const cleaned = await page.evaluate(async () => {
const state = window.__qaDataFocus;
if (!state) return { complete: false, reason: 'No fixture setup state' };
const manager = window.__godsEyeView.dataManager;
state.transition?.releaseEnable?.();
state.transition?.releaseDisable?.();
const removed = [];
for (const id of state.ids) removed.push(await window.__gevQaUnregisterLayer(manager, id));
const { application } = await import('/src/main.js');
application.getComponents().data.presentation.panel._renderToggles();
window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', state.collapsed, { persist: false, syncShare: false });
// A native user-origin toggle legitimately asks the production state
// coordinator to persist. Restore the exact pre-fixture value so this
// hermetic QA journey leaves the user's durable layer snapshot untouched.
if (state.durableBefore === null) localStorage.removeItem('gev:layer-state:v2');
else localStorage.setItem('gev:layer-state:v2', state.durableBefore);
const result = {
complete: removed.every(Boolean) && state.ids.every((id) => !manager.layers.has(id)),
productionUnchanged: JSON.stringify(state.before) === JSON.stringify(state.production()),
durableUnchanged: state.durableBefore === localStorage.getItem('gev:layer-state:v2'),
};
delete window.__qaDataFocus;
return result;
});
check('Data Layers: fixture teardown restores the original production registry and visibility',
cleaned.complete && cleaned.productionUnchanged && cleaned.durableUnchanged, JSON.stringify(cleaned));
await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 });
}
check('no new page or console errors', consoleErrors.length === 0, consoleErrors.join(' | '));
} finally {
await browser.close();
}
if (failures.length) {
console.error(`\nMap Source tray QA failed: ${failures.join(', ')}`);
process.exitCode = 1;
} else {
console.log('\nMap Source tray QA passed.');
}