gods-eye-view/scripts/qa-focus-evidence.mjs

722 lines
28 KiB
JavaScript

#!/usr/bin/env node
/**
* Moving visual evidence for focus de-emphasis and aircraft recession.
*
* The operator owns the live dev server; this script never starts one:
* node scripts/qa-focus-evidence.mjs --url http://localhost:4173 \
* --screenshots-dir qa-shots/focus-evidence \
* --json qa-shots/focus-evidence/report.json
* node scripts/qa-focus-evidence.mjs --headful --smoke
* node scripts/qa-focus-evidence.mjs --url http://localhost:4173 \
* --params '{"focus":{"dimFloor":0.35},"horizon":{"scaleFloor":0.5}}'
* node scripts/qa-focus-evidence.mjs --headful --basemap osm
*
* The harness owns the Cesium frame clock during capture: every frame applies
* synthetic updates, advances explicit virtual time, calls scene.render(),
* then screenshots. Headless mode keeps SwiftShader for CI-relative evidence;
* --headful drops those flags and is the real-GPU visual sign-off path.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2);
const getOpt = (name, fallback = null) => {
const index = argv.indexOf(name);
return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
};
const hasFlag = (name) => argv.includes(name);
const APP_URL = getOpt('--url', 'http://localhost:4173');
const JSON_PATH = path.resolve(getOpt('--json', 'qa-shots/focus-evidence/report.json'));
const SCREENSHOTS_DIR = path.resolve(getOpt('--screenshots-dir', 'qa-shots/focus-evidence'));
const HEADFUL = hasFlag('--headful');
const SMOKE = hasFlag('--smoke');
const MAP_STACK_IDS = Object.freeze(['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm']);
const BASEMAP = getOpt('--basemap', 'photoreal');
const VIEWPORT = Object.freeze({ width: 1440, height: 900 });
const FRAME_COUNT = SMOKE ? 6 : 30;
const FRAME_MS = 100;
const TILE_SETTLE_TIMEOUT_MS = 45_000;
const TARGET_ID = 'f0c001';
const _firstCaptureScenarios = new Set();
if (!MAP_STACK_IDS.includes(BASEMAP)) {
throw new Error(`Invalid --basemap ${BASEMAP}; expected one of ${MAP_STACK_IDS.join(', ')}`);
}
const TARGET = Object.freeze({
id: TARGET_ID,
callsign: 'FOCUS1',
longitude: -97.7431,
latitude: 30.2672,
altitudeM: 3_000,
klass: 'airliner',
});
// Owner's 13:58 horizon composition: a low, east-facing regional view whose
// distant field sits close to the geometric limb instead of a whole-globe view.
const HORIZON_CAMERA = Object.freeze([
-100, 30, 1_000_000, Math.PI / 2, -0.52,
]);
function parseParams() {
const raw = getOpt('--params');
if (!raw) return { focus: {}, horizon: {} };
let parsed;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(`Invalid --params JSON: ${error.message}`);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('--params must be a JSON object');
}
if (parsed.focus || parsed.horizon) {
if ((parsed.focus && (typeof parsed.focus !== 'object' || Array.isArray(parsed.focus)))
|| (parsed.horizon && (typeof parsed.horizon !== 'object' || Array.isArray(parsed.horizon)))) {
throw new Error('--params focus and horizon values must be JSON objects');
}
return { focus: parsed.focus || {}, horizon: parsed.horizon || {} };
}
const focusKeys = new Set([
'paddingPx', 'dimFloor', 'nearerBehavior', 'hysteresisPx',
'distanceHysteresisRatio', 'attackMs', 'releaseMs', 'writeEpsilon',
]);
const horizonKeys = new Set([
'startLimbRatio', 'scaleFloor', 'alphaFloor', 'combinedAlphaFloor',
'globeViewBlendStartM', 'globeViewBlendEndM', 'globeViewHeightM',
'earthRadiusM', 'writeEpsilon',
]);
const focus = {};
const horizon = {};
for (const [key, value] of Object.entries(parsed)) {
if (focusKeys.has(key)) focus[key] = value;
if (horizonKeys.has(key)) horizon[key] = value;
}
return { focus, horizon };
}
function add(a, b) {
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
}
function scale(v, amount) {
return [v[0] * amount, v[1] * amount, v[2] * amount];
}
function screenPlanePosition(basis, xPx, yPx, fartherM = 0) {
return add(
add(
add(basis.target, scale(basis.right, xPx * basis.metresPerPixel)),
scale(basis.up, yPx * basis.metresPerPixel),
),
scale(basis.away, fartherM),
);
}
function shotPath(scenario, frame) {
return path.join(SCREENSHOTS_DIR, `${scenario}-${frame}.png`);
}
async function readTileReadiness(page) {
return page.evaluate(() => {
const gev = window.__godsEyeView;
const controller = gev.mapStackController;
const activeStack = controller?.getActiveId?.() || null;
const tileset = controller?.googleTileset || gev.tileset || null;
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
return {
tilesSettled: !applicable || tileset?.tilesLoaded === true,
tileSettleApplicable: applicable,
activeStack,
};
});
}
async function awaitTilesSettled(page, scenario) {
const result = await page.evaluate(async (timeoutMs) => {
const gev = window.__godsEyeView;
const controller = gev.mapStackController;
const activeStack = controller?.getActiveId?.() || null;
// The map-stack controller owns the authoritative Google tileset handle;
// the bootstrap field is retained only as a compatibility fallback.
const tileset = controller?.googleTileset || gev.tileset || null;
const applicable = activeStack === 'photoreal' && tileset?.show !== false;
if (!applicable) {
return {
tilesSettled: true,
tileSettleApplicable: false,
activeStack,
timedOut: false,
};
}
if (tileset.tilesLoaded === true) {
return {
tilesSettled: true,
tileSettleApplicable: true,
activeStack,
timedOut: false,
};
}
let allTilesLoadedObserved = false;
const removeListener = tileset.allTilesLoaded?.addEventListener?.(() => {
allTilesLoadedObserved = true;
});
const deadline = Date.now() + timeoutMs;
try {
while (Date.now() < deadline) {
gev.viewer.scene.render(gev.viewer.clock.currentTime);
if (tileset.tilesLoaded === true || allTilesLoadedObserved) {
return {
tilesSettled: true,
tileSettleApplicable: true,
activeStack,
timedOut: false,
};
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
return {
tilesSettled: false,
tileSettleApplicable: true,
activeStack,
timedOut: true,
};
} finally {
if (typeof removeListener === 'function') removeListener();
}
}, TILE_SETTLE_TIMEOUT_MS);
const status = result.tilesSettled ? 'settled' : `NOT settled after ${TILE_SETTLE_TIMEOUT_MS / 1000}s`;
console.log(` tiles (${scenario}): ${status}`);
return result;
}
async function capture(page, scenario, frame) {
if (!_firstCaptureScenarios.has(scenario)) {
_firstCaptureScenarios.add(scenario);
await awaitTilesSettled(page, scenario);
}
const tileReadiness = await readTileReadiness(page);
const out = shotPath(scenario, frame);
// Chromium's Page.captureScreenshot can wedge while a manually driven
// WebGL surface is paused. Reading the explicitly rendered canvas captures
// that same frame without handing frame ownership back to the browser.
const dataUrl = await page.evaluate(() => (
window.__godsEyeView.viewer.scene.canvas.toDataURL('image/png')
));
fs.writeFileSync(out, Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64'));
return { file: out, ...tileReadiness };
}
async function captureSequenceFrame(page, scenario, frameIndex, update) {
if (SMOKE) console.log(` frame ${frameIndex + 1}/${FRAME_COUNT}: update`);
await update();
if (SMOKE) console.log(` frame ${frameIndex + 1}/${FRAME_COUNT}: render`);
await advanceEvidenceFrame(page, FRAME_MS);
if (SMOKE) console.log(` frame ${frameIndex + 1}/${FRAME_COUNT}: screenshot`);
return capture(page, scenario, `frame-${String(frameIndex).padStart(2, '0')}`);
}
async function takeFrameClock(page) {
const result = await page.evaluate(() => {
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
return seam.takeFrameClock();
});
if (!result?.ok) throw new Error('Unable to take ownership of the evidence frame clock');
}
async function advanceEvidenceFrame(page, deltaMs = FRAME_MS) {
return page.evaluate((stepMs) => {
const gev = window.__godsEyeView;
const viewer = gev.viewer;
const seam = gev.dataManager.layers.get('flights').module.__focusEvidence;
const nowMs = seam.advanceFrameClock(stepMs);
const JulianDate = viewer.clock.currentTime.constructor;
JulianDate.addSeconds(viewer.clock.currentTime, stepMs / 1000, viewer.clock.currentTime);
viewer.dataSourceDisplay?.update(viewer.clock.currentTime);
viewer.scene.render(viewer.clock.currentTime);
// Cross a task boundary so Chrome can commit the explicitly rendered
// surface before capture. Cesium's drained default loop stays disabled.
return new Promise((resolve) => setTimeout(() => resolve(nowMs), 0));
}, deltaMs);
}
async function advanceEvidenceDuration(page, durationMs) {
// Cross one 80 ms consumer quantum to publish newly changed desired state,
// then jump to the requested endpoint. A zero-delta render is insufficient:
// the production focus passes correctly retain their cadence gate.
const totalMs = Math.max(0, durationMs);
const publishMs = Math.min(80, totalMs);
await advanceEvidenceFrame(page, publishMs);
if (totalMs > publishMs) await advanceEvidenceFrame(page, totalMs - publishMs);
}
async function releaseFrameClock(page) {
await page.evaluate(() => {
window.__godsEyeView.dataManager.layers
.get('flights').module.__focusEvidence.releaseFrameClock();
});
}
async function installSyntheticFetches(page) {
await page.evaluateOnNewDocument(() => {
const realFetch = window.fetch.bind(window);
const json = (body) => Promise.resolve(new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
window.fetch = (input, init) => {
const url = typeof input === 'string' ? input : input?.url || '';
if (url.includes('/api/opensky-track')) return json({ path: [] });
if (url.includes('/api/adsblol/trace')) return json({ timestamp: Date.now() / 1000, trace: [] });
if (url.includes('/api/opensky')) return json({ time: Math.floor(Date.now() / 1000), states: [] });
if (url.includes('/api/adsbdb/')) return json({ found: false });
if (url.includes('/api/ais-live')) return json({ status: 'open', rows: [] });
return realFetch(input, init);
};
});
}
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, polling: 200 },
);
}
async function setBasemap(page, stackId) {
const result = await page.evaluate(async (id) => (
window.__godsEyeView.styleManager.setMapStack(id)
), stackId);
if (!result?.ok) {
throw new Error(`Unable to activate --basemap ${stackId}: ${result?.error || 'unknown error'}`);
}
return result.activeStack;
}
async function enableLayer(page, layerId) {
await page.evaluate(async (id) => {
await window.__godsEyeView.dataManager.setEnabled(id, true);
}, layerId);
}
async function requireEvidenceSeams(page) {
const result = await page.evaluate(() => {
const manager = window.__godsEyeView.dataManager;
return {
flights: Boolean(manager.layers.get('flights')?.module?.__focusEvidence),
vessels: Boolean(manager.layers.get('ais-live-vessels')?.module?.__focusEvidence),
};
});
if (!result.flights || !result.vessels) {
throw new Error('Focus evidence seams are unavailable. Use the Vite dev server (not a production preview/build).');
}
}
async function setCamera(page, values) {
await page.evaluate(([lon, lat, height, heading, pitch]) => {
const viewer = window.__godsEyeView.viewer;
const ellipsoid = viewer.scene.globe.ellipsoid;
viewer.trackedEntity = undefined;
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 },
});
}, values);
await advanceEvidenceDuration(page, 300);
}
async function injectAndTrackTarget(page, extraAircraft = [], target = TARGET) {
const result = await page.evaluate(({ targetRecord, extras }) => {
const layer = window.__godsEyeView.dataManager.layers.get('flights').module;
const injected = layer.__focusEvidence.setAircraft([targetRecord, ...extras]);
const tracked = layer.trackById(targetRecord.id);
return { injected, tracked };
}, { targetRecord: target, extras: extraAircraft });
if (!result.injected?.ok || !result.tracked) {
throw new Error(`Synthetic target setup failed: ${JSON.stringify(result)}`);
}
await page.waitForFunction(
() => Boolean(window.__godsEyeView.viewer.trackedEntity),
{ timeout: 10_000 },
);
await advanceEvidenceDuration(page, 400);
}
async function readTrackedBasis(page) {
return page.evaluate((targetId) => {
const gev = window.__godsEyeView;
const layer = gev.dataManager.layers.get('flights').module;
const target = layer.getAllPositions(100).find((entry) => entry.id === targetId)?.position;
const camera = gev.viewer.camera;
if (!target) throw new Error('Tracked target position unavailable');
const toTarget = {
x: target.x - camera.positionWC.x,
y: target.y - camera.positionWC.y,
z: target.z - camera.positionWC.z,
};
const distance = Math.hypot(toTarget.x, toTarget.y, toTarget.z);
const canvasHeight = gev.viewer.scene.canvas.clientHeight;
const metresPerPixel = (2 * distance * Math.tan(camera.frustum.fovy / 2)) / canvasHeight;
return {
target: [target.x, target.y, target.z],
right: [camera.rightWC.x, camera.rightWC.y, camera.rightWC.z],
up: [camera.upWC.x, camera.upWC.y, camera.upWC.z],
away: [toTarget.x / distance, toTarget.y / distance, toTarget.z / distance],
metresPerPixel,
targetDistanceM: distance,
};
}, TARGET_ID);
}
async function flightSnapshot(page) {
return page.evaluate(() => (
window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence.snapshot()
));
}
async function setTuning(page, params) {
return page.evaluate((next) => (
window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence.setTuning(next)
), params);
}
async function runControlledCrossing(page, effectiveParams) {
const scenario = 's1-controlled-crossing';
if (SMOKE) console.log(' setup: inject + track');
await injectAndTrackTarget(page, [{ ...TARGET, id: 'f0c002', callsign: 'CROSS2' }]);
if (SMOKE) console.log(' setup: read tracked basis');
const basis = await readTrackedBasis(page);
// Stay beyond the default 8% range-side hysteresis band so S1 exercises
// the farther-contact dim path instead of hovering in its neutral band.
const crossingDepthM = Math.max(600, basis.targetDistanceM * 0.12);
const start = screenPlanePosition(basis, -180, 0, crossingDepthM);
await page.evaluate((position) => {
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
seam.moveAircraft([{ id: 'f0c002', cartesian: position, trackDeg: 90 }]);
}, start);
// The placeholder begins at the target so tracking can establish its camera
// first. Restore fully off-target before frame 0; otherwise the sequence
// would start with inherited dimming rather than showing a true attack.
if (SMOKE) console.log(' setup: settle off-target contact');
await advanceEvidenceDuration(page, effectiveParams.focus.releaseMs + 150);
const frames = [];
for (let i = 0; i < FRAME_COUNT; i += 1) {
const xPx = -180 + (360 * i) / (FRAME_COUNT - 1);
const cartesian = screenPlanePosition(basis, xPx, 0, crossingDepthM);
const captured = await captureSequenceFrame(page, scenario, i, () => page.evaluate((position) => {
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
seam.moveAircraft([{ id: 'f0c002', cartesian: position, trackDeg: 90 }]);
}, cartesian));
const snapshot = await flightSnapshot(page);
frames.push({ index: i, xPx, ...captured, contacts: snapshot });
}
return { id: scenario, frameCount: frames.length, basis, crossingDepthM, frames };
}
async function vesselRowsAroundTarget(page) {
return page.evaluate(() => {
const gev = window.__godsEyeView;
const layer = gev.dataManager.layers.get('flights').module;
const target = layer.getAllPositions(100).find((entry) => entry.id === 'f0c001')?.position;
const Cartographic = gev.viewer.camera.positionCartographic.constructor;
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
const rows = [];
for (let i = 0; i < 12; i += 1) {
const angle = (i / 12) * Math.PI * 2;
const eastM = Math.cos(angle) * (15 + (i % 3) * 12);
const northM = Math.sin(angle) * (15 + (i % 3) * 12);
// Cesium.Transforms is not global; a small local lat/lon offset is
// sufficient at harbor scale and keeps the injected vessel at sea level.
const carto = Cartographic.fromCartesian(target, ellipsoid);
const lat = carto.latitude * 180 / Math.PI + northM / 111_320;
const lon = carto.longitude * 180 / Math.PI
+ eastM / (111_320 * Math.cos(carto.latitude));
rows.push({
mmsi: String(990000000 + i),
name: `HARBOR ${String(i + 1).padStart(2, '0')}`,
lat,
lon,
speed: 4 + (i % 5),
course: (i * 31) % 360,
type: i % 3 === 0 ? 'Cargo' : 'Tug',
});
}
return rows;
});
}
async function runHarborClutter(page, effectiveParams) {
const scenario = 's2-harbor-clutter';
const lowTarget = { ...TARGET, altitudeM: 50 };
// Harbor contacts straddle the tracked low-altitude subject's range. This
// scenario deliberately exercises the documented tunable all-overlap policy
// instead of being neutralized by the production `allow` range band.
const clutterFocus = { ...effectiveParams.focus, nearerBehavior: 'dim' };
await injectAndTrackTarget(page, [], lowTarget);
const rows = await vesselRowsAroundTarget(page);
await page.evaluate((vessels) => {
const seam = window.__godsEyeView.dataManager.layers
.get('ais-live-vessels').module.__focusEvidence;
seam.setVessels(vessels);
}, rows);
await setTuning(page, { focus: { ...clutterFocus, dimFloor: 1 } });
await advanceEvidenceDuration(page, 450);
const beforeCapture = await capture(page, scenario, 'before');
const before = await page.evaluate(() => (
window.__godsEyeView.dataManager.layers
.get('ais-live-vessels').module.__focusEvidence.snapshot()
));
await setTuning(page, { ...effectiveParams, focus: clutterFocus });
await advanceEvidenceDuration(page, Math.max(500, effectiveParams.focus.attackMs + 200));
const afterCapture = await capture(page, scenario, 'after');
const after = await page.evaluate(() => (
window.__godsEyeView.dataManager.layers
.get('ais-live-vessels').module.__focusEvidence.snapshot()
));
await setTuning(page, effectiveParams);
return {
id: scenario,
focusParams: clutterFocus,
frames: [
{ phase: 'before', ...beforeCapture, contacts: before },
{ phase: 'after', ...afterCapture, contacts: after },
],
};
}
async function runAirportTraffic(page, effectiveParams) {
const scenario = 's3-airport-traffic';
const placeholders = Array.from({ length: 40 }, (_, index) => ({
...TARGET,
id: `f3${String(index).padStart(4, '0')}`,
callsign: `APT${String(index).padStart(2, '0')}`,
klass: index % 7 === 0 ? 'widebody' : index % 5 === 0 ? 'fastjet' : 'airliner',
}));
await injectAndTrackTarget(page, placeholders);
const basis = await readTrackedBasis(page);
const trafficDepthM = Math.max(600, basis.targetDistanceM * 0.12);
const startingMoves = placeholders.map((record, index) => {
const angle = (index / placeholders.length) * Math.PI * 2;
return {
id: record.id,
cartesian: screenPlanePosition(
basis,
Math.cos(angle) * 220,
Math.sin(angle) * 220 * 0.55,
trafficDepthM + index * 3,
),
trackDeg: (angle * 180 / Math.PI + 180) % 360,
};
});
await page.evaluate((positions) => {
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
seam.moveAircraft(positions);
}, startingMoves);
await advanceEvidenceDuration(page, effectiveParams.focus.releaseMs + 150);
const frames = [];
for (let frame = 0; frame < FRAME_COUNT; frame += 1) {
const progress = frame / (FRAME_COUNT - 1);
const radiusPx = 220 - 205 * progress;
const moves = placeholders.map((record, index) => {
const angle = (index / placeholders.length) * Math.PI * 2 + progress * 0.35;
return {
id: record.id,
cartesian: screenPlanePosition(
basis,
Math.cos(angle) * radiusPx,
Math.sin(angle) * radiusPx * 0.55,
trafficDepthM + index * 3,
),
trackDeg: (angle * 180 / Math.PI + 180) % 360,
};
});
const captured = await captureSequenceFrame(page, scenario, frame, () => page.evaluate((positions) => {
const seam = window.__godsEyeView.dataManager.layers.get('flights').module.__focusEvidence;
seam.moveAircraft(positions);
}, moves));
frames.push({ index: frame, radiusPx, ...captured, contacts: await flightSnapshot(page) });
}
return { id: scenario, frameCount: frames.length, basis, frames };
}
async function runHorizonBand(page, effectiveParams) {
const scenario = 's4-horizon-band';
await page.evaluate(() => {
window.__godsEyeView.dataManager.layers.get('flights').module.stopTracking();
});
const contacts = [];
for (let row = 0; row < 6; row += 1) {
for (let column = 0; column < 10; column += 1) {
contacts.push({
id: `f4${String(row * 10 + column).padStart(4, '0')}`,
callsign: `LIMB${String(row * 10 + column).padStart(2, '0')}`,
longitude: -70.5 + column * 0.42,
latitude: 27.5 + row * 0.95,
altitudeM: 10_000 + (column % 3) * 800,
klass: column % 4 === 0 ? 'widebody' : 'airliner',
});
}
}
await page.evaluate((records) => {
window.__godsEyeView.dataManager.layers.get('flights')
.module.__focusEvidence.setAircraft(records);
}, contacts);
await setCamera(page, HORIZON_CAMERA);
await setTuning(page, {
horizon: { ...effectiveParams.horizon, scaleFloor: 1, alphaFloor: 1 },
});
await advanceEvidenceDuration(page, 450);
const beforeCapture = await capture(page, scenario, 'before');
const before = await flightSnapshot(page);
await setTuning(page, effectiveParams);
await advanceEvidenceDuration(page, 450);
const afterCapture = await capture(page, scenario, 'after');
const after = await flightSnapshot(page);
return {
id: scenario,
camera: HORIZON_CAMERA,
frames: [
{ phase: 'before', ...beforeCapture, contacts: before },
{ phase: 'after', ...afterCapture, contacts: after },
],
};
}
async function browserContext(page, browser) {
const gl = await page.evaluate(() => {
const canvas = document.createElement('canvas');
const context = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
const debug = context?.getExtension('WEBGL_debug_renderer_info');
return {
userAgent: navigator.userAgent,
dpr: window.devicePixelRatio,
vendor: debug
? context.getParameter(debug.UNMASKED_VENDOR_WEBGL)
: context?.getParameter(context.VENDOR) || 'unavailable',
renderer: debug
? context.getParameter(debug.UNMASKED_RENDERER_WEBGL)
: context?.getParameter(context.RENDERER) || 'unavailable',
};
});
return {
browser: await browser.version(),
userAgent: gl.userAgent,
webglVendor: gl.vendor,
webglRenderer: gl.renderer,
viewport: { ...VIEWPORT, dpr: gl.dpr },
machine: {
hostname: os.hostname(),
platform: os.platform(),
arch: os.arch(),
},
caveat: HEADFUL
? 'Headful capture omits SwiftShader flags and is the real-GPU visual sign-off path.'
: 'SwiftShader/headless output is relative-only; rerun with --headful for real-GPU sign-off.',
};
}
async function main() {
const requestedParams = parseParams();
const response = await fetch(APP_URL).catch((error) => ({ ok: false, statusText: error.message }));
if (!response.ok) {
throw new Error(`Live dev server unavailable at ${APP_URL}: ${response.status || response.statusText}`);
}
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
fs.mkdirSync(path.dirname(JSON_PATH), { recursive: true });
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
protocolTimeout: 300000,
args: [
...(HEADFUL ? [] : ['--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 page = await browser.newPage();
await page.setViewport(VIEWPORT);
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 }));
const report = {
generatedAt: new Date().toISOString(),
url: APP_URL,
screenshotsDir: SCREENSHOTS_DIR,
requestedParams,
requestedBasemap: BASEMAP,
activeBasemap: null,
smoke: SMOKE,
effectiveParams: null,
context: null,
scenarios: [],
consoleMessages,
};
try {
await installSyntheticFetches(page);
await waitForApp(page);
report.activeBasemap = await setBasemap(page, BASEMAP);
await enableLayer(page, 'flights');
await enableLayer(page, 'ais-live-vessels');
await requireEvidenceSeams(page);
await takeFrameClock(page);
report.context = await browserContext(page, browser);
report.effectiveParams = await setTuning(page, requestedParams);
console.log(`Focus evidence → ${APP_URL}`);
console.log(` screenshots: ${SCREENSHOTS_DIR}`);
console.log(` renderer : ${report.context.webglRenderer}`);
console.log(` basemap : ${report.activeBasemap}`);
console.log(` params : ${JSON.stringify(report.effectiveParams)}`);
console.log(` mode : ${SMOKE ? 'smoke (S1, 6 frames)' : 'full (S1-S4)'}`);
console.log(` caveat : ${report.context.caveat}`);
const scenarios = [
['S1 controlled crossing', () => runControlledCrossing(page, report.effectiveParams)],
['S2 harbor clutter', () => runHarborClutter(page, report.effectiveParams)],
['S3 airport traffic', () => runAirportTraffic(page, report.effectiveParams)],
['S4 horizon band', () => runHorizonBand(page, report.effectiveParams)],
];
for (const [label, run] of (SMOKE ? scenarios.slice(0, 1) : scenarios)) {
console.log(` capture : ${label}`);
await page.bringToFront();
report.scenarios.push(await run());
}
} finally {
if (!page.isClosed()) await releaseFrameClock(page).catch(() => {});
await browser.close();
}
fs.writeFileSync(JSON_PATH, `${JSON.stringify(report, null, 2)}\n`);
console.log(` report : ${JSON_PATH}`);
if (consoleMessages.some((message) => message.type === 'error' || message.type === 'pageerror')) {
process.exitCode = 1;
}
}
main().catch((error) => {
console.error(error?.stack || error);
process.exit(1);
});