Move QA scripts to the shared harness, batch 4 of 4

As in the previous batches. Scripts launching Chrome themselves go from
99 to 8 (all outside this conversion), fixed pauses from 86 to 69 and
Cesium-only reads from 75 to 46; the QA harness baseline records them.
This commit is contained in:
German Khokhlov committed 2026-11-10 16:43:58 +04:00
1 parent 53b07ba91f
commit 6c416f3fe6
25 files changed
+1006 -1171

No files matched your search

+5 -5
View File
@@ -1,9 +1,9 @@
{ {
"metrics": { "metrics": {
"ownLaunch": 99, "ownLaunch": 8,
"fixedSleeps": 86, "fixedSleeps": 69,
"loadingScreenReadiness": 1, "loadingScreenReadiness": 0,
"cesiumOnlyReads": 75, "cesiumOnlyReads": 46,
"ownWaitHelpers": 19 "ownWaitHelpers": 16
} }
} }
+17 -53
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
import { evaluateWithGeo } from './qa/geometry.mjs';
import { whenSettled, whenTilesLoaded, whenUiSettled } from './qa/ready.mjs';
/** /**
* qa-traffic-preset-ab.mjs — A/B screenshot capture for preset-aware * qa-traffic-preset-ab.mjs — A/B screenshot capture for preset-aware
* traffic dot styling (owner field finding 2026-07-23: NVG/FLIR/CRT * traffic dot styling (owner field finding 2026-07-23: NVG/FLIR/CRT
@@ -23,7 +25,7 @@
* param not adopted by the layer). * param not adopted by the layer).
*/ */
import puppeteer from 'puppeteer'; import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -69,52 +71,20 @@ const STYLES = [
{ id: 'retro', name: 'retro', label: 'CRT', profile: 'crt' }, { id: 'retro', name: 'retro', label: 'CRT', profile: 'crt' },
]; ];
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
// Chrome: /Applications auto-updates underneath the harnesses, and its
// software-GL behavior shifts across majors (system Chrome 150 blew the
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
].filter(Boolean);
const findChromeExecutable = () =>
CHROME_EXECUTABLE_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } }) || null;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Same pattern as qa-traffic-jamviz-ab: wait for the 3D tileset to settle. */ /** Same pattern as qa-traffic-jamviz-ab: wait for the 3D tileset to settle. */
function waitForTilesLoaded(page, timeoutMs) {
return page.waitForFunction(
() => {
const prims = window.__godsEyeView.viewer.scene.primitives;
for (let i = 0; i < prims.length; i++) {
const p = prims.get(i);
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
return p.tilesLoaded === true;
}
}
return true;
},
{ timeout: timeoutMs }
).then(() => true).catch(() => false);
}
/** Enable traffic + teleport, then poll the layer until settled. */ /** Enable traffic + teleport, then poll the layer until settled. */
function settleTraffic(page, view, { minCount = 100, timeoutS = 45 } = {}) { function settleTraffic(page, view, { minCount = 100, timeoutS = 45 } = {}) {
return page.evaluate(async (v, minC, tS) => { return evaluateWithGeo(page, async (qaGeo, v, minC, tS) => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
await gev.dataManager.setEnabled('traffic', true); await gev.dataManager.setEnabled('traffic', true);
const mod = gev.dataManager.layers.get('traffic').module; const mod = gev.dataManager.layers.get('traffic').module;
try { gev.viewer.camera.cancelFlight(); } catch { /* no flight */ } try { window.__godsEyeView.engine.camera.cancelFlight(); } catch { /* no flight */ }
const ell = gev.viewer.scene.globe.ellipsoid;
const d2r = Math.PI / 180; const d2r = Math.PI / 180;
gev.viewer.camera.setView({ window.__godsEyeView.engine.camera.setView({
destination: ell.cartographicToCartesian({ destination: qaGeo.cartographic.toCartesian({
longitude: v.lon * d2r, latitude: v.lat * d2r, height: v.height, longitude: v.lon * d2r, latitude: v.lat * d2r, height: v.height,
}), }),
orientation: { orientation: {
@@ -161,15 +131,9 @@ async function main() {
} }
fs.mkdirSync(OUT_DIR, { recursive: true }); fs.mkdirSync(OUT_DIR, { recursive: true });
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: 'new', headless: true,
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}), args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1600,900'],
args: [
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
'--disable-dev-shm-usage', '--disable-web-security',
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
'--window-size=1600,900',
],
}); });
const manifest = []; const manifest = [];
@@ -180,10 +144,10 @@ async function main() {
console.log('Loading app...'); console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction( await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager,
{ timeout: 60000 }, { timeout: 60000 },
); );
await sleep(1500); await whenSettled(page);
let firstView = true; let firstView = true;
for (const view of VIEWS) { for (const view of VIEWS) {
@@ -201,7 +165,7 @@ async function main() {
exitCode = 1; exitCode = 1;
continue; continue;
} }
await waitForTilesLoaded(page, firstView ? 30000 : 15000); await whenTilesLoaded(page, { timeoutMs: firstView ? 30000 : 15000 }).then(() => true).catch(() => false);
firstView = false; firstView = false;
for (const style of STYLES) { for (const style of STYLES) {
@@ -213,7 +177,7 @@ async function main() {
sm.setStyle(s.name, { applyPreset: true }); sm.setStyle(s.name, { applyPreset: true });
if (sm.stages.thermal) sm.stages.thermal.uniforms.palette = s.ironbow ? 1.0 : 0.0; if (sm.stages.thermal) sm.stages.thermal.uniforms.palette = s.ironbow ? 1.0 : 0.0;
}, style); }, style);
await sleep(1800); // 500 ms intensity lerp + settle await whenUiSettled(page); // 500 ms intensity lerp + settle
// Round 2 legs: dots alone (DETECT off) vs dots + bucket-colored // Round 2 legs: dots alone (DETECT off) vs dots + bucket-colored
// brackets (DENSE) — presetDots stays at its default 'on'. // brackets (DENSE) — presetDots stays at its default 'on'.
@@ -221,7 +185,7 @@ async function main() {
await page.evaluate((d) => { await page.evaluate((d) => {
window.__godsEyeView.styleManager._setDetectionMode(d === 'on' ? 'DENSE' : 'OFF'); window.__godsEyeView.styleManager._setDetectionMode(d === 'on' ? 'DENSE' : 'OFF');
}, detect); }, detect);
await sleep(1200); // label-arbiter solve + a few rendered frames await whenUiSettled(page); // label-arbiter solve + a few rendered frames
const final = await page.evaluate(() => { const final = await page.evaluate(() => {
const mod = window.__godsEyeView.dataManager.layers.get('traffic').module; const mod = window.__godsEyeView.dataManager.layers.get('traffic').module;
@@ -272,14 +236,14 @@ async function main() {
sm._setDetectionMode('OFF'); sm._setDetectionMode('OFF');
if (sm.stages.thermal) sm.stages.thermal.uniforms.palette = 0.0; if (sm.stages.thermal) sm.stages.thermal.uniforms.palette = 0.0;
}); });
await sleep(1000); await whenUiSettled(page);
} }
} catch (e) { } catch (e) {
console.error('Harness error:', e); console.error('Harness error:', e);
exitCode = 3; exitCode = 3;
} finally { } finally {
fs.writeFileSync(path.join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2)); fs.writeFileSync(path.join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2));
await browser.close(); await closeBrowser(browser);
} }
console.log(`\nShots + manifest.json → ${OUT_DIR}`); console.log(`\nShots + manifest.json → ${OUT_DIR}`);
process.exit(exitCode); process.exit(exitCode);
+20 -48
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
import { evaluateWithGeo } from './qa/geometry.mjs';
import { whenSettled, whenFramesDrawn } from './qa/ready.mjs';
/** /**
* qa-traffic.mjs — headless proof for the TomTom live-flow traffic layer. * qa-traffic.mjs — headless proof for the TomTom live-flow traffic layer.
* *
@@ -30,7 +32,7 @@
* Exits non-zero on any FAIL. Does not commit anything. * Exits non-zero on any FAIL. Does not commit anything.
*/ */
import puppeteer from 'puppeteer'; import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import { captureQaScreenshot } from './qa-chrome.mjs'; import { captureQaScreenshot } from './qa-chrome.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@@ -52,26 +54,7 @@ const HEADFUL = argv.includes('--headful');
const FIXTURES = argv.includes('--fixtures'); const FIXTURES = argv.includes('--fixtures');
const { installTrafficRoutes } = await import('./traffic-fixtures.mjs'); const { installTrafficRoutes } = await import('./traffic-fixtures.mjs');
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
// Chrome: /Applications auto-updates underneath the harnesses, and its
// software-GL behavior shifts across majors (system Chrome 150 blew the
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
].filter(Boolean);
function findChromeExecutable() {
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
try { if (fs.existsSync(candidate)) return candidate; } catch { /* ignore */ }
}
return null;
}
const results = []; const results = [];
function record(name, ok, detail) { function record(name, ok, detail) {
@@ -79,7 +62,6 @@ function record(name, ok, detail) {
const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
} }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Enable traffic + teleport, then poll the layer until settled. */ /** Enable traffic + teleport, then poll the layer until settled. */
async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) { async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
@@ -87,19 +69,18 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
// changes do not activate a headful window the way a real UI click does. // changes do not activate a headful window the way a real UI click does.
await page.bringToFront(); await page.bringToFront();
await page.waitForFunction(() => document.visibilityState === 'visible'); await page.waitForFunction(() => document.visibilityState === 'visible');
return page.evaluate(async (v, minC, tS) => { return evaluateWithGeo(page, async (qaGeo, v, minC, tS) => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
const dm = gev.dataManager; const dm = gev.dataManager;
await dm.setEnabled('traffic', true); await dm.setEnabled('traffic', true);
const mod = dm.layers.get('traffic').module; const mod = dm.layers.get('traffic').module;
const beforeUpdate = mod.getStats().lastUpdate; const beforeUpdate = mod.getStats().lastUpdate;
const ell = gev.viewer.scene.globe.ellipsoid;
const d2r = Math.PI / 180; const d2r = Math.PI / 180;
// The app's intro flyTo animation clobbers a setView issued mid-flight — // The app's intro flyTo animation clobbers a setView issued mid-flight —
// cancel any active tween before teleporting. // cancel any active tween before teleporting.
try { gev.viewer.camera.cancelFlight(); } catch { /* no flight active */ } try { window.__godsEyeView.engine.camera.cancelFlight(); } catch { /* no flight active */ }
gev.viewer.camera.setView({ window.__godsEyeView.engine.camera.setView({
destination: ell.cartographicToCartesian({ longitude: v.lon * d2r, latitude: v.lat * d2r, height: v.height }), destination: qaGeo.cartographic.toCartesian({ longitude: v.lon * d2r, latitude: v.lat * d2r, height: v.height }),
orientation: { heading: (v.heading || 0) * d2r, pitch: (v.pitch ?? -90) * d2r, roll: 0 }, orientation: { heading: (v.heading || 0) * d2r, pitch: (v.pitch ?? -90) * d2r, roll: 0 },
}); });
let s = null; let s = null;
@@ -139,17 +120,9 @@ async function main() {
} }
fs.mkdirSync(SHOTS_DIR, { recursive: true }); fs.mkdirSync(SHOTS_DIR, { recursive: true });
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: HEADFUL ? false : 'new', headless: true,
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}), args: ['--no-sandbox', '--disable-setuid-sandbox', ...(!HEADFUL ? [] : []), '--disable-backgrounding-occluded-windows', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900'],
args: [
'--no-sandbox', '--disable-setuid-sandbox',
...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []),
'--disable-backgrounding-occluded-windows',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
'--window-size=1440,900',
],
}); });
let exitCode = 0; let exitCode = 0;
@@ -174,10 +147,10 @@ async function main() {
console.log('Loading app...'); console.log('Loading app...');
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction( await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager,
{ timeout: 60000 }, { timeout: 60000 },
); );
await sleep(5000); await whenSettled(page);
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
// ── (i) LIVE mode: San Antonio — fast Overpass extract, partial TomTom // ── (i) LIVE mode: San Antonio — fast Overpass extract, partial TomTom
@@ -201,23 +174,22 @@ async function main() {
mumbai.flowCoveragePct > 0 && mumbai.tilesFetched > 0, mumbai.flowCoveragePct > 0 && mumbai.tilesFetched > 0,
`coverage=${mumbai.flowCoveragePct}% tiles=${mumbai.tilesFetched}`); `coverage=${mumbai.flowCoveragePct}% tiles=${mumbai.tilesFetched}`);
if (mumbai.mode !== 'live' || !(mumbai.count > 0) || !(colored > 0)) exitCode = 1; if (mumbai.mode !== 'live' || !(mumbai.count > 0) || !(colored > 0)) exitCode = 1;
await sleep(1200); await whenSettled(page);
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }, { headful: HEADFUL }); await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }, { headful: HEADFUL });
} }
// ── (iv) uncovered-roads param — hide vs sim ───────────────────────────── // ── (iv) uncovered-roads param — hide vs sim ─────────────────────────────
console.log('\n(iv) uncoveredRoads param — hide vs sim...'); console.log('\n(iv) uncoveredRoads param — hide vs sim...');
if ((mumbai.flowBuckets?.sim || 0) > 0) { if ((mumbai.flowBuckets?.sim || 0) > 0) {
const hid = await page.evaluate(async () => { const hid = await evaluateWithGeo(page, async (qaGeo) => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
const mod = gev.dataManager.layers.get('traffic').module; const mod = gev.dataManager.layers.get('traffic').module;
const before = mod.getStats().lastUpdate; const before = mod.getStats().lastUpdate;
mod.setParams({ uncoveredRoads: 'hide' }); mod.setParams({ uncoveredRoads: 'hide' });
const ell = gev.viewer.scene.globe.ellipsoid;
const d2r = Math.PI / 180; const d2r = Math.PI / 180;
// Shift far enough to defeat the overlap gate and force a re-render. // Shift far enough to defeat the overlap gate and force a re-render.
gev.viewer.camera.setView({ window.__godsEyeView.engine.camera.setView({
destination: ell.cartographicToCartesian({ longitude: -98.51 * d2r, latitude: 29.435 * d2r, height: 2800 }), destination: qaGeo.cartographic.toCartesian({ longitude: -98.51 * d2r, latitude: 29.435 * d2r, height: 2800 }),
orientation: { heading: 0.4, pitch: -1.25, roll: 0 }, orientation: { heading: 0.4, pitch: -1.25, roll: 0 },
}); });
// Poll for a NEW render (lastUpdate changes) — the pre-shift stats // Poll for a NEW render (lastUpdate changes) — the pre-shift stats
@@ -289,10 +261,10 @@ async function main() {
}); });
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction( await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, () => window.__godsEyeView?.engine && window.__godsEyeView?.dataManager,
{ timeout: 60000 }, { timeout: 60000 },
); );
await sleep(5000); await whenSettled(page);
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
let simStats = await settleTraffic(page, { lon: -98.4936, lat: 29.4241, height: 3000 }, { minCount: 100, timeoutS: 45 }); let simStats = await settleTraffic(page, { lon: -98.4936, lat: 29.4241, height: 3000 }, { minCount: 100, timeoutS: 45 });
if (!simStats || simStats.count === 0) { if (!simStats || simStats.count === 0) {
@@ -316,7 +288,7 @@ async function main() {
if (simStats.mode !== 'sim' || !allWhite || keylessFlowReqs.length !== 0 || !keylessHonest) { if (simStats.mode !== 'sim' || !allWhite || keylessFlowReqs.length !== 0 || !keylessHonest) {
exitCode = 1; exitCode = 1;
} }
await sleep(1000); await whenFramesDrawn(page);
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }, { headful: HEADFUL }); await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }, { headful: HEADFUL });
} }
record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; ')); record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; '));
@@ -324,7 +296,7 @@ async function main() {
console.error('\x1b[31mHarness error:\x1b[0m', e); console.error('\x1b[31mHarness error:\x1b[0m', e);
exitCode = 3; exitCode = 3;
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
const pass = results.filter((r) => r.ok === true).length; const pass = results.filter((r) => r.ok === true).length;
+26 -21
View File
@@ -1,3 +1,4 @@
import { whenFramesDrawn } from './qa/ready.mjs';
export function transitQaBase(value) { export function transitQaBase(value) {
if (!value) throw new Error('Transit QA requires an explicit QA_BASE_URL'); if (!value) throw new Error('Transit QA requires an explicit QA_BASE_URL');
const url = new URL(value); const url = new URL(value);
@@ -21,12 +22,15 @@ export function boundPageEvaluations(page, defaultTimeoutMs = 30000) {
); );
const label = String(fn).slice(0, 120).replace(/\s+/g, ' '); const label = String(fn).slice(0, 120).replace(/\s+/g, ' ');
return evaluate( return evaluate(
async (source, values, timeoutMs, label) => { async (source, values, timeoutMs, label, isFunction) => {
let timer; let timer;
try { try {
return await Promise.race([ return await Promise.race([
// Puppeteer callbacks are standalone functions, just as in evaluate. // Puppeteer callbacks are standalone functions, just as in evaluate.
Promise.resolve().then(() => (0, eval)(`(${source})`)(...values)), Promise.resolve().then(() => {
const result = (0, eval)(`(${source})`);
return isFunction ? result(...values) : result;
}),
new Promise((_, reject) => { new Promise((_, reject) => {
timer = setTimeout( timer = setTimeout(
() => () =>
@@ -47,6 +51,7 @@ export function boundPageEvaluations(page, defaultTimeoutMs = 30000) {
args, args,
timeoutMs, timeoutMs,
label, label,
typeof fn === 'function',
); );
}; };
} }
@@ -62,8 +67,8 @@ export async function reportTransitVisibility(page, label) {
visible: state._visible.size, visible: state._visible.size,
moving: state._moving.size, moving: state._moving.size,
held: state._renderHeld, held: state._renderHeld,
requestRenderMode: app.viewer.scene.requestRenderMode, requestRenderMode: !app.engine.render.isContinuous(),
renderLoop: app.viewer.useDefaultRenderLoop, renderLoop: !app.engine.isDestroyed(),
renderErrors: window.__transitRenderErrors || [], renderErrors: window.__transitRenderErrors || [],
bounds: state._viewBounds, bounds: state._viewBounds,
hidden: layer._transitVisibilityForTest().filter((e) => !e.shown), hidden: layer._transitVisibilityForTest().filter((e) => !e.shown),
@@ -76,13 +81,14 @@ export async function reportTransitVisibility(page, label) {
); );
if (result.renderLoop === false || result.renderErrors.length) if (result.renderLoop === false || result.renderErrors.length)
throw new Error( throw new Error(
`${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`, `${label}: render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`,
); );
await whenFramesDrawn(page);
return result; return result;
} }
/** Intersect primed histories with the new snapshot; disappearance is availability. */ /** Intersect primed histories with the new snapshot; disappearance is availability. */
export async function restoreRetainedTransit(retained) { export async function restoreRetainedTransit(qaGeo, retained) {
if (!retained.length) if (!retained.length)
return { return {
error: 'No multi-fix history observed during the probe', error: 'No multi-fix history observed during the probe',
@@ -117,9 +123,8 @@ export async function restoreRetainedTransit(retained) {
const entry = state._vehicles.get( const entry = state._vehicles.get(
`${history.feedId || 'mbta'}:${history.vehicleId}`, `${history.feedId || 'mbta'}:${history.vehicleId}`,
); );
const camera = app.viewer.camera, const C = qaGeo.cartographic;
C = camera.positionCartographic.constructor; window.__godsEyeView.engine.camera.setView({
camera.setView({
destination: C.toCartesian( destination: C.toCartesian(
C.fromDegrees(entry.record.lon, entry.record.lat, 600), C.fromDegrees(entry.record.lon, entry.record.lat, 600),
), ),
@@ -173,14 +178,12 @@ export async function sampleTransitPixels(page, palette) {
); );
}, 5000); }, 5000);
const remove = const remove =
window.__godsEyeView.viewer.scene.postRender.addEventListener(() => { window.__godsEyeView.engine.frames.postRender.addEventListener(() => {
const app = window.__godsEyeView; const app = window.__godsEyeView;
const layer = app.dataManager.layers.get('transit').module; const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest(); const state = layer._transitStateForTest();
const scene = app.viewer.scene; const { engine } = app;
const canvas = scene.canvas; const canvas = window.__godsEyeView.engine.host.canvas;
const view = scene.camera.viewMatrix;
const proj = scene.camera.frustum.projectionMatrix;
const mul = (m, v) => [ const mul = (m, v) => [
m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3], m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3],
m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3], m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3],
@@ -269,9 +272,12 @@ export async function sampleTransitPixels(page, palette) {
for (const entry of state._vehicles.values()) { for (const entry of state._vehicles.values()) {
if (!entry.marker || entry.marker.show === false) continue; if (!entry.marker || entry.marker.show === false) continue;
const p = entry.marker.position; const p = entry.marker.position;
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); const clip = mul(
window.__godsEyeView.engine.projector.viewProjection(),
[p.x, p.y, p.z, 1],
);
if (!(clip[3] > 0)) continue; if (!(clip[3] > 0)) continue;
const screen = entry.marker.computeScreenSpacePosition?.(scene) || { const screen = {
x: ((clip[0] / clip[3]) * 0.5 + 0.5) * width, x: ((clip[0] / clip[3]) * 0.5 + 0.5) * width,
y: (1 - ((clip[1] / clip[3]) * 0.5 + 0.5)) * height, y: (1 - ((clip[1] / clip[3]) * 0.5 + 0.5)) * height,
}; };
@@ -492,7 +498,7 @@ export async function sampleTransitPixels(page, palette) {
// label or sprite must not masquerade as a dim sensor signature. // label or sprite must not masquerade as a dim sensor signature.
// Check the whole sampled core, not just its centre pick. // Check the whole sampled core, not just its centre pick.
let coreOwned = true; let coreOwned = true;
if (scene.pick) { {
const framebuffer = gl.getParameter?.(gl.FRAMEBUFFER_BINDING); const framebuffer = gl.getParameter?.(gl.FRAMEBUFFER_BINDING);
for (const core of corePoints) { for (const core of corePoints) {
let u = core.x / width, let u = core.x / width,
@@ -519,13 +525,12 @@ export async function sampleTransitPixels(page, palette) {
v) * v) *
intensity; intensity;
} }
const picked = scene.pick( const picked = engine.picker.pick(
{ x: u * width, y: (1 - v) * height }, { x: u * width, y: (1 - v) * height },
1, 1,
1, 1,
); );
if (picked?.id !== s.key && picked?.primitive?.id !== s.key) if (picked?.id !== s.key) coreOwned = false;
coreOwned = false;
} }
if (gl.bindFramebuffer) if (gl.bindFramebuffer)
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer); gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
@@ -657,7 +662,7 @@ export async function sampleTransitPixels(page, palette) {
); );
resolve(out); resolve(out);
}); });
window.__godsEyeView.viewer.scene.requestRender(); window.__godsEyeView.engine.render.request();
}); });
}, palette); }, palette);
} }
+8 -12
View File
@@ -1,4 +1,4 @@
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); import { whenSettled, whenUiSettled } from './qa/ready.mjs';
export const BOSTON = { export const BOSTON = {
id: 'boston', id: 'boston',
feedId: 'mbta', feedId: 'mbta',
@@ -69,7 +69,7 @@ export function chooseLiveCities(now = new Date()) {
* @param {Record<string, number>} [params] aria-label → slider value * @param {Record<string, number>} [params] aria-label → slider value
* @returns {Promise<string>} how the style was set * @returns {Promise<string>} how the style was set
*/ */
export async function selectStyle(page, name, params = {}, wait = pause) { export async function selectStyle(page, name, params = {}) {
let how = 'button'; let how = 'button';
// The presets live in the bottom control panel, which auto-dismisses when // The presets live in the bottom control panel, which auto-dismisses when
// the pointer leaves it: pin it open the way a user does, once. // the pointer leaves it: pin it open the way a user does, once.
@@ -86,7 +86,7 @@ export async function selectStyle(page, name, params = {}, wait = pause) {
{ explicit: true }, { explicit: true },
); );
}); });
await wait(400); await whenUiSettled(page);
try { try {
await page.$eval(`.style-btn[data-style="${name}"]`, (el) => await page.$eval(`.style-btn[data-style="${name}"]`, (el) =>
el.scrollIntoView({ block: 'center' }), el.scrollIntoView({ block: 'center' }),
@@ -118,7 +118,7 @@ export async function selectStyle(page, name, params = {}, wait = pause) {
{ timeout: 10000 }, { timeout: 10000 },
name, name,
); );
await wait(1_800); await whenUiSettled(page);
for (const [label, value] of Object.entries(params)) { for (const [label, value] of Object.entries(params)) {
const selector = `#param-sliders input.param-slider[aria-label="${label}"]`; const selector = `#param-sliders input.param-slider[aria-label="${label}"]`;
await page.waitForSelector(selector, { timeout: 10000 }); await page.waitForSelector(selector, { timeout: 10000 });
@@ -130,7 +130,7 @@ export async function selectStyle(page, name, params = {}, wait = pause) {
}, },
value, value,
); );
await wait(700); await whenUiSettled(page);
} }
return how; return how;
} }
@@ -141,7 +141,7 @@ export async function selectStyle(page, name, params = {}, wait = pause) {
* @param {import('puppeteer').Page} page * @param {import('puppeteer').Page} page
* @returns {Promise<string>} the profile reached * @returns {Promise<string>} the profile reached
*/ */
export async function detectOn(page, wait = pause) { export async function detectOn(page) {
// The real button toggles OFF/restore. Density determines its on-profile. // The real button toggles OFF/restore. Density determines its on-profile.
const mode = () => const mode = () =>
page.evaluate(() => { page.evaluate(() => {
@@ -153,11 +153,7 @@ export async function detectOn(page, wait = pause) {
}; };
}); });
// The loading screen swallows the first click of a fresh page. // The loading screen swallows the first click of a fresh page.
await page await whenSettled(page);
.waitForFunction(() => !document.getElementById('loading-screen'), {
timeout: 30_000,
})
.catch(() => {});
// The DISPLAY rail starts collapsed on a first run: open it the way a user // The DISPLAY rail starts collapsed on a first run: open it the way a user
// does, then press DETECT until it reports on. // does, then press DETECT until it reports on.
await page.evaluate(() => { await page.evaluate(() => {
@@ -175,7 +171,7 @@ export async function detectOn(page, wait = pause) {
); );
if (!(await mode()).on) { if (!(await mode()).on) {
await page.click('#detection-toggle'); await page.click('#detection-toggle');
await wait(300); await whenUiSettled(page);
} }
await page.$eval('#detection-density-slider', (el) => { await page.$eval('#detection-density-slider', (el) => {
el.value = '100'; el.value = '100';
+34 -26
View File
@@ -1,4 +1,7 @@
import puppeteer from 'puppeteer'; import { evaluateWithGeo } from './qa/geometry.mjs';
import { hookRenderErrors } from './qa-browserEvidence.mjs';
import { whenSettled } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import { blockPaidQaMaps } from './qa-map-policy.mjs'; import { blockPaidQaMaps } from './qa-map-policy.mjs';
import { transitQaBase } from './qa-transit-browser.mjs'; import { transitQaBase } from './qa-transit-browser.mjs';
import { chromeExecutablePath } from './qa-chrome.mjs'; import { chromeExecutablePath } from './qa-chrome.mjs';
@@ -6,18 +9,18 @@ import { chromeExecutablePath } from './qa-chrome.mjs';
/** Prove parked and moving headings in the real scene, without live feeds. */ /** Prove parked and moving headings in the real scene, without live feeds. */
export async function runTransitHeadingRegression(base, check) { export async function runTransitHeadingRegression(base, check) {
base = transitQaBase(base); base = transitQaBase(base);
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: true, headless: true,
executablePath: chromeExecutablePath(),
args: [ args: [
'--no-sandbox', '--no-sandbox',
'--disable-background-timer-throttling', '--disable-background-timer-throttling',
...(process.platform === 'darwin' ? ['--use-angle=metal'] : []), ...(process.platform === 'darwin' ? [] : []),
'--enable-gpu', '--enable-gpu',
], ],
}); });
try { try {
const page = await browser.newPage(); const page = await browser.newPage();
await hookRenderErrors(page);
await blockPaidQaMaps(page); await blockPaidQaMaps(page);
await page.setViewport({ width: 1280, height: 800 }); await page.setViewport({ width: 1280, height: 800 });
await page.goto( await page.goto(
@@ -28,7 +31,7 @@ export async function runTransitHeadingRegression(base, check) {
() => window.__godsEyeView?.dataManager?.layers?.has('transit'), () => window.__godsEyeView?.dataManager?.layers?.has('transit'),
{ timeout: 90000 }, { timeout: 90000 },
); );
await new Promise((resolve) => setTimeout(resolve, 6000)); await whenSettled(page);
await page.evaluate(async () => { await page.evaluate(async () => {
const app = window.__godsEyeView; const app = window.__godsEyeView;
await app.dataManager.setEnabled('transit', true, { source: 'qa' }); await app.dataManager.setEnabled('transit', true, { source: 'qa' });
@@ -63,26 +66,25 @@ export async function runTransitHeadingRegression(base, check) {
parts.rendering.schedulePlayback(entry); parts.rendering.schedulePlayback(entry);
} }
parts.rendering.syncRenderHold(); parts.rendering.syncRenderHold();
window.__headingErrors = []; window.__headingErrors = window.__qaRenderErrors;
app.viewer.scene.renderError.addEventListener((scene, error) =>
window.__headingErrors.push(error.message),
);
}); });
const pose = async (heading) => { const pose = async (heading) => {
await page.evaluate((heading) => { await evaluateWithGeo(
const camera = window.__godsEyeView.viewer.camera; page,
const C = camera.positionCartographic.constructor; async (qaGeo, heading) => {
camera.setView({ const C = qaGeo.cartographic;
destination: C.toCartesian(C.fromDegrees(-71.0589, 42.3601, 1200)), window.__godsEyeView.engine.camera.setView({
orientation: { heading, pitch: -Math.PI / 2, roll: 0 }, destination: C.toCartesian(C.fromDegrees(-71.0589, 42.3601, 1200)),
}); orientation: { heading, pitch: -Math.PI / 2, roll: 0 },
}, heading); });
await new Promise((resolve) => setTimeout(resolve, 700)); },
heading,
);
await whenSettled(page);
}; };
const sample = () => const sample = () =>
page.evaluate(async () => { page.evaluate(async () => {
const app = window.__godsEyeView, const app = window.__godsEyeView;
scene = app.viewer.scene;
const state = app.dataManager.layers const state = app.dataManager.layers
.get('transit') .get('transit')
.module._transitStateForTest(); .module._transitStateForTest();
@@ -96,12 +98,18 @@ export async function runTransitHeadingRegression(base, check) {
); );
const project = (p) => { const project = (p) => {
const clip = mul( const clip = mul(
scene.camera.frustum.projectionMatrix, window.__godsEyeView.engine.projector.viewProjection(),
mul(scene.camera.viewMatrix, [p.x, p.y, p.z, 1]), [p.x, p.y, p.z, 1],
); );
return { return {
x: ((clip[0] / clip[3] + 1) * scene.canvas.clientWidth) / 2, x:
y: ((1 - clip[1] / clip[3]) * scene.canvas.clientHeight) / 2, ((clip[0] / clip[3] + 1) *
window.__godsEyeView.engine.host.canvas.clientWidth) /
2,
y:
((1 - clip[1] / clip[3]) *
window.__godsEyeView.engine.host.canvas.clientHeight) /
2,
}; };
}; };
return [0, 1, 2, 3].map((i) => { return [0, 1, 2, 3].map((i) => {
@@ -139,7 +147,7 @@ export async function runTransitHeadingRegression(base, check) {
'unknown parked course remains screen-up', 'unknown parked course remains screen-up',
before[2].rotation === 0 && after[2].rotation === 0, before[2].rotation === 0 && after[2].rotation === 0,
); );
await new Promise((resolve) => setTimeout(resolve, 1200)); await whenSettled(page);
const moved = (await sample())[3]; const moved = (await sample())[3];
const dx = moved.x - after[3].x, const dx = moved.x - after[3].x,
dy = moved.y - after[3].y; dy = moved.y - after[3].y;
@@ -160,6 +168,6 @@ export async function runTransitHeadingRegression(base, check) {
(await page.evaluate(() => window.__headingErrors)).length === 0, (await page.evaluate(() => window.__headingErrors)).length === 0,
); );
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
} }
+45 -56
View File
@@ -1,5 +1,7 @@
import { evaluateWithGeo } from './qa/geometry.mjs';
/** Camera-jump regression against an existing server; never starts a server. */ /** Camera-jump regression against an existing server; never starts a server. */
import puppeteer from 'puppeteer'; import { whenSettled, whenTilesLoaded } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
import { mkdir, writeFile } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
@@ -9,11 +11,10 @@ import { transitQaBase } from './qa-transit-browser.mjs';
const base = transitQaBase(process.env.QA_BASE_URL); const base = transitQaBase(process.env.QA_BASE_URL);
const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery'; const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery';
await mkdir(shots, { recursive: true }); await mkdir(shots, { recursive: true });
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: true, headless: true,
executablePath: chromeExecutablePath(),
args: [ args: [
...(process.platform === 'darwin' ? ['--use-angle=metal'] : []), ...(process.platform === 'darwin' ? [] : []),
'--enable-gpu', '--enable-gpu',
'--disable-background-timer-throttling', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--disable-renderer-backgrounding',
@@ -35,28 +36,16 @@ try {
const app = window.__godsEyeView; const app = window.__godsEyeView;
await app.dataManager.setEnabled('transit', true, { source: 'qa' }); await app.dataManager.setEnabled('transit', true, { source: 'qa' });
}); });
await page.waitForFunction( await whenTilesLoaded(page, { timeoutMs: 60000 });
() => { await whenSettled(page);
const scene = window.__godsEyeView.viewer.scene; await evaluateWithGeo(page, async (qaGeo) => {
return scene.globe.tilesLoaded && scene.globe.show; const C = qaGeo.cartographic,
},
{ timeout: 60000 },
);
await page.waitForFunction(
() => {
const cover = document.getElementById('loading-screen');
return !cover || cover.classList.contains('hidden');
},
{ timeout: 60000 },
);
await page.evaluate(() => {
const app = window.__godsEyeView,
camera = app.viewer.camera;
const C = camera.positionCartographic.constructor,
lat = 30.267 + 90 / 111320; lat = 30.267 + 90 / 111320;
const floor = const floor =
app.viewer.scene.globe.getHeight(C.fromDegrees(-97.7431, lat)) || 0; window.__godsEyeView.engine.surface.terrainHeight(
camera.setView({ C.fromDegrees(-97.7431, lat),
) || 0;
window.__godsEyeView.engine.camera.setView({
destination: C.toCartesian( destination: C.toCartesian(
C.fromDegrees( C.fromDegrees(
-97.7431, -97.7431,
@@ -67,29 +56,24 @@ try {
orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 }, orientation: { heading: 0, pitch: -Math.PI / 3, roll: 0 },
}); });
}); });
await new Promise((resolve) => setTimeout(resolve, 2000)); await whenSettled(page);
await page.waitForFunction( await whenTilesLoaded(page, { timeoutMs: 60000 });
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded, await whenSettled(page);
{ timeout: 60000 }, const result = await evaluateWithGeo(page, async (qaGeo) => {
); const app = window.__godsEyeView;
await new Promise((resolve) => setTimeout(resolve, 1500));
const result = await page.evaluate(async () => {
const app = window.__godsEyeView,
scene = app.viewer.scene,
camera = app.viewer.camera;
const layer = app.dataManager.layers.get('transit').module; const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest(), const state = layer._transitStateForTest(),
parts = layer._transitPartsForTest(); parts = layer._transitPartsForTest();
const { getRegisteredTransitFeed } = const { getRegisteredTransitFeed } =
await import('/src/data/transitFeeds.js'); await import('/src/data/transitFeeds.js');
const { setRate } = await import('/src/data/contactPlayback.js'); const { setRate } = await import('/src/data/contactPlayback.js');
const C = camera.positionCartographic.constructor; const C = qaGeo.cartographic;
const feed = { const feed = {
...getRegisteredTransitFeed('capmetro-austin'), ...getRegisteredTransitFeed('capmetro-austin'),
id: 'qa-camera-recovery', id: 'qa-camera-recovery',
}; };
const aim = (lat, height = 600) => const aim = (lat, height = 600) =>
camera.setView({ window.__godsEyeView.engine.camera.setView({
destination: C.toCartesian( destination: C.toCartesian(
C.fromDegrees( C.fromDegrees(
-97.7431, -97.7431,
@@ -161,7 +145,7 @@ try {
const preparedAtSelection = !!parts.trails.diagnostics()?.body; const preparedAtSelection = !!parts.trails.diagnostics()?.body;
parts.height.requestHeight = requestHeight; parts.height.requestHeight = requestHeight;
const floor = const floor =
scene.globe.getHeight( window.__godsEyeView.engine.surface.terrainHeight(
C.fromDegrees(entry.sample.lon, entry.sample.lat), C.fromDegrees(entry.sample.lon, entry.sample.lat),
) || 0; ) || 0;
aim(entry.sample.lat, floor + 600); aim(entry.sample.lat, floor + 600);
@@ -170,7 +154,7 @@ try {
while (performance.now() - started < 1400) { while (performance.now() - started < 1400) {
trail = parts.trails.diagnostics(); trail = parts.trails.diagnostics();
if (entry.marker.show && trail?.body?.show && trail.body.ready) break; if (entry.marker.show && trail?.body?.show && trail.body.ready) break;
scene.requestRender(); window.__godsEyeView.engine.render.request();
await wait(25); await wait(25);
} }
const status = { const status = {
@@ -213,8 +197,8 @@ try {
const lengths = [0]; const lengths = [0];
for (let i = 1; i < points.length; i++) for (let i = 1; i < points.length; i++)
lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i])); lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i]));
const canvas = scene.canvas, const canvas = window.__godsEyeView.engine.host.canvas,
gl = scene.context._gl, gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'),
scale = canvas.width / canvas.clientWidth; scale = canvas.width / canvas.clientWidth;
const mul = (m, v) => const mul = (m, v) =>
[0, 1, 2, 3].map( [0, 1, 2, 3].map(
@@ -222,10 +206,12 @@ try {
m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3], m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3],
); );
const project = (p) => { const project = (p) => {
const q = mul( const q = mul(window.__godsEyeView.engine.projector.viewProjection(), [
camera.frustum.projectionMatrix, p.x,
mul(camera.viewMatrix, [p.x, p.y, p.z, 1]), p.y,
); p.z,
1,
]);
return { return {
x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth, x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth,
y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight, y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight,
@@ -242,7 +228,8 @@ try {
(d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]), (d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]),
); );
const cart = C.fromCartesian(p); const cart = C.fromCartesian(p);
cart.height = scene.globe.getHeight(cart) || 0; cart.height =
window.__godsEyeView.engine.surface.terrainHeight(cart) || 0;
return { return {
...project(C.toCartesian(cart)), ...project(C.toCartesian(cart)),
radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale, radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale,
@@ -282,18 +269,20 @@ try {
remove(); remove();
reject(new Error('No recovery frame')); reject(new Error('No recovery frame'));
}, 500); }, 500);
const remove = scene.postRender.addEventListener(() => { const remove =
remove(); window.__godsEyeView.engine.frames.postRender.addEventListener(() => {
clearTimeout(timer); remove();
resolve(read()); clearTimeout(timer);
}); resolve(read());
scene.requestRender(); });
window.__godsEyeView.engine.render.request();
}); });
let visible = true; let visible = true;
// Body and head together, as the trail's own visibility switch does. // Body and head together, as the trail's own visibility switch does.
const remove = scene.preRender.addEventListener(() => const remove =
trailRenderer.setVisible(visible), window.__godsEyeView.engine.frames.preRender.addEventListener(() =>
); trailRenderer.setVisible(visible),
);
const captures = []; const captures = [];
try { try {
for (const show of [false, true, false, true]) { for (const show of [false, true, false, true]) {
@@ -316,7 +305,7 @@ try {
offAgain, offAgain,
onAgain, onAgain,
tilesReady: tilesReady:
scene.globe.tilesLoaded && window.__godsEyeView.engine.surface.tilesLoaded() &&
parts.trails.diagnostics().body === trail.body, parts.trails.diagnostics().body === trail.body,
elapsedMs: performance.now() - started, elapsedMs: performance.now() - started,
// The trail's ground corridors (age bands and head, each with a backing). // The trail's ground corridors (age bands and head, each with a backing).
@@ -350,5 +339,5 @@ try {
console.log(JSON.stringify(result)); console.log(JSON.stringify(result));
process.exitCode = pass ? 0 : 1; process.exitCode = pass ? 0 : 1;
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
+340 -305
View File
@@ -1,3 +1,6 @@
// Cesium-only probes: native globe and ground-primitive coverage are the Cesium visual oracle.
import { whenTilesLoaded, whenFramesDrawn } from './qa/ready.mjs';
import { evaluateWithGeo } from './qa/geometry.mjs';
import { captureQaScreenshot } from './qa-chrome.mjs'; import { captureQaScreenshot } from './qa-chrome.mjs';
import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js'; import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js';
@@ -89,24 +92,27 @@ export async function runFleetBudgets({
const cdp = await page.createCDPSession(); const cdp = await page.createCDPSession();
await cdp.send('HeapProfiler.enable'); await cdp.send('HeapProfiler.enable');
for (const count of [800, 3000]) { for (const count of [800, 3000]) {
await page.evaluate(async (count) => { await evaluateWithGeo(
const app = window.__godsEyeView; page,
await app.dataManager.setEnabled('transit', true, { source: 'qa' }); async (qaGeo, count) => {
const camera = app.viewer.camera, const app = window.__godsEyeView;
C = camera.positionCartographic.constructor; await app.dataManager.setEnabled('transit', true, { source: 'qa' });
camera.setView({ const C = qaGeo.cartographic;
destination: C.toCartesian( window.__godsEyeView.engine.camera.setView({
C.fromDegrees(-71.0605, 42.3554, count === 800 ? 600 : 30000), destination: C.toCartesian(
), C.fromDegrees(-71.0605, 42.3554, count === 800 ? 600 : 30000),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, ),
}); orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
const layer = app.dataManager.layers.get('transit').module; });
layer._transitPartsForTest().ingestion.abortAllInFlight(); const layer = app.dataManager.layers.get('transit').module;
layer._transitStateForTest()._activeFeeds.clear(); layer._transitPartsForTest().ingestion.abortAllInFlight();
for (const key of layer._transitStateForTest()._vehicles.keys()) layer._transitStateForTest()._activeFeeds.clear();
layer._transitPartsForTest().ingestion.removeVehicle(key); for (const key of layer._transitStateForTest()._vehicles.keys())
}, count); layer._transitPartsForTest().ingestion.removeVehicle(key);
await wait(8000); },
count,
);
await whenTilesLoaded(page);
// Camera proximity can finish a live poll during tile warm-up. Remove it // Camera proximity can finish a live poll during tile warm-up. Remove it
// before the heap baseline, or its memory would discount the fixture fleet. // before the heap baseline, or its memory would discount the fixture fleet.
await page.evaluate(() => { await page.evaluate(() => {
@@ -121,9 +127,9 @@ export async function runFleetBudgets({
for (const key of state._vehicles.keys()) for (const key of state._vehicles.keys())
parts.ingestion.removeVehicle(key); parts.ingestion.removeVehicle(key);
parts.rendering.syncRenderHold(); parts.rendering.syncRenderHold();
window.__godsEyeView.viewer.scene.requestRender(); window.__godsEyeView.engine.render.request();
}); });
await wait(500); await whenFramesDrawn(page);
await cdp.send('HeapProfiler.collectGarbage'); await cdp.send('HeapProfiler.collectGarbage');
const before = await cdp.send('Runtime.getHeapUsage'); const before = await cdp.send('Runtime.getHeapUsage');
const loaded = await page.evaluate( const loaded = await page.evaluate(
@@ -141,7 +147,7 @@ export async function runFleetBudgets({
page, page,
`${count} budget visibility after load`, `${count} budget visibility after load`,
); );
await wait(2000); await whenFramesDrawn(page);
await cdp.send('HeapProfiler.collectGarbage'); await cdp.send('HeapProfiler.collectGarbage');
// The retained delta is conservative: it includes concurrent page growth. // The retained delta is conservative: it includes concurrent page growth.
const retained = await cdp.send('Runtime.getHeapUsage'); const retained = await cdp.send('Runtime.getHeapUsage');
@@ -156,7 +162,7 @@ export async function runFleetBudgets({
.get('transit') .get('transit')
.module._resetTransitFixtureClockForTest(), .module._resetTransitFixtureClockForTest(),
); );
await wait(1500); await whenFramesDrawn(page);
await cdp.send('HeapProfiler.startSampling', { await cdp.send('HeapProfiler.startSampling', {
samplingInterval: 128, samplingInterval: 128,
includeObjectsCollectedByMajorGC: true, includeObjectsCollectedByMajorGC: true,
@@ -315,15 +321,18 @@ export async function runBostonMatrix({
await selectStyle(page, 'normal'); await selectStyle(page, 'normal');
for (const candidate of group.candidates) { for (const candidate of group.candidates) {
background = { ...candidate, name: group.name }; background = { ...candidate, name: group.name };
await page.evaluate((b) => { await evaluateWithGeo(
const camera = window.__godsEyeView.viewer.camera, page,
C = camera.positionCartographic.constructor; async (qaGeo, b) => {
camera.setView({ const C = qaGeo.cartographic;
destination: C.toCartesian(C.fromDegrees(b.lon, b.lat, 600)), window.__godsEyeView.engine.camera.setView({
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, destination: C.toCartesian(C.fromDegrees(b.lon, b.lat, 600)),
}); orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
}, background); });
await wait(8000); },
background,
);
await whenTilesLoaded(page);
await page.evaluate( await page.evaluate(
({ background, routes }) => { ({ background, routes }) => {
const layer = const layer =
@@ -336,7 +345,7 @@ export async function runBostonMatrix({
}, },
{ background, routes }, { background, routes },
); );
await wait(1000); await whenFramesDrawn(page);
backgroundResult = reduceBackground( backgroundResult = reduceBackground(
group.name, group.name,
await sampleRendered(page), await sampleRendered(page),
@@ -352,7 +361,7 @@ export async function runBostonMatrix({
); );
for (const look of looks) { for (const look of looks) {
await selectStyle(page, look.style, look.params); await selectStyle(page, look.style, look.params);
await wait(1200); await whenFramesDrawn(page);
await reportTransitVisibility( await reportTransitVisibility(
page, page,
`Boston ${background.name}/${look.name} visibility`, `Boston ${background.name}/${look.name} visibility`,
@@ -407,7 +416,7 @@ export async function runBostonMatrix({
.selection.selectVehicle(key), .selection.selectVehicle(key),
target.key, target.key,
); );
await wait(500); await whenFramesDrawn(page);
await captureQaScreenshot(page, { await captureQaScreenshot(page, {
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`, path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`,
type: 'jpeg', type: 'jpeg',
@@ -431,7 +440,7 @@ export async function runBostonMatrix({
restored.color === target.color, restored.color === target.color,
); );
} }
await wait(300); await whenFramesDrawn(page);
await captureQaScreenshot(page, { await captureQaScreenshot(page, {
path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`, path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`,
type: 'jpeg', type: 'jpeg',
@@ -464,12 +473,11 @@ export async function runBostonLive({
sampleRendered, sampleRendered,
}) { }) {
console.log('\n== Boston live oblique streets =='); console.log('\n== Boston live oblique streets ==');
await page.evaluate(async () => { await evaluateWithGeo(page, async (qaGeo) => {
const app = window.__godsEyeView; const app = window.__godsEyeView;
await app.dataManager.setEnabled('transit', false, { source: 'qa' }); await app.dataManager.setEnabled('transit', false, { source: 'qa' });
const camera = app.viewer.camera, const C = qaGeo.cartographic;
C = camera.positionCartographic.constructor; window.__godsEyeView.engine.camera.setView({
camera.setView({
destination: C.toCartesian(C.fromDegrees(-71.0605, 42.3554, 600)), destination: C.toCartesian(C.fromDegrees(-71.0605, 42.3554, 600)),
orientation: { heading: 0, pitch: -Math.PI / 4, roll: 0 }, orientation: { heading: 0, pitch: -Math.PI / 4, roll: 0 },
}); });
@@ -478,7 +486,7 @@ export async function runBostonLive({
layer._setTransitFixtureFloorsForTest([], 0); layer._setTransitFixtureFloorsForTest([], 0);
await layer.update(); await layer.update();
}); });
await wait(20000); await whenTilesLoaded(page);
await detectOn(page); await detectOn(page);
const evidence = []; const evidence = [];
for (const view of [ for (const view of [
@@ -507,27 +515,30 @@ export async function runBostonLive({
params: {}, params: {},
}, },
]) { ]) {
await page.evaluate((view) => { await evaluateWithGeo(
const app = window.__godsEyeView, page,
camera = app.viewer.camera, async (qaGeo, view) => {
C = camera.positionCartographic.constructor; const app = window.__godsEyeView,
app.dataManager.layers C = qaGeo.cartographic;
.get('transit') app.dataManager.layers
.module._transitPartsForTest() .get('transit')
.selection.clearSelection(); .module._transitPartsForTest()
camera.setView({ .selection.clearSelection();
destination: C.toCartesian( window.__godsEyeView.engine.camera.setView({
C.fromDegrees(-71.0605, 42.3554, view.altitude), destination: C.toCartesian(
), C.fromDegrees(-71.0605, 42.3554, view.altitude),
orientation: { ),
heading: (view.heading * Math.PI) / 180, orientation: {
pitch: (view.pitch * Math.PI) / 180, heading: (view.heading * Math.PI) / 180,
roll: 0, pitch: (view.pitch * Math.PI) / 180,
}, roll: 0,
}); },
}, view); });
},
view,
);
await selectStyle(page, view.style, view.params); await selectStyle(page, view.style, view.params);
await wait(8000); await whenTilesLoaded(page);
const fleet = await page.evaluate(() => { const fleet = await page.evaluate(() => {
const state = window.__godsEyeView.dataManager.layers const state = window.__godsEyeView.dataManager.layers
.get('transit') .get('transit')
@@ -562,7 +573,7 @@ export async function runBostonLive({
let selection = null; let selection = null;
if (target) { if (target) {
await page.mouse.click(target.x, target.y); await page.mouse.click(target.x, target.y);
await wait(250); await whenFramesDrawn(page);
selection = await page.evaluate((key) => { selection = await page.evaluate((key) => {
const state = window.__godsEyeView.dataManager.layers const state = window.__godsEyeView.dataManager.layers
.get('transit') .get('transit')
@@ -606,15 +617,15 @@ export async function runTrailVisibility({
}) { }) {
const name = 'Google 3D selected trail visible at 6 of 8 projected samples'; const name = 'Google 3D selected trail visible at 6 of 8 projected samples';
await selectStyle(page, 'normal'); await selectStyle(page, 'normal');
await page.evaluate( await evaluateWithGeo(
async ({ altitude, pitch }) => { page,
async (qaGeo, { altitude, pitch }) => {
const app = window.__godsEyeView; const app = window.__godsEyeView;
await app.dataManager.setEnabled('transit', true, { source: 'qa' }); await app.dataManager.setEnabled('transit', true, { source: 'qa' });
if (app.mapStackController.isStackAvailable('photoreal')) if (app.mapStackController.isStackAvailable('photoreal'))
await app.mapStackController.setStack('photoreal'); await app.mapStackController.setStack('photoreal');
const camera = app.viewer.camera, const C = qaGeo.cartographic;
C = camera.positionCartographic.constructor; window.__godsEyeView.engine.camera.setView({
camera.setView({
destination: C.toCartesian( destination: C.toCartesian(
C.fromDegrees( C.fromDegrees(
-97.7431, -97.7431,
@@ -627,11 +638,11 @@ export async function runTrailVisibility({
}, },
{ altitude, pitch }, { altitude, pitch },
); );
await wait(8000); await whenTilesLoaded(page);
const setup = await page.evaluate( const setup = await evaluateWithGeo(
async ({ altitude, pitch, feed }) => { page,
const app = window.__godsEyeView, async (qaGeo, { altitude, pitch, feed }) => {
scene = app.viewer.scene; const app = window.__godsEyeView;
const layer = app.dataManager.layers.get('transit').module; const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest(), const state = layer._transitStateForTest(),
parts = layer._transitPartsForTest(); parts = layer._transitPartsForTest();
@@ -641,11 +652,13 @@ export async function runTrailVisibility({
app.mapStackController.getActiveId() === 'photoreal'; app.mapStackController.getActiveId() === 'photoreal';
if (!google) if (!google)
return { google: false, reason: 'visible Google 3D tileset required' }; return { google: false, reason: 'visible Google 3D tileset required' };
const C = scene.camera.positionCartographic.constructor; const C = qaGeo.cartographic;
const centreLat = 30.267 + 90 / 111320; const centreLat = 30.267 + 90 / 111320;
const floor = scene.sampleHeight(C.fromDegrees(-97.7431, centreLat)); const floor = window.__godsEyeView.engine.surface.sampleHeight(
C.fromDegrees(-97.7431, centreLat),
);
if (Number.isFinite(floor)) if (Number.isFinite(floor))
scene.camera.setView({ window.__godsEyeView.engine.camera.setView({
destination: C.toCartesian( destination: C.toCartesian(
C.fromDegrees( C.fromDegrees(
-97.7431, -97.7431,
@@ -744,7 +757,7 @@ export async function runTrailVisibility({
const layer = app.dataManager.layers.get('transit').module; const layer = app.dataManager.layers.get('transit').module;
const parts = layer._transitPartsForTest(), const parts = layer._transitPartsForTest(),
entry = layer._transitStateForTest()._vehicles.get(key); entry = layer._transitStateForTest()._vehicles.get(key);
const gl = scene.context._gl, const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2'),
originals = new Map(), originals = new Map(),
wrapped = new Map(); wrapped = new Map();
let owner = null, let owner = null,
@@ -791,29 +804,35 @@ export async function runTrailVisibility({
return original.apply(this, args); return original.apply(this, args);
}; };
} }
const pre = scene.preRender.addEventListener(() => { const pre = window.__godsEyeView.engine.frames.preRender.addEventListener(
headBytes = bodyBytes = 0; () => {
const walk = (collection) => { headBytes = bodyBytes = 0;
for (let i = 0; i < collection.length; i++) { const walk = (collection) => {
const primitive = collection.get(i); for (let i = 0; i < collection.length; i++) {
const kind = window.__qaTransitLineKinds.get(primitive?.gevGroundSet); const primitive = collection.get(i);
if (kind) wrap(primitive, kind); const kind = window.__qaTransitLineKinds.get(
else if (typeof primitive?.get === 'function') walk(primitive); primitive?.gevGroundSet,
} );
}; if (kind) wrap(primitive, kind);
walk(scene.primitives); else if (typeof primitive?.get === 'function') walk(primitive);
walk(scene.groundPrimitives); }
}); };
const post = scene.postRender.addEventListener(() => { walk(scene.primitives);
frames++; walk(scene.groundPrimitives);
headUploadMax = Math.max(headUploadMax, headBytes); },
bodyUploadMax = Math.max(bodyUploadMax, bodyBytes); );
}); const post = window.__godsEyeView.engine.frames.postRender.addEventListener(
() => {
frames++;
headUploadMax = Math.max(headUploadMax, headBytes);
bodyUploadMax = Math.max(bodyUploadMax, bodyBytes);
},
);
try { try {
// Include a full history upload, even if the first selection already rendered. // Include a full history upload, even if the first selection already rendered.
entry.pathRevision = -1; entry.pathRevision = -1;
parts.trails.prepareEntry(entry); parts.trails.prepareEntry(entry);
scene.requestRender(); window.__godsEyeView.engine.render.request();
await new Promise((resolve) => setTimeout(resolve, 20000)); await new Promise((resolve) => setTimeout(resolve, 20000));
headCpu.sort((a, b) => a - b); headCpu.sort((a, b) => a - b);
return { return {
@@ -832,221 +851,237 @@ export async function runTrailVisibility({
if (!primitive.isDestroyed()) primitive.update = update; if (!primitive.isDestroyed()) primitive.update = update;
} }
}, setup.key); }, setup.key);
const result = await page.evaluate(async (setup) => { const result = await evaluateWithGeo(
const app = window.__godsEyeView, page,
scene = app.viewer.scene; async (qaGeo, setup) => {
const layer = app.dataManager.layers.get('transit').module; const app = window.__godsEyeView,
const state = layer._transitStateForTest(), scene = app.viewer.scene;
parts = layer._transitPartsForTest(); const layer = app.dataManager.layers.get('transit').module;
const entry = state._vehicles.get(setup.key); const state = layer._transitStateForTest(),
const trail = parts.trails.diagnostics(); parts = layer._transitPartsForTest();
if (!entry || !trail) const entry = state._vehicles.get(setup.key);
return { ...setup, present: 0, reason: 'selection disappeared' }; const trail = parts.trails.diagnostics();
const { setRate } = await import('/src/data/contactPlayback.js'); if (!entry || !trail)
setRate(entry.track, 0, { return { ...setup, present: 0, reason: 'selection disappeared' };
wallNowMs: Date.now(), const { setRate } = await import('/src/data/contactPlayback.js');
monoNowMs: performance.now(), setRate(entry.track, 0, {
}); wallNowMs: Date.now(),
parts.rendering.sampleIdle(entry); monoNowMs: performance.now(),
parts.rendering.schedulePlayback(entry); });
parts.rendering.syncRenderHold(); parts.rendering.sampleIdle(entry);
const ready = () => parts.rendering.schedulePlayback(entry);
app.tileset?.show !== false && parts.rendering.syncRenderHold();
app.tileset?.tilesLoaded === true && const ready = () =>
(scene.globe?.show === false || scene.globe?.tilesLoaded === true) && app.tileset?.show !== false &&
trail.body?.ready === true && window.__godsEyeView.engine.surface.tilesLoaded() === true &&
(!trail.headPrimitive || trail.headPrimitive.ready === true); (scene.globe?.show === false ||
// Require sustained readiness after freezing playback, before projecting window.__godsEyeView.engine.surface.tilesLoaded() === true) &&
// onto the refined surface. Repeated captures still check pixel stability. trail.body?.ready === true &&
const tilesReady = await new Promise((resolve) => { (!trail.headPrimitive || trail.headPrimitive.ready === true);
const started = performance.now(); // Require sustained readiness after freezing playback, before projecting
let readySince = null; // onto the refined surface. Repeated captures still check pixel stability.
const timer = setInterval(() => { const tilesReady = await new Promise((resolve) => {
const now = performance.now(); const started = performance.now();
readySince = ready() ? (readySince ?? now) : null; let readySince = null;
scene.requestRender(); const timer = setInterval(() => {
if ( const now = performance.now();
(readySince !== null && now - readySince >= 500) || readySince = ready() ? (readySince ?? now) : null;
now - started >= 30000 window.__godsEyeView.engine.render.request();
) { if (
clearInterval(timer); (readySince !== null && now - readySince >= 500) ||
resolve(readySince !== null && now - readySince >= 500); now - started >= 30000
) {
clearInterval(timer);
resolve(readySince !== null && now - readySince >= 500);
}
}, 80);
});
if (!tilesReady)
return {
...setup,
present: 0,
reason: 'tiles or trail geometry did not become ready',
};
const canvas = window.__godsEyeView.engine.host.canvas,
gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
const scale = canvas.width / canvas.clientWidth;
const points = entry.trailSegments
.filter((s) => s.toT <= entry.sample.displayT)
.flatMap((s) => s.positions);
if (trail.head?.show) points.push(...trail.head.positions);
if (points.length < 2)
return { ...setup, present: 0, reason: 'no completed path' };
const C = qaGeo.cartographic;
// Marker positions are plain { x, y, z } now (the display's handles), not
// Cesium Cartesian3 instances with static helpers.
const V = {
distance: (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z),
lerp: (a, b, t) => ({
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
z: a.z + (b.z - a.z) * t,
}),
};
// What the floor samples leave out: the Cesium primitives the engine
// draws the transit mover sets and trail lines with.
const drawnByTransit = [];
const walk = (collection) => {
for (let i = 0; i < collection.length; i++) {
const item = collection.get(i);
if (
String(item?.gevMoverSet ?? '').startsWith('transit') ||
String(item?.gevGroundSet ?? '').startsWith('transit-trail-')
)
drawnByTransit.push(item);
else if (typeof item?.get === 'function' && 'length' in item)
walk(item);
} }
}, 80); };
}); walk(scene.primitives);
if (!tilesReady) walk(scene.groundPrimitives);
const lengths = [0];
for (let i = 1; i < points.length; i++)
lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i]));
const mul = (m, v) =>
[0, 1, 2, 3].map(
(i) =>
m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3],
);
const samples = Array.from({ length: 8 }, (_, i) => {
const d = (lengths.at(-1) * (i + 1)) / 9;
let j = 1;
while (j < lengths.length - 1 && lengths[j] < d) j++;
const p = V.lerp(
points[j - 1],
points[j],
(d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]),
);
const cart = C.fromCartesian(p);
const height = window.__godsEyeView.engine.surface.sampleHeight(
cart,
drawnByTransit,
);
if (Number.isFinite(height)) cart.height = height;
const surface = C.toCartesian(cart);
const q = mul(window.__godsEyeView.engine.projector.viewProjection(), [
surface.x,
surface.y,
surface.z,
1,
]);
return {
x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth,
y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight,
inFront: q[3] > 0,
radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale,
};
});
const marker = entry.marker.position;
const markerClip = mul(
window.__godsEyeView.engine.projector.viewProjection(),
[marker.x, marker.y, marker.z, 1],
);
const sprite = {
x: ((markerClip[0] / markerClip[3]) * 0.5 + 0.5) * canvas.clientWidth,
y: (0.5 - (markerClip[1] / markerClip[3]) * 0.5) * canvas.clientHeight,
// Include the padded, rotated sprite and its selection bracket.
radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4,
};
const read = () =>
samples.map(({ x, y, inFront }) => {
const r = Math.ceil(3 * scale),
px = Math.round(x * scale),
py = Math.round(canvas.height - 1 - y * scale);
if (
!inFront ||
px < r ||
py < r ||
px + r >= canvas.width ||
py + r >= canvas.height
)
return null;
const data = new Uint8Array((2 * r + 1) ** 2 * 4);
gl.readPixels(
px - r,
py - r,
2 * r + 1,
2 * r + 1,
gl.RGBA,
gl.UNSIGNED_BYTE,
data,
);
return data;
});
const frame = () =>
new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
remove();
reject(new Error('Trail framebuffer did not render within 5 s'));
}, 5000);
const remove =
window.__godsEyeView.engine.frames.postRender.addEventListener(
() => {
remove();
clearTimeout(timeout);
resolve(read());
},
);
window.__godsEyeView.engine.render.request();
});
const trailRenderer = window.__qaTransitTrail;
if (!trailRenderer)
return { ...setup, present: 0, reason: 'no trail renderer captured' };
let showTrail = false;
// Body and head together, as the trail's own visibility switch does.
const applyVisibility = () => trailRenderer.setVisible(showTrail);
// Override the layer's frame update in both states; selection stays fixed.
const removeVisibility =
window.__godsEyeView.engine.frames.preRender.addEventListener(
applyVisibility,
);
const captures = [];
let captureReady = true;
try {
for (const visible of [false, true, false, true]) {
showTrail = visible;
// Let post-processing settle before each measured framebuffer.
for (let i = 0; i < 3; i++) await frame();
captures.push(await frame());
const current = parts.trails.diagnostics();
captureReady &&=
ready() &&
current?.body === trail.body &&
current?.headPrimitive === trail.headPrimitive;
}
} finally {
removeVisibility();
trailRenderer.setVisible(true);
await frame();
}
const [off, on, offAgain, onAgain] = captures.map((patches) =>
patches.map((p) => (p ? Array.from(p) : null)),
);
return { return {
...setup, ...setup,
present: 0, fixes: entry.track.count,
reason: 'tiles or trail geometry did not become ready', selected: state._selectedKey === setup.key,
pathMetres: lengths.at(-1),
samples,
on,
off,
onAgain,
offAgain,
sprite,
tilesReady: captureReady,
elapsedDisplayMs: entry.sample.displayT - setup.startDisplayT,
movedMetres: Math.abs(entry.sample.lat - setup.startLat) * 111320,
lines: trail.body?.lines ?? 0,
headStrategy: trail.headStrategy,
headRebuilds: trail.headRebuilds ?? null,
}; };
const canvas = scene.canvas, },
gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); setup,
const scale = canvas.width / canvas.clientWidth; );
const points = entry.trailSegments
.filter((s) => s.toT <= entry.sample.displayT)
.flatMap((s) => s.positions);
if (trail.head?.show) points.push(...trail.head.positions);
if (points.length < 2)
return { ...setup, present: 0, reason: 'no completed path' };
const C = scene.camera.positionCartographic.constructor;
// Marker positions are plain { x, y, z } now (the display's handles), not
// Cesium Cartesian3 instances with static helpers.
const V = {
distance: (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z),
lerp: (a, b, t) => ({
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
z: a.z + (b.z - a.z) * t,
}),
};
// What the floor samples leave out: the Cesium primitives the engine
// draws the transit mover sets and trail lines with.
const drawnByTransit = [];
const walk = (collection) => {
for (let i = 0; i < collection.length; i++) {
const item = collection.get(i);
if (
String(item?.gevMoverSet ?? '').startsWith('transit') ||
String(item?.gevGroundSet ?? '').startsWith('transit-trail-')
)
drawnByTransit.push(item);
else if (typeof item?.get === 'function' && 'length' in item)
walk(item);
}
};
walk(scene.primitives);
walk(scene.groundPrimitives);
const lengths = [0];
for (let i = 1; i < points.length; i++)
lengths.push(lengths.at(-1) + V.distance(points[i - 1], points[i]));
const mul = (m, v) =>
[0, 1, 2, 3].map(
(i) =>
m[i] * v[0] + m[i + 4] * v[1] + m[i + 8] * v[2] + m[i + 12] * v[3],
);
const samples = Array.from({ length: 8 }, (_, i) => {
const d = (lengths.at(-1) * (i + 1)) / 9;
let j = 1;
while (j < lengths.length - 1 && lengths[j] < d) j++;
const p = V.lerp(
points[j - 1],
points[j],
(d - lengths[j - 1]) / Math.max(0.001, lengths[j] - lengths[j - 1]),
);
const cart = C.fromCartesian(p);
const height = scene.sampleHeight(cart, drawnByTransit);
if (Number.isFinite(height)) cart.height = height;
const surface = C.toCartesian(cart);
const q = mul(
scene.camera.frustum.projectionMatrix,
mul(scene.camera.viewMatrix, [surface.x, surface.y, surface.z, 1]),
);
return {
x: ((q[0] / q[3]) * 0.5 + 0.5) * canvas.clientWidth,
y: (0.5 - (q[1] / q[3]) * 0.5) * canvas.clientHeight,
inFront: q[3] > 0,
radius: (Math.SQRT2 * Math.ceil(3 * scale)) / scale,
};
});
const marker = entry.marker.position;
const markerClip = mul(
scene.camera.frustum.projectionMatrix,
mul(scene.camera.viewMatrix, [marker.x, marker.y, marker.z, 1]),
);
const sprite = {
x: ((markerClip[0] / markerClip[3]) * 0.5 + 0.5) * canvas.clientWidth,
y: (0.5 - (markerClip[1] / markerClip[3]) * 0.5) * canvas.clientHeight,
// Include the padded, rotated sprite and its selection bracket.
radius: Math.hypot(entry.marker.width, entry.marker.height) / 2 + 4,
};
const read = () =>
samples.map(({ x, y, inFront }) => {
const r = Math.ceil(3 * scale),
px = Math.round(x * scale),
py = Math.round(canvas.height - 1 - y * scale);
if (
!inFront ||
px < r ||
py < r ||
px + r >= canvas.width ||
py + r >= canvas.height
)
return null;
const data = new Uint8Array((2 * r + 1) ** 2 * 4);
gl.readPixels(
px - r,
py - r,
2 * r + 1,
2 * r + 1,
gl.RGBA,
gl.UNSIGNED_BYTE,
data,
);
return data;
});
const frame = () =>
new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
remove();
reject(new Error('Trail framebuffer did not render within 5 s'));
}, 5000);
const remove = scene.postRender.addEventListener(() => {
remove();
clearTimeout(timeout);
resolve(read());
});
scene.requestRender();
});
const trailRenderer = window.__qaTransitTrail;
if (!trailRenderer)
return { ...setup, present: 0, reason: 'no trail renderer captured' };
let showTrail = false;
// Body and head together, as the trail's own visibility switch does.
const applyVisibility = () => trailRenderer.setVisible(showTrail);
// Override the layer's frame update in both states; selection stays fixed.
const removeVisibility = scene.preRender.addEventListener(applyVisibility);
const captures = [];
let captureReady = true;
try {
for (const visible of [false, true, false, true]) {
showTrail = visible;
// Let post-processing settle before each measured framebuffer.
for (let i = 0; i < 3; i++) await frame();
captures.push(await frame());
const current = parts.trails.diagnostics();
captureReady &&=
ready() &&
current?.body === trail.body &&
current?.headPrimitive === trail.headPrimitive;
}
} finally {
removeVisibility();
trailRenderer.setVisible(true);
await frame();
}
const [off, on, offAgain, onAgain] = captures.map((patches) =>
patches.map((p) => (p ? Array.from(p) : null)),
);
return {
...setup,
fixes: entry.track.count,
selected: state._selectedKey === setup.key,
pathMetres: lengths.at(-1),
samples,
on,
off,
onAgain,
offAgain,
sprite,
tilesReady: captureReady,
elapsedDisplayMs: entry.sample.displayT - setup.startDisplayT,
movedMetres: Math.abs(entry.sample.lat - setup.startLat) * 111320,
lines: trail.body?.lines ?? 0,
headStrategy: trail.headStrategy,
headRebuilds: trail.headRebuilds ?? null,
};
}, setup);
const pixels = reduceTrailPixels(result.on, result.off, result); const pixels = reduceTrailPixels(result.on, result.off, result);
result.cost = cost; result.cost = cost;
result.altitude = altitude; result.altitude = altitude;
+169 -147
View File
@@ -1,4 +1,9 @@
#!/usr/bin/env node #!/usr/bin/env node
// Cesium-only probes: native entity counts audit Cesium resource ownership alongside the contract checks.
import { whenSettled, whenUiSettled, whenFramesDrawn } from './qa/ready.mjs';
import { evaluateWithGeo } from './qa/geometry.mjs';
import { hookRenderErrors } from './qa-browserEvidence.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import { runTransitHeadingRegression } from './qa-transit-heading.mjs'; import { runTransitHeadingRegression } from './qa-transit-heading.mjs';
import { import {
boundPageEvaluations, boundPageEvaluations,
@@ -36,7 +41,6 @@ import {
* exercises the same paths. * exercises the same paths.
*/ */
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import puppeteer from 'puppeteer';
import { blockPaidQaMaps } from './qa-map-policy.mjs'; import { blockPaidQaMaps } from './qa-map-policy.mjs';
import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs';
import { createTransitHistory } from '../server/providers/transitHistory.js'; import { createTransitHistory } from '../server/providers/transitHistory.js';
@@ -167,13 +171,10 @@ async function sampleScreen(page) {
const app = window.__godsEyeView; const app = window.__godsEyeView;
const layer = app.dataManager.layers.get('transit').module; const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest(); const state = layer._transitStateForTest();
const scene = app.viewer.scene; const canvas = window.__godsEyeView.engine.host.canvas;
const canvas = scene.canvas;
// The app exposes no `window.Cesium`, so the projection is done here in // The app exposes no `window.Cesium`, so the projection is done here in
// plain numbers: Cesium matrices are column-major arrays of 16 doubles, // plain numbers: Cesium matrices are column-major arrays of 16 doubles,
// and window coordinates are the standard clip -> NDC -> viewport walk. // and window coordinates are the standard clip -> NDC -> viewport walk.
const view = scene.camera.viewMatrix;
const proj = scene.camera.frustum.projectionMatrix;
const mul = (m, v) => [ const mul = (m, v) => [
m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3], m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3],
m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3], m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3],
@@ -186,7 +187,12 @@ async function sampleScreen(page) {
for (const entry of state._vehicles.values()) { for (const entry of state._vehicles.values()) {
if (!entry.marker || entry.marker.show === false) continue; if (!entry.marker || entry.marker.show === false) continue;
const p = entry.marker.position; const p = entry.marker.position;
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); const clip = mul(window.__godsEyeView.engine.projector.viewProjection(), [
p.x,
p.y,
p.z,
1,
]);
if (!(clip[3] > 0)) continue; if (!(clip[3] > 0)) continue;
const ndcX = clip[0] / clip[3]; const ndcX = clip[0] / clip[3];
const ndcY = clip[1] / clip[3]; const ndcY = clip[1] / clip[3];
@@ -207,11 +213,11 @@ async function sampleScreen(page) {
} }
async function sampleVisible(page) { async function sampleVisible(page) {
return page.evaluate(() => { return evaluateWithGeo(page, async (qaGeo) => {
const app = window.__godsEyeView; const app = window.__godsEyeView;
const layer = app.dataManager.layers.get('transit').module; const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest(); const state = layer._transitStateForTest();
const Cartographic = app.viewer.camera.positionCartographic.constructor; const Cartographic = qaGeo.cartographic;
return [...state._vehicles.values()] return [...state._vehicles.values()]
.filter((entry) => entry.marker && entry.marker.show !== false) .filter((entry) => entry.marker && entry.marker.show !== false)
.slice(0, 200) .slice(0, 200)
@@ -263,13 +269,11 @@ check(
historyProbe.clear(); historyProbe.clear();
await mkdir(SHOTS, { recursive: true }); await mkdir(SHOTS, { recursive: true });
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: true, headless: true,
protocolTimeout: 150000, protocolTimeout: 150000,
executablePath: chromeExecutablePath(),
args: [ args: [
'--no-sandbox', '--no-sandbox',
'--use-angle=metal',
'--enable-gpu', '--enable-gpu',
'--disable-background-timer-throttling', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--disable-renderer-backgrounding',
@@ -278,6 +282,7 @@ const browser = await puppeteer.launch({
], ],
}); });
const page = await browser.newPage(); const page = await browser.newPage();
await hookRenderErrors(page);
await blockPaidQaMaps(page); await blockPaidQaMaps(page);
// This gate measures visible-page motion. OS minimization must not silently // This gate measures visible-page motion. OS minimization must not silently
// switch it into the app's intentional hidden-tab polling pause. // switch it into the app's intentional hidden-tab polling pause.
@@ -320,7 +325,7 @@ try {
Boolean(document.querySelector('[data-layer-id="transit"]')), Boolean(document.querySelector('[data-layer-id="transit"]')),
{ timeout: 90_000 }, { timeout: 90_000 },
); );
await wait(6_000); await whenSettled(page);
// The Data Layers panel starts collapsed; a user opens it before toggling. // The Data Layers panel starts collapsed; a user opens it before toggling.
const expanded = await page.$eval( const expanded = await page.$eval(
@@ -331,7 +336,7 @@ try {
return false; return false;
}, },
); );
if (!expanded) await wait(800); if (!expanded) await whenUiSettled(page);
check( check(
'the Data Layers panel opens from its own header button', 'the Data Layers panel opens from its own header button',
await page.$eval( await page.$eval(
@@ -371,7 +376,7 @@ try {
await page.$eval(row, (element) => await page.$eval(row, (element) =>
element.scrollIntoView({ block: 'center' }), element.scrollIntoView({ block: 'center' }),
); );
await wait(400); await whenUiSettled(page);
const before = transitRequests.length; const before = transitRequests.length;
await page.click(`${row} .data-toggle-btn`); await page.click(`${row} .data-toggle-btn`);
await page.waitForFunction( await page.waitForFunction(
@@ -385,24 +390,24 @@ try {
); );
await page.evaluate(() => { await page.evaluate(() => {
window.__transitRenderErrors = []; window.__transitRenderErrors = window.__qaRenderErrors;
window.__godsEyeView.viewer.scene.renderError.addEventListener(
(scene, error) => window.__transitRenderErrors.push(error.message),
);
}); });
for (const city of runs('cities') ? CITIES : []) { for (const city of runs('cities') ? CITIES : []) {
console.log(`\n== ${city.label} ==`); console.log(`\n== ${city.label} ==`);
// City-wide first: enough altitude to see the fleet as a fleet. // City-wide first: enough altitude to see the fleet as a fleet.
await page.evaluate((c) => { await evaluateWithGeo(
const camera = window.__godsEyeView.viewer.camera; page,
const Cartographic = camera.positionCartographic.constructor; async (qaGeo, c) => {
camera.setView({ const Cartographic = qaGeo.cartographic;
destination: Cartographic.toCartesian( window.__godsEyeView.engine.camera.setView({
Cartographic.fromDegrees(c.lon, c.lat - 0.03, 3_500), destination: Cartographic.toCartesian(
), Cartographic.fromDegrees(c.lon, c.lat - 0.03, 3_500),
orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 }, ),
}); orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 },
}, city); });
},
city,
);
const arrived = await page const arrived = await page
.waitForFunction( .waitForFunction(
(feedId) => (feedId) =>
@@ -428,7 +433,7 @@ try {
); );
continue; continue;
} }
await wait(9_000); await whenSettled(page);
const fleet = await page.evaluate((feedId) => { const fleet = await page.evaluate((feedId) => {
const layer = const layer =
window.__godsEyeView.dataManager.layers.get('transit').module; window.__godsEyeView.dataManager.layers.get('transit').module;
@@ -451,49 +456,52 @@ try {
// Street level: 600 m up, 45 degrees down, framed on a real vehicle so the // Street level: 600 m up, 45 degrees down, framed on a real vehicle so the
// shot shows what a user sees when they drop in on one. // shot shows what a user sees when they drop in on one.
const framed = await page.evaluate((c) => { const framed = await evaluateWithGeo(
const app = window.__godsEyeView; page,
const camera = app.viewer.camera; async (qaGeo, c) => {
const Cartographic = camera.positionCartographic.constructor; const app = window.__godsEyeView;
const state = app.dataManager.layers const Cartographic = qaGeo.cartographic;
.get('transit') const state = app.dataManager.layers
.module._transitStateForTest(); .get('transit')
let best = null; .module._transitStateForTest();
for (const entry of state._vehicles.values()) { let best = null;
if (!entry.key.startsWith(`${c.feedId}:`)) continue; for (const entry of state._vehicles.values()) {
const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon); if (!entry.key.startsWith(`${c.feedId}:`)) continue;
if (!best || d < best.d) const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon);
best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key }; if (!best || d < best.d)
} best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key };
if (!best) return null; }
// Camera 600 m back along the heading, 600 m up, looking 45 degrees down: if (!best) return null;
// the vehicle lands in the middle of the frame. // Camera 600 m back along the heading, 600 m up, looking 45 degrees down:
const heading = (c.heading * Math.PI) / 180; // the vehicle lands in the middle of the frame.
const lat = best.lat - (600 * Math.cos(heading)) / 111_320; const heading = (c.heading * Math.PI) / 180;
const lon = const lat = best.lat - (600 * Math.cos(heading)) / 111_320;
best.lon - const lon =
(600 * Math.sin(heading)) / best.lon -
(111_320 * Math.cos((best.lat * Math.PI) / 180)); (600 * Math.sin(heading)) /
camera.setView({ (111_320 * Math.cos((best.lat * Math.PI) / 180));
destination: Cartographic.toCartesian( window.__godsEyeView.engine.camera.setView({
Cartographic.fromDegrees(lon, lat, 600), destination: Cartographic.toCartesian(
), Cartographic.fromDegrees(lon, lat, 600),
orientation: { heading, pitch: (-45 * Math.PI) / 180, roll: 0 }, ),
}); orientation: { heading, pitch: (-45 * Math.PI) / 180, roll: 0 },
return best; });
}, city); return best;
},
city,
);
check( check(
`${city.label}: a vehicle to fly to`, `${city.label}: a vehicle to fly to`,
Boolean(framed), Boolean(framed),
framed?.key || 'none', framed?.key || 'none',
); );
await wait(9_000); await whenSettled(page);
const first = await page.evaluate(() => { const first = await evaluateWithGeo(page, async (qaGeo) => {
const app = window.__godsEyeView; const app = window.__godsEyeView;
const layer = app.dataManager.layers.get('transit').module; const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest(); const state = layer._transitStateForTest();
const Cartographic = app.viewer.camera.positionCartographic.constructor; const Cartographic = qaGeo.cartographic;
return { return {
count: state._vehicles.size, count: state._vehicles.size,
stats: layer.getStats(), stats: layer.getStats(),
@@ -528,18 +536,19 @@ try {
const state = app.dataManager.layers const state = app.dataManager.layers
.get('transit') .get('transit')
.module._transitStateForTest(); .module._transitStateForTest();
const scene = app.viewer.scene;
const seen = []; const seen = [];
for (const entry of state._vehicles.values()) { for (const entry of state._vehicles.values()) {
const screen = scene.cartesianToCanvasCoordinates( const screen = window.__godsEyeView.engine.projector.worldToWindow(
entry.marker.position, entry.marker.position,
); );
if ( if (
screen && screen &&
screen.x > 360 && screen.x > 360 &&
screen.x < scene.canvas.clientWidth - 120 && screen.x <
window.__godsEyeView.engine.host.canvas.clientWidth - 120 &&
screen.y > 120 && screen.y > 120 &&
screen.y < scene.canvas.clientHeight - 160 screen.y <
window.__godsEyeView.engine.host.canvas.clientHeight - 160
) { ) {
seen.push({ seen.push({
key: entry.key, key: entry.key,
@@ -552,7 +561,7 @@ try {
}, attempt); }, attempt);
if (!target) break; if (!target) break;
await page.mouse.click(target.x, target.y); await page.mouse.click(target.x, target.y);
await wait(900); await whenUiSettled(page);
selected = await page.evaluate(() => { selected = await page.evaluate(() => {
const app = window.__godsEyeView; const app = window.__godsEyeView;
const key = app.dataManager.layers const key = app.dataManager.layers
@@ -711,7 +720,7 @@ try {
}); });
for (const preset of presets) { for (const preset of presets) {
const how = await selectStyle(page, preset.style, preset.params); const how = await selectStyle(page, preset.style, preset.params);
await wait(1_200); await whenUiSettled(page);
const styling = await page.evaluate(() => const styling = await page.evaluate(() =>
window.__godsEyeView.dataManager.layers window.__godsEyeView.dataManager.layers
.get('transit') .get('transit')
@@ -782,7 +791,7 @@ try {
const clickTarget = rendered[0]; const clickTarget = rendered[0];
if (clickTarget) { if (clickTarget) {
await page.mouse.click(clickTarget.x, clickTarget.y); await page.mouse.click(clickTarget.x, clickTarget.y);
await wait(100); await whenUiSettled(page);
const picked = await page.evaluate((key) => { const picked = await page.evaluate((key) => {
const state = window.__godsEyeView.dataManager.layers const state = window.__godsEyeView.dataManager.layers
.get('transit') .get('transit')
@@ -816,7 +825,7 @@ try {
const target = (await sampleRendered(page))[0]; const target = (await sampleRendered(page))[0];
if (target) { if (target) {
await page.mouse.click(target.x, target.y); await page.mouse.click(target.x, target.y);
await wait(800); await whenUiSettled(page);
const underFlir = await page.evaluate((key) => { const underFlir = await page.evaluate((key) => {
const layer = const layer =
window.__godsEyeView.dataManager.layers.get('transit').module; window.__godsEyeView.dataManager.layers.get('transit').module;
@@ -851,7 +860,7 @@ try {
JSON.stringify(underFlir), JSON.stringify(underFlir),
); );
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await wait(500); await whenUiSettled(page);
} else { } else {
check( check(
'clicking a sprite under FLIR selects it and keeps it white-hot', 'clicking a sprite under FLIR selects it and keeps it white-hot',
@@ -881,16 +890,18 @@ try {
// Motion acceptance uses known report-time playback even when live fleets park. // Motion acceptance uses known report-time playback even when live fleets park.
await selectStyle(page, 'normal'); await selectStyle(page, 'normal');
await page.evaluate((city) => { await evaluateWithGeo(
const app = window.__godsEyeView, page,
camera = app.viewer.camera; async (qaGeo, city) => {
const C = camera.positionCartographic.constructor; const C = qaGeo.cartographic;
camera.setView({ window.__godsEyeView.engine.camera.setView({
destination: C.toCartesian(C.fromDegrees(city.lon, city.lat, 600)), destination: C.toCartesian(C.fromDegrees(city.lon, city.lat, 600)),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
}); });
}, city); },
await wait(2000); city,
);
await whenUiSettled(page);
await page.evaluate( await page.evaluate(
(city) => (city) =>
window.__godsEyeView.dataManager.layers window.__godsEyeView.dataManager.layers
@@ -901,11 +912,11 @@ try {
await wait(1000); await wait(1000);
const motionFirst = await sampleVisible(page); const motionFirst = await sampleVisible(page);
await wait(30_000); await wait(30_000);
const second = await page.evaluate(() => { const second = await evaluateWithGeo(page, async (qaGeo) => {
const app = window.__godsEyeView; const app = window.__godsEyeView;
const layer = app.dataManager.layers.get('transit').module; const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest(); const state = layer._transitStateForTest();
const Cartographic = app.viewer.camera.positionCartographic.constructor; const Cartographic = qaGeo.cartographic;
return { return {
count: state._vehicles.size, count: state._vehicles.size,
// The RENDERED primitive position, read back through Cesium — not the // The RENDERED primitive position, read back through Cesium — not the
@@ -1052,6 +1063,7 @@ try {
if (runs('history')) { if (runs('history')) {
console.log('\n== retained history reload =='); console.log('\n== retained history reload ==');
const recovery = await browser.newPage(); const recovery = await browser.newPage();
await hookRenderErrors(recovery);
await blockPaidQaMaps(recovery); await blockPaidQaMaps(recovery);
boundPageEvaluations(recovery); boundPageEvaluations(recovery);
try { try {
@@ -1106,10 +1118,7 @@ try {
{ timeout: 90000 }, { timeout: 90000 },
); );
await recovery.evaluate(() => { await recovery.evaluate(() => {
window.__transitRenderErrors = []; window.__transitRenderErrors = window.__qaRenderErrors;
window.__godsEyeView.viewer.scene.renderError.addEventListener(
(scene, error) => window.__transitRenderErrors.push(error.message),
);
}); });
const restored = await recovery.evaluate( const restored = await recovery.evaluate(
restoreRetainedTransit, restoreRetainedTransit,
@@ -1124,7 +1133,7 @@ try {
.trails.requestDiagnostics().pending, .trails.requestDiagnostics().pending,
{ timeout: 20000 }, { timeout: 20000 },
); );
await wait(4000); await whenFramesDrawn(recovery);
const proof = await recovery.evaluate( const proof = await recovery.evaluate(
({ key, oldest }) => { ({ key, oldest }) => {
const layer = const layer =
@@ -1189,16 +1198,19 @@ try {
// every rendered frame is sampled. // every rendered frame is sampled.
if (runs('playback')) { if (runs('playback')) {
console.log('\n== deterministic playback =='); console.log('\n== deterministic playback ==');
await page.evaluate((c) => { await evaluateWithGeo(
const camera = window.__godsEyeView.viewer.camera; page,
const Cartographic = camera.positionCartographic.constructor; async (qaGeo, c) => {
camera.setView({ const Cartographic = qaGeo.cartographic;
destination: Cartographic.toCartesian( window.__godsEyeView.engine.camera.setView({
Cartographic.fromDegrees(c.lon, c.lat + 0.005, 2400), destination: Cartographic.toCartesian(
), Cartographic.fromDegrees(c.lon, c.lat + 0.005, 2400),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, ),
}); orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
}, BOSTON); });
},
BOSTON,
);
await page.evaluate(() => { await page.evaluate(() => {
window.__godsEyeView.dataManager.layers window.__godsEyeView.dataManager.layers
.get('transit') .get('transit')
@@ -1345,14 +1357,14 @@ try {
console.log(` scripted fleet before trace: ${await simState()}`); console.log(` scripted fleet before trace: ${await simState()}`);
await reportTransitVisibility(page, 'scripted visibility before trace'); await reportTransitVisibility(page, 'scripted visibility before trace');
// Sample every rendered frame for two minutes. // Sample every rendered frame for two minutes.
const trace = await page.evaluate( const trace = await evaluateWithGeo(
(durationMs) => page,
(qaGeo, durationMs) =>
new Promise((resolve) => { new Promise((resolve) => {
const app = window.__godsEyeView; const app = window.__godsEyeView;
const layer = app.dataManager.layers.get('transit').module; const layer = app.dataManager.layers.get('transit').module;
const state = layer._transitStateForTest(); const state = layer._transitStateForTest();
const Cartographic = const Cartographic = qaGeo.cartographic;
app.viewer.camera.positionCartographic.constructor;
const keys = [ const keys = [
'sim-straight', 'sim-straight',
'sim-corner', 'sim-corner',
@@ -1410,7 +1422,10 @@ try {
resolve({ out, words }); resolve({ out, words });
} }
}; };
remove = app.viewer.scene.postRender.addEventListener(tick); remove =
window.__godsEyeView.engine.frames.postRender.addEventListener(
tick,
);
}), }),
120_000, 120_000,
); );
@@ -1552,23 +1567,22 @@ try {
// ---- Card anchor synchronisation --------------------------------------- // ---- Card anchor synchronisation ---------------------------------------
console.log('\n== card anchor =='); console.log('\n== card anchor ==');
await page.evaluate(() => { await evaluateWithGeo(page, async (qaGeo) => {
const app = window.__godsEyeView, const app = window.__godsEyeView;
camera = app.viewer.camera;
const entry = app.dataManager.layers const entry = app.dataManager.layers
.get('transit') .get('transit')
.module._transitStateForTest() .module._transitStateForTest()
._vehicles.get('mbta:sim-straight'); ._vehicles.get('mbta:sim-straight');
if (!entry) return; if (!entry) return;
const C = camera.positionCartographic.constructor; const C = qaGeo.cartographic;
const at = C.fromCartesian(entry.marker.position); const at = C.fromCartesian(entry.marker.position);
at.height = 600; at.height = 600;
camera.setView({ window.__godsEyeView.engine.camera.setView({
destination: C.toCartesian(at), destination: C.toCartesian(at),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
}); });
}); });
await wait(1500); await whenUiSettled(page);
await reportTransitVisibility(page, 'scripted visibility before click'); await reportTransitVisibility(page, 'scripted visibility before click');
const target = await page.evaluate(() => { const target = await page.evaluate(() => {
const app = window.__godsEyeView; const app = window.__godsEyeView;
@@ -1576,10 +1590,7 @@ try {
const state = layer._transitStateForTest(); const state = layer._transitStateForTest();
const entry = state._vehicles.get('mbta:sim-straight'); const entry = state._vehicles.get('mbta:sim-straight');
if (!entry?.marker?.show) return null; if (!entry?.marker?.show) return null;
const scene = app.viewer.scene; const canvas = window.__godsEyeView.engine.host.canvas;
const canvas = scene.canvas;
const view = scene.camera.viewMatrix;
const proj = scene.camera.frustum.projectionMatrix;
const mul = (m, v) => [ const mul = (m, v) => [
m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3], m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3],
m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3], m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3],
@@ -1587,7 +1598,12 @@ try {
m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3], m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3],
]; ];
const p = entry.marker.position; const p = entry.marker.position;
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); const clip = mul(window.__godsEyeView.engine.projector.viewProjection(), [
p.x,
p.y,
p.z,
1,
]);
const x = ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth; const x = ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth;
const y = (1 - ((clip[1] / clip[3]) * 0.5 + 0.5)) * canvas.clientHeight; const y = (1 - ((clip[1] / clip[3]) * 0.5 + 0.5)) * canvas.clientHeight;
if ( if (
@@ -1615,7 +1631,7 @@ try {
JSON.stringify(target), JSON.stringify(target),
); );
await page.mouse.click(target.x, target.y); await page.mouse.click(target.x, target.y);
await wait(600); await whenUiSettled(page);
anchor = await page.evaluate( anchor = await page.evaluate(
async (key, durationMs) => { async (key, durationMs) => {
const overlay = ( const overlay = (
@@ -1637,8 +1653,7 @@ try {
).pointerOwner(), ).pointerOwner(),
}, },
}; };
const scene = app.viewer.scene; const canvas = window.__godsEyeView.engine.host.canvas;
const canvas = scene.canvas;
const mul = (m, v) => [ const mul = (m, v) => [
m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3], m[0] * v[0] + m[4] * v[1] + m[8] * v[2] + m[12] * v[3],
m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3], m[1] * v[0] + m[5] * v[1] + m[9] * v[2] + m[13] * v[3],
@@ -1659,10 +1674,11 @@ try {
const entry = state._vehicles.get(key); const entry = state._vehicles.get(key);
const rect = overlay.getPaintRect('transit-selected', key); const rect = overlay.getPaintRect('transit-selected', key);
if (entry?.marker) { if (entry?.marker) {
const view = scene.camera.viewMatrix;
const proj = scene.camera.frustum.projectionMatrix;
const p = entry.marker.position; const p = entry.marker.position;
const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); const clip = mul(
window.__godsEyeView.engine.projector.viewProjection(),
[p.x, p.y, p.z, 1],
);
if ( if (
entry.marker.show !== false && entry.marker.show !== false &&
rect && rect &&
@@ -1698,8 +1714,8 @@ try {
if (trail.head.show && trail.head.positions.length) { if (trail.head.show && trail.head.positions.length) {
const project = (p) => { const project = (p) => {
const clip = mul( const clip = mul(
scene.camera.frustum.projectionMatrix, window.__godsEyeView.engine.projector.viewProjection(),
mul(scene.camera.viewMatrix, [p.x, p.y, p.z, 1]), [p.x, p.y, p.z, 1],
); );
return [ return [
((clip[0] / clip[3]) * canvas.clientWidth) / 2, ((clip[0] / clip[3]) * canvas.clientWidth) / 2,
@@ -1742,7 +1758,10 @@ try {
}); });
}; };
const timer = setTimeout(finish, durationMs + 1000); const timer = setTimeout(finish, durationMs + 1000);
remove = scene.postRender.addEventListener(tick); remove =
window.__godsEyeView.engine.frames.postRender.addEventListener(
tick,
);
}); });
}, },
target.key, target.key,
@@ -1816,21 +1835,24 @@ try {
afterRemoval.gone && afterRemoval.selected !== 'mbta:sim-straight', afterRemoval.gone && afterRemoval.selected !== 'mbta:sim-straight',
JSON.stringify(afterRemoval), JSON.stringify(afterRemoval),
); );
await page.evaluate((c) => { await evaluateWithGeo(
const camera = window.__godsEyeView.viewer.camera; page,
const Cartographic = camera.positionCartographic.constructor; async (qaGeo, c) => {
camera.setView({ const Cartographic = qaGeo.cartographic;
destination: Cartographic.toCartesian( window.__godsEyeView.engine.camera.setView({
Cartographic.fromDegrees(c.lon, c.lat, 600), destination: Cartographic.toCartesian(
), Cartographic.fromDegrees(c.lon, c.lat, 600),
orientation: { ),
heading: (c.heading * Math.PI) / 180, orientation: {
pitch: (-45 * Math.PI) / 180, heading: (c.heading * Math.PI) / 180,
roll: 0, pitch: (-45 * Math.PI) / 180,
}, roll: 0,
}); },
}, CITIES.at(-1)); });
await wait(4_000); },
CITIES.at(-1),
);
await whenSettled(page);
} }
const sceneQA = { const sceneQA = {
@@ -1921,7 +1943,7 @@ try {
if (button.getAttribute('aria-expanded') !== 'true') button.click(); if (button.getAttribute('aria-expanded') !== 'true') button.click();
}, },
); );
await wait(1_200); await whenFramesDrawn(page);
check( check(
'the CONTEXT panel opens from its own header button', 'the CONTEXT panel opens from its own header button',
await page.$eval( await page.$eval(
@@ -1937,12 +1959,12 @@ try {
origin: 'user', origin: 'user',
}), }),
); );
await wait(6_000); await whenFramesDrawn(page);
check('transit is on before entering a Context mode', await isOn()); check('transit is on before entering a Context mode', await isOn());
// CONTACTS // CONTACTS
await page.click('#global-context-flights-btn'); await page.click('#global-context-flights-btn');
await wait(18_000); await whenFramesDrawn(page);
const inContacts = { const inContacts = {
transit: await isOn(), transit: await isOn(),
vehicles: await page.evaluate( vehicles: await page.evaluate(
@@ -1959,7 +1981,7 @@ try {
JSON.stringify(inContacts), JSON.stringify(inContacts),
); );
await page.click('#global-context-flights-btn'); await page.click('#global-context-flights-btn');
await wait(12_000); await whenFramesDrawn(page);
check('leaving Contacts restores Transit', await isOn()); check('leaving Contacts restores Transit', await isOn());
// SPACE MISSIONS // SPACE MISSIONS
@@ -1968,9 +1990,9 @@ try {
origin: 'user', origin: 'user',
}), }),
); );
await wait(2_000); await whenFramesDrawn(page);
await page.click('#global-context-missions-btn'); await page.click('#global-context-missions-btn');
await wait(20_000); await whenFramesDrawn(page);
const blocked = await page.evaluate(async () => { const blocked = await page.evaluate(async () => {
const settled = await window.__godsEyeView.dataManager.setEnabled( const settled = await window.__godsEyeView.dataManager.setEnabled(
'transit', 'transit',
@@ -1988,7 +2010,7 @@ try {
JSON.stringify(blocked), JSON.stringify(blocked),
); );
await page.click('#global-context-missions-btn'); await page.click('#global-context-missions-btn');
await wait(12_000); await whenFramesDrawn(page);
check( check(
'leaving Space Missions leaves Transit off, as it was on entry', 'leaving Space Missions leaves Transit off, as it was on entry',
(await isOn()) === false, (await isOn()) === false,
@@ -2017,7 +2039,7 @@ try {
error.stack || error.message, error.stack || error.message,
); );
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
console.log( console.log(
failures failures
+7 -9
View File
@@ -1,13 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env node
import { whenSettled } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
/** Exercise the real UI disposal path, including unrelated resource owners. */ /** Exercise the real UI disposal path, including unrelated resource owners. */
import puppeteer from 'puppeteer'; const browser = await launchBrowser({
const browser = await puppeteer.launch({
headless: true, headless: true,
args: [ args: [
'--no-sandbox', '--no-sandbox',
...(process.platform === 'darwin' ...(process.platform === 'darwin' ? ['--enable-gpu'] : []),
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
], ],
}); });
const page = await browser.newPage(); const page = await browser.newPage();
@@ -24,11 +23,10 @@ try {
{ waitUntil: 'domcontentloaded' }, { waitUntil: 'domcontentloaded' },
); );
await page.waitForFunction( await page.waitForFunction(
() => () => window.__godsEyeView?.styleManager?._dataManager,
window.__godsEyeView?.styleManager?._dataManager &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60000 }, { timeout: 60000 },
); );
await whenSettled(page);
const result = await page.evaluate(async () => { const result = await page.evaluate(async () => {
const ui = window.__godsEyeView.styleManager; const ui = window.__godsEyeView.styleManager;
const counts = {}; const counts = {};
@@ -287,6 +285,6 @@ try {
errors.length === 0, errors.length === 0,
); );
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
if (failures) process.exitCode = 1; if (failures) process.exitCode = 1;
+22 -55
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
import { evaluateWithGeo } from './qa/geometry.mjs';
import { whenFramesDrawn } from './qa/ready.mjs';
/** /**
* qa-vessel-cards.mjs — operator-side visual proof harness for AIS cards in * qa-vessel-cards.mjs — operator-side visual proof harness for AIS cards in
* the shared world-overlay host. * the shared world-overlay host.
@@ -26,10 +28,10 @@
* The name and manifest both record the data source; the manifest also records * The name and manifest both record the data source; the manifest also records
* headless/headful launch mode and the browser's WebGL renderer. * headless/headful launch mode and the browser's WebGL renderer.
*/ */
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const getOpt = (name, dflt) => { const getOpt = (name, dflt) => {
@@ -220,28 +222,7 @@ function syntheticVesselRows(portKey, port) {
})); }));
} }
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
// Chrome: /Applications auto-updates underneath the harnesses, and its
// software-GL behavior shifts across majors (system Chrome 150 blew the
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
].filter(Boolean);
function findChromeExecutable() {
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch { /* fall through to Puppeteer's cache */ }
}
return null;
}
async function main() { async function main() {
console.log(`\nAIS Vessel Cards — visual proof harness`); console.log(`\nAIS Vessel Cards — visual proof harness`);
@@ -265,19 +246,9 @@ async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true }); fs.mkdirSync(OUT_DIR, { recursive: true });
const chromeExecutable = findChromeExecutable(); const browser = await launchBrowser({
const browser = await puppeteer.launch({ headless: true,
headless: HEADFUL ? false : 'new', args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1600,900'],
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-angle=metal',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1600,900',
],
}); });
let failures = 0; let failures = 0;
@@ -312,10 +283,10 @@ async function main() {
if (ENGINE) if (ENGINE)
await page.waitForFunction(() => !!window.__godsEyeView?.layers, { timeout: 180000 }); await page.waitForFunction(() => !!window.__godsEyeView?.layers, { timeout: 180000 });
else else
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 60000 }); await page.waitForFunction(() => !!window.__godsEyeView?.engine, { timeout: 60000 });
// Frame the port (kill the intro flight first) and enable the layer. // Frame the port (kill the intro flight first) and enable the layer.
const dataEvidence = await page.evaluate(async ({ p, dataMode, fixtureRows, engineNeutral }) => { const dataEvidence = await evaluateWithGeo(page, async (qaGeo, { p, dataMode, fixtureRows, engineNeutral }) => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
if (engineNeutral) { if (engineNeutral) {
const geo = await import('/src/geo/index.js'); const geo = await import('/src/geo/index.js');
@@ -325,16 +296,14 @@ async function main() {
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 }, orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
}); });
} else { } else {
const v = gev.viewer; window.__godsEyeView.engine.camera.cancelFlight?.();
v.camera.cancelFlight?.();
v.scene.tweens?.removeAll?.();
const carto = { const carto = {
longitude: (p.lon * Math.PI) / 180, longitude: (p.lon * Math.PI) / 180,
latitude: (p.lat * Math.PI) / 180, latitude: (p.lat * Math.PI) / 180,
height: p.height, height: p.height,
}; };
v.camera.setView({ window.__godsEyeView.engine.camera.setView({
destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto), destination: qaGeo.cartographic.toCartesian(carto),
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 }, orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
}); });
} }
@@ -347,7 +316,7 @@ async function main() {
throw new Error(`Synthetic AIS injection failed (${result?.count || 0}/${fixtureRows.length})`); throw new Error(`Synthetic AIS injection failed (${result?.count || 0}/${fixtureRows.length})`);
} }
if (engineNeutral) gev.engine.render.request('qa-vessel-cards'); if (engineNeutral) gev.engine.render.request('qa-vessel-cards');
else gev.viewer.scene.requestRender?.(); else window.__godsEyeView.engine.render.request?.();
return { mode: 'synthetic', injected: result.count }; return { mode: 'synthetic', injected: result.count };
}, { p: port, dataMode: DATA_MODE, fixtureRows: syntheticRows, engineNeutral: Boolean(ENGINE) }); }, { p: port, dataMode: DATA_MODE, fixtureRows: syntheticRows, engineNeutral: Boolean(ENGINE) });
console.log( console.log(
@@ -361,7 +330,7 @@ async function main() {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
const tilesLoaded = engineNeutral const tilesLoaded = engineNeutral
? gev.engine.surface.tilesLoaded() ? gev.engine.surface.tilesLoaded()
: gev.tileset?.tilesLoaded; : window.__godsEyeView.engine.surface.tilesLoaded();
const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels'); const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels');
const count = ais?.stats?.count ?? 0; const count = ais?.stats?.count ?? 0;
return tilesLoaded && count > 0; return tilesLoaded && count > 0;
@@ -372,7 +341,7 @@ async function main() {
// Re-assert the port framing: on slow (SwiftShader) runs the app's // Re-assert the port framing: on slow (SwiftShader) runs the app's
// intro flight can start AFTER the first cancelFlight and land mid-wait, // intro flight can start AFTER the first cancelFlight and land mid-wait,
// dragging the camera back to the boot city before the screenshot. // dragging the camera back to the boot city before the screenshot.
await page.evaluate(async (p, engineNeutral) => { await evaluateWithGeo(page, async (qaGeo, p, engineNeutral) => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
if (engineNeutral) { if (engineNeutral) {
const geo = await import('/src/geo/index.js'); const geo = await import('/src/geo/index.js');
@@ -382,44 +351,42 @@ async function main() {
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 }, orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
}); });
} else { } else {
const v = gev.viewer; window.__godsEyeView.engine.camera.cancelFlight?.();
v.camera.cancelFlight?.();
v.scene.tweens?.removeAll?.();
const carto = { const carto = {
longitude: (p.lon * Math.PI) / 180, longitude: (p.lon * Math.PI) / 180,
latitude: (p.lat * Math.PI) / 180, latitude: (p.lat * Math.PI) / 180,
height: p.height, height: p.height,
}; };
v.camera.setView({ window.__godsEyeView.engine.camera.setView({
destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto), destination: qaGeo.cartographic.toCartesian(carto),
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 }, orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
}); });
} }
}, port, Boolean(ENGINE)); }, port, Boolean(ENGINE));
// A changed camera invalidates the previous settled result. Let the new // A changed camera invalidates the previous settled result. Let the new
// view schedule its tiles before checking readiness again. // view schedule its tiles before checking readiness again.
await new Promise((resolve) => setTimeout(resolve, 1500)); await whenFramesDrawn(page);
const resettled = await page const resettled = await page
.waitForFunction((engineNeutral) => { .waitForFunction((engineNeutral) => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels'); const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels');
const tilesLoaded = engineNeutral const tilesLoaded = engineNeutral
? gev.engine.surface.tilesLoaded() ? gev.engine.surface.tilesLoaded()
: gev.tileset?.tilesLoaded; : window.__godsEyeView.engine.surface.tilesLoaded();
return tilesLoaded && (ais?.stats?.count ?? 0) > 0; return tilesLoaded && (ais?.stats?.count ?? 0) > 0;
}, { timeout: 60000, polling: 500 }, Boolean(ENGINE)) }, { timeout: 60000, polling: 500 }, Boolean(ENGINE))
.then(() => true) .then(() => true)
.catch(() => false); .catch(() => false);
// Let two visibility/declutter cycles (800 ms) and host rendering settle. // Let two visibility/declutter cycles (800 ms) and host rendering settle.
await new Promise((r) => setTimeout(r, 2500)); await whenFramesDrawn(page);
const expectedFixtureIds = syntheticRows.map((row) => row.mmsi); const expectedFixtureIds = syntheticRows.map((row) => row.mmsi);
const overlayEvidence = await page.evaluate((fixtureIds, engineNeutral) => { const overlayEvidence = await page.evaluate((fixtureIds, engineNeutral) => {
const diagnostics = window.__gevWorldOverlay?.getDiagnostics?.(); const diagnostics = window.__gevWorldOverlay?.getDiagnostics?.();
const canvas = window.__godsEyeView?.engine?.host?.canvas; const canvas = window.__godsEyeView?.engine?.host?.canvas;
const gl = engineNeutral const gl = engineNeutral
? canvas?.getContext('webgl2') || canvas?.getContext('webgl') ? canvas?.getContext('webgl2') || canvas?.getContext('webgl')
: window.__godsEyeView?.viewer?.scene?.context?._gl; : window.__godsEyeView.engine.host.canvas.getContext('webgl2');
const debugInfo = gl?.getExtension?.('WEBGL_debug_renderer_info'); const debugInfo = gl?.getExtension?.('WEBGL_debug_renderer_info');
const seam = window.__godsEyeView?.dataManager?.layers const seam = window.__godsEyeView?.dataManager?.layers
?.get('ais-live-vessels')?.module?.__focusEvidence; ?.get('ais-live-vessels')?.module?.__focusEvidence;
@@ -485,7 +452,7 @@ async function main() {
await page.close(); await page.close();
} }
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
console.log('\nRetained PNG+JSON pairs are local QA evidence; each basename records live or synthetic provenance.'); console.log('\nRetained PNG+JSON pairs are local QA evidence; each basename records live or synthetic provenance.');
+8 -43
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
import { whenSettled, whenTilesLoaded } from './qa/ready.mjs';
/** /**
* qa-vessel-datum.mjs — assertion harness for the AIS vessel vertical-datum * qa-vessel-datum.mjs — assertion harness for the AIS vessel vertical-datum
* pass (docs/superpowers/specs/2026-07-27-vessel-datum-design.md). * pass (docs/superpowers/specs/2026-07-27-vessel-datum-design.md).
@@ -25,9 +26,9 @@
* --engine cesium|navara puts ?engine= on the URL; without it, the app's * --engine cesium|navara puts ?engine= on the URL; without it, the app's
* default engine. * default engine.
*/ */
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const getOpt = (name, dflt) => { const getOpt = (name, dflt) => {
@@ -67,28 +68,7 @@ const NEAR_DEG = 1.0;
/** Max billboards sampled for the depth-test sweep. */ /** Max billboards sampled for the depth-test sweep. */
const SAMPLE_CAP = 500; const SAMPLE_CAP = 500;
const CHROME_EXECUTABLE_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
// Chrome: /Applications auto-updates underneath the harnesses, and its
// software-GL behavior shifts across majors (system Chrome 150 blew the
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
// pinned browser beats the newest one for regression harnesses.
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
].filter(Boolean);
function findChromeExecutable() {
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch { /* fall through to Puppeteer's cache */ }
}
return null;
}
/** /**
* In-page probe on either engine (the QA probe): the chevrons the layer * In-page probe on either engine (the QA probe): the chevrons the layer
@@ -160,20 +140,9 @@ async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true }); fs.mkdirSync(OUT_DIR, { recursive: true });
const chromeExecutable = findChromeExecutable(); const browser = await launchBrowser({
const browser = await puppeteer.launch({ headless: true,
headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1600,900'],
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-gl=angle',
'--use-angle=metal',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1600,900',
],
}); });
let failures = 0; let failures = 0;
@@ -243,20 +212,16 @@ async function main() {
// Visual proof — re-assert the framing (the app's intro flight can land // Visual proof — re-assert the framing (the app's intro flight can land
// mid-probe on slow runs), wait for the photoreal tileset, screenshot. // mid-probe on slow runs), wait for the photoreal tileset, screenshot.
await page.evaluate(frameEngine, port, false); await page.evaluate(frameEngine, port, false);
await page await whenTilesLoaded(page, { timeoutMs: 60000 })
.waitForFunction(
() => window.__godsEyeView?.engine?.surface?.tilesLoaded?.(),
{ timeout: 60000, polling: 500 },
)
.catch(() => console.log(' (tileset settle timeout — screenshotting anyway)')); .catch(() => console.log(' (tileset settle timeout — screenshotting anyway)'));
await new Promise((r) => setTimeout(r, 2500)); await whenSettled(page);
const outPath = path.join(OUT_DIR, `vessel-datum-${ENGINE ? `${ENGINE}-` : ''}${key}.png`); const outPath = path.join(OUT_DIR, `vessel-datum-${ENGINE ? `${ENGINE}-` : ''}${key}.png`);
await page.screenshot({ path: outPath }); await page.screenshot({ path: outPath });
console.log(` [SHOT] ${outPath}`); console.log(` [SHOT] ${outPath}`);
await page.close(); await page.close();
} }
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
console.log(failures ? `\n\x1b[31m${failures} failure(s)\x1b[0m` : '\n\x1b[32mAll datum checks passed\x1b[0m'); console.log(failures ? `\n\x1b[31m${failures} failure(s)\x1b[0m` : '\n\x1b[32mAll datum checks passed\x1b[0m');
+28 -43
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
// Cesium-only probes: native pickPosition failure injection exercises the Cesium HUD boundary.
import { evaluateWithGeo } from './qa/geometry.mjs';
/** /**
* Regression proof for the view-target prewarm crash. * Regression proof for the view-target prewarm crash.
* *
@@ -18,10 +20,11 @@
* `QA_LABEL=before` names the evidence files so a before/after pair can sit * `QA_LABEL=before` names the evidence files so a before/after pair can sit
* side by side in qa-shots/prewarm/. * side by side in qa-shots/prewarm/.
*/ */
import { whenSettled } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const shotsDir = path.join(repoRoot, 'qa-shots', 'prewarm'); const shotsDir = path.join(repoRoot, 'qa-shots', 'prewarm');
@@ -30,22 +33,9 @@ const label = process.env.QA_LABEL || 'after';
const headful = process.argv.includes('--headful'); const headful = process.argv.includes('--headful');
fs.mkdirSync(shotsDir, { recursive: true }); fs.mkdirSync(shotsDir, { recursive: true });
const chromeCandidates = [ const browser = await launchBrowser({
process.env.PUPPETEER_EXECUTABLE_PATH, headless: true,
await puppeteer.executablePath().catch(() => null), args: [...(process.platform === 'darwin' ? ['--enable-gpu'] : []), '--no-sandbox'],
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
].filter(Boolean);
const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candidate));
const browser = await puppeteer.launch({
headless: headful ? false : 'new',
...(executablePath ? { executablePath } : {}),
// Metal is a macOS-only ANGLE backend; anywhere else it fails WebGL init.
args: [
...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
'--no-sandbox',
],
}); });
const page = await browser.newPage(); const page = await browser.newPage();
const failures = []; const failures = [];
@@ -91,26 +81,22 @@ let rejections = [];
try { try {
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 90_000 }); await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 90_000 });
await page.waitForFunction(() => window.__godsEyeView?.viewer, { timeout: 90_000 }); await page.waitForFunction(() => window.__godsEyeView?.engine, { timeout: 90_000 });
await page.waitForFunction( await whenSettled(page);
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90_000 },
);
// Boot noise (tile / network warm-up) is not what this harness is about. // Boot noise (tile / network warm-up) is not what this harness is about.
await new Promise((resolve) => setTimeout(resolve, 4_000)); await whenSettled(page);
pageErrors.length = 0; pageErrors.length = 0;
consoleErrors.length = 0; consoleErrors.length = 0;
await page.evaluate(() => { window.__qaPrewarmRejections.length = 0; }); await page.evaluate(() => { window.__qaPrewarmRejections.length = 0; });
const legReports = []; const legReports = [];
for (const leg of FLIGHT_LEGS) { for (const leg of FLIGHT_LEGS) {
const report = await page.evaluate(async (spec) => { const report = await evaluateWithGeo(page, async (qaGeo, spec) => {
const { viewer } = window.__godsEyeView;
// No window.Cesium global (qa-cctv-v2 / qa-height-datum precedent) — // No window.Cesium global (qa-cctv-v2 / qa-height-datum precedent) —
// borrow Cartesian3's statics off a live camera-position instance. // borrow Cartesian3's statics off a live camera-position instance.
const Cartesian3 = viewer.camera.position.constructor; const Cartesian3 = qaGeo.cartesian3;
await new Promise((resolve) => { await new Promise((resolve) => {
viewer.camera.flyTo({ window.__godsEyeView.engine.camera.flyTo({
destination: Cartesian3.fromDegrees(spec.lon, spec.lat, spec.height), destination: Cartesian3.fromDegrees(spec.lon, spec.lat, spec.height),
orientation: { heading: 0, pitch: (spec.pitch * Math.PI) / 180, roll: 0 }, orientation: { heading: 0, pitch: (spec.pitch * Math.PI) / 180, roll: 0 },
duration: 1.2, duration: 1.2,
@@ -123,15 +109,14 @@ try {
// Record what the center pick actually yields so the root cause is // Record what the center pick actually yields so the root cause is
// visible in the evidence dump rather than merely asserted. // visible in the evidence dump rather than merely asserted.
const scene = viewer.scene; const canvas = window.__godsEyeView.engine.host.canvas;
const canvas = scene.canvas;
const center = { const center = {
x: (canvas.clientWidth || canvas.width) / 2, x: (canvas.clientWidth || canvas.width) / 2,
y: (canvas.clientHeight || canvas.height) / 2, y: (canvas.clientHeight || canvas.height) / 2,
}; };
let picked; let picked;
try { try {
const raw = scene.pickPosition(center); const raw = window.__godsEyeView.engine.picker.pickPosition(center);
picked = raw ? { x: raw.x, y: raw.y, z: raw.z } : null; picked = raw ? { x: raw.x, y: raw.y, z: raw.z } : null;
} catch (error) { } catch (error) {
picked = { threw: String(error?.message || error) }; picked = { threw: String(error?.message || error) };
@@ -151,19 +136,19 @@ try {
// `scene.pickPosition` with one that returns each degenerate shape in turn. // `scene.pickPosition` with one that returns each degenerate shape in turn.
// Everything downstream — the moveEnd prewarm inside requestIdleCallback and // Everything downstream — the moveEnd prewarm inside requestIdleCallback and
// the HUD summary interval — is the real shipped code. // the HUD summary interval — is the real shipped code.
const injection = await page.evaluate(async () => { const injection = await evaluateWithGeo(page, async (qaGeo) => {
const { viewer, styleManager } = window.__godsEyeView; const { viewer, styleManager } = window.__godsEyeView;
const Cartesian3 = viewer.camera.position.constructor; const Cartesian3 = qaGeo.cartesian3;
const shapes = [ const shapes = [
// Throws inside Cesium's normalize. // Throws inside Cesium's normalize.
{ name: 'nan', value: new Cartesian3(Number.NaN, Number.NaN, Number.NaN) }, { name: 'nan', value: Cartesian3.create(Number.NaN, Number.NaN, Number.NaN) },
{ name: 'origin', value: new Cartesian3(0, 0, 0) }, { name: 'origin', value: Cartesian3.create(0, 0, 0) },
{ name: 'infinite', value: new Cartesian3(Number.POSITIVE_INFINITY, 0, 0) }, { name: 'infinite', value: Cartesian3.create(Number.POSITIVE_INFINITY, 0, 0) },
// Converts SILENTLY to a point 6,378 km underground — the quiet failure a // Converts SILENTLY to a point 6,378 km underground — the quiet failure a
// bare non-zero check lets through, which then reverse-geocodes 0°, 0°. // bare non-zero check lets through, which then reverse-geocodes 0°, 0°.
{ name: 'core-interior', value: new Cartesian3(500, 0, 0) }, { name: 'core-interior', value: Cartesian3.create(500, 0, 0) },
// Finite, but large enough to overflow the geodetic iteration into NaN. // Finite, but large enough to overflow the geodetic iteration into NaN.
{ name: 'absurd-magnitude', value: new Cartesian3(1e155, 0, 0) }, { name: 'absurd-magnitude', value: Cartesian3.create(1e155, 0, 0) },
]; ];
const scene = viewer.scene; const scene = viewer.scene;
const originalPick = Object.getOwnPropertyDescriptor(scene, 'pickPosition'); const originalPick = Object.getOwnPropertyDescriptor(scene, 'pickPosition');
@@ -175,16 +160,16 @@ try {
const hudErrors = []; const hudErrors = [];
const nudge = (step) => { const nudge = (step) => {
const carto = viewer.camera.positionCartographic; const carto = window.__godsEyeView.engine.camera.pose().cartographic;
viewer.camera.setView({ window.__godsEyeView.engine.camera.setView({
destination: Cartesian3.fromRadians( destination: Cartesian3.fromRadians(
carto.longitude + 0.0015 * (step + 1), carto.longitude + 0.0015 * (step + 1),
carto.latitude, carto.latitude,
carto.height, carto.height,
), ),
orientation: { heading: viewer.camera.heading, pitch: viewer.camera.pitch, roll: 0 }, orientation: { heading: window.__godsEyeView.engine.camera.pose().heading, pitch: window.__godsEyeView.engine.camera.pose().pitch, roll: 0 },
}); });
viewer.camera.moveEnd.raiseEvent(); window.__godsEyeView.engine.camera.moveEnd.raiseEvent();
}; };
try { try {
@@ -213,7 +198,7 @@ try {
} }
} finally { } finally {
if (originalPick) Object.defineProperty(scene, 'pickPosition', originalPick); if (originalPick) Object.defineProperty(scene, 'pickPosition', originalPick);
else delete scene.pickPosition; else delete window.__godsEyeView.engine.picker.pickPosition;
} }
return { hudErrors, shapes: shapes.length, pickCalls }; return { hudErrors, shapes: shapes.length, pickCalls };
}); });
@@ -348,7 +333,7 @@ try {
} catch (error) { } catch (error) {
check('harness ran to completion', false, String(error?.message || error)); check('harness ran to completion', false, String(error?.message || error));
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
console.log(`\n${failures.length ? `FAIL (${failures.length})` : 'PASS'} — evidence in ${path.relative(repoRoot, shotsDir)}/`); console.log(`\n${failures.length ? `FAIL (${failures.length})` : 'PASS'} — evidence in ${path.relative(repoRoot, shotsDir)}/`);
+18 -36
View File
@@ -1,15 +1,16 @@
#!/usr/bin/env node #!/usr/bin/env node
// Cesium-only probes: the native bloom stage verifies the Cesium visual-effects adapter.
import { evaluateWithGeo } from './qa/geometry.mjs';
/** Rendered acceptance for style transitions and effect-state ownership. */ /** Rendered acceptance for style transitions and effect-state ownership. */
import { whenSettled, whenTilesLoaded } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: true, headless: true,
args: [ args: [
'--no-sandbox', '--no-sandbox',
...(process.platform === 'darwin' ...(process.platform === 'darwin' ? ['--enable-gpu'] : []),
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
], ],
}); });
const page = await browser.newPage(); const page = await browser.newPage();
@@ -26,24 +27,21 @@ try {
url.searchParams.set('welcome', '0'); url.searchParams.set('welcome', '0');
await page.goto(url.href, { waitUntil: 'domcontentloaded' }); await page.goto(url.href, { waitUntil: 'domcontentloaded' });
await page.waitForFunction( await page.waitForFunction(
() => () => window.__godsEyeView?.styleManager?._visualEffects,
window.__godsEyeView?.styleManager?._visualEffects &&
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 60_000 }, { timeout: 60_000 },
); );
await page.evaluate(() => { await whenSettled(page);
const viewer = window.__godsEyeView.viewer; await evaluateWithGeo(page, async (qaGeo) => {
viewer.camera.cancelFlight?.(); window.__godsEyeView.engine.camera.cancelFlight?.();
viewer.scene.tweens?.removeAll?.(); window.__godsEyeView.engine.camera.setView({
viewer.camera.setView({ destination: qaGeo.cartographic.toCartesian({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (-97.7431 * Math.PI) / 180, longitude: (-97.7431 * Math.PI) / 180,
latitude: (30.2568 * Math.PI) / 180, latitude: (30.2568 * Math.PI) / 180,
height: 1200, height: 1200,
}), }),
orientation: { heading: 0, pitch: -0.85, roll: 0 }, orientation: { heading: 0, pitch: -0.85, roll: 0 },
}); });
viewer.scene.requestRender(); window.__godsEyeView.engine.render.request();
}); });
await page.evaluate( await page.evaluate(
() => () =>
@@ -51,26 +49,10 @@ try {
requestAnimationFrame(() => requestAnimationFrame(resolve)), requestAnimationFrame(() => requestAnimationFrame(resolve)),
), ),
); );
const settled = await page const settled = await whenTilesLoaded(page, { timeoutMs: 60_000 }).then(
.waitForFunction( () => true,
() => { () => false,
const primitives = window.__godsEyeView.viewer.scene.primitives; );
for (let i = 0; i < primitives.length; i++) {
const primitive = primitives.get(i);
if (
typeof primitive.tilesLoaded === 'boolean' &&
!primitive.tilesLoaded
)
return false;
}
return true;
},
{ timeout: 60_000 },
)
.then(
() => true,
() => false,
);
check('visible tile content settles before visual captures', settled); check('visible tile content settles before visual captures', settled);
fs.mkdirSync('qa-shots/visual-effects', { recursive: true }); fs.mkdirSync('qa-shots/visual-effects', { recursive: true });
for (const style of [ for (const style of [
@@ -228,7 +210,7 @@ try {
); );
if (errors.length) console.log(JSON.stringify(errors)); if (errors.length) console.log(JSON.stringify(errors));
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
console.log(`RESULT: ${failures} failures`); console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0; process.exitCode = failures ? 1 : 0;
+6 -16
View File
@@ -1,15 +1,14 @@
#!/usr/bin/env node #!/usr/bin/env node
/** Browser acceptance for application shortcuts and generated parameter controls. */ /** Browser acceptance for application shortcuts and generated parameter controls. */
import { whenSettled } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: true, headless: true,
args: [ args: [
'--no-sandbox', '--no-sandbox',
...(process.platform === 'darwin' ...(process.platform === 'darwin' ? ['--enable-gpu'] : []),
? ['--use-angle=metal', '--enable-gpu']
: ['--use-gl=angle', '--use-angle=swiftshader']),
], ],
}); });
const page = await browser.newPage(); const page = await browser.newPage();
@@ -29,16 +28,7 @@ try {
() => window.__godsEyeView?.styleManager?._applicationShortcuts, () => window.__godsEyeView?.styleManager?._applicationShortcuts,
{ timeout: 60_000 }, { timeout: 60_000 },
); );
await page.waitForFunction( await whenSettled(page);
() => {
const cover = document.getElementById('loading-screen');
return (
cover?.classList.contains('hidden') &&
Number(getComputedStyle(cover).opacity) === 0
);
},
{ timeout: 60_000 },
);
await page.evaluate(() => { await page.evaluate(() => {
const manager = window.__godsEyeView.styleManager; const manager = window.__godsEyeView.styleManager;
manager.setStyle('normal'); manager.setStyle('normal');
@@ -279,7 +269,7 @@ try {
); );
check('runtime has no uncaught browser errors', errors.length === 0); check('runtime has no uncaught browser errors', errors.length === 0);
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
console.log(`RESULT: ${failures} failures`); console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0; process.exitCode = failures ? 1 : 0;
+3 -7
View File
@@ -4,12 +4,12 @@
* Run: node --test --test-reporter=spec scripts/qa-voice-auth-focus.mjs * Run: node --test --test-reporter=spec scripts/qa-voice-auth-focus.mjs
* Sign-in responses are fixtures. No credentials or live login are used. * Sign-in responses are fixtures. No credentials or live login are used.
*/ */
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { after, before, test } from 'node:test'; import { after, before, test } from 'node:test';
import puppeteer from 'puppeteer';
const root = new URL('../', import.meta.url); const root = new URL('../', import.meta.url);
const view = await readFile( const view = await readFile(
@@ -63,17 +63,13 @@ before(async () => {
server.listen(0, '127.0.0.1', resolve); server.listen(0, '127.0.0.1', resolve);
}); });
origin = `http://127.0.0.1:${server.address().port}`; origin = `http://127.0.0.1:${server.address().port}`;
const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; browser = await launchBrowser({
browser = await puppeteer.launch({
headless: true, headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
(existsSync(chrome) ? chrome : await puppeteer.executablePath()),
}); });
}); });
after(async () => { after(async () => {
await browser?.close(); await closeBrowser(browser);
if (server?.listening) await new Promise((resolve) => server.close(resolve)); if (server?.listening) await new Promise((resolve) => server.close(resolve));
const output = new URL('.gev-logs/voice-auth-focus/', root); const output = new URL('.gev-logs/voice-auth-focus/', root);
await mkdir(output, { recursive: true }); await mkdir(output, { recursive: true });
+8 -15
View File
@@ -6,11 +6,12 @@
* Use a short spoken reply request with three seconds of leading silence. * Use a short spoken reply request with three seconds of leading silence.
* Saves only usage, transport metadata, and screenshots; never credentials. * Saves only usage, transport metadata, and screenshots; never credentials.
*/ */
import { whenSettled } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
const root = fileURLToPath(new URL('../', import.meta.url)); const root = fileURLToPath(new URL('../', import.meta.url));
const url = new URL(process.argv[2] || 'http://127.0.0.1:4189'); const url = new URL(process.argv[2] || 'http://127.0.0.1:4189');
@@ -27,14 +28,8 @@ url.searchParams.set('welcome', '0');
url.searchParams.set('setup', '1'); url.searchParams.set('setup', '1');
const output = path.join(root, '.gev-logs', 'voice-auth'); const output = path.join(root, '.gev-logs', 'voice-auth');
fs.mkdirSync(output, { recursive: true }); fs.mkdirSync(output, { recursive: true });
const executablePath = const browser = await launchBrowser({
process.env.PUPPETEER_EXECUTABLE_PATH ||
(fs.existsSync('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome')
? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
: puppeteer.executablePath());
const browser = await puppeteer.launch({
headless: true, headless: true,
executablePath,
args: [ args: [
'--no-sandbox', '--no-sandbox',
'--disable-setuid-sandbox', '--disable-setuid-sandbox',
@@ -149,12 +144,10 @@ try {
}; };
}); });
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 30_000 }); await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForFunction( await page.waitForFunction(() => window.__godsEyeView?.voiceCommands, {
() => timeout: 45_000,
window.__godsEyeView?.voiceCommands && });
document.getElementById('loading-screen')?.classList.contains('hidden'), await whenSettled(page);
{ timeout: 45_000 },
);
await page.waitForSelector('[data-cloud-voice-auth-toggle]', { await page.waitForSelector('[data-cloud-voice-auth-toggle]', {
visible: true, visible: true,
}); });
@@ -281,7 +274,7 @@ try {
await page await page
?.evaluate(() => window.__godsEyeView?.voiceCommands?.session?.stop()) ?.evaluate(() => window.__godsEyeView?.voiceCommands?.session?.stop())
.catch(() => {}); .catch(() => {});
await browser.close(); await closeBrowser(browser);
fs.writeFileSync( fs.writeFileSync(
path.join(output, `${auth}-result.json`), path.join(output, `${auth}-result.json`),
`${JSON.stringify(result, null, 2)}\n`, `${JSON.stringify(result, null, 2)}\n`,
+45 -81
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
// Cesium-only probes: native pick and tracking probes validate the legacy Cesium voice route.
import { evaluateWithGeo } from './qa/geometry.mjs';
import { whenSettled } from './qa/ready.mjs';
/** /**
* qa-voice-routing.mjs — voice-surface QA harness (overnight 2026-07-27). * qa-voice-routing.mjs — voice-surface QA harness (overnight 2026-07-27).
* *
@@ -36,11 +39,11 @@
* routing assertions pin tool NAMES (model wording varies, tool choice must not). * routing assertions pin tool NAMES (model wording varies, tool choice must not).
*/ */
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import WebSocket from 'ws'; import WebSocket from 'ws';
import puppeteer from 'puppeteer';
import sharp from 'sharp'; import sharp from 'sharp';
import { ROUTING_PHRASES } from './voice-bench/phrases.mjs'; import { ROUTING_PHRASES } from './voice-bench/phrases.mjs';
import { gradeCore, expectLabel } from './voice-bench/grade.mjs'; import { gradeCore, expectLabel } from './voice-bench/grade.mjs';
@@ -52,19 +55,6 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..'); const ROOT = path.resolve(__dirname, '..');
const CHROME_CANDIDATES = [
process.env.PUPPETEER_EXECUTABLE_PATH,
await puppeteer.executablePath().catch(() => null),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
].filter(Boolean);
const CHROME_EXECUTABLE = CHROME_CANDIDATES.find((candidate) => {
try {
return fs.existsSync(candidate);
} catch {
return false;
}
});
// ── CLI ───────────────────────────────────────────────────── // ── CLI ─────────────────────────────────────────────────────
function getOpt(flag, fallback) { function getOpt(flag, fallback) {
@@ -674,21 +664,9 @@ async function runDeixisRoutingLayer() {
async function runDeixisLiveLayer() { async function runDeixisLiveLayer() {
console.log('\nLAYER 1c — deixis live (in-app text turns)'); console.log('\nLAYER 1c — deixis live (in-app text turns)');
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: HEADFUL ? false : 'new', headless: true,
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}), args: ['--no-sandbox', '--disable-setuid-sandbox', ...(HEADFUL ? [] : []), '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', '--window-size=1500,950'],
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
...(HEADFUL ? [] : ['--use-gl=angle', '--use-angle=swiftshader']),
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
'--autoplay-policy=no-user-gesture-required',
'--window-size=1500,950',
],
protocolTimeout: 180000, protocolTimeout: 180000,
}); });
const appUrl = new URL(APP_URL); const appUrl = new URL(APP_URL);
@@ -716,11 +694,12 @@ async function runDeixisLiveLayer() {
timeout: 60000, timeout: 60000,
}); });
await page.waitForFunction( await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__gevVoiceCommands?.session, () => window.__godsEyeView?.engine && window.__gevVoiceCommands?.session,
{ timeout: 120000, polling: 250 }, { timeout: 120000, polling: 250 },
); );
await whenSettled(page);
await page.evaluate(() => await page.evaluate(() =>
window.__godsEyeView.viewer.camera.cancelFlight(), window.__godsEyeView.engine.camera.cancelFlight(),
); );
await run('fly_to_location', { query: 'Austin, Texas' }); await run('fly_to_location', { query: 'Austin, Texas' });
await settle(9000); await settle(9000);
@@ -778,7 +757,7 @@ async function runDeixisLiveLayer() {
).find((e) => e.id === p.aircraftId); ).find((e) => e.id === p.aircraftId);
viewer.scene.render(); viewer.scene.render();
at = found?.position at = found?.position
? viewer.scene.cartesianToCanvasCoordinates(found.position) ? window.__godsEyeView.engine.projector.worldToWindow(found.position)
: null; : null;
} }
if (at) window.__gevVoiceCommands.pointer.moveTo(at.x, at.y); if (at) window.__gevVoiceCommands.pointer.moveTo(at.x, at.y);
@@ -865,8 +844,8 @@ async function runDeixisLiveLayer() {
} }
const size = await page.evaluate(() => ({ const size = await page.evaluate(() => ({
w: window.__godsEyeView.viewer.scene.canvas.clientWidth, w: window.__godsEyeView.engine.host.canvas.clientWidth,
h: window.__godsEyeView.viewer.scene.canvas.clientHeight, h: window.__godsEyeView.engine.host.canvas.clientHeight,
})); }));
const events = await turn('How many flights are around here?', { const events = await turn('How many flights are around here?', {
x: size.w * 0.3, x: size.w * 0.3,
@@ -893,7 +872,7 @@ async function runDeixisLiveLayer() {
} catch { } catch {
/* closing */ /* closing */
} }
await browser.close(); await closeBrowser(browser);
console.log(` deixis live turns used: ${turnsUsed}`); console.log(` deixis live turns used: ${turnsUsed}`);
} }
} }
@@ -986,8 +965,8 @@ function findPickableAircraft(page) {
return page.evaluate(() => { return page.evaluate(() => {
const { viewer, dataManager } = window.__godsEyeView; const { viewer, dataManager } = window.__godsEyeView;
const module = dataManager.layers.get('flights')?.module; const module = dataManager.layers.get('flights')?.module;
const canvas = viewer.scene.canvas; const canvas = window.__godsEyeView.engine.host.canvas;
const eye = viewer.camera.positionWC; const eye = window.__godsEyeView.engine.camera.pose().position;
viewer.scene.render(); viewer.scene.render();
let picks = 0; let picks = 0;
const candidates = []; const candidates = [];
@@ -995,7 +974,7 @@ function findPickableAircraft(page) {
// Horizon test: far-side contacts still project onto the canvas. // Horizon test: far-side contacts still project onto the canvas.
const p = entry.position; const p = entry.position;
if (eye.x * p.x + eye.y * p.y + eye.z * p.z < 6356000 ** 2) continue; if (eye.x * p.x + eye.y * p.y + eye.z * p.z < 6356000 ** 2) continue;
const at = viewer.scene.cartesianToCanvasCoordinates(entry.position); const at = window.__godsEyeView.engine.projector.worldToWindow(entry.position);
if ( if (
!at || !at ||
at.x < 60 || at.x < 60 ||
@@ -1176,7 +1155,7 @@ async function runDeixisBehavior(page, run, settle) {
// D2 — bare ground: the chip gives coordinates and "around here" is a radius. // D2 — bare ground: the chip gives coordinates and "around here" is a radius.
const size = await page.evaluate(() => { const size = await page.evaluate(() => {
const canvas = window.__godsEyeView.viewer.scene.canvas; const canvas = window.__godsEyeView.engine.host.canvas;
return { w: canvas.clientWidth, h: canvas.clientHeight }; return { w: canvas.clientWidth, h: canvas.clientHeight };
}); });
let groundTurn = null; let groundTurn = null;
@@ -1320,20 +1299,9 @@ async function runBehaviorLayer() {
return; return;
} }
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: HEADFUL ? false : 'new', headless: true,
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}), args: ['--no-sandbox', '--disable-setuid-sandbox', ...(HEADFUL ? [] : []), '--mute-audio', '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1500,950'],
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
...(HEADFUL ? [] : ['--use-gl=angle', `--use-angle=${process.env.QA_ANGLE || 'swiftshader'}`]),
'--mute-audio',
'--disable-dev-shm-usage',
'--disable-web-security',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1500,950',
],
protocolTimeout: 180000, protocolTimeout: 180000,
}); });
const page = await browser.newPage(); const page = await browser.newPage();
@@ -1389,14 +1357,15 @@ async function runBehaviorLayer() {
}); });
await page.waitForFunction( await page.waitForFunction(
() => () =>
window.__godsEyeView?.viewer && window.__godsEyeView?.engine &&
window.__gevVoiceCommands?.runner && window.__gevVoiceCommands?.runner &&
window.__gevAnnotations, window.__gevAnnotations,
{ timeout: 120000, polling: 250 }, { timeout: 120000, polling: 250 },
); );
await whenSettled(page);
// House rule: the intro flight clobbers teleports issued mid-flight. // House rule: the intro flight clobbers teleports issued mid-flight.
await page.evaluate(() => await page.evaluate(() =>
window.__godsEyeView.viewer.camera.cancelFlight(), window.__godsEyeView.engine.camera.cancelFlight(),
); );
const run = (name, args) => const run = (name, args) =>
@@ -1414,13 +1383,12 @@ async function runBehaviorLayer() {
); );
const camState = () => const camState = () =>
page.evaluate(() => { page.evaluate(() => {
const c = window.__godsEyeView.viewer.camera; const p = window.__godsEyeView.engine.camera.pose().cartographic;
const p = c.positionCartographic;
return { return {
lat: (p.latitude * 180) / Math.PI, lat: (p.latitude * 180) / Math.PI,
lon: (p.longitude * 180) / Math.PI, lon: (p.longitude * 180) / Math.PI,
altKm: p.height / 1000, altKm: p.height / 1000,
pitchDeg: (c.pitch * 180) / Math.PI, pitchDeg: (window.__godsEyeView.engine.camera.pose().pitch * 180) / Math.PI,
}; };
}); });
const settle = (ms) => new Promise((r) => setTimeout(r, ms)); const settle = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -1465,7 +1433,7 @@ async function runBehaviorLayer() {
// final tween frame. Cancel that already-arrived flight so the assertion // final tween frame. Cancel that already-arrived flight so the assertion
// measures Radio's camera ownership, not residual navigation motion. // measures Radio's camera ownership, not residual navigation motion.
await page.evaluate(() => await page.evaluate(() =>
window.__godsEyeView.viewer.camera.cancelFlight(), window.__godsEyeView.engine.camera.cancelFlight(),
); );
await settle(250); await settle(250);
const radioCameraBefore = await camState(); const radioCameraBefore = await camState();
@@ -1832,7 +1800,7 @@ async function runBehaviorLayer() {
await page await page
.waitForFunction( .waitForFunction(
() => () =>
window.__godsEyeView.viewer.camera.positionCartographic.height / window.__godsEyeView.engine.camera.pose().cartographic.height /
1000 > 1000 >
12000, 12000,
{ timeout: 30_000, polling: 250 }, { timeout: 30_000, polling: 250 },
@@ -1853,7 +1821,7 @@ async function runBehaviorLayer() {
await page await page
.waitForFunction( .waitForFunction(
() => () =>
window.__godsEyeView.viewer.camera.positionCartographic.height / window.__godsEyeView.engine.camera.pose().cartographic.height /
1000 < 1000 <
900, 900,
{ timeout: 40_000, polling: 250 }, { timeout: 40_000, polling: 250 },
@@ -1923,7 +1891,7 @@ async function runBehaviorLayer() {
// (6c) move_camera orbit ONCE: bounded eased ~30° heading advance. // (6c) move_camera orbit ONCE: bounded eased ~30° heading advance.
const heading = () => const heading = () =>
page.evaluate( page.evaluate(
() => (window.__godsEyeView.viewer.camera.heading * 180) / Math.PI, () => (window.__godsEyeView.engine.camera.pose().heading * 180) / Math.PI,
); );
let h0 = await heading(); let h0 = await heading();
r = await run('move_camera', { motion: 'orbit', mode: 'once' }); r = await run('move_camera', { motion: 'orbit', mode: 'once' });
@@ -2105,10 +2073,9 @@ async function runBehaviorLayer() {
// (6e5) tilt at the clamp answers honestly instead of silently no-oping. // (6e5) tilt at the clamp answers honestly instead of silently no-oping.
await page.evaluate(() => { await page.evaluate(() => {
const c = window.__godsEyeView.viewer.camera; window.__godsEyeView.engine.camera.setView({
c.setView({
orientation: { orientation: {
heading: c.heading, heading: window.__godsEyeView.engine.camera.pose().heading,
pitch: (-5.2 * Math.PI) / 180, pitch: (-5.2 * Math.PI) / 180,
roll: 0, roll: 0,
}, },
@@ -2395,7 +2362,7 @@ async function runBehaviorLayer() {
String(e?.message || e).slice(0, 200), String(e?.message || e).slice(0, 200),
); );
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
} }
@@ -2425,11 +2392,10 @@ async function runBundledOutlineScenarios({
const timings = []; const timings = [];
for (const scenario of BUNDLED_OUTLINES) { for (const scenario of BUNDLED_OUTLINES) {
const label = `behavior: "outline ${scenario.target}" draws the bundled ${scenario.pack} outline`; const label = `behavior: "outline ${scenario.target}" draws the bundled ${scenario.pack} outline`;
await page.evaluate(({ lat, lon }) => { await evaluateWithGeo(page, async (qaGeo, { lat, lon }) => {
const { viewer } = window.__godsEyeView; window.__godsEyeView.engine.camera.cancelFlight();
viewer.camera.cancelFlight(); window.__godsEyeView.engine.camera.setView({
viewer.camera.setView({ destination: qaGeo.cartographic.toCartesian({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (lon * Math.PI) / 180, longitude: (lon * Math.PI) / 180,
latitude: (lat * Math.PI) / 180, latitude: (lat * Math.PI) / 180,
height: 9000, height: 9000,
@@ -2529,11 +2495,10 @@ async function runGeometryScenarios({ page, run, settle, overpassRequests }) {
const shot = (name) => page.screenshot({ path: path.join(shotDir, `${name}.png`) }).catch(() => {}); const shot = (name) => page.screenshot({ path: path.join(shotDir, `${name}.png`) }).catch(() => {});
const nominatimStart = nominatimCount(); const nominatimStart = nominatimCount();
const overpassStart = overpassRequests.length; const overpassStart = overpassRequests.length;
const teleport = (lat, lon, height, pitch = -90) => page.evaluate(({ lat, lon, height, pitch }) => { const teleport = (lat, lon, height, pitch = -90) => evaluateWithGeo(page, async (qaGeo, { lat, lon, height, pitch }) => {
const { viewer } = window.__godsEyeView; window.__godsEyeView.engine.camera.cancelFlight();
viewer.camera.cancelFlight(); window.__godsEyeView.engine.camera.setView({
viewer.camera.setView({ destination: qaGeo.cartographic.toCartesian({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (lon * Math.PI) / 180, latitude: (lat * Math.PI) / 180, height, longitude: (lon * Math.PI) / 180, latitude: (lat * Math.PI) / 180, height,
}), }),
orientation: { heading: 0, pitch: (pitch * Math.PI) / 180, roll: 0 }, orientation: { heading: 0, pitch: (pitch * Math.PI) / 180, roll: 0 },
@@ -2729,11 +2694,10 @@ async function runOutlineAcceptance({
const overpassStart = overpassRequests.length; const overpassStart = overpassRequests.length;
if (SHOT_DIR) fs.mkdirSync(path.resolve(ROOT, SHOT_DIR), { recursive: true }); if (SHOT_DIR) fs.mkdirSync(path.resolve(ROOT, SHOT_DIR), { recursive: true });
const teleport = (ask) => page.evaluate(({ lat, lon, height }) => { const teleport = (ask) => evaluateWithGeo(page, async (qaGeo, { lat, lon, height }) => {
const { viewer } = window.__godsEyeView; window.__godsEyeView.engine.camera.cancelFlight();
viewer.camera.cancelFlight(); window.__godsEyeView.engine.camera.setView({
viewer.camera.setView({ destination: qaGeo.cartographic.toCartesian({
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
longitude: (lon * Math.PI) / 180, longitude: (lon * Math.PI) / 180,
latitude: (lat * Math.PI) / 180, latitude: (lat * Math.PI) / 180,
height, height,
+8 -19
View File
@@ -4,11 +4,12 @@
* *
* Run: node scripts/qa-voice-wav.mjs http://localhost:4189 [--push-to-talk] * Run: node scripts/qa-voice-wav.mjs http://localhost:4189 [--push-to-talk]
*/ */
import { whenSettled } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const pushToTalk = process.argv.includes('--push-to-talk'); const pushToTalk = process.argv.includes('--push-to-talk');
@@ -33,20 +34,9 @@ if (fixtureSha256 !== expectedFixtureSha256) {
} }
const appOrigin = new URL(appUrl).origin; const appOrigin = new URL(appUrl).origin;
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: 'new', headless: true,
executablePath: await puppeteer.executablePath(), args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--use-fake-device-for-media-stream', `--use-file-for-fake-audio-capture=${wavPath}%noloop`, '--autoplay-policy=no-user-gesture-required', '--window-size=1440,900'],
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--use-fake-device-for-media-stream',
`--use-file-for-fake-audio-capture=${wavPath}%noloop`,
'--autoplay-policy=no-user-gesture-required',
'--window-size=1440,900',
],
}); });
try { try {
@@ -65,15 +55,14 @@ try {
await page.waitForFunction(() => ( await page.waitForFunction(() => (
window.__godsEyeView?.voiceCommands window.__godsEyeView?.voiceCommands
&& document.getElementById('gev-voice-button') && document.getElementById('gev-voice-button')
&& document.getElementById('loading-screen')?.classList.contains('hidden')
), { timeout: 30_000 }); ), { timeout: 30_000 });
await whenSettled(page);
const readState = () => page.evaluate(() => { const readState = () => page.evaluate(() => {
const voice = window.__godsEyeView?.voiceCommands; const voice = window.__godsEyeView?.voiceCommands;
const radio = window.__godsEyeView?.dataManager?.layers?.get('radio')?.module; const radio = window.__godsEyeView?.dataManager?.layers?.get('radio')?.module;
const radioState = radio?.getUIState?.() || null; const radioState = radio?.getUIState?.() || null;
const camera = window.__godsEyeView?.viewer?.camera; const cartographic = window.__godsEyeView.engine.camera.pose().cartographic;
const cartographic = camera?.positionCartographic;
return { return {
at: Date.now(), at: Date.now(),
voiceStatus: voice?.status || null, voiceStatus: voice?.status || null,
@@ -157,5 +146,5 @@ try {
console.log(JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2));
process.exitCode = result.ok ? 0 : 1; process.exitCode = result.ok ? 0 : 1;
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
+44 -41
View File
@@ -1,4 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
import { evaluateWithGeo } from './qa/geometry.mjs';
import { whenSettled } from './qa/ready.mjs';
/** /**
* qa-weather-journey — drive the Weather layers the way a person would and * qa-weather-journey — drive the Weather layers the way a person would and
* keep the evidence: numbered screenshots plus per-step layer diagnostics. * keep the evidence: numbered screenshots plus per-step layer diagnostics.
@@ -19,9 +21,9 @@
* Requires a running dev server. Screenshots and steps.json land in * Requires a running dev server. Screenshots and steps.json land in
* <out>/<label>/. * <out>/<label>/.
*/ */
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const arg = (name, fallback) => { const arg = (name, fallback) => {
@@ -41,13 +43,8 @@ mkdirSync(outDir, { recursive: true });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const WEATHER = ['weather-radar', 'weather-satellite', 'weather-lightning']; const WEATHER = ['weather-radar', 'weather-satellite', 'weather-lightning'];
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: flag('--headless') ? 'new' : false, headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((p) =>
existsSync(p),
),
protocolTimeout: 300_000, protocolTimeout: 300_000,
defaultViewport: null, defaultViewport: null,
args: [ args: [
@@ -95,10 +92,10 @@ try {
} }
}); });
await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { await page.waitForFunction(() => !!window.__godsEyeView?.engine, {
timeout: 120_000, timeout: 120_000,
}); });
await sleep(12_000); await whenSettled(page);
const diagnostics = () => const diagnostics = () =>
page.evaluate( page.evaluate(
@@ -106,9 +103,13 @@ try {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
const dm = gev.dataManager; const dm = gev.dataManager;
const out = { const out = {
basemap: gev.tileset ? 'google-3d' : 'keyless-globe', basemap: gev.engine.maps.photorealActive()
globeShown: gev.viewer.scene.globe.show, ? 'google-3d'
heightM: Math.round(gev.viewer.camera.positionCartographic.height), : 'keyless-globe',
imagerySurface: gev.engine.imagery.surface(),
heightM: Math.round(
window.__godsEyeView.engine.camera.pose().cartographic.height,
),
summaryHeight: Math.round( summaryHeight: Math.round(
document.querySelector('.weather-summary')?.getBoundingClientRect() document.querySelector('.weather-summary')?.getBoundingClientRect()
.height ?? 0, .height ?? 0,
@@ -151,11 +152,13 @@ try {
page.evaluate( page.evaluate(
(ms) => (ms) =>
new Promise((resolve) => { new Promise((resolve) => {
const scene = window.__godsEyeView.viewer.scene;
let n = 0; let n = 0;
const off = scene.postRender.addEventListener(() => { const off =
n += 1; window.__godsEyeView.engine.frames.postRender.addEventListener(
}); () => {
n += 1;
},
);
setTimeout(() => { setTimeout(() => {
off(); off();
resolve(n); resolve(n);
@@ -235,13 +238,13 @@ try {
heading = 0, heading = 0,
duration = 2, duration = 2,
) => { ) => {
await page.evaluate( await evaluateWithGeo(
({ lon, lat, height, pitch, heading, duration }) => { page,
const v = window.__godsEyeView.viewer; async (qaGeo, { lon, lat, height, pitch, heading, duration }) => {
const d2r = Math.PI / 180; const d2r = Math.PI / 180;
v.camera.cancelFlight(); window.__godsEyeView.engine.camera.cancelFlight();
v.camera.flyTo({ window.__godsEyeView.engine.camera.flyTo({
destination: v.scene.ellipsoid.cartographicToCartesian({ destination: qaGeo.cartographic.toCartesian({
longitude: lon * d2r, longitude: lon * d2r,
latitude: lat * d2r, latitude: lat * d2r,
height, height,
@@ -252,7 +255,7 @@ try {
}, },
{ lon, lat, height, pitch, heading, duration }, { lon, lat, height, pitch, heading, duration },
); );
await sleep(duration * 1000 + 3000); await whenSettled(page);
}; };
console.log(`\nqa-weather-journey · ${label} · ${url}`); console.log(`\nqa-weather-journey · ${label} · ${url}`);
@@ -271,40 +274,40 @@ try {
// 2. Wind from its row, then each field, then ECMWF. // 2. Wind from its row, then each field, then ECMWF.
await clickRow('wind'); await clickRow('wind');
await waitReady(['wind']); await waitReady(['wind']);
await sleep(1500); await whenSettled(page);
await shot('globe-wind-trails', 'Wind on (trails, no field)'); await shot('globe-wind-trails', 'Wind on (trails, no field)');
await clickChip('wind', 'overlay-speed'); await clickChip('wind', 'overlay-speed');
await sleep(2500); await whenSettled(page);
await shot('globe-wind-speed', 'Wind speed field'); await shot('globe-wind-speed', 'Wind speed field');
await clickChip('wind', 'overlay-temperature'); await clickChip('wind', 'overlay-temperature');
await sleep(2500); await whenSettled(page);
await waitReady(['wind']); await waitReady(['wind']);
await shot('globe-wind-temperature', 'Wind + 2 m temperature'); await shot('globe-wind-temperature', 'Wind + 2 m temperature');
await clickChip('wind', 'overlay-pressure'); await clickChip('wind', 'overlay-pressure');
await sleep(2500); await whenSettled(page);
await waitReady(['wind']); await waitReady(['wind']);
await shot('globe-wind-pressure', 'Wind + MSL pressure'); await shot('globe-wind-pressure', 'Wind + MSL pressure');
await clickChip('wind', 'model-ifs'); await clickChip('wind', 'model-ifs');
await waitReady(['wind']); await waitReady(['wind']);
await sleep(2500); await whenSettled(page);
await shot('globe-wind-ecmwf-pressure', 'ECMWF IFS model'); await shot('globe-wind-ecmwf-pressure', 'ECMWF IFS model');
await clickChip('wind', 'model-gfs'); await clickChip('wind', 'model-gfs');
await clickChip('wind', 'overlay-speed'); await clickChip('wind', 'overlay-speed');
await waitReady(['wind']); await waitReady(['wind']);
await sleep(1500); await whenSettled(page);
// 3. Observed products. // 3. Observed products.
for (const id of WEATHER) await clickRow(id); for (const id of WEATHER) await clickRow(id);
await clickRow('weather-cyclones'); await clickRow('weather-cyclones');
await waitReady([...WEATHER]); await waitReady([...WEATHER]);
await sleep(2500); await whenSettled(page);
await shot( await shot(
'globe-all-weather', 'globe-all-weather',
'Wind speed + radar + regional IR + lightning + cyclones', 'Wind speed + radar + regional IR + lightning + cyclones',
); );
await clickChip('weather-satellite', 'clouds'); await clickChip('weather-satellite', 'clouds');
await waitReady(['weather-satellite']); await waitReady(['weather-satellite']);
await sleep(2500); await whenSettled(page);
await shot('globe-global-infrared', 'Satellite: global infrared mosaic'); await shot('globe-global-infrared', 'Satellite: global infrared mosaic');
await clickChip('weather-satellite', 'clouds-regional'); await clickChip('weather-satellite', 'clouds-regional');
await waitReady(['weather-satellite']); await waitReady(['weather-satellite']);
@@ -328,21 +331,21 @@ try {
return first.textContent.trim(); return first.textContent.trim();
}); });
if (storm) { if (storm) {
await sleep(1500); await whenSettled(page);
if (await clickChip('weather-cyclones', 'focus')) { if (await clickChip('weather-cyclones', 'focus')) {
await sleep(6000); await whenSettled(page);
await shot('storm-view', `View storm: ${storm}`); await shot('storm-view', `View storm: ${storm}`);
await page.evaluate(() => await page.evaluate(() =>
window.__godsEyeView.viewer.camera.zoomIn(400_000), window.__godsEyeView.engine.camera.zoomIn(400_000),
); );
await sleep(4000); await whenSettled(page);
await shot('storm-closer', 'Zoomed toward the storm'); await shot('storm-closer', 'Zoomed toward the storm');
} }
} else console.log(' (no active cyclone advisories to follow)'); } else console.log(' (no active cyclone advisories to follow)');
// 5. US radar, then regional → city → street over the same spot. // 5. US radar, then regional → city → street over the same spot.
await clickChip('weather-radar', 'coverage'); await clickChip('weather-radar', 'coverage');
await sleep(6000); await whenSettled(page);
await shot('us-radar', 'View US radar'); await shot('us-radar', 'View US radar');
await fly(-94.6, 37.5, 600_000); await fly(-94.6, 37.5, 600_000);
await shot('regional-600km', 'Regional 600 km'); await shot('regional-600km', 'Regional 600 km');
@@ -391,7 +394,7 @@ try {
await page.mouse.wheel({ deltaY: 240 }); await page.mouse.wheel({ deltaY: 240 });
await sleep(120); await sleep(120);
} }
await sleep(3000); await whenSettled(page);
await shot('wheel-out', 'Wheel zoom out ×12'); await shot('wheel-out', 'Wheel zoom out ×12');
await page.mouse.move(cx - 200, cy); await page.mouse.move(cx - 200, cy);
await page.mouse.down(); await page.mouse.down();
@@ -400,13 +403,13 @@ try {
await sleep(30); await sleep(30);
} }
await page.mouse.up(); await page.mouse.up();
await sleep(2500); await whenSettled(page);
await shot('drag-pan', 'Drag pan'); await shot('drag-pan', 'Drag pan');
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
await page.mouse.wheel({ deltaY: -240 }); await page.mouse.wheel({ deltaY: -240 });
await sleep(120); await sleep(120);
} }
await sleep(4000); await whenSettled(page);
await shot('wheel-in', 'Wheel zoom in ×10'); await shot('wheel-in', 'Wheel zoom in ×10');
// 8. Back to Earth with everything on. // 8. Back to Earth with everything on.
@@ -419,5 +422,5 @@ try {
); );
console.log(`\nWrote ${steps.length} steps to ${outDir}`); console.log(`\nWrote ${steps.length} steps to ${outDir}`);
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
+38 -44
View File
@@ -1,4 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
// Cesium-only probes: resolution scale, MSAA and native camera transforms measure Cesium GPU cost.
import { whenSettled, whenFramesDrawn } from './qa/ready.mjs';
import { evaluateWithGeo } from './qa/geometry.mjs';
/** /**
* qa-weather-perf — matched-route throughput and idle-work measurement for the * qa-weather-perf — matched-route throughput and idle-work measurement for the
* Weather layers (Wind, Rain radar, Satellite clouds, Lightning density). * Weather layers (Wind, Rain radar, Satellite clouds, Lightning density).
@@ -33,9 +36,9 @@
* Requires a running dev server. Screenshots and results.json land in * Requires a running dev server. Screenshots and results.json land in
* <out>/<label>/. * <out>/<label>/.
*/ */
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const arg = (name, fallback) => { const arg = (name, fallback) => {
@@ -82,17 +85,9 @@ mkdirSync(outDir, { recursive: true });
const WEATHER = ['weather-radar', 'weather-satellite', 'weather-lightning']; const WEATHER = ['weather-radar', 'weather-satellite', 'weather-lightning'];
const AUSTIN = { lon: -97.7431, lat: 30.2672 }; const AUSTIN = { lon: -97.7431, lat: 30.2672 };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const chromePath = const browser = await launchBrowser({
process.env.PUPPETEER_EXECUTABLE_PATH || headless: true,
['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((p) =>
existsSync(p),
);
const browser = await puppeteer.launch({
headless: flag('--headless') ? 'new' : false,
executablePath: chromePath,
protocolTimeout: 300_000, protocolTimeout: 300_000,
defaultViewport: null, defaultViewport: null,
args: [ args: [
@@ -152,15 +147,15 @@ try {
failures.clear(); failures.clear();
}; };
await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { await page.waitForFunction(() => !!window.__godsEyeView?.engine, {
timeout: 120_000, timeout: 120_000,
}); });
await sleep(15_000); await whenSettled(page);
if (scale !== 1) if (scale !== 1)
await page.evaluate((scale) => { await page.evaluate((scale) => {
window.__godsEyeView.viewer.resolutionScale = scale; window.__godsEyeView.viewer.resolutionScale = scale;
window.__godsEyeView.viewer.scene.requestRender(); window.__godsEyeView.engine.render.request();
}, scale); }, scale);
const env = await page.evaluate(() => { const env = await page.evaluate(() => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
@@ -168,7 +163,7 @@ try {
return { return {
basemap: gev.tileset ? 'google-3d' : 'keyless-globe', basemap: gev.tileset ? 'google-3d' : 'keyless-globe',
globeShown: scene.globe.show, globeShown: scene.globe.show,
canvas: `${scene.canvas.width}x${scene.canvas.height}`, canvas: `${window.__godsEyeView.engine.host.canvas.width}x${window.__godsEyeView.engine.host.canvas.height}`,
dpr: devicePixelRatio, dpr: devicePixelRatio,
ua: ua:
navigator.userAgent.match(/Chrome\/[\d.]+/)?.[0] || navigator.userAgent, navigator.userAgent.match(/Chrome\/[\d.]+/)?.[0] || navigator.userAgent,
@@ -182,8 +177,7 @@ try {
// Park deterministically, disable every layer, and instrument once. // Park deterministically, disable every layer, and instrument once.
await page.evaluate(async () => { await page.evaluate(async () => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
const v = gev.viewer; window.__godsEyeView.engine.camera.cancelFlight();
v.camera.cancelFlight();
for (const [id, entry] of gev.dataManager.layers) for (const [id, entry] of gev.dataManager.layers)
if (entry.enabled) { if (entry.enabled) {
try { try {
@@ -198,7 +192,7 @@ try {
longTaskMs: 0, longTaskMs: 0,
mutations: 0, mutations: 0,
}); });
v.scene.postRender.addEventListener(() => { window.__godsEyeView.engine.frames.postRender.addEventListener(() => {
P.renders += 1; P.renders += 1;
}); });
try { try {
@@ -224,18 +218,18 @@ try {
}); });
const pose = (which) => const pose = (which) =>
page.evaluate( evaluateWithGeo(
({ which, AUSTIN }) => { page,
async (qaGeo, { which, AUSTIN }) => {
const C = window.Cesium || null; const C = window.Cesium || null;
const v = window.__godsEyeView.viewer; const v = window.__godsEyeView.viewer;
v.camera.cancelFlight(); window.__godsEyeView.engine.camera.cancelFlight();
v.camera.lookAtTransform?.( window.__godsEyeView.engine.camera.lookAtTransform?.(
(C || v.camera.constructor).Matrix4?.IDENTITY ?? (C || v.camera.constructor).Matrix4?.IDENTITY ??
v.scene.camera.transform, v.scene.camera.transform,
); );
const ell = v.scene.ellipsoid || v.scene.globe.ellipsoid;
const d2r = Math.PI / 180; const d2r = Math.PI / 180;
const destination = ell.cartographicToCartesian({ const destination = qaGeo.cartographic.toCartesian({
longitude: AUSTIN.lon * d2r, longitude: AUSTIN.lon * d2r,
latitude: AUSTIN.lat * d2r, latitude: AUSTIN.lat * d2r,
height: height:
@@ -245,7 +239,7 @@ try {
? 1_200 ? 1_200
: 17_368_000, : 17_368_000,
}); });
v.camera.setView({ window.__godsEyeView.engine.camera.setView({
destination, destination,
orientation: orientation:
which === 'close' which === 'close'
@@ -258,13 +252,12 @@ try {
/** Start a 3 s flight without awaiting it; the caller measures alongside. */ /** Start a 3 s flight without awaiting it; the caller measures alongside. */
const fly = (which) => const fly = (which) =>
page.evaluate( evaluateWithGeo(
({ which, AUSTIN }) => { page,
const v = window.__godsEyeView.viewer; async (qaGeo, { which, AUSTIN }) => {
const ell = v.scene.ellipsoid || v.scene.globe.ellipsoid;
const d2r = Math.PI / 180; const d2r = Math.PI / 180;
v.camera.flyTo({ window.__godsEyeView.engine.camera.flyTo({
destination: ell.cartographicToCartesian({ destination: qaGeo.cartographic.toCartesian({
longitude: AUSTIN.lon * d2r, longitude: AUSTIN.lon * d2r,
latitude: AUSTIN.lat * d2r, latitude: AUSTIN.lat * d2r,
height: which === 'close' ? 1_200 : 17_368_000, height: which === 'close' ? 1_200 : 17_368_000,
@@ -281,13 +274,13 @@ try {
/** Start an 8 s orbit at 1.8 km range around the Austin anchor (not awaited). */ /** Start an 8 s orbit at 1.8 km range around the Austin anchor (not awaited). */
const orbit = () => const orbit = () =>
page.evaluate( evaluateWithGeo(
({ AUSTIN }) => { page,
async (qaGeo, { AUSTIN }) => {
const v = window.__godsEyeView.viewer; const v = window.__godsEyeView.viewer;
const camera = v.camera; const camera = v.camera;
const ell = v.scene.ellipsoid || v.scene.globe.ellipsoid;
const d2r = Math.PI / 180; const d2r = Math.PI / 180;
const center = ell.cartographicToCartesian({ const center = qaGeo.cartographic.toCartesian({
longitude: AUSTIN.lon * d2r, longitude: AUSTIN.lon * d2r,
latitude: AUSTIN.lat * d2r, latitude: AUSTIN.lat * d2r,
height: 0, height: 0,
@@ -303,9 +296,12 @@ try {
const hpr = HPR const hpr = HPR
? new HPR(heading, -45 * d2r, 1800) ? new HPR(heading, -45 * d2r, 1800)
: { heading, pitch: -45 * d2r, range: 1800 }; : { heading, pitch: -45 * d2r, range: 1800 };
camera.lookAt(center, hpr); window.__godsEyeView.engine.camera.lookAt(center, hpr);
if (elapsed < 8) requestAnimationFrame(step); if (elapsed < 8) requestAnimationFrame(step);
else camera.lookAtTransform(camera.transform.constructor.IDENTITY); else
window.__godsEyeView.engine.camera.lookAtTransform(
camera.transform.constructor.IDENTITY,
);
}; };
requestAnimationFrame(step); requestAnimationFrame(step);
}, },
@@ -317,10 +313,8 @@ try {
page.evaluate( page.evaluate(
({ ms, hold }) => ({ ms, hold }) =>
new Promise((resolve) => { new Promise((resolve) => {
const gev = window.__godsEyeView;
const scene = gev.viewer.scene;
const P = window.__gevWeatherPerf; const P = window.__gevWeatherPerf;
const canvas = scene.canvas; const canvas = window.__godsEyeView.engine.host.canvas;
const w0 = canvas.width; const w0 = canvas.width;
const h0 = canvas.height; const h0 = canvas.height;
const r0 = P.renders; const r0 = P.renders;
@@ -364,7 +358,7 @@ try {
rafs += 1; rafs += 1;
intervals.push(now - last); intervals.push(now - last);
last = now; last = now;
if (hold) scene.requestRender(); if (hold) window.__godsEyeView.engine.render.request();
if (summary && !summary.hidden) { if (summary && !summary.hidden) {
const h = const h =
Math.round(summary.getBoundingClientRect().height * 2) / 2; Math.round(summary.getBoundingClientRect().height * 2) / 2;
@@ -478,7 +472,7 @@ try {
}, },
{ wantPlaying, WEATHER }, { wantPlaying, WEATHER },
); );
await sleep(2_500); await whenFramesDrawn(page);
return { ready, readyMs, layers: await diagnostics() }; return { ready, readyMs, layers: await diagnostics() };
}; };
@@ -530,7 +524,7 @@ try {
for (const state of ['off', 'history-paused', 'history-playing', 'wind']) { for (const state of ['off', 'history-paused', 'history-playing', 'wind']) {
await pose('close'); await pose('close');
const setup = await applyState(state); const setup = await applyState(state);
await sleep(4_000); await whenFramesDrawn(page);
for (let run = 1; run <= 2; run++) for (let run = 1; run <= 2; run++)
record({ record({
phase: 'idle', phase: 'idle',
@@ -564,5 +558,5 @@ try {
writeFileSync(path.join(outDir, 'summary.md'), lines.join('\n') + '\n'); writeFileSync(path.join(outDir, 'summary.md'), lines.join('\n') + '\n');
console.log(`\nWrote ${path.join(outDir, 'results.json')} and summary.md`); console.log(`\nWrote ${path.join(outDir, 'results.json')} and summary.md`);
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
+18 -20
View File
@@ -1,3 +1,5 @@
import { evaluateWithGeo } from './qa/geometry.mjs';
import { whenSettled } from './qa/ready.mjs';
/** /**
* Weather history-swap probe: does the scene keep drawing while a draped * Weather history-swap probe: does the scene keep drawing while a draped
* weather frame is replaced? Runs against a live dev server, turns on one * weather frame is replaced? Runs against a live dev server, turns on one
@@ -8,9 +10,9 @@
* node scripts/qa-weather-swap.mjs --url http://localhost:4173 --label google * node scripts/qa-weather-swap.mjs --url http://localhost:4173 --label google
* [--layer weather-satellite] [--product clouds] [--headless] * [--layer weather-satellite] [--product clouds] [--headless]
*/ */
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const arg = (name, fallback) => { const arg = (name, fallback) => {
@@ -32,17 +34,11 @@ const product = arg(
); );
const outDir = path.resolve(arg('--out', 'qa-shots/weather-swap'), label); const outDir = path.resolve(arg('--out', 'qa-shots/weather-swap'), label);
mkdirSync(outDir, { recursive: true }); mkdirSync(outDir, { recursive: true });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Globe disc in CSS pixels for the 1400×920 viewport at the Earth view. // Globe disc in CSS pixels for the 1400×920 viewport at the Earth view.
const DISC = { cx: 700, cy: 460, r: 300 }; const DISC = { cx: 700, cy: 460, r: 300 };
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: flag('--headless') ? 'new' : false, headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((p) =>
existsSync(p),
),
protocolTimeout: 300_000, protocolTimeout: 300_000,
defaultViewport: null, defaultViewport: null,
args: [ args: [
@@ -59,20 +55,20 @@ try {
await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 1 }); await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 1 });
page.on('pageerror', (e) => console.log(' [pageerror]', e.message)); page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { await page.waitForFunction(() => !!window.__godsEyeView?.engine, {
timeout: 120_000, timeout: 120_000,
}); });
await sleep(12_000); await whenSettled(page);
await page.evaluate( await evaluateWithGeo(
async ({ layerId, product }) => { page,
async (qaGeo, { layerId, product }) => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
const dm = gev.dataManager; const dm = gev.dataManager;
for (const [id, e] of dm.layers) for (const [id, e] of dm.layers)
if (e.enabled) await dm.setEnabled(id, false, { origin: 'user' }); if (e.enabled) await dm.setEnabled(id, false, { origin: 'user' });
const v = gev.viewer;
const d = Math.PI / 180; const d = Math.PI / 180;
v.camera.setView({ window.__godsEyeView.engine.camera.setView({
destination: v.scene.ellipsoid.cartographicToCartesian({ destination: qaGeo.cartographic.toCartesian({
longitude: -80 * d, longitude: -80 * d,
latitude: 25 * d, latitude: 25 * d,
height: 17_368_000, height: 17_368_000,
@@ -96,7 +92,7 @@ try {
{ layerId, product }, { layerId, product },
) )
.catch(() => console.log(' !! first frame not ready')); .catch(() => console.log(' !! first frame not ready'));
await sleep(3000); await whenSettled(page);
const diag = () => const diag = () =>
page.evaluate((layerId) => { page.evaluate((layerId) => {
const d = window.__godsEyeView.dataManager.layers const d = window.__godsEyeView.dataManager.layers
@@ -126,13 +122,15 @@ try {
{ layerId, params }, { layerId, params },
); );
await step({ step: -1 }); await step({ step: -1 });
// Sample the replacement over six seconds, including frames still loading.
for (let i = 1; i <= 15; i++) { for (let i = 1; i <= 15; i++) {
await sleep(400); await new Promise((resolve) => setTimeout(resolve, 400));
await capture(`earlier-${String(i).padStart(2, '0')}`); await capture(`earlier-${String(i).padStart(2, '0')}`);
} }
await step({ play: true }); await step({ play: true });
// History playback is observed over ten seconds.
for (let i = 1; i <= 20; i++) { for (let i = 1; i <= 20; i++) {
await sleep(500); await new Promise((resolve) => setTimeout(resolve, 500));
if (i % 2 === 0) await capture(`play-${String(i).padStart(2, '0')}`); if (i % 2 === 0) await capture(`play-${String(i).padStart(2, '0')}`);
} }
await step({ play: true }); await step({ play: true });
@@ -193,5 +191,5 @@ try {
`frames=${frames.length} black(>15%)=${blackFrames} max=${(summary.maxBlack * 100).toFixed(1)}% distinctTimes=${distinctTimes}`, `frames=${frames.length} black(>15%)=${blackFrames} max=${(summary.maxBlack * 100).toFixed(1)}% distinctTimes=${distinctTimes}`,
); );
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
+29 -26
View File
@@ -1,3 +1,6 @@
// Cesium-only probes: native imagery, primitive and listener counts audit Cesium resource ownership.
import { evaluateWithGeo } from './qa/geometry.mjs';
import { whenSettled, whenFramesDrawn } from './qa/ready.mjs';
/** /**
* Weather teardown and cross-layer regression probe. * Weather teardown and cross-layer regression probe.
* *
@@ -14,9 +17,9 @@
* *
* node scripts/qa-weather-teardown.mjs --url http://localhost:4173 --label google * node scripts/qa-weather-teardown.mjs --url http://localhost:4173 --label google
*/ */
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const arg = (name, fallback) => { const arg = (name, fallback) => {
@@ -42,13 +45,8 @@ const WEATHER = [
'weather-cyclones', 'weather-cyclones',
]; ];
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: flag('--headless') ? 'new' : false, headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((p) =>
existsSync(p),
),
protocolTimeout: 300_000, protocolTimeout: 300_000,
defaultViewport: null, defaultViewport: null,
args: [ args: [
@@ -75,19 +73,18 @@ const boot = async () => {
if (m.type() === 'error') errors.push(`console: ${m.text()}`.slice(0, 200)); if (m.type() === 'error') errors.push(`console: ${m.text()}`.slice(0, 200));
}); });
await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { await page.waitForFunction(() => !!window.__godsEyeView?.engine, {
timeout: 120_000, timeout: 120_000,
}); });
await sleep(10_000); await whenSettled(page);
await page.evaluate(async () => { await evaluateWithGeo(page, async (qaGeo) => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
const dm = gev.dataManager; const dm = gev.dataManager;
for (const [id, e] of dm.layers) for (const [id, e] of dm.layers)
if (e.enabled) await dm.setEnabled(id, false, { origin: 'user' }); if (e.enabled) await dm.setEnabled(id, false, { origin: 'user' });
const v = gev.viewer;
const d = Math.PI / 180; const d = Math.PI / 180;
v.camera.setView({ window.__godsEyeView.engine.camera.setView({
destination: v.scene.ellipsoid.cartographicToCartesian({ destination: qaGeo.cartographic.toCartesian({
longitude: -95 * d, longitude: -95 * d,
latitude: 32 * d, latitude: 32 * d,
height: 4_500_000, height: 4_500_000,
@@ -95,7 +92,7 @@ const boot = async () => {
orientation: { heading: 0, pitch: -90 * d, roll: 0 }, orientation: { heading: 0, pitch: -90 * d, roll: 0 },
}); });
}); });
await sleep(3000); await whenSettled(page);
return { page, errors }; return { page, errors };
}; };
@@ -167,12 +164,15 @@ const snapshot = (page) =>
dataSources: v.dataSources.length, dataSources: v.dataSources.length,
entities: v.entities.values.length, entities: v.entities.values.length,
listeners: { listeners: {
postRender: s.postRender.numberOfListeners, postRender:
preRender: s.preRender.numberOfListeners, window.__godsEyeView.engine.frames.postRender.numberOfListeners,
preRender:
window.__godsEyeView.engine.frames.preRender.numberOfListeners,
postUpdate: s.postUpdate.numberOfListeners, postUpdate: s.postUpdate.numberOfListeners,
moveEnd: v.camera.moveEnd.numberOfListeners, moveEnd: window.__godsEyeView.engine.camera.moveEnd.numberOfListeners,
moveStart: v.camera.moveStart.numberOfListeners, moveStart:
changed: v.camera.changed.numberOfListeners, window.__godsEyeView.engine.camera.moveStart.numberOfListeners,
changed: window.__godsEyeView.engine.camera.changed.numberOfListeners,
tick: v.clock.onTick.numberOfListeners, tick: v.clock.onTick.numberOfListeners,
}, },
dom: { dom: {
@@ -194,7 +194,10 @@ const sample = async (page, seconds) => {
window.__qaRenders = 0; window.__qaRenders = 0;
window.__qaLong = { count: 0, ms: 0 }; window.__qaLong = { count: 0, ms: 0 };
const s = window.__godsEyeView.viewer.scene; const s = window.__godsEyeView.viewer.scene;
window.__qaOff = s.postRender.addEventListener(() => window.__qaRenders++); window.__qaOff =
window.__godsEyeView.engine.frames.postRender.addEventListener(
() => window.__qaRenders++,
);
window.__qaObs = new PerformanceObserver((list) => { window.__qaObs = new PerformanceObserver((list) => {
for (const e of list.getEntries()) { for (const e of list.getEntries()) {
window.__qaLong.count++; window.__qaLong.count++;
@@ -203,7 +206,7 @@ const sample = async (page, seconds) => {
}); });
window.__qaObs.observe({ entryTypes: ['longtask'] }); window.__qaObs.observe({ entryTypes: ['longtask'] });
window.__qaRaf = () => { window.__qaRaf = () => {
s.requestRender(); window.__godsEyeView.engine.render.request();
window.__qaRafId = requestAnimationFrame(window.__qaRaf); window.__qaRafId = requestAnimationFrame(window.__qaRaf);
}; };
window.__qaRaf(); window.__qaRaf();
@@ -313,7 +316,7 @@ try {
console.log('weather on', JSON.stringify(weatherOn)); console.log('weather on', JSON.stringify(weatherOn));
results.weatherOnSample = await sample(a.page, 5); results.weatherOnSample = await sample(a.page, 5);
for (const id of WEATHER) await setLayer(a.page, id, false); for (const id of WEATHER) await setLayer(a.page, id, false);
await sleep(4000); await whenFramesDrawn(a.page);
const afterOff = await snapshot(a.page); const afterOff = await snapshot(a.page);
const leak = diff( const leak = diff(
{ ...baseline, enabled: undefined, weather: undefined }, { ...baseline, enabled: undefined, weather: undefined },
@@ -331,7 +334,7 @@ try {
results.afterWeather = []; results.afterWeather = [];
for (const id of others) { for (const id of others) {
const ok = await setLayer(a.page, id, true); const ok = await setLayer(a.page, id, true);
await sleep(9000); await whenFramesDrawn(a.page);
const m = await sample(a.page, 5); const m = await sample(a.page, 5);
const snap = await snapshot(a.page); const snap = await snapshot(a.page);
results.afterWeather.push({ results.afterWeather.push({
@@ -354,7 +357,7 @@ try {
results.control = []; results.control = [];
for (const id of others) { for (const id of others) {
const ok = await setLayer(b.page, id, true); const ok = await setLayer(b.page, id, true);
await sleep(9000); await whenFramesDrawn(b.page);
const m = await sample(b.page, 5); const m = await sample(b.page, 5);
const snap = await snapshot(b.page); const snap = await snapshot(b.page);
results.control.push({ results.control.push({
@@ -400,5 +403,5 @@ try {
JSON.stringify(results, null, 2), JSON.stringify(results, null, 2),
); );
console.log(`wrote ${path.join(outDir, 'results.json')}`); console.log(`wrote ${path.join(outDir, 'results.json')}`);
await browser.close(); await closeBrowser(browser);
} }
+21 -21
View File
@@ -1,22 +1,20 @@
#!/usr/bin/env node #!/usr/bin/env node
// Cesium-only probes: native inertia and render-loop control reproduce the Cesium zoom guard.
/** Real-app zoom and tilt regression. Start a dev server, then run this file. */ /** Real-app zoom and tilt regression. Start a dev server, then run this file. */
import { whenSettled } from './qa/ready.mjs';
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import puppeteer from 'puppeteer';
const origin = process.env.QA_BASE_URL || 'http://localhost:4173'; const origin = process.env.QA_BASE_URL || 'http://localhost:4173';
const browser = await puppeteer.launch({ const browser = await launchBrowser({
headless: true, headless: true,
args: [ args: [
'--no-sandbox', '--no-sandbox',
...(process.platform === 'darwin' ...(process.platform === 'darwin'
? ['--use-angle=metal', '--enable-gpu'] ? ['--enable-gpu']
: [ : ['--enable-unsafe-swiftshader']),
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
]),
], ],
protocolTimeout: 120_000, protocolTimeout: 120_000,
}); });
@@ -28,12 +26,12 @@ try {
await page.goto(`${origin}/?welcome=0`, { waitUntil: 'domcontentloaded' }); await page.goto(`${origin}/?welcome=0`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction( await page.waitForFunction(
() => () =>
window.__godsEyeView?.viewer && window.__godsEyeView?.engine &&
window.__godsEyeView.viewer.scene.screenSpaceCameraController window.__godsEyeView.viewer.scene.screenSpaceCameraController
.enableInputs && .enableInputs,
document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90_000 }, { timeout: 90_000 },
); );
await whenSettled(page);
const renderer = await page.evaluate(async () => { const renderer = await page.evaluate(async () => {
const C = await import('/node_modules/cesium/Build/Cesium/index.js'); const C = await import('/node_modules/cesium/Build/Cesium/index.js');
const { maxPitchForHeight } = const { maxPitchForHeight } =
@@ -41,21 +39,23 @@ try {
const { readCameraTargetFrame } = const { readCameraTargetFrame } =
await import('/src/ui/cameraOrientationControls.js'); await import('/src/ui/cameraOrientationControls.js');
const { viewer, engine } = window.__godsEyeView; const { viewer, engine } = window.__godsEyeView;
viewer.camera.cancelFlight(); window.__godsEyeView.engine.camera.cancelFlight();
viewer.trackedEntity = undefined; viewer.trackedEntity = undefined;
// Explicit real renders make each gesture independent of host frame rate. // Explicit real renders make each gesture independent of host frame rate.
viewer.useDefaultRenderLoop = false; viewer.useDefaultRenderLoop = false;
const controller = viewer.scene.screenSpaceCameraController; const controller = viewer.scene.screenSpaceCameraController;
controller.inertiaZoom = 0; controller.inertiaZoom = 0;
controller.inertiaSpin = 0; controller.inertiaSpin = 0;
const original = viewer.camera.setView.bind(viewer.camera); const original = window.__godsEyeView.engine.camera.setView.bind(
viewer.camera,
);
const qa = { const qa = {
viewer, viewer,
guardWrites: 0, guardWrites: 0,
targetPitch: () => readCameraTargetFrame(engine, viewer)?.pitch, targetPitch: () => readCameraTargetFrame(engine, viewer)?.pitch,
history: [], history: [],
frame() { frame() {
viewer.scene.requestRender(); window.__godsEyeView.engine.render.request();
viewer.render(); viewer.render();
qa.history.push({ qa.history.push({
height: viewer.camera._positionCartographic.height, height: viewer.camera._positionCartographic.height,
@@ -66,17 +66,17 @@ try {
}, },
pose() { pose() {
return { return {
height: viewer.camera.positionCartographic.height, height: window.__godsEyeView.engine.camera.pose().cartographic.height,
pitch: viewer.camera.pitch, pitch: window.__godsEyeView.engine.camera.pose().pitch,
maxPitch: maxPitchForHeight( maxPitch: maxPitchForHeight(
viewer.camera.positionCartographic.height, window.__godsEyeView.engine.camera.pose().cartographic.height,
), ),
guardWrites: qa.guardWrites, guardWrites: qa.guardWrites,
}; };
}, },
reset(height = 1_000_000, pitch = -35) { reset(height = 1_000_000, pitch = -35) {
viewer.camera.cancelFlight(); window.__godsEyeView.engine.camera.cancelFlight();
viewer.camera.lookAtTransform(C.Matrix4.IDENTITY); window.__godsEyeView.engine.camera.lookAtTransform(C.Matrix4.IDENTITY);
original({ original({
destination: C.Cartesian3.fromDegrees(0, 0, height), destination: C.Cartesian3.fromDegrees(0, 0, height),
orientation: { heading: 0, pitch: C.Math.toRadians(pitch), roll: 0 }, orientation: { heading: 0, pitch: C.Math.toRadians(pitch), roll: 0 },
@@ -92,7 +92,7 @@ try {
return original(options); return original(options);
}; };
window.__zoomGuardQa = qa; window.__zoomGuardQa = qa;
const gl = viewer.scene.context._gl; const gl = window.__godsEyeView.engine.host.canvas.getContext('webgl2');
const debug = gl.getExtension('WEBGL_debug_renderer_info'); const debug = gl.getExtension('WEBGL_debug_renderer_info');
return gl.getParameter(debug ? debug.UNMASKED_RENDERER_WEBGL : gl.RENDERER); return gl.getParameter(debug ? debug.UNMASKED_RENDERER_WEBGL : gl.RENDERER);
}); });
@@ -330,5 +330,5 @@ try {
assert.deepEqual(errors, [], 'no JavaScript page errors'); assert.deepEqual(errors, [], 'no JavaScript page errors');
console.log('[PASS] no JavaScript page errors'); console.log('[PASS] no JavaScript page errors');
} finally { } finally {
await browser.close(); await closeBrowser(browser);
} }
+39 -22
View File
@@ -2,6 +2,8 @@ import '../testSupport/registerDom.mjs';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import * as geo from '../geo/index.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { getTransitFeed } from '../data/transitFeeds.js'; import { getTransitFeed } from '../data/transitFeeds.js';
import { import {
chooseLiveCities, chooseLiveCities,
@@ -58,10 +60,19 @@ function pageFixture(t) {
createElement: () => new Element(), createElement: () => new Element(),
getElementById: (id) => elements.get(id), getElementById: (id) => elements.get(id),
querySelector: () => null, querySelector: () => null,
getAnimations: () => [],
}; };
globalThis.document = document; globalThis.document = document;
globalThis.window = {}; const engine = createFakeEngine();
engine.render.request = () => setImmediate(() => engine.fake.frame());
globalThis.window = {
__godsEyeView: {
engine,
styleManager: { initialRestorePromise: Promise.resolve() },
},
};
t.after(() => { t.after(() => {
engine.destroy();
for (const [name, value] of Object.entries(prior)) { for (const [name, value] of Object.entries(prior)) {
if (value === undefined) delete globalThis[name]; if (value === undefined) delete globalThis[name];
else globalThis[name] = value; else globalThis[name] = value;
@@ -77,9 +88,12 @@ function pageFixture(t) {
return elements.get(selector.slice(1)); return elements.get(selector.slice(1));
}; };
const page = { const page = {
evaluate: async (fn, ...args) => fn(...args), evaluate: async (fn, ...args) =>
waitForFunction: async (fn, options, ...args) => typeof fn === 'string' ? (0, eval)(fn) : fn(...args),
assert.ok(fn(...args), 'UI condition must hold'), waitForFunction: async (fn, options, ...args) => {
assert.ok(fn(...args), 'UI condition must hold');
return { dispose: async () => {} };
},
waitForSelector: async (selector) => waitForSelector: async (selector) =>
assert.ok(find(selector), `missing ${selector}`), assert.ok(find(selector), `missing ${selector}`),
$eval: async (selector, fn, ...args) => { $eval: async (selector, fn, ...args) => {
@@ -241,7 +255,7 @@ test('city motion checks load deterministic playback before reading rendered sam
source.indexOf('const headingA = await sampleScreen(page)', fixture) > source.indexOf('const headingA = await sampleScreen(page)', fixture) >
fixture, fixture,
); );
assert.match(source, /scene.renderError.addEventListener/); assert.match(source, /hookRenderErrors\(page\)/);
}); });
const { boundPageEvaluations, restoreRetainedTransit, sampleTransitPixels } = const { boundPageEvaluations, restoreRetainedTransit, sampleTransitPixels } =
@@ -259,7 +273,9 @@ test('page promise deadline rejects a stuck enable or teardown and preserves ord
test('reload picks a surviving primed vehicle and reports unavailable snapshots as unexercised', async (t) => { test('reload picks a surviving primed vehicle and reports unavailable snapshots as unexercised', async (t) => {
const previous = globalThis.window; const previous = globalThis.window;
const engine = createFakeEngine();
t.after(() => { t.after(() => {
engine.destroy();
globalThis.window = previous; globalThis.window = previous;
}); });
let selected; let selected;
@@ -267,14 +283,7 @@ test('reload picks a surviving primed vehicle and reports unavailable snapshots
const vehicles = new Map([[entry.key, entry]]); const vehicles = new Map([[entry.key, entry]]);
globalThis.window = { globalThis.window = {
__godsEyeView: { __godsEyeView: {
viewer: { engine,
camera: {
positionCartographic: {
constructor: { fromDegrees: (...v) => v, toCartesian: (v) => v },
},
setView() {},
},
},
dataManager: { dataManager: {
setEnabled: async () => {}, setEnabled: async () => {},
layers: new Map([ layers: new Map([
@@ -302,7 +311,7 @@ test('reload picks a surviving primed vehicle and reports unavailable snapshots
{ vehicleId: 'departed', oldestT: 10 }, { vehicleId: 'departed', oldestT: 10 },
{ vehicleId: 'survivor', oldestT: 20 }, { vehicleId: 'survivor', oldestT: 20 },
]; ];
assert.deepEqual(await restoreRetainedTransit(primed), { assert.deepEqual(await restoreRetainedTransit(geo, primed), {
key: entry.key, key: entry.key,
oldest: 20, oldest: 20,
}); });
@@ -318,14 +327,14 @@ test('reload picks a surviving primed vehicle and reports unavailable snapshots
setTimeout(() => vehicles.set(entry.key, entry), 5); setTimeout(() => vehicles.set(entry.key, entry), 5);
}; };
assert.equal( assert.equal(
(await restoreRetainedTransit(primed)).key, (await restoreRetainedTransit(geo, primed)).key,
entry.key, entry.key,
'wait for discovery instead of declaring every vehicle departed', 'wait for discovery instead of declaring every vehicle departed',
); );
manager.setEnabled = async () => {}; manager.setEnabled = async () => {};
assert.equal( assert.equal(
( (
await restoreRetainedTransit([ await restoreRetainedTransit(geo, [
{ ...primed[1], feedId: 'mbta', primedAt: Date.now() - 60000 }, { ...primed[1], feedId: 'mbta', primedAt: Date.now() - 60000 },
]) ])
).unexercised, ).unexercised,
@@ -334,8 +343,8 @@ test('reload picks a surviving primed vehicle and reports unavailable snapshots
); );
layer._transitStateForTest = () => ({ _vehicles: vehicles }); layer._transitStateForTest = () => ({ _vehicles: vehicles });
vehicles.clear(); vehicles.clear();
assert.equal((await restoreRetainedTransit(primed)).unexercised, true); assert.equal((await restoreRetainedTransit(geo, primed)).unexercised, true);
assert.equal((await restoreRetainedTransit([])).unexercised, true); assert.equal((await restoreRetainedTransit(geo, [])).unexercised, true);
}); });
test('sensor sampler waits for postRender and reads framebuffer pixels; stopped rendering rejects and removes listener', async (t) => { test('sensor sampler waits for postRender and reads framebuffer pixels; stopped rendering rejects and removes listener', async (t) => {
@@ -382,6 +391,12 @@ test('sensor sampler waits for postRender and reads framebuffer pixels; stopped
if (render) queueMicrotask(() => listener?.()); if (render) queueMicrotask(() => listener?.());
}, },
}; };
const engine = createFakeEngine({ canvas: scene.canvas });
engine.frames.postRender = scene.postRender;
engine.projector.viewProjection = () => identity;
engine.picker.pick = () => ({ id: 'bus' });
engine.render.request = () => scene.requestRender();
t.after(() => engine.destroy());
const raster = new Uint8ClampedArray(16 * 16 * 4).fill(255); const raster = new Uint8ClampedArray(16 * 16 * 4).fill(255);
globalThis.Image = class { globalThis.Image = class {
width = 16; width = 16;
@@ -399,7 +414,7 @@ test('sensor sampler waits for postRender and reads framebuffer pixels; stopped
}; };
globalThis.window = { globalThis.window = {
__godsEyeView: { __godsEyeView: {
viewer: { scene }, engine,
dataManager: { dataManager: {
layers: new Map([ layers: new Map([
[ [
@@ -494,19 +509,21 @@ test('sensor sampler waits for postRender and reads framebuffer pixels; stopped
'equal-width tram bands prefer the upper solid body', 'equal-width tram bands prefer the upper solid body',
); );
sampledLayer._transitStateForTest = originalState; sampledLayer._transitStateForTest = originalState;
scene.pick = () => ({ id: 'overlapping-label' }); engine.picker.pick = () => ({ id: 'overlapping-label' });
assert.equal( assert.equal(
(await sampleTransitPixels(page, {})).length, (await sampleTransitPixels(page, {})).length,
0, 0,
'a foreign pick across the sampled core disqualifies an obscured sprite', 'a foreign pick across the sampled core disqualifies an obscured sprite',
); );
scene.pick = ({ x }) => ({ id: x < 200 ? 'overlapping-sprite' : 'bus' }); engine.picker.pick = ({ x }) => ({
id: x < 200 ? 'overlapping-sprite' : 'bus',
});
assert.equal( assert.equal(
(await sampleTransitPixels(page, {})).length, (await sampleTransitPixels(page, {})).length,
0, 0,
'a clear centre cannot qualify a partially obscured core', 'a clear centre cannot qualify a partially obscured core',
); );
delete scene.pick; engine.picker.pick = () => ({ id: 'bus' });
const module = window.__godsEyeView.dataManager.layers.get('transit').module; const module = window.__godsEyeView.dataManager.layers.get('transit').module;
const getState = module._transitStateForTest; const getState = module._transitStateForTest;
module._transitStateForTest = () => { module._transitStateForTest = () => {