#!/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 [tabs, style] of [[0, 'normal'], [3, 'thermal'], [2, 'noir']]) { if (tabs) await pressTabs(tabs); 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 noirFocus = await styleFocusMetrics(); check('Shift+Tab reaches Noir with a visible ring while Normal and OSM stay selected', hasVisibleStyleFocus(noirFocus, 'noir') && !noirFocus.selected && noirFocus.selectedStyle === 'normal' && noirFocus.selectedMap === 'osm', JSON.stringify(noirFocus)); await page.screenshot({ path: path.join(shotsDir, 'keyboard-style-focus.png') }); await pressTabs(2, 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 }); // The dock renders the published row on Vue's next flush. const rendered = () => new Promise((resolve) => setTimeout(resolve, 0)); await rendered(); 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; await rendered(); 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 //