944 lines
38 KiB
JavaScript
944 lines
38 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Phase 0 world-overlay baseline harness.
|
||
*
|
||
* The harness intentionally measures the current implementation without
|
||
* importing or changing application modules. Each scene gets a fresh page,
|
||
* enables its layers through DataLayerManager.toggle(), waits for layer data,
|
||
* then samples five seconds of scripted camera motion and five seconds at rest.
|
||
*
|
||
* Usage:
|
||
* node scripts/qa-overlay-baseline.mjs
|
||
* node scripts/qa-overlay-baseline.mjs --scene datacenters
|
||
* node scripts/qa-overlay-baseline.mjs --scene cctv-street,detection-50
|
||
* node scripts/qa-overlay-baseline.mjs --json /tmp/overlay-baseline.json
|
||
* node scripts/qa-overlay-baseline.mjs --screenshots-dir /tmp/overlay-shots
|
||
* node scripts/qa-overlay-baseline.mjs --hardware-gpu --headful
|
||
* node scripts/qa-overlay-baseline.mjs --dist-dir /tmp/gev-dist
|
||
*/
|
||
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import puppeteer from 'puppeteer';
|
||
|
||
const CHROME_EXECUTABLE = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||
const DEFAULT_URL = 'http://localhost:4176';
|
||
const VIEWPORT = Object.freeze({ width: 1440, height: 900 });
|
||
const SAMPLE_MS = 5_000;
|
||
const BETWEEN_PHASE_SETTLE_MS = 1_000;
|
||
const DEFAULT_SETTLE_MS = 3_000;
|
||
const LAYER_WAIT_MS = 60_000;
|
||
const KNOWN_OVERLAY_CANVASES = new Set([
|
||
'world-overlay-canvas',
|
||
'tracked-readout',
|
||
'firms-labels',
|
||
'vessel-labels',
|
||
'cctv-cards',
|
||
]);
|
||
|
||
const SCENES = Object.freeze([
|
||
{ id: 'datacenters', layers: ['local-datacenters'], camera: [-98, 38, 6_000_000, 0, -Math.PI / 2] },
|
||
{ id: 'dams', layers: ['local-dams'], camera: [-98, 38, 6_000_000, 0, -Math.PI / 2] },
|
||
{ id: 'datacenters+dams', layers: ['local-datacenters', 'local-dams'], camera: [-98, 38, 6_000_000, 0, -Math.PI / 2] },
|
||
{ id: 'submarine-cables', layers: ['telegeography-submarine-cables'], camera: [-20, 12, 11_000_000, 0, -Math.PI / 2] },
|
||
{ id: 'cctv-street', layers: ['cctv'], cctvHeightM: 1_500 },
|
||
{ id: 'cctv-city', layers: ['cctv'], cctvHeightM: 6_000 },
|
||
{ id: 'cctv-high', layers: ['cctv'], cctvHeightM: 12_000 },
|
||
{ id: 'firms', layers: ['local-firms'], camera: [-110, 45, 5_000_000, 0, -Math.PI / 2] },
|
||
{ id: 'vessels', layers: ['ais-live-vessels'], camera: [4.05, 51.93, 18_000, 0.3, -1.25] },
|
||
{ id: 'detection-25', layers: ['flights', 'satellites'], detectionDensity: 25, camera: [-98, 38, 2_500_000, 0, -Math.PI / 2] },
|
||
{ id: 'detection-50', layers: ['flights', 'satellites'], detectionDensity: 50, camera: [-98, 38, 2_500_000, 0, -Math.PI / 2] },
|
||
{ id: 'detection-100', layers: ['flights', 'satellites'], detectionDensity: 100, camera: [-98, 38, 2_500_000, 0, -Math.PI / 2] },
|
||
{ id: 'tracked-civil-aircraft', layers: ['flights'], trackedFlight: true, camera: [-98, 38, 2_500_000, 0, -Math.PI / 2] },
|
||
{ id: 'missions-selected', layers: ['rocket-launches'], missionSelected: true, camera: [0, 15, 18_000_000, 0, -Math.PI / 2] },
|
||
{ id: 'cockpit-mode', layers: ['flights'], trackedFlight: true, cockpit: true, camera: [-98, 38, 2_500_000, 0, -Math.PI / 2] },
|
||
]);
|
||
|
||
const SCENE_ALIASES = Object.freeze({
|
||
'datacenters-dams': 'datacenters+dams',
|
||
cables: 'submarine-cables',
|
||
'tracked-civil': 'tracked-civil-aircraft',
|
||
missions: 'missions-selected',
|
||
cockpit: 'cockpit-mode',
|
||
});
|
||
|
||
const argv = process.argv.slice(2);
|
||
|
||
function getOpt(name, fallback = null) {
|
||
const index = argv.indexOf(name);
|
||
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
|
||
}
|
||
|
||
function hasFlag(name) {
|
||
return argv.includes(name);
|
||
}
|
||
|
||
function selectedScenes() {
|
||
const raw = getOpt('--scene');
|
||
if (!raw) return [...SCENES];
|
||
const requested = raw.split(',').map((value) => value.trim()).filter(Boolean);
|
||
const selected = [];
|
||
for (const requestedId of requested) {
|
||
const id = SCENE_ALIASES[requestedId] || requestedId;
|
||
const scene = SCENES.find((candidate) => candidate.id === id);
|
||
if (!scene) {
|
||
throw new Error(`Unknown scene '${requestedId}'. Available: ${SCENES.map((candidate) => candidate.id).join(', ')}`);
|
||
}
|
||
selected.push(scene);
|
||
}
|
||
return selected;
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
function contentType(filePath) {
|
||
const extension = path.extname(filePath).toLowerCase();
|
||
return ({
|
||
'.css': 'text/css',
|
||
'.gif': 'image/gif',
|
||
'.html': 'text/html',
|
||
'.ico': 'image/x-icon',
|
||
'.jpeg': 'image/jpeg',
|
||
'.jpg': 'image/jpeg',
|
||
'.js': 'text/javascript',
|
||
'.json': 'application/json',
|
||
'.ktx2': 'image/ktx2',
|
||
'.png': 'image/png',
|
||
'.svg': 'image/svg+xml',
|
||
'.wasm': 'application/wasm',
|
||
'.webp': 'image/webp',
|
||
'.woff': 'font/woff',
|
||
'.woff2': 'font/woff2',
|
||
})[extension] || 'application/octet-stream';
|
||
}
|
||
|
||
async function installStaticDist(page, distDir) {
|
||
if (!distDir) return;
|
||
const root = path.resolve(distDir);
|
||
await page.setRequestInterception(true);
|
||
page.on('request', async (request) => {
|
||
const url = new URL(request.url());
|
||
try {
|
||
if (url.origin === new URL(APP_URL).origin) {
|
||
if (url.pathname.startsWith('/api/')) {
|
||
await request.respond({
|
||
status: 503,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({ error: 'offline_dist_capture', message: 'Dev-server proxies unavailable' }),
|
||
});
|
||
return;
|
||
}
|
||
const relativePath = decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname).replace(/^\/+/, '');
|
||
const filePath = path.resolve(root, relativePath);
|
||
if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) {
|
||
await request.respond({ status: 403, body: 'Forbidden' });
|
||
return;
|
||
}
|
||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||
await request.respond({ status: 404, body: 'Not found' });
|
||
return;
|
||
}
|
||
await request.respond({
|
||
status: 200,
|
||
contentType: contentType(filePath),
|
||
body: fs.readFileSync(filePath),
|
||
});
|
||
return;
|
||
}
|
||
if (url.hostname === 'tile.googleapis.com' && url.pathname.includes('/3dtiles/')) {
|
||
await request.respond({
|
||
status: 403,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({ error: { message: 'Offline dist capture: Google tiles unavailable' } }),
|
||
});
|
||
return;
|
||
}
|
||
await request.continue();
|
||
} catch {
|
||
try { await request.abort(); } catch { /* request already resolved */ }
|
||
}
|
||
});
|
||
}
|
||
|
||
async function installDeterministicDevEndpoints(page) {
|
||
if (DIST_DIR) return;
|
||
await page.setRequestInterception(true);
|
||
page.on('request', async (request) => {
|
||
const url = new URL(request.url());
|
||
if (url.origin !== new URL(APP_URL).origin) {
|
||
await request.continue();
|
||
return;
|
||
}
|
||
if (url.pathname === '/api/openai/hud-summary') {
|
||
await request.respond({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({ summary: 'QA globe ready' }),
|
||
});
|
||
return;
|
||
}
|
||
if (url.pathname === '/api/ais-live') {
|
||
await request.respond({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({ status: 'connected', rows: [], lastMessageAt: null }),
|
||
});
|
||
return;
|
||
}
|
||
await request.continue();
|
||
});
|
||
}
|
||
|
||
function round(value, digits = 3) {
|
||
if (!Number.isFinite(value)) return null;
|
||
const factor = 10 ** digits;
|
||
return Math.round(value * factor) / factor;
|
||
}
|
||
|
||
function percentile(values, fraction) {
|
||
const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
|
||
if (!sorted.length) return null;
|
||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * fraction) - 1));
|
||
return sorted[index];
|
||
}
|
||
|
||
function summarizeFrameIntervals(intervals) {
|
||
const usable = intervals.filter((value) => Number.isFinite(value) && value > 0);
|
||
if (!usable.length) {
|
||
return { samples: 0, meanMs: null, p50Ms: null, p95Ms: null, p99Ms: null, maxMs: null };
|
||
}
|
||
const mean = usable.reduce((sum, value) => sum + value, 0) / usable.length;
|
||
return {
|
||
samples: usable.length,
|
||
meanMs: round(mean),
|
||
p50Ms: round(percentile(usable, 0.50)),
|
||
p95Ms: round(percentile(usable, 0.95)),
|
||
p99Ms: round(percentile(usable, 0.99)),
|
||
maxMs: round(Math.max(...usable)),
|
||
};
|
||
}
|
||
|
||
function numericDelta(after, before, key) {
|
||
return (after?.totals?.[key] || 0) - (before?.totals?.[key] || 0);
|
||
}
|
||
|
||
function inventoryDelta(before, after) {
|
||
return {
|
||
entities: numericDelta(after, before, 'entities'),
|
||
labelGraphics: numericDelta(after, before, 'labelGraphics'),
|
||
nonemptyLabelText: numericDelta(after, before, 'nonemptyLabelText'),
|
||
emptyLabelText: numericDelta(after, before, 'emptyLabelText'),
|
||
shownLabelGraphics: numericDelta(after, before, 'shownLabelGraphics'),
|
||
primitiveLabels: numericDelta(after, before, 'primitiveLabels'),
|
||
primitiveBillboards: numericDelta(after, before, 'primitiveBillboards'),
|
||
primitivePoints: numericDelta(after, before, 'primitivePoints'),
|
||
};
|
||
}
|
||
|
||
function printContext(context) {
|
||
console.log('\nWorld Overlay Phase 0 Baseline');
|
||
console.log(` URL : ${context.url}`);
|
||
console.log(` Machine : ${context.machine.hostname} · ${context.machine.platform} ${context.machine.release} · ${context.machine.arch}`);
|
||
console.log(` CPU / RAM : ${context.machine.cpuModel} · ${context.machine.logicalCpuCount} logical · ${context.machine.totalMemoryGiB} GiB`);
|
||
console.log(` Browser : ${context.browser.version}`);
|
||
console.log(` User agent : ${context.browser.userAgent}`);
|
||
console.log(` Viewport : ${context.viewport.width}×${context.viewport.height} CSS px · DPR ${context.viewport.dpr}`);
|
||
console.log(` WebGL : ${context.browser.webglVendor} · ${context.browser.webglRenderer}`);
|
||
console.log(` Capture : ${context.captureMode}`);
|
||
console.log(context.gpuMode === 'hardware'
|
||
? ' Timing note: hardware-GPU run; retain the renderer string with every comparison.\n'
|
||
: ' Timing note: SwiftShader absolute FPS is non-representative; compare motion/rest and before/after only.\n');
|
||
}
|
||
|
||
function printScene(result) {
|
||
const tag = result.status === 'ok' ? 'OK' : result.status === 'skipped' ? 'SKIP' : 'ERROR';
|
||
console.log(`[${tag}] ${result.scene}`);
|
||
if (result.reason) console.log(` reason : ${result.reason}`);
|
||
for (const activation of result.layerActivations || []) {
|
||
const delta = activation.delta;
|
||
console.log(` toggle ${activation.layerId.padEnd(31)} entities ${String(delta.entities).padStart(6)} · LabelGraphics ${String(delta.labelGraphics).padStart(6)} · primitive labels ${String(delta.primitiveLabels).padStart(6)}`);
|
||
}
|
||
if (result.entityInventory) {
|
||
const totals = result.entityInventory.totals;
|
||
console.log(` scene total : entities ${totals.entities} · LabelGraphics ${totals.labelGraphics} (${totals.shownLabelGraphics} show=true) · primitive labels ${totals.primitiveLabels}`);
|
||
}
|
||
for (const phaseName of ['motion', 'rest']) {
|
||
const phase = result.samples?.[phaseName];
|
||
if (!phase) continue;
|
||
const frame = phase.frameTime;
|
||
console.log(` ${phaseName.padEnd(6)} frame: n=${frame.samples} mean=${frame.meanMs} ms p50=${frame.p50Ms} ms p95=${frame.p95Ms} ms p99=${frame.p99Ms} ms`);
|
||
for (const canvas of phase.canvases || []) {
|
||
console.log(` ${canvas.id.padEnd(18)} frames≈${String(canvas.clearRectCalls).padStart(4)} fillText=${String(canvas.fillTextCalls).padStart(5)} drawImage=${String(canvas.drawImageCalls).padStart(5)} 2D-sync=${String(canvas.totalSyncMs).padStart(8)} ms`);
|
||
}
|
||
}
|
||
const detection = result.samples?.rest?.detectionDiagnostics;
|
||
if (detection) {
|
||
console.log(` detection : observations=${detection.observationCount ?? 'n/a'} candidates=${detection.candidateCount ?? 'n/a'} visible=${detection.visibleCount ?? 'n/a'} selected=${detection.selectedCount ?? 'n/a'} solve=${round(detection.solveMs)} ms paint=${round(detection.paintMs)} ms`);
|
||
}
|
||
const exposed = result.samples?.rest?.exposedCounts;
|
||
if (exposed && Object.values(exposed).some((value) => value != null)) {
|
||
console.log(` exposed : CCTV cards=${exposed.cctvOwnedEntries ?? 'n/a'}/${exposed.cctvEntryLimit ?? 'n/a'} · FIRMS objects=${exposed.firmsSourceObjects ?? 'n/a'} · vessel objects=${exposed.vesselSourceObjects ?? 'n/a'}`);
|
||
}
|
||
console.log('');
|
||
}
|
||
|
||
async function installPageInstrumentation(page) {
|
||
await page.evaluateOnNewDocument(() => {
|
||
const trackedMethods = [
|
||
'arc', 'arcTo', 'beginPath', 'clearRect', 'closePath', 'drawImage', 'fill',
|
||
'fillRect', 'fillText', 'lineTo', 'measureText', 'moveTo', 'quadraticCurveTo',
|
||
'rect', 'resetTransform', 'restore', 'rotate', 'roundRect', 'save', 'scale',
|
||
'setTransform', 'stroke', 'strokeRect', 'strokeText', 'translate',
|
||
];
|
||
const metrics = new Map();
|
||
const metricFor = (canvas) => {
|
||
const id = canvas?.id || '(anonymous-canvas)';
|
||
let metric = metrics.get(id);
|
||
if (!metric) {
|
||
metric = { id, calls: 0, totalSyncMs: 0, maxSyncMs: 0, methods: {} };
|
||
metrics.set(id, metric);
|
||
}
|
||
return metric;
|
||
};
|
||
const proto = globalThis.CanvasRenderingContext2D?.prototype;
|
||
if (proto) {
|
||
for (const method of trackedMethods) {
|
||
const original = proto[method];
|
||
if (typeof original !== 'function' || original.__gevBaselineWrapped) continue;
|
||
const wrapped = function(...args) {
|
||
const startedAt = performance.now();
|
||
try {
|
||
return Reflect.apply(original, this, args);
|
||
} finally {
|
||
const elapsed = performance.now() - startedAt;
|
||
const metric = metricFor(this.canvas);
|
||
metric.calls++;
|
||
metric.totalSyncMs += elapsed;
|
||
metric.maxSyncMs = Math.max(metric.maxSyncMs, elapsed);
|
||
const methodMetric = metric.methods[method] || (metric.methods[method] = { calls: 0, totalSyncMs: 0 });
|
||
methodMetric.calls++;
|
||
methodMetric.totalSyncMs += elapsed;
|
||
}
|
||
};
|
||
Object.defineProperty(wrapped, '__gevBaselineWrapped', { value: true });
|
||
try { proto[method] = wrapped; } catch { /* A non-writable method stays uninstrumented. */ }
|
||
}
|
||
}
|
||
globalThis.__overlayBaselineInstrumentation = {
|
||
reset() { metrics.clear(); },
|
||
snapshot() {
|
||
return Array.from(metrics.values()).map((metric) => ({
|
||
id: metric.id,
|
||
calls: metric.calls,
|
||
totalSyncMs: metric.totalSyncMs,
|
||
maxSyncMs: metric.maxSyncMs,
|
||
methods: JSON.parse(JSON.stringify(metric.methods)),
|
||
}));
|
||
},
|
||
};
|
||
});
|
||
}
|
||
|
||
async function waitForApp(page) {
|
||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||
await page.waitForFunction(
|
||
() => Boolean(window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager),
|
||
{ timeout: 60_000 },
|
||
);
|
||
await page.evaluate(() => {
|
||
const viewer = window.__godsEyeView.viewer;
|
||
try { viewer.camera.cancelFlight(); } catch { /* no flight */ }
|
||
try { viewer.scene.tweens?.removeAll?.(); } catch { /* no tween collection */ }
|
||
});
|
||
await sleep(1_250);
|
||
}
|
||
|
||
async function setCamera(page, camera) {
|
||
if (!camera) return;
|
||
await page.evaluate(([lon, lat, height, heading, pitch]) => {
|
||
const viewer = window.__godsEyeView.viewer;
|
||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
||
viewer.camera.cancelFlight?.();
|
||
viewer.scene.tweens?.removeAll?.();
|
||
viewer.camera.setView({
|
||
destination: ellipsoid.cartographicToCartesian({
|
||
longitude: lon * Math.PI / 180,
|
||
latitude: lat * Math.PI / 180,
|
||
height,
|
||
}),
|
||
orientation: { heading, pitch, roll: 0 },
|
||
});
|
||
viewer.scene.requestRender?.();
|
||
}, camera);
|
||
}
|
||
|
||
async function readLayerState(page, layerId) {
|
||
return page.evaluate((id) => {
|
||
const manager = window.__godsEyeView.dataManager;
|
||
const entry = manager.layers.get(id);
|
||
if (!entry) return null;
|
||
let stats = {};
|
||
try { stats = entry.initialized ? entry.module.getStats?.() || {} : {}; } catch (error) { stats = { error: error.message }; }
|
||
return { enabled: entry.enabled, initialized: entry.initialized, stats };
|
||
}, layerId);
|
||
}
|
||
|
||
async function waitForLayer(page, layerId) {
|
||
const dataBearing = new Set([
|
||
'local-datacenters', 'local-dams', 'telegeography-submarine-cables', 'cctv',
|
||
'local-firms', 'ais-live-vessels', 'flights', 'satellites', 'rocket-launches',
|
||
]);
|
||
if (!dataBearing.has(layerId)) return readLayerState(page, layerId);
|
||
try {
|
||
await page.waitForFunction((id) => {
|
||
const entry = window.__godsEyeView?.dataManager?.layers?.get(id);
|
||
if (!entry?.enabled || !entry.initialized) return false;
|
||
let stats;
|
||
try { stats = entry.module.getStats?.() || {}; } catch { return false; }
|
||
if (stats.loading === true) return false;
|
||
if (stats.error) return true;
|
||
return Number(stats.count || 0) > 0;
|
||
}, { timeout: LAYER_WAIT_MS, polling: 250 }, layerId);
|
||
} catch {
|
||
// The caller records the final state and decides whether live-data absence
|
||
// makes the specialized scene skippable.
|
||
}
|
||
return readLayerState(page, layerId);
|
||
}
|
||
|
||
async function readEntityInventory(page) {
|
||
return page.evaluate(() => {
|
||
const viewer = window.__godsEyeView.viewer;
|
||
const time = viewer.clock.currentTime;
|
||
const collections = [];
|
||
const totals = {
|
||
entities: 0,
|
||
labelGraphics: 0,
|
||
shownLabelGraphics: 0,
|
||
nonemptyLabelText: 0,
|
||
emptyLabelText: 0,
|
||
primitiveLabels: 0,
|
||
primitiveBillboards: 0,
|
||
primitivePoints: 0,
|
||
scenePrimitives: 0,
|
||
};
|
||
|
||
const propertyValue = (property, fallback) => {
|
||
if (property == null) return fallback;
|
||
try {
|
||
return typeof property.getValue === 'function' ? property.getValue(time) : property;
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
};
|
||
const labelGroup = (entityId) => {
|
||
const id = String(entityId || '');
|
||
if (id.startsWith('rocket-launch:')) return 'mission-anchor';
|
||
if (id.startsWith('rocket-satellite:')) return 'mission-live-or-estimated';
|
||
if (id.startsWith('rocket-orbit-label:')) return 'mission-orbit';
|
||
if (id.startsWith('rocket-reentry-label:')) return 'mission-reentry';
|
||
if (id.startsWith('cctv-')) return 'cctv';
|
||
return id.includes(':') ? id.split(':', 1)[0] : '(other)';
|
||
};
|
||
const addCollection = (scope, name, entityCollection, collectionShown = true) => {
|
||
const entities = Array.from(entityCollection?.values || []);
|
||
let labelGraphics = 0;
|
||
let shownLabelGraphics = 0;
|
||
let nonemptyLabelText = 0;
|
||
let emptyLabelText = 0;
|
||
const labelGroups = {};
|
||
for (const entity of entities) {
|
||
if (!entity?.label) continue;
|
||
labelGraphics++;
|
||
const text = String(propertyValue(entity.label.text, '') || '');
|
||
if (text) nonemptyLabelText++;
|
||
else emptyLabelText++;
|
||
const entityShown = entity.show !== false && collectionShown;
|
||
const labelShown = Boolean(propertyValue(entity.label.show, true));
|
||
if (entityShown && labelShown) shownLabelGraphics++;
|
||
const group = labelGroup(entity.id);
|
||
labelGroups[group] = (labelGroups[group] || 0) + 1;
|
||
}
|
||
const row = {
|
||
scope,
|
||
name,
|
||
entities: entities.length,
|
||
labelGraphics,
|
||
shownLabelGraphics,
|
||
nonemptyLabelText,
|
||
emptyLabelText,
|
||
labelGroups,
|
||
};
|
||
collections.push(row);
|
||
totals.entities += row.entities;
|
||
totals.labelGraphics += row.labelGraphics;
|
||
totals.shownLabelGraphics += row.shownLabelGraphics;
|
||
totals.nonemptyLabelText += row.nonemptyLabelText;
|
||
totals.emptyLabelText += row.emptyLabelText;
|
||
};
|
||
|
||
addCollection('viewer', 'viewer.entities', viewer.entities, true);
|
||
for (let index = 0; index < viewer.dataSources.length; index++) {
|
||
const dataSource = viewer.dataSources.get(index);
|
||
addCollection('dataSource', dataSource.name || `dataSource:${index}`, dataSource.entities, dataSource.show !== false);
|
||
}
|
||
|
||
const primitiveTypes = {};
|
||
const seen = new Set();
|
||
const visitPrimitiveCollection = (collection, depth = 0) => {
|
||
if (!collection || depth > 5 || seen.has(collection)) return;
|
||
seen.add(collection);
|
||
const length = Number(collection.length || 0);
|
||
for (let index = 0; index < length; index++) {
|
||
let primitive;
|
||
try { primitive = collection.get(index); } catch { primitive = null; }
|
||
if (!primitive || seen.has(primitive)) continue;
|
||
const type = primitive.constructor?.name || '(anonymous)';
|
||
primitiveTypes[type] = (primitiveTypes[type] || 0) + 1;
|
||
totals.scenePrimitives++;
|
||
if (Array.isArray(primitive._labels)) totals.primitiveLabels += primitive._labels.length;
|
||
if (Array.isArray(primitive._billboards)) totals.primitiveBillboards += primitive._billboards.length;
|
||
if (Array.isArray(primitive._pointPrimitives)) totals.primitivePoints += primitive._pointPrimitives.length;
|
||
if (typeof primitive.length === 'number' && typeof primitive.get === 'function') {
|
||
visitPrimitiveCollection(primitive, depth + 1);
|
||
}
|
||
}
|
||
};
|
||
visitPrimitiveCollection(viewer.scene.primitives);
|
||
|
||
return { totals, collections, primitiveTypes };
|
||
});
|
||
}
|
||
|
||
async function activateLayers(page, layerIds) {
|
||
const activations = [];
|
||
for (const layerId of layerIds) {
|
||
const before = await readEntityInventory(page);
|
||
const toggleResult = await page.evaluate(async (id) => {
|
||
const manager = window.__godsEyeView.dataManager;
|
||
if (!manager.layers.has(id)) return { error: `unregistered layer: ${id}` };
|
||
if (!manager.isEnabled(id)) await manager.toggle(id);
|
||
return { enabled: manager.isEnabled(id) };
|
||
}, layerId);
|
||
const state = await waitForLayer(page, layerId);
|
||
const after = await readEntityInventory(page);
|
||
activations.push({
|
||
layerId,
|
||
toggleResult,
|
||
state,
|
||
delta: inventoryDelta(before, after),
|
||
collectionsAdded: after.collections.filter((row) => !before.collections.some((prior) => prior.scope === row.scope && prior.name === row.name)),
|
||
});
|
||
}
|
||
return activations;
|
||
}
|
||
|
||
async function prepareCctv(page, heightM) {
|
||
const camera = await page.evaluate((height) => {
|
||
const layer = window.__godsEyeView.dataManager.layers.get('cctv')?.module;
|
||
const state = layer?.getUIState?.();
|
||
const record = state?.cameras?.find((candidate) => Number.isFinite(candidate.lon) && Number.isFinite(candidate.lat));
|
||
if (!record) return null;
|
||
return [record.lon, record.lat, height, 0, -1.35];
|
||
}, heightM);
|
||
if (!camera) return 'CCTV catalog did not expose a geolocated camera';
|
||
await setCamera(page, camera);
|
||
await sleep(7_000);
|
||
return null;
|
||
}
|
||
|
||
async function prepareFirms(page) {
|
||
const result = await page.evaluate(() => {
|
||
const layer = window.__godsEyeView.dataManager.layers.get('local-firms')?.module;
|
||
const stats = layer?.getStats?.() || {};
|
||
const fire = layer?.getStrongestFire?.();
|
||
return { stats, fire };
|
||
});
|
||
if (!result.fire) return `FIRMS unavailable (${result.stats.error || 'no detections'})`;
|
||
await setCamera(page, [result.fire.longitude, result.fire.latitude, 60_000, 0, -1.45]);
|
||
await sleep(4_000);
|
||
return null;
|
||
}
|
||
|
||
async function prepareVessels(page) {
|
||
const state = await readLayerState(page, 'ais-live-vessels');
|
||
if (!(state?.stats?.count > 0)) return `AIS unavailable (${state?.stats?.error || 'no live vessels'})`;
|
||
await sleep(2_500);
|
||
return null;
|
||
}
|
||
|
||
async function prepareDetection(page, densityPct) {
|
||
const sourceStates = await Promise.all(['flights', 'satellites'].map((id) => readLayerState(page, id)));
|
||
if (!sourceStates.some((state) => (state?.stats?.count || 0) > 0)) {
|
||
return 'Detection sources exposed no observations';
|
||
}
|
||
const control = await page.evaluate((density) => window.__godsEyeView.styleManager.setDetection({
|
||
enabled: true,
|
||
densityPct: density,
|
||
}), densityPct);
|
||
if (!control?.ok) return `Detection control failed (${control?.error || 'unknown error'})`;
|
||
await sleep(2_000);
|
||
return null;
|
||
}
|
||
|
||
async function prepareTrackedFlight(page, cockpit) {
|
||
if (cockpit) {
|
||
const contacts = await page.evaluate(() => window.__godsEyeView.styleManager.setContextMode(
|
||
'contacts',
|
||
{ origin: 'user' },
|
||
));
|
||
if (!contacts?.ok) {
|
||
return `Contacts activation failed (${contacts?.error || 'unknown error'})`;
|
||
}
|
||
}
|
||
const tracked = await page.evaluate(() => {
|
||
const entry = window.__godsEyeView.dataManager.layers.get('flights');
|
||
const layer = entry?.module;
|
||
const candidates = layer?.getAllPositions?.(500) || [];
|
||
const airborne = candidates.find((candidate) => Number(candidate.altitudeM) > 1_000) || candidates[0];
|
||
if (!airborne) return { ok: false, reason: 'no rendered civil aircraft' };
|
||
const ok = layer.trackById?.(airborne.id);
|
||
return { ok: Boolean(ok), id: airborne.id, label: airborne.label, reason: ok ? null : 'trackById rejected candidate' };
|
||
});
|
||
if (!tracked.ok) return `Civil tracking unavailable (${tracked.reason})`;
|
||
try {
|
||
await page.waitForFunction(() => Boolean(window.__godsEyeView.viewer.trackedEntity?.position), { timeout: 10_000 });
|
||
} catch {
|
||
return 'Civil tracking did not create a tracked entity';
|
||
}
|
||
await sleep(2_000);
|
||
if (!cockpit) return null;
|
||
const entryAvailable = await page.evaluate(() => {
|
||
const button = document.getElementById('cockpit-entry');
|
||
if (!button || button.hidden || button.disabled) return false;
|
||
button.click();
|
||
return true;
|
||
});
|
||
if (!entryAvailable) return 'Cockpit entry control was unavailable for the tracked aircraft';
|
||
try {
|
||
await page.waitForFunction(() => document.body.classList.contains('cockpit-mode'), { timeout: 5_000 });
|
||
} catch {
|
||
return 'Cockpit entry control did not enter cockpit mode';
|
||
}
|
||
await sleep(2_000);
|
||
return null;
|
||
}
|
||
|
||
async function prepareMission(page) {
|
||
const state = await readLayerState(page, 'rocket-launches');
|
||
if (!(state?.stats?.count > 0)) return `Mission feed unavailable (${state?.stats?.error || 'no missions'})`;
|
||
const selected = await page.evaluate(() => {
|
||
const button = document.querySelector('[data-mission-roster-index="0"]');
|
||
if (!button) return false;
|
||
button.click();
|
||
return true;
|
||
});
|
||
if (!selected) return 'Mission roster did not expose a selectable mission';
|
||
try {
|
||
await page.waitForFunction(() => String(window.__godsEyeView.viewer.selectedEntity?.id || '').startsWith('rocket-launch:'), { timeout: 10_000 });
|
||
} catch {
|
||
return 'Mission selection did not reach the Cesium selected entity';
|
||
}
|
||
await sleep(2_000);
|
||
return null;
|
||
}
|
||
|
||
async function prepareScene(page, scene) {
|
||
if (scene.camera) await setCamera(page, scene.camera);
|
||
const layerActivations = await activateLayers(page, scene.layers);
|
||
let skipReason = null;
|
||
if (scene.cctvHeightM) skipReason = await prepareCctv(page, scene.cctvHeightM);
|
||
if (!skipReason && scene.id === 'firms') skipReason = await prepareFirms(page);
|
||
if (!skipReason && scene.id === 'vessels') skipReason = await prepareVessels(page);
|
||
if (!skipReason && scene.detectionDensity) skipReason = await prepareDetection(page, scene.detectionDensity);
|
||
if (!skipReason && scene.trackedFlight) skipReason = await prepareTrackedFlight(page, scene.cockpit);
|
||
if (!skipReason && scene.missionSelected) skipReason = await prepareMission(page);
|
||
await sleep(DEFAULT_SETTLE_MS);
|
||
return { layerActivations, skipReason };
|
||
}
|
||
|
||
async function readCanvasSummary(page) {
|
||
return page.evaluate(() => Array.from(document.querySelectorAll('canvas')).map((canvas) => {
|
||
const style = getComputedStyle(canvas);
|
||
return {
|
||
id: canvas.id || '(anonymous-canvas)',
|
||
width: canvas.width,
|
||
height: canvas.height,
|
||
cssWidth: canvas.clientWidth,
|
||
cssHeight: canvas.clientHeight,
|
||
display: style.display,
|
||
opacity: style.opacity,
|
||
zIndex: style.zIndex,
|
||
};
|
||
}));
|
||
}
|
||
|
||
async function samplePhase(page, moving) {
|
||
const raw = await page.evaluate(async ({ durationMs, movingCamera }) => {
|
||
const viewer = window.__godsEyeView.viewer;
|
||
const instrumentation = window.__overlayBaselineInstrumentation;
|
||
instrumentation?.reset?.();
|
||
const intervals = [];
|
||
const startedAt = performance.now();
|
||
let lastFrameAt = null;
|
||
await new Promise((resolve) => {
|
||
const step = (now) => {
|
||
if (lastFrameAt != null) intervals.push(now - lastFrameAt);
|
||
lastFrameAt = now;
|
||
const elapsed = now - startedAt;
|
||
if (movingCamera) {
|
||
viewer.camera.rotateRight(0.00035);
|
||
viewer.camera.rotateUp(0.00008 * Math.sin(elapsed / 600));
|
||
}
|
||
viewer.scene.requestRender?.();
|
||
if (elapsed >= durationMs) resolve();
|
||
else requestAnimationFrame(step);
|
||
};
|
||
requestAnimationFrame(step);
|
||
});
|
||
const canvasMetrics = instrumentation?.snapshot?.() || [];
|
||
const detectionDiagnostics = window.__godsEyeView.styleManager?.getDetectionDiagnostics?.() || null;
|
||
const manager = window.__godsEyeView.dataManager;
|
||
const cctvEntry = manager.layers.get('cctv');
|
||
const cctvState = cctvEntry?.initialized ? cctvEntry.module.getUIState?.() : null;
|
||
const firmsEntry = manager.layers.get('local-firms');
|
||
const vesselsEntry = manager.layers.get('ais-live-vessels');
|
||
return {
|
||
intervals,
|
||
canvasMetrics,
|
||
detectionDiagnostics,
|
||
exposedCounts: {
|
||
cctvOwnedEntries: cctvState?.ambientCards?.count ?? null,
|
||
cctvEntryLimit: cctvState?.ambientCards?.limit ?? null,
|
||
firmsSourceObjects: firmsEntry?.initialized ? firmsEntry.module.getStats?.()?.count ?? null : null,
|
||
vesselSourceObjects: vesselsEntry?.initialized ? vesselsEntry.module.getStats?.()?.count ?? null : null,
|
||
},
|
||
elapsedMs: performance.now() - startedAt,
|
||
};
|
||
}, { durationMs: SAMPLE_MS, movingCamera: moving });
|
||
|
||
const canvases = raw.canvasMetrics
|
||
.filter((metric) => KNOWN_OVERLAY_CANVASES.has(metric.id))
|
||
.map((metric) => ({
|
||
id: metric.id,
|
||
calls: metric.calls,
|
||
totalSyncMs: round(metric.totalSyncMs),
|
||
maxSyncMs: round(metric.maxSyncMs),
|
||
clearRectCalls: metric.methods.clearRect?.calls || 0,
|
||
fillTextCalls: metric.methods.fillText?.calls || 0,
|
||
drawImageCalls: metric.methods.drawImage?.calls || 0,
|
||
measureTextCalls: metric.methods.measureText?.calls || 0,
|
||
strokeCalls: metric.methods.stroke?.calls || 0,
|
||
fillCalls: metric.methods.fill?.calls || 0,
|
||
methods: Object.fromEntries(Object.entries(metric.methods).map(([name, value]) => [name, {
|
||
calls: value.calls,
|
||
totalSyncMs: round(value.totalSyncMs),
|
||
}])),
|
||
}))
|
||
.sort((a, b) => a.id.localeCompare(b.id));
|
||
return {
|
||
requestedDurationMs: SAMPLE_MS,
|
||
actualDurationMs: round(raw.elapsedMs),
|
||
frameTime: summarizeFrameIntervals(raw.intervals),
|
||
canvases,
|
||
detectionDiagnostics: raw.detectionDiagnostics,
|
||
exposedCounts: raw.exposedCounts,
|
||
};
|
||
}
|
||
|
||
async function captureShot(page, shotsDir, sceneId, suffix) {
|
||
if (!shotsDir) return null;
|
||
fs.mkdirSync(shotsDir, { recursive: true });
|
||
const safeScene = sceneId.replace(/[^a-z0-9-]+/gi, '-');
|
||
const outPath = path.join(shotsDir, `${safeScene}-${suffix}.png`);
|
||
await page.screenshot({ path: outPath });
|
||
return outPath;
|
||
}
|
||
|
||
async function runScene(browser, scene, shotsDir) {
|
||
const page = await browser.newPage();
|
||
await page.setViewport({ ...VIEWPORT, deviceScaleFactor: DPR });
|
||
await installStaticDist(page, DIST_DIR);
|
||
await installDeterministicDevEndpoints(page);
|
||
const consoleMessages = [];
|
||
page.on('console', (message) => {
|
||
if (['error', 'warning', 'warn'].includes(message.type())) {
|
||
consoleMessages.push({ type: message.type(), text: message.text() });
|
||
}
|
||
});
|
||
page.on('pageerror', (error) => consoleMessages.push({ type: 'pageerror', text: error.message }));
|
||
await installPageInstrumentation(page);
|
||
|
||
const result = {
|
||
scene: scene.id,
|
||
status: 'ok',
|
||
reason: null,
|
||
layerActivations: [],
|
||
entityInventory: null,
|
||
canvasInventory: [],
|
||
samples: null,
|
||
screenshots: [],
|
||
consoleMessages,
|
||
};
|
||
try {
|
||
await waitForApp(page);
|
||
const prepared = await prepareScene(page, scene);
|
||
result.layerActivations = prepared.layerActivations;
|
||
result.entityInventory = await readEntityInventory(page);
|
||
result.canvasInventory = await readCanvasSummary(page);
|
||
if (prepared.skipReason) {
|
||
result.status = 'skipped';
|
||
result.reason = prepared.skipReason;
|
||
return result;
|
||
}
|
||
const beforeShot = await captureShot(page, shotsDir, scene.id, 'before-motion');
|
||
if (beforeShot) result.screenshots.push(beforeShot);
|
||
const motion = await samplePhase(page, true);
|
||
const afterMotionShot = await captureShot(page, shotsDir, scene.id, 'after-motion');
|
||
if (afterMotionShot) result.screenshots.push(afterMotionShot);
|
||
await sleep(BETWEEN_PHASE_SETTLE_MS);
|
||
const rest = await samplePhase(page, false);
|
||
result.samples = { motion, rest };
|
||
result.entityInventoryAfter = await readEntityInventory(page);
|
||
return result;
|
||
} catch (error) {
|
||
result.status = 'error';
|
||
result.reason = error?.stack || error?.message || String(error);
|
||
return result;
|
||
} finally {
|
||
await page.close();
|
||
}
|
||
}
|
||
|
||
const APP_URL = getOpt('--url', DEFAULT_URL);
|
||
const DPR = Number(getOpt('--dpr', '1'));
|
||
const JSON_PATH = getOpt('--json');
|
||
const SCREENSHOTS_DIR = getOpt('--screenshots-dir');
|
||
const DIST_DIR = getOpt('--dist-dir');
|
||
const HEADFUL = hasFlag('--headful');
|
||
const HARDWARE_GPU = hasFlag('--hardware-gpu');
|
||
|
||
function softwareRenderer(renderer) {
|
||
return /swiftshader|software|llvmpipe/i.test(String(renderer || ''));
|
||
}
|
||
|
||
function unavailableRenderer(renderer) {
|
||
return !String(renderer || '').trim() || String(renderer).toLowerCase() === 'unavailable';
|
||
}
|
||
|
||
async function main() {
|
||
if (!fs.existsSync(CHROME_EXECUTABLE)) {
|
||
throw new Error(`Required Chrome executable not found: ${CHROME_EXECUTABLE}`);
|
||
}
|
||
if (!Number.isFinite(DPR) || DPR <= 0) throw new Error(`Invalid --dpr value: ${DPR}`);
|
||
const scenes = selectedScenes();
|
||
if (DIST_DIR && (!fs.existsSync(DIST_DIR) || !fs.statSync(DIST_DIR).isDirectory())) {
|
||
throw new Error(`--dist-dir is not a directory: ${DIST_DIR}`);
|
||
}
|
||
if (!DIST_DIR) {
|
||
const response = await fetch(APP_URL).catch((error) => ({ ok: false, statusText: error.message }));
|
||
if (!response.ok) throw new Error(`Dev server unavailable at ${APP_URL}: ${response.status || response.statusText}`);
|
||
}
|
||
|
||
const browser = await puppeteer.launch({
|
||
executablePath: CHROME_EXECUTABLE,
|
||
headless: HEADFUL ? false : 'new',
|
||
args: [
|
||
...(HARDWARE_GPU ? [] : ['--enable-unsafe-swiftshader', '--use-gl=swiftshader']),
|
||
'--no-sandbox',
|
||
'--disable-setuid-sandbox',
|
||
'--disable-dev-shm-usage',
|
||
'--disable-background-timer-throttling',
|
||
'--disable-renderer-backgrounding',
|
||
`--window-size=${VIEWPORT.width},${VIEWPORT.height}`,
|
||
],
|
||
});
|
||
|
||
const run = { generatedAt: new Date().toISOString(), context: null, scenes: [] };
|
||
try {
|
||
const contextPage = await browser.newPage();
|
||
await contextPage.setViewport({ ...VIEWPORT, deviceScaleFactor: DPR });
|
||
await installStaticDist(contextPage, DIST_DIR);
|
||
await contextPage.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||
const browserContext = await contextPage.evaluate(() => {
|
||
const canvas = document.createElement('canvas');
|
||
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||
const debug = gl?.getExtension('WEBGL_debug_renderer_info');
|
||
return {
|
||
userAgent: navigator.userAgent,
|
||
dpr: window.devicePixelRatio,
|
||
webglVendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : gl?.getParameter(gl.VENDOR) || 'unavailable',
|
||
webglRenderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : gl?.getParameter(gl.RENDERER) || 'unavailable',
|
||
};
|
||
});
|
||
await contextPage.close();
|
||
const cpus = os.cpus();
|
||
if (HARDWARE_GPU && (
|
||
unavailableRenderer(browserContext.webglRenderer)
|
||
|| softwareRenderer(browserContext.webglRenderer)
|
||
)) {
|
||
throw new Error(`Hardware GPU requested but Chrome reported ${browserContext.webglRenderer}`);
|
||
}
|
||
run.context = {
|
||
url: APP_URL,
|
||
machine: {
|
||
hostname: os.hostname(),
|
||
platform: os.platform(),
|
||
release: os.release(),
|
||
arch: os.arch(),
|
||
cpuModel: cpus[0]?.model || 'unknown',
|
||
logicalCpuCount: cpus.length,
|
||
totalMemoryGiB: round(os.totalmem() / (1024 ** 3), 1),
|
||
},
|
||
browser: {
|
||
version: await browser.version(),
|
||
userAgent: browserContext.userAgent,
|
||
webglVendor: browserContext.webglVendor,
|
||
webglRenderer: browserContext.webglRenderer,
|
||
},
|
||
viewport: { ...VIEWPORT, dpr: browserContext.dpr },
|
||
gpuMode: HARDWARE_GPU ? 'hardware' : 'swiftshader',
|
||
chromeExecutable: CHROME_EXECUTABLE,
|
||
chromeArgs: HARDWARE_GPU
|
||
? ['--no-sandbox']
|
||
: ['--enable-unsafe-swiftshader', '--use-gl=swiftshader', '--no-sandbox'],
|
||
sampleMs: SAMPLE_MS,
|
||
captureMode: DIST_DIR ? `static-dist:${path.resolve(DIST_DIR)}` : 'dev-server',
|
||
};
|
||
printContext(run.context);
|
||
|
||
for (const scene of scenes) {
|
||
const result = await runScene(browser, scene, SCREENSHOTS_DIR);
|
||
run.scenes.push(result);
|
||
printScene(result);
|
||
}
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
|
||
run.summary = {
|
||
ok: run.scenes.filter((scene) => scene.status === 'ok').length,
|
||
skipped: run.scenes.filter((scene) => scene.status === 'skipped').length,
|
||
errors: run.scenes.filter((scene) => scene.status === 'error').length,
|
||
};
|
||
console.log(`Summary: ${run.summary.ok} measured · ${run.summary.skipped} skipped · ${run.summary.errors} errors`);
|
||
if (JSON_PATH) {
|
||
fs.mkdirSync(path.dirname(path.resolve(JSON_PATH)), { recursive: true });
|
||
fs.writeFileSync(JSON_PATH, `${JSON.stringify(run, null, 2)}\n`);
|
||
console.log(`JSON: ${path.resolve(JSON_PATH)}`);
|
||
}
|
||
if (run.summary.errors > 0) process.exitCode = 1;
|
||
if (HARDWARE_GPU && run.summary.ok === 0) {
|
||
console.error('Hardware GPU run produced no measured scenes.');
|
||
process.exitCode = 1;
|
||
}
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error(error?.stack || error);
|
||
process.exit(1);
|
||
});
|