gods-eye-view/scripts/qa-vessel-cards.mjs

417 lines
17 KiB
JavaScript

#!/usr/bin/env node
/**
* qa-vessel-cards.mjs — operator-side visual proof harness for AIS cards in
* the shared world-overlay host.
*
* Drives the REAL app in headless Chromium, frames a busy port, waits for the
* photoreal tileset + vessel refresh to settle, and captures a full-viewport
* screenshot to the gitignored qa-shots/. Live AISStream data remains the
* default. An explicit synthetic mode uses the layer's dev-only evidence seam
* to exercise the production reconciliation/render path when AISStream is open
* but silent (https://github.com/aisstream/aisstream/issues/23 and
* https://github.com/aisstream/aisstream/issues/15).
*
* Usage:
* node scripts/qa-vessel-cards.mjs --tag new # current code
* node scripts/qa-vessel-cards.mjs --tag old # after swapping in the old layer file
* node scripts/qa-vessel-cards.mjs --ports rotterdam # subset
* node scripts/qa-vessel-cards.mjs --url http://localhost:4173
* node scripts/qa-vessel-cards.mjs --data synthetic # deterministic test fixture
* node scripts/qa-vessel-cards.mjs --data synthetic --headful # real-GPU capture
*
* A/B flow: run --tag new, `git show <old-rev>:src/data/aisLiveVessels.js >
* src/data/aisLiveVessels.js`, run --tag old, then `git restore` the file.
* Retained screenshots and same-basename JSON manifests land at
* qa-shots/vessel-cards-<live-aisstream|synthetic-fixture>-<tag>-<port>.*.
* The name and manifest both record the data source; the manifest also records
* headless/headful launch mode and the browser's WebGL renderer.
*/
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import puppeteer from 'puppeteer';
const argv = process.argv.slice(2);
const getOpt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const APP_URL = getOpt('--url', 'http://localhost:4173');
const TAG = getOpt('--tag', 'new');
const PORT_KEYS = getOpt('--ports', 'rotterdam,houston').split(',').map((s) => s.trim()).filter(Boolean);
const OUT_DIR = getOpt('--out', 'qa-shots');
export function isHeadfulMode(args = []) {
return args.includes('--headful');
}
const HEADFUL = isHeadfulMode(argv);
export const DEFAULT_DATA_MODE = 'live';
const DATA_PROVENANCE_SLUGS = Object.freeze({
live: 'live-aisstream',
synthetic: 'synthetic-fixture',
});
/** Parse the explicit evidence-source mode, defaulting to the live feed. */
export function parseDataMode(args = []) {
const index = args.indexOf('--data');
const mode = index >= 0 && args[index + 1] ? args[index + 1] : DEFAULT_DATA_MODE;
if (!Object.hasOwn(DATA_PROVENANCE_SLUGS, mode)) {
throw new Error(`Unknown --data mode '${mode}' (have: live, synthetic)`);
}
return mode;
}
/** Build a screenshot name whose data provenance cannot collide with another mode. */
export function vesselCardScreenshotFilename({ dataMode, tag, portKey }) {
const provenanceSlug = DATA_PROVENANCE_SLUGS[dataMode];
if (!provenanceSlug) throw new Error(`Unsupported vessel-card data mode '${dataMode}'`);
return `vessel-cards-${provenanceSlug}-${tag}-${portKey}.png`;
}
/** Build the same-basename provenance manifest filename for one PNG. */
export function vesselCardManifestFilename(screenshotFilename) {
if (!/\.png$/i.test(screenshotFilename)) {
throw new Error(`Expected a PNG screenshot filename, received '${screenshotFilename}'`);
}
return screenshotFilename.replace(/\.png$/i, '.json');
}
/** True when renderer evidence is available and does not identify a software rasterizer. */
export function isHardwareRenderer(renderer = {}) {
const description = `${renderer.vendor || ''} ${renderer.renderer || ''}`.trim();
return Boolean(description)
&& !/(swiftshader|software|llvmpipe|unavailable|unknown)/i.test(description);
}
/** Human-readable provenance emitted before any screenshot is captured. */
export function vesselCardProvenanceLines(dataMode) {
if (dataMode === 'live') {
return ['[PROVENANCE] LIVE AISStream feed; no synthetic vessel rows are injected.'];
}
if (dataMode === 'synthetic') {
return [
'[PROVENANCE] SYNTHETIC FIXTURE; this is not evidence of live AISStream availability.',
'[UPSTREAM] AISStream Issue #23: https://github.com/aisstream/aisstream/issues/23',
'[UPSTREAM] AISStream Issue #15: https://github.com/aisstream/aisstream/issues/15',
];
}
throw new Error(`Unsupported vessel-card data mode '${dataMode}'`);
}
/** Build the provenance record retained beside one screenshot. */
export function buildVesselCardManifest({
dataMode,
tag,
portKey,
screenshot,
headful,
renderer,
captureEvidence,
appUrl,
capturedAt,
}) {
const provenanceSlug = DATA_PROVENANCE_SLUGS[dataMode];
if (!provenanceSlug) throw new Error(`Unsupported vessel-card data mode '${dataMode}'`);
return {
schemaVersion: 1,
screenshot,
dataMode,
provenance: provenanceSlug,
source: dataMode === 'live' ? 'AISStream live feed' : 'GEV synthetic AIS fixture',
liveAisStreamAvailabilityAsserted: dataMode === 'live',
launchMode: headful ? 'headful' : 'headless',
renderer,
captureEvidence,
tag,
port: portKey,
appUrl,
capturedAt,
upstreamContext: dataMode === 'synthetic' ? [
'https://github.com/aisstream/aisstream/issues/23',
'https://github.com/aisstream/aisstream/issues/15',
] : [],
};
}
let DATA_MODE;
try {
DATA_MODE = parseDataMode(argv);
} catch (error) {
console.error(error.message);
process.exit(2);
}
/** Busy-port camera framings (lon/lat deg, height m, heading/pitch rad). */
const PORTS = {
rotterdam: { lon: 4.05, lat: 51.93, height: 18000, heading: 0.3, pitch: -1.25 },
houston: { lon: -95.08, lat: 29.72, height: 16000, heading: 5.9, pitch: -1.3 },
};
const SYNTHETIC_OFFSETS = [
[0, 0],
[-0.055, -0.025],
[-0.02, 0.045],
[0.045, -0.04],
[0.065, 0.03],
[-0.08, 0.055],
[0.01, -0.075],
[0.09, -0.015],
];
function syntheticVesselRows(portKey, port) {
const mmsiBase = portKey === 'rotterdam' ? 990100000 : 990200000;
return SYNTHETIC_OFFSETS.map(([lonOffset, latOffset], index) => ({
mmsi: String(mmsiBase + index),
name: `QA ${portKey.toUpperCase()} ${String(index + 1).padStart(2, '0')}`,
lat: port.lat + latOffset,
lon: port.lon + lonOffset,
speed: 3 + index,
course: (25 + index * 41) % 360,
heading: (25 + index * 41) % 360,
type: index % 3 === 0 ? 'Cargo' : index % 3 === 1 ? 'Tug' : 'Tanker',
destination: portKey.toUpperCase(),
}));
}
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.
(() => { try { return puppeteer.executablePath(); } catch { return 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() {
console.log(`\nAIS Vessel Cards — visual proof harness`);
console.log(` App URL : ${APP_URL}`);
console.log(` Tag : ${TAG}`);
console.log(` Data : ${DATA_MODE}`);
console.log(` Browser : ${HEADFUL ? 'headful (hardware renderer requested)' : 'headless (SwiftShader)'}`);
console.log(` Ports : ${PORT_KEYS.join(', ')}\n`);
for (const line of vesselCardProvenanceLines(DATA_MODE)) console.log(` ${line}`);
console.log('');
try {
const res = await fetch(APP_URL, { method: 'GET' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
console.error(`Start it first: ./scripts/dev-fresh.sh`);
process.exit(2);
}
fs.mkdirSync(OUT_DIR, { recursive: true });
const chromeExecutable = findChromeExecutable();
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []),
'--disable-dev-shm-usage',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--window-size=1600,900',
],
});
let failures = 0;
try {
for (const key of PORT_KEYS) {
const port = PORTS[key];
if (!port) {
console.error(` [\x1b[31mFAIL\x1b[0m] unknown port '${key}' (have: ${Object.keys(PORTS).join(', ')})`);
failures += 1;
continue;
}
const outPath = path.join(OUT_DIR, vesselCardScreenshotFilename({
dataMode: DATA_MODE,
tag: TAG,
portKey: key,
}));
console.log(`${key}${outPath}`);
const page = await browser.newPage();
await page.setViewport({ width: 1600, height: 900 });
page.on('pageerror', (err) => console.error(` [page-error] ${err.message}`));
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 60000 });
// Frame the port (kill the intro flight first) and enable the layer.
const syntheticRows = DATA_MODE === 'synthetic' ? syntheticVesselRows(key, port) : [];
const dataEvidence = await page.evaluate(async ({ p, dataMode, fixtureRows }) => {
const gev = window.__godsEyeView;
const v = gev.viewer;
v.camera.cancelFlight?.();
v.scene.tweens?.removeAll?.();
const carto = {
longitude: (p.lon * Math.PI) / 180,
latitude: (p.lat * Math.PI) / 180,
height: p.height,
};
v.camera.setView({
destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto),
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
});
await gev.dataManager.setEnabled('ais-live-vessels', true);
if (dataMode !== 'synthetic') return { mode: 'live', injected: 0 };
const seam = gev.dataManager.layers.get('ais-live-vessels')?.module?.__focusEvidence;
if (!seam?.setVessels) throw new Error('Synthetic AIS evidence seam is unavailable');
const result = seam.setVessels(fixtureRows);
if (!result?.ok || result.count !== fixtureRows.length) {
throw new Error(`Synthetic AIS injection failed (${result?.count || 0}/${fixtureRows.length})`);
}
v.scene.requestRender?.();
return { mode: 'synthetic', injected: result.count };
}, { p: port, dataMode: DATA_MODE, fixtureRows: syntheticRows });
console.log(
` [DATA] ${dataEvidence.mode}${dataEvidence.injected ? ` (${dataEvidence.injected} injected)` : ''}; `
+ `artifact provenance=${DATA_PROVENANCE_SLUGS[DATA_MODE]}`,
);
// Wait for the photoreal tileset and at least one vessel refresh.
const settled = await page
.waitForFunction(() => {
const gev = window.__godsEyeView;
const t = gev.tileset;
const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels');
const count = ais?.stats?.count ?? 0;
return t?.tilesLoaded && count > 0;
}, { timeout: 90000, polling: 500 })
.then(() => true)
.catch(() => false);
// Re-assert the port framing: on slow (SwiftShader) runs the app's
// intro flight can start AFTER the first cancelFlight and land mid-wait,
// dragging the camera back to the boot city before the screenshot.
await page.evaluate((p) => {
const v = window.__godsEyeView.viewer;
v.camera.cancelFlight?.();
v.scene.tweens?.removeAll?.();
const carto = {
longitude: (p.lon * Math.PI) / 180,
latitude: (p.lat * Math.PI) / 180,
height: p.height,
};
v.camera.setView({
destination: v.scene.globe.ellipsoid.cartographicToCartesian(carto),
orientation: { heading: p.heading, pitch: p.pitch, roll: 0 },
});
}, port);
const resettled = settled ? true : await page
.waitForFunction(() => {
const gev = window.__godsEyeView;
const ais = gev.dataManager.getAll().find((l) => l.id === 'ais-live-vessels');
return gev.tileset?.tilesLoaded && (ais?.stats?.count ?? 0) > 0;
}, { timeout: 60000, polling: 500 })
.then(() => true)
.catch(() => false);
// Let two visibility/declutter cycles (800 ms) and host rendering settle.
await new Promise((r) => setTimeout(r, 2500));
const expectedFixtureIds = syntheticRows.map((row) => row.mmsi);
const overlayEvidence = await page.evaluate((fixtureIds) => {
const diagnostics = window.__gevWorldOverlay?.getDiagnostics?.();
const gl = window.__godsEyeView?.viewer?.scene?.context?._gl;
const debugInfo = gl?.getExtension?.('WEBGL_debug_renderer_info');
const seam = window.__godsEyeView?.dataManager?.layers
?.get('ais-live-vessels')?.module?.__focusEvidence;
const snapshotIds = new Set((seam?.snapshot?.() || []).map((record) => String(record.id)));
return {
hostCanvas: Boolean(document.getElementById('world-overlay-canvas')),
legacyCanvas: Boolean(document.getElementById('vessel-labels')),
entries: diagnostics?.entriesBySource?.['ais-live-vessels'] || 0,
painted: diagnostics?.paintedBySource?.['ais-live-vessels'] || 0,
fixtureRowsRetained: fixtureIds.length
? fixtureIds.every((id) => snapshotIds.has(String(id)))
: null,
renderer: gl ? {
vendor: String(gl.getParameter(debugInfo?.UNMASKED_VENDOR_WEBGL || gl.VENDOR) || 'unknown'),
renderer: String(gl.getParameter(debugInfo?.UNMASKED_RENDERER_WEBGL || gl.RENDERER) || 'unknown'),
} : { vendor: 'unavailable', renderer: 'unavailable' },
};
}, expectedFixtureIds);
await page.screenshot({ path: outPath });
const st = fs.statSync(outPath);
const hardwareRenderer = isHardwareRenderer(overlayEvidence.renderer);
const capturePassed = resettled
&& overlayEvidence.hostCanvas
&& !overlayEvidence.legacyCanvas
&& overlayEvidence.entries > 0
&& overlayEvidence.painted > 0
&& (DATA_MODE !== 'synthetic' || overlayEvidence.fixtureRowsRetained)
&& (!HEADFUL || hardwareRenderer);
const manifestPath = path.join(OUT_DIR, vesselCardManifestFilename(path.basename(outPath)));
const manifest = buildVesselCardManifest({
dataMode: DATA_MODE,
tag: TAG,
portKey: key,
screenshot: path.basename(outPath),
headful: HEADFUL,
renderer: overlayEvidence.renderer,
captureEvidence: {
tilesAndVesselsSettled: resettled,
injectedRows: dataEvidence.injected,
syntheticFixtureRowsRetained: overlayEvidence.fixtureRowsRetained,
overlayEntries: overlayEvidence.entries,
overlayPainted: overlayEvidence.painted,
hostCanvas: overlayEvidence.hostCanvas,
legacyCanvas: overlayEvidence.legacyCanvas,
screenshotBytes: st.size,
hardwareAccelerated: hardwareRenderer,
passed: capturePassed,
},
appUrl: APP_URL,
capturedAt: new Date().toISOString(),
});
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
const tiles = resettled ? 'tiles+vessels settled' : 'TIMEOUT waiting for tiles/vessels (shot anyway)';
console.log(` [\x1b[32mSHOT\x1b[0m] ${Math.round(st.size / 1024)} KB — ${tiles}`);
console.log(` [RENDERER] ${overlayEvidence.renderer.vendor} / ${overlayEvidence.renderer.renderer}`);
console.log(` [GPU] hardware accelerated=${hardwareRenderer}`);
if (DATA_MODE === 'synthetic') {
console.log(` [FIXTURE] retained at capture=${overlayEvidence.fixtureRowsRetained}`);
}
console.log(` [MANIFEST] ${manifestPath}`);
console.log(` [HOST] ${overlayEvidence.entries} entries / ${overlayEvidence.painted} painted`);
if (!capturePassed) failures += 1;
await page.close();
}
} finally {
await browser.close();
}
console.log('\nRetained PNG+JSON pairs are local QA evidence; each basename records live or synthetic provenance.');
console.log(`\nRESULT: ${PORT_KEYS.length - failures} passed, ${failures} failed, 0 skipped`);
process.exit(failures ? 1 : 0);
}
const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : '';
if (import.meta.url === invokedPath) {
main().catch((e) => {
console.error(e);
process.exit(1);
});
}