mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Each script launches and closes Chrome through scripts/qa/browser.mjs, waits on the application through scripts/qa/ready.mjs instead of the loading screen and fixed pauses, and reads the contract instead of Cesium where the check is not about Cesium itself.
547 lines
19 KiB
JavaScript
547 lines
19 KiB
JavaScript
#!/usr/bin/env node
|
|
// Cesium-only probes: native primitive inspection independently measures the Cesium road paint.
|
|
import { evaluateWithGeo } from './qa/geometry.mjs';
|
|
/**
|
|
* Traffic motion acceptance: real-GPU Puppeteer, stable identities, stationary
|
|
* camera holds and timestamped CDP JPEGs encoded with ffmpeg. No tile mocks.
|
|
* Run: node scripts/qa-traffic-motion.mjs http://localhost:4186
|
|
* --baseline records the unmodified pre-fix implementation (object identities
|
|
* via WeakMap, never collection indices); it reports failures without exiting 1.
|
|
* --mode=tomtom|osm|hybrid limits a diagnostic run. --tag=name keeps captures.
|
|
* --journey follows a neighborhood-to-city route with layer toggles, then
|
|
* checks Capitol/greenbelt/parking/corridor views against a 1.5 s limit.
|
|
*/
|
|
import { whenSettled } from './qa/ready.mjs';
|
|
import { launchBrowser, closeBrowser } from './qa/browser.mjs';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { execFileSync } from 'node:child_process';
|
|
const args = process.argv.slice(2);
|
|
if (args.includes('--help')) {
|
|
console.log(
|
|
'Usage: node scripts/qa-traffic-motion.mjs <url> [--baseline] [--mode=tomtom|osm|hybrid] [--tag=name] [--journey]',
|
|
);
|
|
process.exit(0);
|
|
}
|
|
const url = args.find((a) => /^https?:/.test(a));
|
|
if (!url) throw new Error('A running dev server URL is required');
|
|
const baseline = args.includes('--baseline');
|
|
const journey = args.includes('--journey');
|
|
const option = (key) =>
|
|
args.find((a) => a.startsWith(`--${key}=`))?.split('=')[1];
|
|
const modes = option('mode') ? [option('mode')] : ['tomtom', 'osm', 'hybrid'];
|
|
if (modes.some((m) => !['tomtom', 'osm', 'hybrid'].includes(m)))
|
|
throw new Error('Invalid road mode');
|
|
const root = path.resolve(
|
|
'qa-shots/motion',
|
|
option('tag') || (baseline ? 'before' : '.'),
|
|
);
|
|
fs.mkdirSync(root, { recursive: true });
|
|
const revision = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
encoding: 'utf8',
|
|
}).trim();
|
|
const browser = await launchBrowser({
|
|
headless: true,
|
|
protocolTimeout: 300000,
|
|
args: [
|
|
'--window-size=1280,800',
|
|
'--disable-background-timer-throttling',
|
|
'--disable-renderer-backgrounding',
|
|
],
|
|
});
|
|
const results = [];
|
|
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
try {
|
|
for (const mode of modes) {
|
|
const dir = path.join(root, mode);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const page = await browser.newPage();
|
|
const errors = [],
|
|
forbidden = [],
|
|
logs = [],
|
|
tileRequests = [];
|
|
page.on('pageerror', (e) => errors.push(e.message));
|
|
page.on('console', (m) => {
|
|
if (m.type() === 'error') errors.push(m.text());
|
|
if (m.text().startsWith('[Data:Traffic]')) logs.push(m.text());
|
|
});
|
|
page.on('request', (r) => {
|
|
const u = new URL(r.url());
|
|
if (
|
|
u.hostname === 'tiles.openfreemap.org' &&
|
|
/\/\d+\/\d+\/\d+(?:\.pbf)?$/.test(u.pathname)
|
|
)
|
|
tileRequests.push(u.pathname);
|
|
if (
|
|
/overpass/i.test(u.hostname) ||
|
|
u.hostname === 'nominatim.openstreetmap.org'
|
|
)
|
|
forbidden.push(u.hostname);
|
|
});
|
|
await page.setViewport({ width: 1280, height: 800 });
|
|
await page.evaluateOnNewDocument(() =>
|
|
sessionStorage.setItem('gev:first-run-mission-session:v1', 'dismissed'),
|
|
);
|
|
const u = new URL(url);
|
|
u.searchParams.set('trafficRoads', mode);
|
|
u.searchParams.set('trafficDebug', '1');
|
|
await page.goto(u.href, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
|
timeout: 120000,
|
|
});
|
|
await whenSettled(page);
|
|
await page.evaluate(
|
|
() => window.__godsEyeView.styleManager.initialRestorePromise,
|
|
);
|
|
await page.keyboard.press('Escape');
|
|
await page.evaluate(() => {
|
|
const g = window.__godsEyeView;
|
|
g.styleManager.setDetection({ enabled: false });
|
|
for (const [id] of g.dataManager.layers)
|
|
g.dataManager.setEnabled(id, false);
|
|
});
|
|
const identity = await page.evaluate(() => ({
|
|
title: document.title,
|
|
text: document.body.innerText.length,
|
|
overlay: Boolean(document.querySelector('vite-error-overlay')),
|
|
}));
|
|
await page.evaluate((baseline) => {
|
|
const { viewer, dataManager } = window.__godsEyeView;
|
|
const layer = dataManager.layers.get('traffic').module;
|
|
if (!baseline && typeof layer.visitMotionDots !== 'function')
|
|
throw new Error('Traffic must expose stable dot identities');
|
|
const scene = viewer.scene;
|
|
const weakIds = new WeakMap();
|
|
let nextId = 0,
|
|
rebuilds = 0;
|
|
const collections = [];
|
|
// A rebuild: the traffic display's mover sets made again
|
|
// (src/layers/traffic/render/display.js writes its two point sets in
|
|
// place every frame; it never empties and refills a collection).
|
|
// --baseline records the pre-contract build, whose PointPrimitive
|
|
// collections sat in scene.primitives and were rebuilt by removeAll.
|
|
if (!baseline) {
|
|
const engine = window.__godsEyeView.engine;
|
|
const createMoverSet = engine.movers.createSet.bind(engine.movers);
|
|
engine.movers.createSet = (options) => {
|
|
if (/^traffic-(fill|outline)$/.test(options?.id ?? '')) rebuilds++;
|
|
return createMoverSet(options);
|
|
};
|
|
}
|
|
function discoverCollections() {
|
|
if (!baseline) return;
|
|
for (let i = 0; i < scene.primitives.length; i++) {
|
|
const p = scene.primitives.get(i);
|
|
if (!Array.isArray(p._pointPrimitives) || collections.includes(p))
|
|
continue;
|
|
collections.push(p);
|
|
const removeAll = p.removeAll;
|
|
p.removeAll = function () {
|
|
if (this.length) rebuilds++;
|
|
return removeAll.call(this);
|
|
};
|
|
}
|
|
}
|
|
let previous = new Map(),
|
|
previousN = 0,
|
|
lastRebuilds = 0,
|
|
lastTime = 0;
|
|
const rows = [];
|
|
window.__motion = {
|
|
rows,
|
|
active: false,
|
|
moveEnds: [],
|
|
label: 'camera moving',
|
|
holdStart: null,
|
|
};
|
|
const clock = document.createElement('div');
|
|
clock.style.cssText =
|
|
'position:fixed;bottom:4px;left:38%;padding:3px 8px;background:#000c;color:#fff;font:12px monospace;z-index:999999;pointer-events:none';
|
|
document.body.append(clock);
|
|
window.__godsEyeView.engine.camera.moveEnd.addEventListener(() =>
|
|
window.__motion.moveEnds.push(performance.now()),
|
|
);
|
|
window.__godsEyeView.engine.frames.postRender.addEventListener(() => {
|
|
if (!window.__motion.active) return;
|
|
discoverCollections();
|
|
const now = performance.now(),
|
|
points = new Map();
|
|
clock.textContent =
|
|
`QA: ${window.__motion.label}` +
|
|
(window.__motion.holdStart === null
|
|
? ''
|
|
: ` | camera stopped +${((now - window.__motion.holdStart) / 1000).toFixed(1)}s`);
|
|
const visit = (id, roadId, point) => {
|
|
if (!point.show) return;
|
|
const s = window.__godsEyeView.engine.projector.worldToWindow(
|
|
point.position,
|
|
);
|
|
if (s && s.x >= 0 && s.y >= 0 && s.x <= 1280 && s.y <= 800)
|
|
points.set(id, { x: s.x, y: s.y, roadId });
|
|
};
|
|
let n = 0,
|
|
offRoad = 0;
|
|
if (!baseline && layer.visitMotionDots) {
|
|
layer.visitMotionDots((id, roadId, point, road) => {
|
|
if (point.show) {
|
|
const p = road?.roadProperties;
|
|
const allowed = road?.directFlow
|
|
? [
|
|
'Motorway',
|
|
'International road',
|
|
'Major road',
|
|
'Secondary road',
|
|
'Connecting road',
|
|
'Major local road',
|
|
'Local road',
|
|
'Minor local road',
|
|
].includes(p?.roadType)
|
|
: [
|
|
'motorway',
|
|
'trunk',
|
|
'primary',
|
|
'secondary',
|
|
'tertiary',
|
|
'minor',
|
|
].includes(p?.class) &&
|
|
!p?.service &&
|
|
!p?.subclass &&
|
|
!['no', 'private'].includes(p?.access) &&
|
|
p?.brunnel !== 'tunnel';
|
|
if (!allowed) offRoad++;
|
|
}
|
|
if (point.show) n++;
|
|
visit(id, roadId, point);
|
|
});
|
|
} else {
|
|
for (const coll of collections)
|
|
if (coll.show)
|
|
for (let i = 0; i < coll.length; i++) {
|
|
const p = coll.get(i);
|
|
if (!weakIds.has(p)) weakIds.set(p, ++nextId);
|
|
if (p.show) n++;
|
|
visit(weakIds.get(p), null, p);
|
|
}
|
|
}
|
|
let tracked = 0,
|
|
jumps = 0,
|
|
reassigned = 0,
|
|
births = 0,
|
|
deaths = 0;
|
|
for (const [id, p] of points) {
|
|
const prev = previous.get(id);
|
|
if (!prev) {
|
|
births++;
|
|
continue;
|
|
}
|
|
tracked++;
|
|
if (Math.hypot(p.x - prev.x, p.y - prev.y) > 30) jumps++;
|
|
if (p.roadId !== prev.roadId) reassigned++;
|
|
}
|
|
for (const id of previous.keys()) if (!points.has(id)) deaths++;
|
|
rows.push({
|
|
t: now,
|
|
loading: layer.getStats().loading,
|
|
syncing:
|
|
document
|
|
.getElementById('traffic-sync-chip')
|
|
?.classList.contains('visible') &&
|
|
document.getElementById('traffic-sync-progress')?.textContent ===
|
|
'...',
|
|
surfacePending: layer.getStats().surfacePending,
|
|
offRoad,
|
|
dt: now - lastTime,
|
|
n,
|
|
visible: points.size,
|
|
tracked,
|
|
jumps,
|
|
jumpFrac: tracked ? jumps / tracked : 0,
|
|
reassigned,
|
|
births,
|
|
deaths,
|
|
sizeChanged: n !== previousN,
|
|
rebuilds: rebuilds - lastRebuilds,
|
|
});
|
|
previous = points;
|
|
previousN = n;
|
|
lastRebuilds = rebuilds;
|
|
lastTime = now;
|
|
});
|
|
}, baseline);
|
|
const fly = (lat, lon, height, pitch, duration, heading = 20) =>
|
|
evaluateWithGeo(
|
|
page,
|
|
async (qaGeo, v) => {
|
|
window.__motion.label = 'camera moving';
|
|
window.__motion.holdStart = null;
|
|
window.__godsEyeView.engine.camera.cancelFlight();
|
|
await new Promise((resolve) =>
|
|
window.__godsEyeView.engine.camera.flyTo({
|
|
destination: qaGeo.cartographic.toCartesian({
|
|
latitude: (v.lat * Math.PI) / 180,
|
|
longitude: (v.lon * Math.PI) / 180,
|
|
height: v.height,
|
|
}),
|
|
orientation: {
|
|
heading: (v.heading * Math.PI) / 180,
|
|
pitch: (v.pitch * Math.PI) / 180,
|
|
roll: 0,
|
|
},
|
|
duration: v.duration,
|
|
complete: resolve,
|
|
cancel: resolve,
|
|
}),
|
|
);
|
|
},
|
|
{ lat, lon, height, pitch, duration, heading },
|
|
);
|
|
const cdp = await page.createCDPSession();
|
|
let fi = 0;
|
|
const frames = [];
|
|
cdp.on('Page.screencastFrame', (f) => {
|
|
const name = `f${String(fi++).padStart(5, '0')}.jpg`;
|
|
fs.writeFileSync(path.join(dir, name), Buffer.from(f.data, 'base64'));
|
|
frames.push({ name, timestamp: f.metadata.timestamp });
|
|
cdp
|
|
.send('Page.screencastFrameAck', { sessionId: f.sessionId })
|
|
.catch(() => {});
|
|
});
|
|
await fly(
|
|
...(journey
|
|
? [30.25, -97.7497, 700, -40, 0, 10]
|
|
: [30.2685, -97.7425, 450, -35, 0]),
|
|
);
|
|
await cdp.send('Page.startScreencast', {
|
|
format: 'jpeg',
|
|
quality: 65,
|
|
maxWidth: 1280,
|
|
maxHeight: 800,
|
|
everyNthFrame: journey ? 1 : 3,
|
|
});
|
|
await page.evaluate(() => {
|
|
window.__motion.active = true;
|
|
});
|
|
const holds = [];
|
|
const hold = async (name, warm = false, holdMs = 10000, empty = false) => {
|
|
const start = await page.evaluate((label) => {
|
|
const start = performance.now();
|
|
window.__motion.label = label;
|
|
window.__motion.holdStart = start;
|
|
return start;
|
|
}, name);
|
|
await delay(holdMs);
|
|
const end = await page.evaluate(() => performance.now());
|
|
holds.push({
|
|
name,
|
|
warm,
|
|
start,
|
|
end,
|
|
empty,
|
|
settleMs: journey ? 1500 : warm ? 1000 : 3000,
|
|
});
|
|
await page.screenshot({ path: path.join(dir, `${name}.png`) });
|
|
console.log(`${mode}: captured ${name}`);
|
|
};
|
|
await page.evaluate(() =>
|
|
window.__godsEyeView.dataManager.setEnabled('traffic', true),
|
|
);
|
|
// Match motion-reference: begin the camera-motion scenario from a
|
|
// settled street view. Initial mesh acquisition is recorded in the clip
|
|
// and first-dot diagnostics, but is not a stationary motion hold.
|
|
if (journey) {
|
|
await delay(4000);
|
|
await hold('south-congress', true, 6000);
|
|
const step = async (name, view, ms = 6000, empty = false) => {
|
|
await fly(...view);
|
|
await hold(name, false, ms, empty);
|
|
};
|
|
await step('pan-north-400m', [30.2536, -97.7497, 700, -40, 2, 10], 4000);
|
|
await step('pan-east-500m', [30.2536, -97.7445, 700, -40, 2, 10], 4000);
|
|
await step('zoom-out-2.5km', [30.2536, -97.7445, 2500, -45, 2.5, 10]);
|
|
await step(
|
|
'downtown-2.5km',
|
|
[30.2672, -97.7431, 2500, -45, 2.5, 10],
|
|
5000,
|
|
);
|
|
await step('downtown-500m', [30.2672, -97.7431, 500, -35, 2.5, 20]);
|
|
await page.evaluate(() =>
|
|
window.__godsEyeView.dataManager.setEnabled('alpr-cameras', true),
|
|
);
|
|
await hold('enable-alpr', true, 6000);
|
|
await step('pan-west-600m', [30.2672, -97.7494, 500, -35, 2, 20], 5000);
|
|
await step('city-12km', [30.28, -97.75, 12000, -60, 3, 0], 8000, true);
|
|
await fly(30.3125, -97.765, 1000, -45, 3, 0);
|
|
await page.evaluate(() =>
|
|
window.__godsEyeView.dataManager.setEnabled(
|
|
'military-installations',
|
|
true,
|
|
),
|
|
);
|
|
await hold('camp-mabry-1km', false, 8000);
|
|
await step('camp-mabry-pan', [30.3125, -97.76, 1000, -45, 2, 30], 5000);
|
|
await step('greenbelt', [30.312, -97.775, 1000, -55, 3, 0], 6000);
|
|
await step('capitol', [30.2747, -97.7404, 500, -70, 3, 0], 6000);
|
|
await step('commercial-parking', [30.307, -97.735, 600, -60, 3, 0], 6000);
|
|
await step('corridor', [30.347, -97.799, 1000, -45, 3, 0], 6000);
|
|
} else {
|
|
await delay(12000);
|
|
await page.waitForFunction(
|
|
() => window.__motion.rows.some((r) => r.n > 0),
|
|
{ timeout: 1000 },
|
|
);
|
|
await hold('street-warm', true);
|
|
await fly(30.2685, -97.7425, 3000, -45, 1.5);
|
|
await hold('zoom-out');
|
|
await fly(30.2685, -97.72, 3000, -45, 1.5);
|
|
await hold('pan-east');
|
|
await fly(30.2685, -97.725, 600, -35, 1.5);
|
|
await hold('zoom-in');
|
|
await fly(-23.5505, -46.6333, 1250, -35, 1.5);
|
|
await hold('sao-paulo');
|
|
await fly(30.2685, -97.7425, 450, -35, 1.5);
|
|
await hold('austin-revisit', true);
|
|
}
|
|
await page.evaluate(() => {
|
|
window.__motion.active = false;
|
|
});
|
|
await cdp.send('Page.stopScreencast');
|
|
const rows = await page.evaluate(() => window.__motion.rows);
|
|
const surfacePhases = await page.evaluate(() =>
|
|
performance
|
|
.getEntriesByType('measure')
|
|
.filter(
|
|
(e) => e.name.startsWith('roads:') || e.name.startsWith('traffic:'),
|
|
)
|
|
.map((e) => ({
|
|
name: e.name,
|
|
start: e.startTime,
|
|
duration: e.duration,
|
|
detail: e.detail,
|
|
})),
|
|
);
|
|
const stats = await page.evaluate(() =>
|
|
window.__godsEyeView.dataManager.layers.get('traffic').module.getStats(),
|
|
);
|
|
const phases = holds.map((h) => {
|
|
const all = rows.filter((r) => r.t >= h.start && r.t < h.end);
|
|
const settled = all.filter((r) => r.t >= h.start + h.settleMs);
|
|
const n = settled.map((r) => r.n);
|
|
const lastBusy = all.findLastIndex(
|
|
(r) => r.loading || r.syncing || r.sizeChanged || r.jumpFrac > 0.02,
|
|
);
|
|
// First observed settled frame after the last population/loading event.
|
|
const steadyMs =
|
|
lastBusy < 0
|
|
? 0
|
|
: Math.round((all[lastBusy + 1]?.t ?? h.end) - h.start);
|
|
const phase = {
|
|
mode,
|
|
phase: h.name,
|
|
steadyMs,
|
|
lateLoadingFrames: settled.filter((r) => r.loading).length,
|
|
offRoadDots: Math.max(0, ...all.map((r) => r.offRoad)),
|
|
settleMs: h.settleMs,
|
|
frames: settled.length,
|
|
rebuilds: all.reduce((s, r) => s + r.rebuilds, 0),
|
|
lateRebuilds: settled.reduce((s, r) => s + r.rebuilds, 0),
|
|
jumpFrames: settled.filter((r) => r.jumpFrac > 0.02).length,
|
|
reassigned: settled.reduce((s, r) => s + r.reassigned, 0),
|
|
sizeChanges: settled.filter((r) => r.sizeChanged).length,
|
|
minDots: Math.min(...n),
|
|
maxDots: Math.max(...n),
|
|
maxFrameMs: Math.round(Math.max(...settled.map((r) => r.dt))),
|
|
};
|
|
phase.passed =
|
|
phase.frames >= 20 &&
|
|
phase.rebuilds <= 1 &&
|
|
phase.lateRebuilds === 0 &&
|
|
phase.jumpFrames === 0 &&
|
|
phase.reassigned === 0 &&
|
|
phase.sizeChanges === 0 &&
|
|
(h.empty ? phase.maxDots === 0 : phase.minDots > 0) &&
|
|
(!journey ||
|
|
(phase.steadyMs <= 1500 &&
|
|
phase.offRoadDots === 0 &&
|
|
all.at(-1)?.surfacePending === 0));
|
|
return phase;
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(dir, 'frames.ffconcat'),
|
|
'ffconcat version 1.0\n' +
|
|
frames
|
|
.map(
|
|
(f, i) =>
|
|
`file '${f.name}'\nduration ${Math.max(0.001, Math.min(2, (frames[i + 1]?.timestamp || f.timestamp + 0.1) - f.timestamp))}\n`,
|
|
)
|
|
.join(''),
|
|
);
|
|
execFileSync('ffmpeg', [
|
|
'-y',
|
|
'-loglevel',
|
|
'error',
|
|
'-safe',
|
|
'0',
|
|
'-f',
|
|
'concat',
|
|
'-i',
|
|
path.join(dir, 'frames.ffconcat'),
|
|
'-fps_mode',
|
|
'vfr',
|
|
'-c:v',
|
|
'libx264',
|
|
'-pix_fmt',
|
|
'yuv420p',
|
|
'-vf',
|
|
journey ? 'scale=1280:-2' : 'scale=960:-2',
|
|
path.join(dir, 'clip.mp4'),
|
|
]);
|
|
const result = {
|
|
mode,
|
|
revision,
|
|
baseline,
|
|
identity,
|
|
url: u.href,
|
|
phases,
|
|
errors,
|
|
forbidden,
|
|
tileRequests,
|
|
duplicateTiles: tileRequests.filter(
|
|
(p, i) => tileRequests.indexOf(p) !== i,
|
|
),
|
|
stats,
|
|
surfacePhases,
|
|
logs,
|
|
holds,
|
|
rows,
|
|
moveEnds: await page.evaluate(() => window.__motion.moveEnds),
|
|
clip: path.join(dir, 'clip.mp4'),
|
|
frames: frames.length,
|
|
};
|
|
result.passed =
|
|
phases.every((p) => p.passed) &&
|
|
!errors.length &&
|
|
!forbidden.length &&
|
|
result.duplicateTiles.length === 0 &&
|
|
!identity.overlay &&
|
|
identity.text > 100;
|
|
fs.writeFileSync(
|
|
path.join(dir, 'metrics.json'),
|
|
JSON.stringify(result, null, 2),
|
|
);
|
|
results.push(result);
|
|
console.table(phases);
|
|
await page.close();
|
|
}
|
|
} finally {
|
|
await closeBrowser(browser);
|
|
fs.writeFileSync(
|
|
path.join(root, 'results.json'),
|
|
JSON.stringify(
|
|
results.map(({ rows, ...r }) => r),
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
}
|
|
if (!baseline && results.some((r) => !r.passed)) process.exitCode = 1;
|