mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Layer display tests no longer re-check Cesium primitives in every layer: they run on the fake engine, and one Cesium translation check per ground kind lives in the Cesium engine's ground tests. Their harnesses are shared, and the fake-engine, Cesium-engine and flights tests that repeated the contract suite are gone, 340 tests in all. The press-travel measurement is shared by the selection dispatcher and CCTV, which loses its ScreenSpaceEventHandler shim and world-click module. ALPR, installations and bikeshare share one selection keeper and selected marker write. Layers with their own click rules keep their handlers. Navara's pulse is a class like the ground pass, the ground and imagery passes share the surface uniforms, and the colour, log-depth and occlusion GLSL lives in one chunk module, each caller keeping its depth bias. The ground suite runs inside the contract suite (--only=ground:), and fixture and script names say what they test. Behaviour is unchanged on both engines.
254 lines
8.9 KiB
JavaScript
254 lines
8.9 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Run the globe contract suite (src/globe/testing/contractSuite.mjs) in
|
|
* headless Chrome against the real engines of a running app, the way
|
|
* contract.test.mjs runs it against the fake engine.
|
|
*
|
|
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4241
|
|
* node scripts/qa-contract-suite.mjs http://localhost:4241 [--engine=cesium,navara] [--json=FILE]
|
|
*
|
|
* Serve the app keyless from the dev server: Google and Cesium ion requests
|
|
* are refused here anyway, so neither engine draws photoreal tiles. The page
|
|
* imports the suite from the dev server, so it shares the app's modules (an
|
|
* engine event is recognised by the module that made it). It gets a real
|
|
* pointer (the pick checks), real 2D canvases for sprite atlases, and no map
|
|
* sources (the maps checks would ask for photoreal tiles). Exits 1 when any check fails on any engine.
|
|
*/
|
|
import fs from 'node:fs';
|
|
import puppeteer from 'puppeteer';
|
|
|
|
const BLOCKED_HOSTS =
|
|
/(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/;
|
|
|
|
const args = process.argv.slice(2);
|
|
const origin = args.find((arg) => !arg.startsWith('--'));
|
|
const option = (name) =>
|
|
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
if (!origin) {
|
|
console.error(
|
|
'Usage: node scripts/qa-contract-suite.mjs <origin> [--engine=cesium,navara] [--only=prefix,...] [--json=FILE]',
|
|
);
|
|
process.exit(2);
|
|
}
|
|
const engines = (option('engine') ?? 'cesium,navara').split(',');
|
|
// --only=prefix,prefix runs just the checks whose names start with one.
|
|
const only = option('only')?.split(',') ?? null;
|
|
|
|
// Checks of state the running app drives itself, by name prefix, with the
|
|
// owner. The app sets contact sonar on every frame (src/cyberSonarEngine.js),
|
|
// so a value the suite sets does not survive to its next frame.
|
|
const APP_OWNED = Object.freeze([
|
|
['render.setContactSonar', 'src/cyberSonarEngine.js sets it every frame'],
|
|
]);
|
|
|
|
async function runOn(browser, kind) {
|
|
const page = await browser.newPage();
|
|
await page.setViewport({ width: 1280, height: 800 });
|
|
const pageErrors = [];
|
|
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
await page.setRequestInterception(true);
|
|
page.on('request', (request) => {
|
|
const { hostname } = new URL(request.url());
|
|
if (BLOCKED_HOSTS.test(hostname)) request.abort();
|
|
else request.continue();
|
|
});
|
|
// A view in the link: the app starts there instead of flying to a city.
|
|
await page.goto(
|
|
`${origin}/?welcome=0&engine=${kind}#v=2&lat=10&lon=20&alt=1000000&heading=0&pitch=-90&map=osm&l=`,
|
|
);
|
|
await page.waitForFunction(
|
|
(expected) =>
|
|
window.__godsEyeView?.engine?.kind === expected &&
|
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
|
// A cold dev server compiles Navara's modules on the first visit.
|
|
{ timeout: 300_000, polling: 250 },
|
|
kind,
|
|
);
|
|
// The suite owns the globe: no camera flight running, and the app's panels
|
|
// let the pointer through to the canvas.
|
|
// The app restores the view in the link with a flight that can start
|
|
// after the loading screen goes: wait until the camera is there and still,
|
|
// or that flight lands in the middle of a check.
|
|
await page.waitForFunction(
|
|
() => {
|
|
const { camera } = window.__godsEyeView.engine;
|
|
return (
|
|
!camera.flightActive() && Math.abs(camera.height() - 1_000_000) < 1000
|
|
);
|
|
},
|
|
{ timeout: 120_000, polling: 250 },
|
|
);
|
|
// Only the engine's canvas takes the pointer: the world overlay draws on a
|
|
// canvas of its own above it.
|
|
await page.evaluate(() =>
|
|
window.__godsEyeView.engine.host.canvas.setAttribute(
|
|
'data-contract-target',
|
|
'',
|
|
),
|
|
);
|
|
await page.addStyleTag({
|
|
content:
|
|
'* { pointer-events: none !important; } [data-contract-target] { pointer-events: auto !important; }',
|
|
});
|
|
// The globe drawn and its tiles loaded: a restored view hides the globe
|
|
// for a moment after the loading screen goes.
|
|
await page.evaluate(async () => {
|
|
const { engine } = window.__godsEyeView;
|
|
const canvas = engine.host.canvas;
|
|
const probe = document.createElement('canvas');
|
|
probe.width = 1;
|
|
probe.height = 1;
|
|
const context = probe.getContext('2d', { willReadFrequently: true });
|
|
const started = performance.now();
|
|
let settled = 0;
|
|
while (performance.now() - started < 120_000 && settled < 20) {
|
|
await new Promise((resolve) => {
|
|
const remove = engine.frames.postRender.addEventListener(() => {
|
|
remove();
|
|
resolve();
|
|
});
|
|
engine.render.request('contract-suite-settle');
|
|
});
|
|
context.drawImage(
|
|
canvas,
|
|
canvas.width / 2,
|
|
canvas.height / 2,
|
|
1,
|
|
1,
|
|
0,
|
|
0,
|
|
1,
|
|
1,
|
|
);
|
|
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
|
|
settled =
|
|
r + g + b > 60 && engine.surface.tilesLoaded() ? settled + 1 : 0;
|
|
}
|
|
});
|
|
// A real left click and pointer move at a canvas position.
|
|
await page.exposeFunction('__contractPointer', async (type, x, y) => {
|
|
const box = await page.evaluate(() => {
|
|
const rect =
|
|
window.__godsEyeView.engine.host.canvas.getBoundingClientRect();
|
|
return { left: rect.left, top: rect.top };
|
|
});
|
|
const at = [box.left + x, box.top + y];
|
|
if (type === 'click') await page.mouse.click(...at);
|
|
else {
|
|
await page.mouse.move(at[0] - 1, at[1]);
|
|
await page.mouse.move(...at);
|
|
}
|
|
});
|
|
const results = await page.evaluate(
|
|
async (appOwned, only) => {
|
|
const { engine } = window.__godsEyeView;
|
|
const waitFrames = (count) =>
|
|
new Promise((resolve) => {
|
|
let left = count;
|
|
const remove = engine.frames.postRender.addEventListener(() => {
|
|
if (--left > 0) return;
|
|
remove();
|
|
resolve();
|
|
});
|
|
engine.render.request('contract-suite');
|
|
});
|
|
const pointer = {
|
|
click: (x, y) => window.__contractPointer('click', x, y),
|
|
move: (x, y) => window.__contractPointer('move', x, y),
|
|
};
|
|
const { createContractSuite } =
|
|
await import('/src/globe/testing/contractSuite.mjs');
|
|
const outcome = [];
|
|
// A real 2D canvas: a sprite atlas the engines can draw and pick.
|
|
const makeCanvas = (width, height) =>
|
|
Object.assign(document.createElement('canvas'), { width, height });
|
|
// RGBA of the last drawn frame (both engines keep their drawing buffer).
|
|
const scratch = document.createElement('canvas');
|
|
const context = scratch.getContext('2d', { willReadFrequently: true });
|
|
const readRegion = (x, y, width, height) => {
|
|
const canvas = engine.host.canvas;
|
|
const scale = canvas.width / canvas.clientWidth;
|
|
scratch.width = width;
|
|
scratch.height = height;
|
|
context.clearRect(0, 0, width, height);
|
|
context.drawImage(
|
|
canvas,
|
|
x * scale,
|
|
y * scale,
|
|
width * scale,
|
|
height * scale,
|
|
0,
|
|
0,
|
|
width,
|
|
height,
|
|
);
|
|
return context.getImageData(0, 0, width, height).data;
|
|
};
|
|
for (const { name, run } of createContractSuite(engine, {
|
|
waitFrames,
|
|
pointer,
|
|
readRegion,
|
|
makeCanvas,
|
|
})) {
|
|
if (only && !only.some((prefix) => name.startsWith(prefix))) continue;
|
|
const owner = appOwned.find(([prefix]) => name.startsWith(prefix));
|
|
if (owner) {
|
|
outcome.push({ name, skipped: owner[1] });
|
|
continue;
|
|
}
|
|
try {
|
|
await run();
|
|
outcome.push({ name, ok: true });
|
|
} catch (error) {
|
|
outcome.push({
|
|
name,
|
|
ok: false,
|
|
error: String(error?.message ?? error),
|
|
});
|
|
}
|
|
}
|
|
return outcome;
|
|
},
|
|
APP_OWNED,
|
|
only,
|
|
);
|
|
await page.close();
|
|
return { engine: kind, results, pageErrors };
|
|
}
|
|
|
|
const browser = await puppeteer.launch({
|
|
headless: true,
|
|
// A Navara page can hold its main thread for minutes while it starts.
|
|
protocolTimeout: 600_000,
|
|
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
|
|
});
|
|
const report = [];
|
|
try {
|
|
for (const kind of engines) {
|
|
const run = await runOn(browser, kind);
|
|
report.push(run);
|
|
const ran = run.results.filter(({ skipped }) => !skipped);
|
|
const failed = ran.filter(({ ok }) => !ok);
|
|
console.log(
|
|
`${kind}: ${ran.length - failed.length}/${ran.length} checks pass`,
|
|
);
|
|
for (const { name, skipped } of run.results)
|
|
if (skipped) console.log(` skipped ${name}: ${skipped}`);
|
|
for (const { name, error } of failed)
|
|
console.log(` FAIL ${name}: ${error}`);
|
|
for (const message of run.pageErrors)
|
|
console.log(` page error: ${message}`);
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
const json = option('json');
|
|
if (json) fs.writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`);
|
|
process.exit(
|
|
report.every(({ results }) =>
|
|
results.every(({ ok, skipped }) => ok || skipped),
|
|
)
|
|
? 0
|
|
: 1,
|
|
);
|