mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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.
1744 lines
87 KiB
JavaScript
1744 lines
87 KiB
JavaScript
#!/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.');
|
||
}
|