Files
gods-eye-view/scripts/qa-draw-tool.mjs
T
Said Turksever 08dd88b2ce Gather Directions, Draw and Import in a Utilities panel
UTILITIES becomes its own panel in the left column, after Data Layers and
CCTV and above Scenes. The layer panel renders the Directions row into the
panel's list instead of Data Layers, and Draw and Import move there from
DISPLAY with their ids unchanged. Radio, the other row under the old
Utilities heading, stays in Data Layers under "Audio".

The panel collapses and remembers its state like the other left panels; share
links do not carry it. It hides in clean view, recording and Cockpit, takes
the Cyber left-rail treatment, and sits collapsed in the phone's Layers sheet.
Voice focuses the Directions row in its new list. The Directions, Draw,
Import and form-field gates open UTILITIES to reach their controls.
2026-10-29 11:12:46 +00:00

881 lines
32 KiB
JavaScript

#!/usr/bin/env node
/** Rendered acceptance for DISPLAY ▸ Draw: real clicks, real keys, real teardown. */
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from './qa-chrome.mjs';
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/draw';
const SHOT_PREFIX = process.env.QA_SHOT_PREFIX || '';
const HEADFUL = process.env.QA_HEADFUL === '1';
// A dense, recognisable grid: neighbourhood blocks to enclose and a straight
// road to trace along.
const VIEW = {
lon: -122.4194,
lat: 37.7749,
height: 900,
heading: 0,
pitch: -85,
};
const browser = await puppeteer.launch({
headless: !HEADFUL,
...(HEADFUL
? {
executablePath: chromeExecutablePath(),
}
: {}),
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
...(HEADFUL ? ['--window-size=1280,860'] : []),
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
let failures = 0;
function check(name, passed, detail = '') {
console.log(
`[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
);
if (!passed) failures++;
}
fs.mkdirSync(SHOT_DIR, { recursive: true });
const shot = (name) =>
page.screenshot({
path: path.join(SHOT_DIR, `${SHOT_PREFIX}${name}.jpg`),
type: 'jpeg',
quality: 82,
});
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** A clip of the globe canvas, clear of the panels on every side. */
const CANVAS_CLIP = { x: 340, y: 200, width: 560, height: 380 };
/**
* How many amber pixels the clip contains. The drawn area's stroke and fill are
* the only amber in this scene, so the count going UP after a shape is finished
* is the shape actually painting — which is what CESIUM_3D_TILE-only
* classification failed to do on a keyless boot while still reporting a mark on
* the board. Decoding happens in the page because Node here has no image
* decoder.
*/
async function amberPixels() {
const shot64 = await page.screenshot({
clip: CANVAS_CLIP,
type: 'png',
encoding: 'base64',
});
return page.evaluate(async (base64) => {
const bitmap = await createImageBitmap(
await (await fetch(`data:image/png;base64,${base64}`)).blob(),
);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
const { data } = context.getImageData(0, 0, bitmap.width, bitmap.height);
let amber = 0;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// Warm and saturated: the amber mark (#ffb547) blended over imagery.
if (r > 150 && g > 90 && g < 220 && b < 130 && r - b > 70 && r - g > 20)
amber += 1;
}
return amber;
}, shot64);
}
/**
* Press Enter as the person drawing does — with the map focused, not a button.
* Enter on a focused control means "press this control", so the tool ignores it
* there; the harness has to move focus the way a user would before using the
* finish key.
*/
async function pressFinish() {
await page.evaluate(() => document.activeElement?.blur?.());
await page.keyboard.press('Enter');
}
/** One click on the globe canvas, the way a person makes one. */
async function clickWorld(x, y, options = {}) {
await page.mouse.click(x, y, options);
await wait(options.settle ?? 140);
}
const drawState = () =>
page.evaluate(() => {
const tool = window.__gevDrawTool;
return {
...tool.diagnostics(),
shape: tool.shape,
vertices: tool.session?.vertices?.length ?? null,
hint: document.getElementById('draw-hint')?.textContent || '',
marks: window.__gevAnnotations.count(),
};
});
const marks = () =>
page.evaluate(() =>
window.__gevAnnotations.list().map((mark) => ({
type: mark.type,
label: mark.label,
color: mark.color,
source: mark.source ?? null,
})),
);
/** Wait until the board has settled at `expected` marks (annotate is async). */
async function waitForMarks(expected, timeout = 15_000) {
try {
await page.waitForFunction(
(want) => window.__gevAnnotations.count() === want,
{ timeout },
expected,
);
return true;
} catch {
return false;
}
}
try {
await page.setViewport({ width: 1280, height: 860 });
await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__gevDrawTool &&
window.__gevAnnotations &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90_000 },
);
await page.evaluate((view) => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
viewer.scene.tweens?.removeAll?.();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (view.lon * Math.PI) / 180,
latitude: (view.lat * Math.PI) / 180,
height: view.height,
}),
orientation: {
heading: (view.heading * Math.PI) / 180,
pitch: (view.pitch * Math.PI) / 180,
roll: 0,
},
});
viewer.scene.requestRender();
}, VIEW);
const settled = await page
.waitForFunction(
() => {
const primitives = window.__godsEyeView.viewer.scene.primitives;
for (let index = 0; index < primitives.length; index++) {
const primitive = primitives.get(index);
if (
typeof primitive.tilesLoaded === 'boolean' &&
!primitive.tilesLoaded
)
return false;
}
return true;
},
{ timeout: 90_000 },
)
.then(
() => true,
() => false,
);
check('visible tile content settles before drawing', settled);
// UTILITIES starts collapsed on a first run: open it the way a user does.
await page.evaluate(() => {
const panel = document.getElementById('utilities-panel');
if (panel?.classList.contains('collapsed'))
document
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
});
await page.waitForFunction(
() =>
document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0,
{ timeout: 10_000 },
);
check(
'the Draw control is on the DISPLAY rail and starts off',
await page.evaluate(() => {
const toggle = document.getElementById('draw-toggle');
return (
toggle?.getAttribute('aria-pressed') === 'false' &&
!document.getElementById('draw-mode-row')?.classList.contains('visible')
);
}),
);
// The viewer's OWN click actions, captured before the tool ever runs. Both
// must be absent while drawing and back — as the SAME functions — after.
await page.evaluate(() => {
const Cesium = window.__CESIUM__;
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
window.__qaStock = {
click:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
double:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null,
};
});
const stockActions = () =>
page.evaluate(() => {
const Cesium = window.__CESIUM__;
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
const live = {
click:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
double:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null,
};
return {
clickPresent: Boolean(live.click),
doublePresent: Boolean(live.double),
clickIsOriginal: live.click === window.__qaStock.click,
doubleIsOriginal: live.double === window.__qaStock.double,
hadClick: Boolean(window.__qaStock.click),
hadDouble: Boolean(window.__qaStock.double),
};
});
// ── enable / disable cycles leave nothing behind ────────────────────────
const before = await drawState();
for (let cycle = 0; cycle < 3; cycle += 1) {
await page.click('#draw-toggle');
await wait(120);
await page.click('#draw-toggle');
await wait(120);
}
const after = await drawState();
check(
'three enable/disable cycles add no listener, handler or preview data source',
after.domListeners === before.domListeners &&
after.previewDataSources === 1 &&
before.previewDataSources === 1 &&
after.sceneHandler === false &&
after.pointerOwner === null &&
after.stockDoubleClick === before.stockDoubleClick,
`listeners ${before.domListeners}→${after.domListeners}, previews ${after.previewDataSources}, ` +
`handler ${after.sceneHandler}, owner ${after.pointerOwner}, stock dblclick ${after.stockDoubleClick}`,
);
// ── the viewer's own click actions are borrowed, then given back ────────
await page.click('#draw-toggle');
const duringDraw = await stockActions();
check(
"Cesium's own selecting click and tracking double-click are both borrowed while drawing",
duringDraw.clickPresent === false && duringDraw.doublePresent === false,
`click present ${duringDraw.clickPresent}, double present ${duringDraw.doublePresent} ` +
`(viewer had click ${duringDraw.hadClick}, double ${duringDraw.hadDouble})`,
);
await page.click('#draw-toggle');
const afterDraw = await stockActions();
check(
'and both are restored as the SAME functions, not replacements',
afterDraw.clickPresent === duringDraw.hadClick &&
afterDraw.doublePresent === duringDraw.hadDouble &&
afterDraw.clickIsOriginal &&
afterDraw.doubleIsOriginal,
JSON.stringify(afterDraw),
);
// ── a pin, finished with Enter ──────────────────────────────────────────
await page.click('#draw-toggle');
await page.click('.pp-mode-btn[data-shape="pin"]');
await page.click('#draw-label-input');
await page.keyboard.type('Pin one');
await clickWorld(520, 330);
check(
'the pointer is claimed while a session is open',
(await drawState()).pointerOwner === 'draw',
);
await page.keyboard.press('Enter');
check(
'a pin placed by mouse and finished with Enter lands on the board',
await waitForMarks(1),
);
// ── a line along a road, with Backspace ─────────────────────────────────
await page.click('.pp-mode-btn[data-shape="line"]');
await clickWorld(360, 300);
await clickWorld(470, 380);
await clickWorld(600, 470);
const beforeBackspace = (await drawState()).vertices;
await page.keyboard.press('Backspace');
await wait(120);
const afterBackspace = (await drawState()).vertices;
check(
'Backspace removes the last vertex only',
beforeBackspace === 3 && afterBackspace === 2,
`${beforeBackspace} → ${afterBackspace}`,
);
await clickWorld(640, 500);
await page.click('#draw-label-input');
await page.keyboard.type('Market St');
await page.keyboard.press('Enter');
check(
'a line finished from the label field lands on the board',
await waitForMarks(2),
);
const lineMark = (await marks()).find((mark) =>
mark.label?.startsWith('Market St'),
);
check(
'a drawn line reports its length and never a travel time',
Boolean(lineMark) &&
/\d/.test(lineMark.label) &&
!/min|walk|drive/i.test(lineMark.label),
lineMark?.label,
);
await shot('02-line-along-a-road');
// ── an area, coloured and labelled, finished with a double-click ────────
await page.click('.pp-mode-btn[data-shape="area"]');
await page.select('#draw-color-select', 'amber');
const amberBefore = await amberPixels();
await page.click('#draw-label-input');
await page.keyboard.type('Civic Center block');
await clickWorld(400, 280);
await clickWorld(700, 290);
await clickWorld(720, 520);
await clickWorld(410, 500);
const areaVertices = (await drawState()).vertices;
check(
'four clicks on the globe are four vertices',
areaVertices === 4,
`${areaVertices} vertices`,
);
await page.mouse.click(410, 500, { count: 2 });
const areaLanded = await waitForMarks(3);
await wait(900);
const afterDoubleClick = await page.evaluate(() =>
window.__gevAnnotations.count(),
);
check('an area finished by double-click lands on the board', areaLanded);
check(
'a double-click finishes the shape exactly once',
afterDoubleClick === 3,
`${afterDoubleClick} marks`,
);
const areaMark = (await marks()).find(
(mark) => mark.label === 'Civic Center block',
);
check(
'the label and colour chosen in the panel are what is drawn',
areaMark?.color === 'amber',
JSON.stringify(areaMark),
);
await wait(1200);
const amberAfter = await amberPixels();
check(
'the finished area actually PAINTS — amber pixels appear on this surface',
amberAfter - amberBefore > 500,
`${amberBefore} → ${amberAfter} amber pixels (+${amberAfter - amberBefore})`,
);
await shot('01-area-with-label');
await shot('03-pins-and-shapes');
// ── Escape cancels the shape, a second Escape leaves draw mode ──────────
await page.evaluate(() => window.__gevDrawTool.cancel());
await clickWorld(500, 300);
await clickWorld(560, 340);
check('a new shape is in progress', (await drawState()).vertices === 2);
await page.keyboard.press('Escape');
await wait(150);
const afterFirstEscape = await drawState();
check(
'Escape cancels the shape in progress and stays in draw mode',
afterFirstEscape.vertices === 0 && afterFirstEscape.active === true,
`vertices ${afterFirstEscape.vertices}, active ${afterFirstEscape.active}`,
);
await page.keyboard.press('Escape');
await wait(150);
const afterSecondEscape = await drawState();
check(
'a second Escape leaves draw mode and gives the pointer back',
afterSecondEscape.active === false &&
afterSecondEscape.pointerOwner === null &&
afterSecondEscape.sceneHandler === false,
JSON.stringify(afterSecondEscape),
);
check(
'cancelling never removes a mark already on the board',
afterSecondEscape.marks === 3,
`${afterSecondEscape.marks} marks`,
);
// ── a click that would select something selects nothing while drawing ───
const target = await findClickableTarget();
check(
'a selectable layer entity is on screen to test click ownership against',
Boolean(target),
target
? `${target.layer} at ${target.x},${target.y}`
: 'no CCTV camera or bundled site could be reached — the ownership case cannot be proved',
);
if (target) {
await clickWorld(target.x, target.y, { settle: 900 });
const selectedNormally = await page.evaluate(() =>
Boolean(window.__godsEyeView.viewer.selectedEntity),
);
check(
`clicking a ${target.layer} selects it when nothing owns the pointer`,
selectedNormally,
);
await page.evaluate(() => {
window.__godsEyeView.viewer.selectedEntity = undefined;
});
await page.click('#draw-toggle');
await page.click('.pp-mode-btn[data-shape="area"]');
await clickWorld(target.x, target.y, { settle: 900 });
const whileDrawing = await drawState();
const selectedWhileDrawing = await page.evaluate(() =>
Boolean(window.__godsEyeView.viewer.selectedEntity),
);
check(
`clicking the same ${target.layer} while drawing adds a vertex and selects nothing`,
whileDrawing.vertices === 1 && selectedWhileDrawing === false,
`vertices ${whileDrawing.vertices}, selected ${selectedWhileDrawing}`,
);
await page.keyboard.press('Escape');
await page.keyboard.press('Escape');
await wait(150);
}
// ── what a reload actually does ─────────────────────────────────────────
const beforeReload = await page.evaluate(() =>
window.__gevAnnotations.count(),
);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(
() =>
window.__gevAnnotations &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90_000 },
);
const afterReload = await page.evaluate(() =>
window.__gevAnnotations.count(),
);
check(
'a reload starts an empty board — marks live for the session, not across reloads',
beforeReload > 0 && afterReload === 0,
`${beforeReload} before, ${afterReload} after`,
);
// ── Clear wipes the board ───────────────────────────────────────────────
await page.evaluate(() => {
const panel = document.getElementById('utilities-panel');
if (panel?.classList.contains('collapsed'))
document
.querySelector(
'.panel-collapse-btn[data-collapse-target="utilities-panel"]',
)
?.click();
});
await page.waitForFunction(
() =>
document.getElementById('draw-toggle')?.getBoundingClientRect().width > 0,
{ timeout: 10_000 },
);
// The reload built a new page context: re-capture the viewer's own click
// actions so the identity comparisons below still mean something.
await page.evaluate(() => {
const Cesium = window.__CESIUM__;
const stock = window.__godsEyeView.viewer.screenSpaceEventHandler;
window.__qaStock = {
click:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) || null,
double:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
null,
};
});
await page.evaluate((view) => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (view.lon * Math.PI) / 180,
latitude: (view.lat * Math.PI) / 180,
height: view.height,
}),
orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
});
}, VIEW);
await wait(2500);
await page.click('#draw-toggle');
await page.click('.pp-mode-btn[data-shape="area"]');
await clickWorld(420, 280);
await clickWorld(700, 300);
await clickWorld(660, 520);
await pressFinish();
const drewAgain = await waitForMarks(1);
check('a shape can be drawn again after a reload', drewAgain);
// Enter belongs to whatever the keyboard is on. With Clear focused it must
// PRESS CLEAR — the shape in progress goes, and so does the mark already on
// the board — and it must NOT quietly finish a second shape.
await clickWorld(430, 300);
await clickWorld(690, 320);
await clickWorld(670, 500);
const beforeButtonEnter = await drawState();
await page.focus('#draw-clear');
await page.keyboard.press('Enter');
await wait(600);
const afterButtonEnter = await drawState();
check(
'Enter on a keyboard-focused button activates the button, it does not finish the shape',
beforeButtonEnter.vertices === 3 &&
beforeButtonEnter.marks === 1 &&
afterButtonEnter.marks === 0 &&
afterButtonEnter.vertices === 0,
`before ${beforeButtonEnter.vertices} vertices / ${beforeButtonEnter.marks} marks, ` +
`after ${afterButtonEnter.vertices} / ${afterButtonEnter.marks} ` +
'(a finish would have made it 2 marks)',
);
// Put one back so the Clear BUTTON check below has something to remove.
await clickWorld(420, 280);
await clickWorld(700, 300);
await clickWorld(660, 520);
await pressFinish();
check(
'a shape can be drawn after the board was cleared from the keyboard',
await waitForMarks(1),
);
await page.click('#draw-clear');
await wait(400);
const cleared = await drawState();
check(
'Clear removes every mark from the board',
cleared.marks === 0,
`${cleared.marks} marks left`,
);
await shot('04-after-clear');
// ── a shape that describes nothing is refused, not placed ───────────────
const degenerateVertices = await page.evaluate(() => {
const tool = window.__gevDrawTool;
tool.cancel();
// Three points on one line, placed through the same addVertex the clicks use.
tool.addVertex(-122.42, 37.77);
tool.addVertex(-122.42, 37.771);
tool.addVertex(-122.42, 37.772);
return tool.session?.vertices?.length ?? null;
});
check(
'three collinear vertices are accepted into the session',
degenerateVertices === 3,
`${degenerateVertices} vertices`,
);
await pressFinish();
await wait(600);
const degenerate = await drawState();
check(
'three points in a line are refused with a reason, not placed',
degenerate.marks === 0 && /in a line/.test(degenerate.hint),
`${degenerate.marks} marks, hint "${degenerate.hint}"`,
);
// ── a shape drawn across the antimeridian lands where it was drawn ──────
await page.evaluate(() => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (179.9995 * Math.PI) / 180,
latitude: 0,
height: 3000,
}),
orientation: { heading: 0, pitch: (-88 * Math.PI) / 180, roll: 0 },
});
viewer.scene.requestRender();
});
await wait(2500);
await page.evaluate(() => {
const tool = window.__gevDrawTool;
tool.cancel();
tool.setShape('area');
// A ~200 m square straddling 180.
tool.addVertex(179.999, -0.001);
tool.addVertex(-179.999, -0.001);
tool.addVertex(-179.999, 0.001);
tool.addVertex(179.999, 0.001);
});
await pressFinish();
const datelineLanded = await waitForMarks(1);
const datelineAnchor = await page.evaluate(() => {
const mark = window.__gevAnnotations.list()[0];
return mark ? { lon: mark.anchor?.lon, lat: mark.anchor?.lat } : null;
});
check(
'a shape drawn across the antimeridian anchors there, not on the Greenwich meridian',
datelineLanded &&
datelineAnchor &&
Math.abs(Math.abs(datelineAnchor.lon) - 180) < 0.1 &&
Math.abs(datelineAnchor.lat) < 0.1,
JSON.stringify(datelineAnchor),
);
await page.evaluate(() => window.__gevAnnotations.clear());
await page.click('#draw-toggle');
await wait(200);
const finalState = await drawState();
check(
'leaving draw mode gives back the pointer, the handler and the double-click',
finalState.active === false &&
finalState.pointerOwner === null &&
finalState.sceneHandler === false &&
finalState.previewDataSources === 1,
JSON.stringify(finalState),
);
// ── destroy(): the shell disposing the tool leaves nothing behind ───────
const beforeDestroy = await page.evaluate(
() => window.__godsEyeView.viewer.dataSources.length,
);
await page.click('#draw-toggle'); // destroy from an ACTIVE session: the harder case
await wait(200);
const destroyed = await page.evaluate(async () => {
const tool = window.__gevDrawTool;
const wasActive = tool.active;
await tool.destroy();
await new Promise((resolve) => setTimeout(resolve, 50));
const Cesium = window.__CESIUM__;
const viewer = window.__godsEyeView.viewer;
const stock = viewer.screenSpaceEventHandler;
let previews = 0;
for (let i = 0; i < viewer.dataSources.length; i += 1)
if (viewer.dataSources.get(i)?.name === 'gev-draw-preview') previews += 1;
return {
wasActive,
diagnostics: tool.diagnostics(),
previews,
dataSources: viewer.dataSources.length,
handleGone: window.__gevDrawTool === undefined,
clickRestored:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) ===
window.__qaStock.click,
doubleRestored:
stock.getInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ===
window.__qaStock.double,
drawingClass: document.body.classList.contains('gev-drawing'),
};
});
check(
'destroy() from an active session releases the pointer, handler and listeners',
destroyed.wasActive === true &&
destroyed.diagnostics.destroyed === true &&
destroyed.diagnostics.pointerOwner === null &&
destroyed.diagnostics.sceneHandler === false &&
destroyed.diagnostics.domListeners === 0 &&
destroyed.drawingClass === false,
JSON.stringify(destroyed.diagnostics),
);
check(
'destroy() detaches the preview data source and gives the window handle back',
destroyed.previews === 0 &&
destroyed.dataSources === beforeDestroy - 1 &&
destroyed.handleGone,
`previews ${destroyed.previews}, dataSources ${beforeDestroy} → ${destroyed.dataSources}, handle gone ${destroyed.handleGone}`,
);
check(
"destroy() returns Cesium's own click actions",
destroyed.clickRestored && destroyed.doubleRestored,
`click ${destroyed.clickRestored}, double ${destroyed.doubleRestored}`,
);
check(
'no uncaught page errors across the run',
errors.length === 0,
errors.join(' | '),
);
} finally {
await browser.close();
}
console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0;
/**
* Find something on screen that a layer would select on click, so the ownership
* guard can be proved against a real selection rather than a stub. Bundled
* infrastructure first (no network, works keyless); CCTV cameras if they are up.
*/
async function findClickableTarget() {
// CCTV first: it is the layer the brief names, and it goes through the shared
// tracking click gesture that flights and military use too.
const camera = await page
.evaluate(async () => {
const manager = window.__godsEyeView.dataManager;
await manager.setEnabled('cctv', true, { origin: 'user' });
const module = manager.layers.get('cctv')?.module;
if (!module?.getUIState) return null;
for (let attempt = 0; attempt < 40; attempt += 1) {
const cameras = module.getUIState()?.cameras || [];
const found = cameras.find((entry) => {
const lat = entry.lat ?? entry.latitude;
const lon = entry.lon ?? entry.longitude;
return Number.isFinite(lat) && Number.isFinite(lon);
});
if (found)
return {
id: found.id,
lat: found.lat ?? found.latitude,
lon: found.lon ?? found.longitude,
};
await new Promise((resolve) => setTimeout(resolve, 500));
}
return null;
})
.catch(() => null);
if (camera) {
await page.evaluate((spot) => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (spot.lon * Math.PI) / 180,
latitude: (spot.lat * Math.PI) / 180,
height: 600,
}),
orientation: { heading: 0, pitch: (-80 * Math.PI) / 180, roll: 0 },
});
viewer.scene.requestRender();
}, camera);
await wait(4000);
const spot = await onScreen(
(entity) => typeof entity.id === 'string' && entity.id.startsWith('CAM-'),
);
if (spot) return { ...spot, layer: 'CCTV camera' };
}
// Bundled infrastructure as the keyless fallback: a different guarded handler,
// the same shared claim, and always present without a key.
const site = await page
.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
await window.__godsEyeView.dataManager.setEnabled(
'local-datacenters',
true,
{
origin: 'user',
},
);
const centreOf = (entity) => {
const time = viewer.clock.currentTime;
const point = entity.position?.getValue?.(time);
if (point) return point;
const hierarchy = entity.polygon?.hierarchy?.getValue?.(time);
if (hierarchy?.positions?.length) return hierarchy.positions[0];
const line = entity.polyline?.positions?.getValue?.(time);
return line?.length ? line[0] : null;
};
for (let attempt = 0; attempt < 40; attempt += 1) {
for (let i = 0; i < viewer.dataSources.length; i += 1) {
for (const entity of viewer.dataSources.get(i).entities.values) {
if (entity.__localLayerId !== 'local-datacenters') continue;
const point = centreOf(entity);
if (!point) continue;
const carto = window.Cesium
? window.Cesium.Cartographic.fromCartesian(point)
: viewer.scene.globe.ellipsoid.cartesianToCartographic(point);
if (!carto) continue;
return {
lon: (carto.longitude * 180) / Math.PI,
lat: (carto.latitude * 180) / Math.PI,
};
}
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
return null;
})
.catch(() => null);
if (!site) return null;
await page.evaluate((spot) => {
const viewer = window.__godsEyeView.viewer;
viewer.camera.cancelFlight?.();
viewer.camera.setView({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (spot.lon * Math.PI) / 180,
latitude: (spot.lat * Math.PI) / 180,
height: 2500,
}),
orientation: { heading: 0, pitch: (-88 * Math.PI) / 180, roll: 0 },
});
viewer.scene.requestRender();
}, site);
await wait(4000);
const spot = await onScreen(
(entity) => entity.__localLayerId === 'local-datacenters',
);
return spot ? { ...spot, layer: 'data centre' } : null;
}
/**
* Canvas coordinates of the first entity matching `belongs` that is on screen
* and clear of the side panels. Annotation marks and the draw tool's own
* preview vertices are on screen too, so the predicate has to be specific.
*/
async function onScreen(belongs, timeout = 20_000) {
return page
.waitForFunction(
(belongsSource) => {
// eslint-disable-next-line no-new-func
const belongsTo = new Function(`return (${belongsSource})`)();
const viewer = window.__godsEyeView.viewer;
const scene = viewer.scene;
const collections = [viewer.entities];
for (let i = 0; i < viewer.dataSources.length; i += 1) {
const source = viewer.dataSources.get(i);
if (source?.name === 'gev-draw-preview') continue;
collections.push(source.entities);
}
for (const source of collections) {
for (const entity of source.values) {
if (!belongsTo(entity)) continue;
const position = entity.position?.getValue?.(
viewer.clock.currentTime,
);
if (!position) continue;
const window2d = scene.cartesianToCanvasCoordinates(position);
if (!window2d) continue;
const { x, y } = window2d;
if (!(x > 300 && x < 900 && y > 180 && y < 600)) continue;
// Projecting into the window is not the same as being clickable:
// the entity may be beyond the horizon or behind a building. Only a
// spot where the scene actually picks it proves the control case.
const hit = scene.pick({ x: Math.round(x), y: Math.round(y) });
if (!hit?.id || !belongsTo(hit.id)) continue;
return { x: Math.round(x), y: Math.round(y) };
}
}
return false;
},
{ timeout, polling: 500 },
belongs.toString(),
)
.then(
(handle) => handle.jsonValue(),
() => null,
);
}