#!/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 = 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 || (() => { try { return puppeteer.executablePath(); } catch { return 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, args: ['--use-angle=metal', '--enable-gpu', '--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, }; }); 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 }); 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. Give that // callback one turn so the DOM assertion observes the completed contract. await new Promise((resolve) => setTimeout(resolve, 50)); 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), ); await page.focus('#control-panel-toggle'); await page.keyboard.press('Enter'); await new Promise((resolve) => setTimeout(resolve, 300)); 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 a Map Source tile', keyboardOpen.expanded === 'true' && keyboardOpen.activeStack === 'photoreal', JSON.stringify(keyboardOpen), ); await page.keyboard.press('Escape'); await new Promise((resolve) => setTimeout(resolve, 40)); 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 new Promise((resolve) => setTimeout(resolve, 300)); 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 === 'photoreal', 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 page.keyboard.press('Escape'); await page.keyboard.press('Enter'); await new Promise((resolve) => setTimeout(resolve, 300)); 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 === 'photoreal', JSON.stringify(longHoldRecovery), ); 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 = ''; 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() )); await page.focus('[data-stack-id="bing-aerial"]'); const ionAvailable = await page.$eval( '[data-stack-id="bing-aerial"]', (chip) => chip.getAttribute('aria-disabled') !== 'true', ); await page.click('[data-stack-id="bing-aerial"]'); 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), }; }); if (forceKeyless || ionSource.ariaDisabled === 'true') { check( 'key-required sources stay focusable, explained, and inert when no ion token is configured', ionSource.ariaDisabled === 'true' && ionSource.focused // #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._globalStatusNotice?.label !== label && performance.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 16)); } return styleManager._globalStatusNotice?.label === label; }; const baseNow = performance.now(); try { styleManager._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._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 //