Files
gods-eye-view/scripts/qa-strict-harness.mjs
T
German Khokhlov 0ef29781e5 Fix the review findings across the engines, layers, data and QA
Navara now matches Cesium where the review found it diverging: a flight
holds the camera inputs, camera.changed counts turns as Cesium does,
render requests made during a frame survive it, follow pinches zoom,
ground widths follow the pixel ratio and Cyber sonar reaches glyph caches
and models. On Cesium, a staged imagery swap keeps loading tiles, setView
replaces a running flight, and a cleared weather detail goes away.

The contract gains MoverSet.failed, maps.terrainRevision,
render.maxTextureSize and surface.sampleTerrainMostDetailed on every
engine, so layers retry failed models, key road heights by terrain,
size weather textures to the GPU and place Bhote Koshi cards on detailed
terrain.

Engine switches keep the live view, follows, focus, layer options and
cards: a pick hands over the exact pose, applies no address options and
pans no carried-over card; a history visit still applies its entry.

Layers put GhostMaps pins, cable landings, OSM Places and directions on
the measured surface without per-frame sampling, draw the Cockpit contact
dot, hide disabled CCTV on attach, and keep ALPR running outside secure
contexts. KML imports draw raised polygon fills, rotated and coloured
ground overlays and north-referenced icon headings, and report files
with nothing to draw.

QA scripts read the contract instead of Cesium internals (test:track is
109/109 again), wait for the camera to rest before clicking, and give
cold switches and parked engines realistic time. Docs follow the code.
2026-11-09 21:34:42 +04:00

911 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The strict acceptance harness shared by every browser scenario that
// qa-all-layer-switches.mjs runs.
//
// - Network: a default-deny request policy on every page, frame and worker
// of the browser, installed before the target runs (CDP auto-attach with
// waitForDebuggerOnStart). Paid hosts and paid preview routes are blocked,
// then only the preview origin, the scenario's declared fixture URLs and
// the free tile hosts (answered from a disk cache) are allowed. A blocked
// request, a fixture or tile URL answered by the real network, or a socket
// to another origin is a violation and fails the scenario with its URL.
// - Coverage: the runner passes the layer ids (--layers=a,b). The scenario
// records each assertion it made per layer id and engine; finish() fails
// when a declared layer has no assertion on either engine, and writes the
// manifest (--evidence=FILE) the runner cross-checks against the catalog.
// - Engine picks: pickEngine() switches with real input on the picker (a
// pointer click on its label, then keyboard type-ahead), never page.select.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
import os from 'node:os';
import {
KEYLESS_PROBES,
probePattern,
strictBlockPatterns,
STRICT_TILE_HOSTS,
} from './qa-map-policy.mjs';
import { createMissLoader, tileCacheFile } from './qa/tileCache.mjs';
export const ENGINES = Object.freeze(['cesium', 'navara']);
/** What the Globe Engine picker offers: the engines and Navara HD. */
const CHOICES = Object.freeze([...ENGINES, 'navara-hd']);
/** Local ports that belong to the user's own servers: never a QA preview. */
export const RESERVED_PORTS = Object.freeze([
'',
...(process.env.QA_RESERVED_PORTS ?? '4173,4174,4185,4186,4187,4188,4189,4194,4196')
.split(',').map((port) => port.trim()).filter(Boolean),
]);
/** Chrome flags every scenario's browser starts with (the GPU path). */
export const CHROME_ARGS = Object.freeze([`--use-angle=${process.env.QA_ANGLE || 'metal'}`, '--enable-gpu']);
/** The values of --layers=a,b and --evidence=FILE (null when absent). */
export function strictArguments(argv = process.argv) {
const value = (name) =>
argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const layers = value('layers');
return {
layers: layers ? layers.split(',').filter(Boolean) : null,
evidence: value('evidence') ?? process.env.QA_STRICT_EVIDENCE ?? null,
};
}
/** Refuse anything but an unreserved local preview. */
export function localPreview(href) {
const url = new URL(href);
if (
!['localhost', '127.0.0.1'].includes(url.hostname) ||
RESERVED_PORTS.includes(url.port)
)
throw Error('Supply an unreserved local keyless preview URL');
return url;
}
/** Refuse to start a preview over an existing local server. */
export async function assertPreviewPortAvailable(href, request = fetch) {
const url = localPreview(href);
let response;
try {
response = await request(url.origin, { signal: AbortSignal.timeout(1000) });
} catch (error) {
if (error.cause?.code === 'ECONNREFUSED') return;
throw error;
}
if (response) throw new Error(`Preview port ${url.port} already answers`);
}
/** Check the running page before any scenario touches its layers. */
export async function assertKeylessPreview(page) {
await page.evaluate(() => {
if (window.__godsEyeView.tileset || window.__GOOGLE_MAPS_API_KEY__)
throw new Error('Keyless preview required');
});
}
/** The URL with no remote IP: fulfilled by a fixture or a cache, or a data URL. */
const live = (response) => Boolean(response.remoteIPAddress);
/**
* Follow every request of every target of `browser` under the strict policy.
* Returns the ledger the policy fills in.
*/
export async function installStrictNetwork(
browser,
{ origin, fixtures = [], routes = [], tileHosts = STRICT_TILE_HOSTS, ledger },
) {
const preview = new URL(origin);
fixtures = [...fixtures, ...routes.map((route) => route.pattern)];
const urlPatterns = strictBlockPatterns(origin, { fixtures, tileHosts });
const answers = routes.map((route) => ({
...route,
test: new URLPattern(route.pattern),
}));
const fixturePatterns = fixtures.map((p) => new URLPattern(p));
const isFixture = (url) => fixturePatterns.some((p) => p.test(url));
const root = await browser.target().createCDPSession();
const connection = root.connection();
const probes = KEYLESS_PROBES.map((probe) => ({
...probe,
test: new URLPattern(probePattern(origin, probe)),
}));
const misses = createMissLoader();
const cacheDir = path.join(os.tmpdir(), 'gev-qa-tiles');
fs.mkdirSync(cacheDir, { recursive: true });
const tiles = ledger.tileCache;
// One CDP Fetch handler per target: the keyless probes and the tile hosts
// (from the disk cache, filled on a miss). Nothing else is paused, so
// workers' own requests are never held.
const intercept = async (session, type) => {
session.on('Fetch.requestPaused', async ({ requestId, request }) => {
// Every answer allows any origin; `noStore` keeps it out of the cache.
const fulfil = (status, contentType, body, { noStore = false } = {}) =>
session.send('Fetch.fulfillRequest', {
requestId,
responseCode: status,
responseHeaders: [
...(contentType
? [{ name: 'Content-Type', value: contentType }]
: []),
...(noStore ? [{ name: 'Cache-Control', value: 'no-store' }] : []),
{ name: 'Access-Control-Allow-Origin', value: '*' },
],
...(body == null
? {}
: { body: Buffer.from(body).toString('base64') }),
});
try {
const route = answers.find((r) => r.test.test(request.url));
if (route) {
ledger.routes[route.pattern] =
(ledger.routes[route.pattern] ?? 0) + 1;
const answer = await route.respond(request);
return await fulfil(
answer.status ?? 200,
answer.contentType,
answer.body ?? '',
);
}
const probe = probes.find((p) => p.test.test(request.url));
if (probe) {
if (request.method !== probe.method && request.method !== 'HEAD') {
violation('probe-method', request.url, { method: request.method });
return await session.send('Fetch.failRequest', {
requestId,
errorReason: 'BlockedByClient',
});
}
ledger.probes[probe.pattern] =
(ledger.probes[probe.pattern] ?? 0) + 1;
return await fulfil(
probe.status,
probe.contentType,
probe.body ?? null,
{ noStore: true },
);
}
const accept = Object.entries(request.headers ?? {}).find(
([name]) => name.toLowerCase() === 'accept',
)?.[1];
const file =
request.method === 'GET'
? tileCacheFile(request.url, accept, cacheDir, tileHosts)
: null;
if (!file) {
violation('unhandled-intercept', request.url, { target: type });
return await session.send('Fetch.failRequest', {
requestId,
errorReason: 'BlockedByClient',
});
}
let entry;
if (fs.existsSync(`${file}.json`)) {
entry = {
...JSON.parse(fs.readFileSync(`${file}.json`, 'utf8')),
body: fs.readFileSync(file),
};
tiles.hits++;
} else {
entry = await misses.load(request.url, accept);
if (entry.status === 200) {
fs.writeFileSync(file, entry.body);
fs.writeFileSync(
`${file}.json`,
JSON.stringify({ status: 200, contentType: entry.contentType }),
);
tiles.fetched++;
} else tiles.failed++;
const host = new URL(request.url).host;
tiles.fetchedByHost[host] = (tiles.fetchedByHost[host] ?? 0) + 1;
}
await fulfil(entry.status, entry.contentType, entry.body);
} catch (error) {
if (
/Invalid InterceptionId|closed|detached/i.test(String(error?.message))
)
return void tiles.cancelled++;
tiles.failed++;
await session
.send('Fetch.failRequest', { requestId, errorReason: 'Failed' })
.catch(() => {});
}
});
await session
.send('Fetch.enable', {
patterns: [
...probes.map((probe) => ({
urlPattern: probePattern(origin, probe).replace(/\*$/, '') + '*',
})),
...tileHosts.map((host) => ({ urlPattern: `https://${host}/*` })),
...routes.map((route) => ({ urlPattern: route.pattern })),
],
})
.catch((error) =>
ledger.setupErrors.push(
type + ' Fetch.enable: ' + String(error?.message).slice(0, 120),
),
);
};
const violation = (kind, url, detail = {}) => {
ledger.violations.push({ kind, url: String(url).slice(0, 300), ...detail });
};
const setup = async (session, info) => {
const type = info.type;
const urls = new Map();
session.on('Network.requestWillBeSent', (event) => {
const { url } = event.request;
urls.set(event.requestId, url);
if (!/^https?:/.test(url)) return;
ledger.requests++;
const address = new URL(url);
if (address.origin === preview.origin) {
ledger.preview++;
if (address.pathname.startsWith('/api/')) {
const route = address.pathname.split('/').slice(0, 4).join('/');
ledger.previewApi[route] = (ledger.previewApi[route] ?? 0) + 1;
}
} else {
ledger.external[address.host] =
(ledger.external[address.host] ?? 0) + 1;
}
});
session.on('Network.loadingFailed', (event) => {
if (!event.blockedReason) return;
violation('blocked', urls.get(event.requestId) ?? '?', {
reason: event.blockedReason,
target: type,
resource: event.type,
});
});
session.on('Network.responseReceived', ({ response, type: resource }) => {
if (!/^https?:/.test(response.url)) return;
const address = new URL(response.url);
if (probes.some((p) => p.test.test(response.url)) && live(response))
violation('probe-live', response.url, { target: type });
if (address.origin === preview.origin) return;
if (!live(response)) {
if (isFixture(response.url)) ledger.fixtureResponses++;
return;
}
// A fixture or tile URL answered by the real network, or any other
// external host that slipped past the block (a frame navigation).
violation('live', response.url, {
remote: response.remoteIPAddress,
target: type,
resource,
});
});
session.on('Network.webSocketCreated', ({ url }) => {
const address = new URL(url);
if (address.host !== preview.host)
violation('socket', url, { target: type });
});
session.on('Target.attachedToTarget', (event) =>
attach(event.sessionId, event.targetInfo, event.waitingForDebugger),
);
const steps = [
session.send('Network.enable'),
session.send('Network.setBlockedURLs', { urlPatterns }),
session
.send('Target.setAutoAttach', {
autoAttach: true,
waitForDebuggerOnStart: true,
flatten: true,
})
.catch(() => {}),
];
// Workers have no Fetch domain; the page's interception answers their
// requests, and a live answer would still show up as a violation.
if (type === 'page' || type === 'iframe')
steps.push(intercept(session, type));
if (type === 'page') steps.push(trapCesiumGlobal(session, ledger));
await Promise.all(steps);
};
const attach = async (sessionId, info, waiting) => {
const session = connection.session(sessionId);
if (!session) return;
try {
await setup(session, info);
} catch (error) {
// A target that closes while it attaches has nothing left to load.
if (!/closed|detached|not found/i.test(String(error?.message)))
ledger.setupErrors.push(String(error?.message ?? error).slice(0, 200));
} finally {
if (waiting)
await session.send('Runtime.runIfWaitingForDebugger').catch(() => {});
}
};
root.on('Target.attachedToTarget', (event) =>
attach(event.sessionId, event.targetInfo, event.waitingForDebugger),
);
await root.send('Target.setAutoAttach', {
autoAttach: true,
waitForDebuggerOnStart: true,
flatten: true,
filter: [
{ type: 'page' },
{ type: 'iframe' },
{ type: 'worker' },
{ type: 'shared_worker' },
{ type: 'service_worker' },
{ type: 'tab', exclude: true },
{ type: 'browser', exclude: true },
],
});
return ledger;
}
/**
* The production build reads every `cesium` import from the global that
* /cesium/Cesium.js defines, and the page loads that script only when a
* Cesium globe first starts. A read of the global before then throws in the
* app (a Navara page has no Cesium at all), wherever the app catches it, so
* each page gets an accessor in place of the global: reads before the script
* has assigned it are logged with their stack in `ledger.cesiumBeforeLoad`
* and answer `undefined`, as production would.
*/
async function trapCesiumGlobal(session, ledger) {
const binding = '__gevStrictCesiumRead';
session.on('Runtime.bindingCalled', (event) => {
if (event.name !== binding) return;
const stack = String(event.payload).slice(0, 1500);
const known = ledger.cesiumBeforeLoad.find((read) => read.stack === stack);
if (known) known.count++;
else if (ledger.cesiumBeforeLoad.length < 50)
ledger.cesiumBeforeLoad.push({ stack, count: 1 });
});
await session.send('Runtime.enable');
await session.send('Runtime.addBinding', { name: binding });
await session.send('Page.enable');
await session.send('Page.addScriptToEvaluateOnNewDocument', {
source: `(() => {
if (window.top !== window) return;
const seen = new Set();
let sdk;
const report = () => {
const limit = Error.stackTraceLimit;
Error.stackTraceLimit = 12;
const stack = String(new Error('Cesium read before it loaded').stack);
Error.stackTraceLimit = limit;
const site = stack.split('\\n').slice(2, 6).join('\\n');
if (seen.has(site)) return;
seen.add(site);
window.${binding}?.(stack);
};
Object.defineProperty(window, 'Cesium', {
configurable: true,
get() {
if (sdk === undefined) report();
return sdk;
},
set(value) {
sdk = value;
},
});
})();`,
});
}
/** A new, empty network ledger. */
export function networkLedger() {
return {
requests: 0,
preview: 0,
previewApi: {},
external: {},
fixtureResponses: 0,
probes: {},
routes: {},
tileCache: {
hits: 0,
fetched: 0,
failed: 0,
cancelled: 0,
fetchedByHost: {},
},
violations: [],
setupErrors: [],
cesiumBeforeLoad: [],
};
}
/**
* Switch to `kind` with real input on the Globe Engine picker: a pointer
* click on its label (which focuses the select without opening its menu)
* and keyboard type-ahead. Opens the control panel with a pointer click if
* it is collapsed and collapses it again once the switch has settled
* (`settle: false` returns as soon as the picker reports the change;
* `afterInput` runs right after the key, before anything is read back).
*/
export async function pickEngine(
page,
kind,
{ settle = true, afterInput = null } = {},
) {
assert.ok(CHOICES.includes(kind), kind);
const expanded = () =>
page.$eval(
'#control-panel-toggle',
(el) => el.getAttribute('aria-expanded') === 'true',
);
const opened = !(await expanded());
if (opened) {
await page.click('#control-panel-toggle').catch((error) => {
throw Error(`cannot click the control panel toggle: ${error.message}`);
});
await page.waitForFunction(
() =>
document
.querySelector('#control-panel-toggle')
?.getAttribute('aria-expanded') === 'true',
);
}
// The picker stays focusable while busy: aria-busy marks a switch.
await page.waitForSelector(
'#globe-engine:not([disabled]):not([aria-busy="true"])',
{ visible: true, timeout: 90000 },
);
const from = await page.$eval('#globe-engine', (el) => el.value);
assert.notEqual(from, kind, `the picker already shows ${kind}`);
// The key typed below is `kind`'s first letter: it selects the next option
// starting with it (after `from` when `from` starts with it too). Each key
// is a change that starts a switch, so `kind` must be one key away
// (Cesium → Navara HD goes through Navara).
const values = await page.$$eval('#globe-engine option', (options) =>
options.map((option) => option.value),
);
const matches = values.filter((value) => value[0] === kind[0]);
assert.equal(
from[0] === kind[0]
? matches[(matches.indexOf(from) + 1) % matches.length]
: matches[0],
kind,
`${kind} is not one key away from ${from}`,
);
// Where a pointer can reach the picker: the label's own text (focuses the
// select without opening its menu), else the select itself.
const target = await page.$eval('#globe-engine', (el) => {
const box = el.closest('label').getBoundingClientRect();
const select = el.getBoundingClientRect();
// at: the change event (the person's input reached the page);
// idleAt: the picker enabled again after the switch.
window.__qaPick = { busy: false, changed: false, at: null, idleAt: null };
el.addEventListener(
'change',
() => {
window.__qaPick.changed = true;
window.__qaPick.at = performance.now();
},
{ once: true },
);
window.__qaPickObserver?.disconnect();
window.__qaPickObserver = new MutationObserver(() => {
const disabled = document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
);
if (disabled) window.__qaPick.busy = true;
else if (window.__qaPick.busy && window.__qaPick.idleAt == null)
window.__qaPick.idleAt = performance.now();
});
window.__qaPickObserver.observe(document.body, {
attributes: true,
attributeFilter: ['disabled', 'aria-busy'],
subtree: true,
});
const describe = (hit) =>
hit
? `${hit.tagName.toLowerCase()}#${hit.id}.${String(hit.className).slice(0, 60)}`
: 'nothing';
const label = {
x: box.left + Math.max(4, (select.left - box.left) / 2),
y: box.top + box.height / 2,
};
const onLabel = document.elementFromPoint(label.x, label.y);
if (
onLabel === el.closest('label') ||
(onLabel?.closest('label') === el.closest('label') && onLabel !== el)
)
return { ...label, via: 'label' };
const middle = {
x: select.left + select.width / 2,
y: select.top + select.height / 2,
};
const onSelect = document.elementFromPoint(middle.x, middle.y);
if (onSelect === el)
return { ...middle, via: 'select', labelCoveredBy: describe(onLabel) };
return { covered: describe(onLabel) + ' / ' + describe(onSelect) };
});
assert.ok(!target.covered, 'the picker is covered by ' + target.covered);
await page.mouse.click(target.x, target.y);
assert.equal(
await page.evaluate(() => document.activeElement?.id),
'globe-engine',
'the click focuses the picker',
);
await page.keyboard.type(kind[0]);
// Whatever the person does next, right after the key (no round trip to
// the page in between).
if (afterInput) await afterInput();
// The change starts a cold switch at once: Cesium.js then runs as one
// task, seconds long under CPU throttling, before the page answers.
await page.waitForFunction(() => window.__qaPick?.changed, {
timeout: 30000,
polling: 50,
});
// Headless Chrome opens no menu for a click on the select: the key
// events above reached the page, so there is nothing to dismiss.
const how = {
opened,
clicked: target.via,
...(target.labelCoveredBy ? { labelCoveredBy: target.labelCoveredBy } : {}),
};
if (!settle) return how;
await page.waitForFunction(
() =>
(window.__qaPick.busy &&
!document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
) &&
document.querySelector('#loading-screen.hidden') &&
!document.querySelector('.engine-switch-frame')) ||
document.querySelector('.engine-switch-retry'),
{ timeout: 120000, polling: 50 },
);
await page.evaluate(() => window.__qaPickObserver?.disconnect());
Object.assign(
how,
await page.evaluate(() => ({
at: window.__qaPick.at,
idleAt: window.__qaPick.idleAt,
})),
);
if (opened && (await expanded().catch(() => false))) {
await page.click('#control-panel-toggle');
await page.waitForFunction(
() =>
document
.querySelector('#control-panel-toggle')
?.getAttribute('aria-expanded') !== 'true',
);
}
return how;
}
/** Wait until `kind` runs, its picker is idle and its view is restored. */
export async function waitForEngine(page, kind, { timeout = 120000 } = {}) {
await page.waitForFunction(
(expected) =>
// Navara HD runs the Navara engine.
window.__godsEyeView?.engine?.kind ===
(expected === 'cesium' ? 'cesium' : 'navara') &&
document.querySelector('#globe-engine')?.value === expected &&
!document.querySelector(
'#globe-engine:is([disabled], [aria-busy="true"])',
) &&
!document.querySelector('.engine-switch-frame') &&
document.querySelector('#loading-screen.hidden'),
{ timeout },
kind,
);
await page.evaluate(
() => window.__godsEyeView.styleManager.initialRestorePromise,
);
await assertKeylessPreview(page);
}
/**
* A new 1024×768 page of `browser` whose uncaught errors (and, with
* `shaderErrors`, shader and WebGL program errors on the console) land in
* `errors`; each uncaught error is also logged without its query string.
*/
export async function openPage(browser, { shaderErrors = false } = {}) {
const page = await browser.newPage();
await page.setViewport({ width: 1024, height: 768 });
const errors = [];
page.on('pageerror', (e) => {
errors.push(e.message);
console.log('error', e.message.split('?')[0]);
});
if (shaderErrors)
page.on('console', (msg) => {
if (msg.type() === 'error' && /Shader|WebGLProgram/i.test(msg.text()))
errors.push(msg.text());
});
return { page, errors };
}
/**
* The strict context of one scenario run.
*
* @param {object} options
* @param {string} options.origin The preview origin.
* @param {string[]} options.layers The layer ids this scenario covers (the
* runner's --layers wins when given; both must agree).
* @param {string[]} [options.fixtures] URLPattern strings for external
* requests the scenario answers itself.
* @param {{pattern: string, respond: (request: object) => ({status?: number,
* contentType?: string, body?: string|Buffer}|Promise<object>)}[]} [options.routes]
* External URLs the harness answers for the scenario (the pattern is both
* a URLPattern and a CDP Fetch glob, e.g. `https://i.ytimg.com/*`).
* @param {readonly string[]} [options.tileHosts] Hosts the disk cache
* answers (default STRICT_TILE_HOSTS).
* @param {string} [options.name] Scenario name for the manifest.
*/
export function createStrict({
origin,
layers,
fixtures = [],
routes = [],
tileHosts = STRICT_TILE_HOSTS,
name = path.basename(process.argv[1] ?? 'scenario', '.mjs'),
}) {
const args = strictArguments();
if (args.layers)
assert.deepEqual(
[...args.layers].sort(),
[...layers].sort(),
`${name} covers ${layers.join(',')}, the runner passed ${args.layers.join(',')}`,
);
const declared = args.layers ?? layers;
const ledger = networkLedger();
const assertions = [];
const picks = [];
const timings = [];
const extra = {};
let finished = false;
const record = (ids, engine, claim, detail) => {
for (const id of [ids].flat()) {
assert.ok(declared.includes(id), `${id} is not declared by ${name}`);
assert.ok(ENGINES.includes(engine), `unknown engine ${engine}`);
assertions.push({
layer: id,
engine,
claim,
...(detail === undefined ? {} : { detail }),
});
}
};
const engineOf = (page) =>
page.evaluate(() => window.__godsEyeView?.engine?.kind ?? null);
const strict = {
name,
layers: declared,
ledger,
extra,
timings,
/**
* Launch headless Chrome with the policy on every target; `args` adds
* flags to CHROME_ARGS.
*/
async launch({ args = [], ...options } = {}) {
const browser = await puppeteer.launch({
headless: true,
...options,
args: [...CHROME_ARGS, ...args],
});
await installStrictNetwork(browser, {
origin,
fixtures,
routes,
tileHosts,
ledger,
});
return browser;
},
/** Launch with `options`, run `body(browser)`, close, then finish(). */
async run(options, body) {
const browser = await strict.launch(options);
let failed = null;
try {
await body(browser);
} catch (error) {
failed = error;
} finally {
await browser.close().catch(() => {});
}
await strict.finish({ failed });
if (failed) throw failed;
},
/** Record an assertion that already passed. */
record,
/** Record a passed assertion on the engine the page runs now. */
async passed(page, ids, claim, detail) {
const engine = await engineOf(page);
record(ids, engine, claim, detail);
},
/** Assert `value` and record it on the engine the page runs now. */
async check(page, ids, claim, value, detail) {
assert.ok(
value,
claim + (detail === undefined ? '' : ' ' + JSON.stringify(detail)),
);
await strict.passed(page, ids, claim, detail);
},
/** Switch with real input and log the pick. */
async pick(page, kind, options) {
const result = await pickEngine(page, kind, options);
picks.push({ to: kind, via: 'pointer+keyboard', ...result });
return result;
},
/**
* Switch through the select element without input, only where no
* person can reach the picker (`reason` says why); logged as such.
*/
async selectEngine(page, kind, reason) {
assert.ok(reason, 'say why the picker cannot be used');
await page.select('#globe-engine', kind);
picks.push({ to: kind, via: 'page.select', reason });
},
/**
* Switch with real input in the same document and wait until `kind`
* runs.
*/
async switchTo(page, kind) {
const origin = await page.evaluate(() => performance.timeOrigin);
await strict.pick(page, kind);
await waitForEngine(page, kind);
assert.equal(
await page.evaluate(() => performance.timeOrigin),
origin,
'same document after switching globes',
);
},
/** Write the manifest; fail on violations or missing coverage. */
async finish({ failed = null } = {}) {
if (finished) return;
finished = true;
const coverage = Object.fromEntries(
declared.map((id) => [
id,
Object.fromEntries(
ENGINES.map((engine) => [
engine,
assertions.filter((a) => a.layer === id && a.engine === engine)
.length,
]),
),
]),
);
const missing = Object.entries(coverage).flatMap(([id, byEngine]) =>
ENGINES.filter((engine) => !byEngine[engine]).map(
(engine) => `${id} on ${engine}`,
),
);
const network = {
requests: ledger.requests,
preview: ledger.preview,
previewApi: ledger.previewApi,
external: ledger.external,
fixtureResponses: ledger.fixtureResponses,
probes: ledger.probes,
routes: ledger.routes,
tileCache: ledger.tileCache,
violations: ledger.violations,
setupErrors: ledger.setupErrors,
cesiumBeforeLoad: ledger.cesiumBeforeLoad,
fixtures,
tileHosts: [...tileHosts],
};
const manifest = {
scenario: name,
argv: process.argv.slice(2),
status: failed ? 'fail' : 'pass',
error: failed ? String(failed?.stack ?? failed).slice(0, 2000) : null,
layers: declared,
coverage,
missing,
picks,
assertions,
network,
timings,
...extra,
};
if (args.evidence) {
fs.mkdirSync(path.dirname(args.evidence), { recursive: true });
fs.writeFileSync(
args.evidence,
JSON.stringify(manifest, null, 2) + '\n',
);
}
console.log(
'STRICT',
JSON.stringify({
scenario: name,
assertions: assertions.length,
coverage,
picks: picks.length,
requests: ledger.requests,
violations: ledger.violations.length,
cesiumBeforeLoad: ledger.cesiumBeforeLoad.length,
}),
);
for (const v of ledger.violations.slice(0, 20))
console.log('NETWORK VIOLATION', JSON.stringify(v));
for (const read of ledger.cesiumBeforeLoad.slice(0, 10))
console.log('CESIUM BEFORE LOAD', JSON.stringify(read));
if (failed) return;
assert.equal(
ledger.violations.length,
0,
'network policy violations: ' +
JSON.stringify(ledger.violations.slice(0, 5)),
);
assert.deepEqual(
ledger.cesiumBeforeLoad,
[],
'the page read the Cesium global before Cesium.js loaded',
);
assert.deepEqual(missing, [], 'layers without an assertion');
assert.ok(
picks.some((pick) => pick.via === 'pointer+keyboard'),
'switch at least once with real input on the picker',
);
},
};
return strict;
}
/** Click uncovered canvas where the engine picks nothing within the radius. */
export async function clickEmptyGround(
page,
{ left, top, right, bottom, step = 9, radius = 6 },
) {
const handle = await page.waitForFunction(
({ left, top, right, bottom, step, radius }) => {
const { engine } = window.__godsEyeView;
const canvas = engine.host.canvas;
const box = canvas.getBoundingClientRect();
for (let y = top; y < bottom; y += step)
for (let x = left; x < right; x += step) {
if (
!canvas.contains(
document.elementFromPoint(box.left + x, box.top + y),
)
)
continue;
const free = [
[0, 0],
[radius, 0],
[-radius, 0],
[0, radius],
[0, -radius],
].every(([dx, dy]) => !engine.picker.pick({ x: x + dx, y: y + dy }));
if (free) return { x: box.left + x, y: box.top + y };
}
return false;
},
{ timeout: 15000 },
{ left, top, right, bottom, step, radius },
);
try {
const at = await handle.jsonValue();
await page.mouse.click(at.x, at.y);
} finally {
await handle.dispose();
}
}
/**
* Wait until no flight holds the camera and it stays still. A selection's
* camera transfer is a flight; on both engines a flight turns the camera
* inputs off, and a click does not cut it short.
*/
export async function waitForCameraAtRest(page, { timeout = 15000 } = {}) {
await page.evaluate(() => {
window.__qaRestPose = null;
});
await page.waitForFunction(
() => {
const { engine } = window.__godsEyeView;
const { x, y, z } = engine.camera.pose().position;
const pose = [x, y, z].map((value) => value.toFixed(2)).join();
const still = window.__qaRestPose === pose;
window.__qaRestPose = pose;
return still && engine.input.cameraInputsEnabled();
},
{ timeout, polling: 100 },
);
}