mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
process.exit(2) after a successful fetch trips a libuv assertion on Windows and turns the exit code into 127. The four gates set process.exitCode and return instead, and discard the unread reachability response so an early exit does not wait for the server to finish the body. Co-authored-by: Sameh Khamis <[email protected]>
289 lines
12 KiB
JavaScript
289 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* qa-traffic-preset-ab.mjs — A/B screenshot capture for preset-aware
|
|
* traffic dot styling (owner field finding 2026-07-23: NVG/FLIR/CRT
|
|
* post-FX crush the green/amber/red congestion coding).
|
|
*
|
|
* For each view, settles the live traffic layer ONCE, then for each
|
|
* post-FX style (normal / surveillance=NVG / thermal=FLIR / retro=CRT)
|
|
* drives the REAL StyleManager.setStyle path and captures:
|
|
* A — presetDots 'off' (shipped palette under that preset), and
|
|
* B — presetDots 'on' (preset-aware luminance/size encoding),
|
|
* at identical framing with the identical dot set — the kill switch
|
|
* restyles in place, no refetch, so the pair is a true A/B.
|
|
*
|
|
* Views: Mumbai Western Express Hwy (live rush window for jam coverage)
|
|
* + Austin I-35 downtown corridor (owner's usual target).
|
|
*
|
|
* Shots + per-shot layer stats land in --out
|
|
* (default qa-shots/preset-traffic, gitignored).
|
|
*
|
|
* Run: node scripts/qa-traffic-preset-ab.mjs --url http://localhost:4390
|
|
* Exits non-zero on harness failure (no live mode, zero dots, style or
|
|
* param not adopted by the layer).
|
|
*/
|
|
|
|
import puppeteer from 'puppeteer';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
|
|
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:4390');
|
|
const OUT_DIR = path.resolve(REPO_ROOT, getOpt('--out', 'qa-shots/preset-traffic'));
|
|
|
|
// Framing note: at shallow oblique pitch the camera sits 5+ km from the
|
|
// roads — free/slow dots (2 km depth punch) cull under the mesh and the
|
|
// rest fade to ~2 px, so no styling is judgeable there. Both views keep the
|
|
// camera close/steep enough that every bucket renders (same reason
|
|
// qa-traffic's proof shot is near-nadir).
|
|
const VIEWS = [
|
|
{
|
|
id: 'i35-close',
|
|
label: 'Austin I-35 jam cluster — close queue read',
|
|
lon: -97.7365, lat: 30.252, height: 1400, heading: 8, pitch: -65,
|
|
},
|
|
{
|
|
id: 'i35-nadir',
|
|
label: 'Austin I-35 corridor — near-nadir city scale',
|
|
lon: -97.7365, lat: 30.25, height: 2400, heading: 8, pitch: -70,
|
|
},
|
|
];
|
|
/**
|
|
* StyleManager preset names with owner-facing labels + expected profile.
|
|
* `ironbow` flips the thermal palette uniform (0 = grayscale WHOT,
|
|
* 1 = Ironbow "Predator" ramp) — round 2 requires the dots to read in both.
|
|
*/
|
|
const STYLES = [
|
|
{ id: 'normal', name: 'normal', label: 'Normal', profile: 'normal' },
|
|
{ id: 'surveillance', name: 'surveillance', label: 'NVG', profile: 'mono' },
|
|
{ id: 'thermal', name: 'thermal', label: 'FLIR WHOT', profile: 'mono', ironbow: false },
|
|
{ id: 'thermal-ironbow', name: 'thermal', label: 'FLIR Ironbow', profile: 'mono', ironbow: true },
|
|
{ id: 'retro', name: 'retro', label: 'CRT', profile: 'crt' },
|
|
];
|
|
|
|
const CHROME_EXECUTABLE_CANDIDATES = [
|
|
process.env.PUPPETEER_EXECUTABLE_PATH,
|
|
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
|
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
|
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
|
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
|
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
|
// pinned browser beats the newest one for regression harnesses.
|
|
await puppeteer.executablePath().catch(() => null),
|
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
|
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
].filter(Boolean);
|
|
const findChromeExecutable = () =>
|
|
CHROME_EXECUTABLE_CANDIDATES.find((c) => { try { return fs.existsSync(c); } catch { return false; } }) || null;
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
/** Same pattern as qa-traffic-jamviz-ab: wait for the 3D tileset to settle. */
|
|
function waitForTilesLoaded(page, timeoutMs) {
|
|
return page.waitForFunction(
|
|
() => {
|
|
const prims = window.__godsEyeView.viewer.scene.primitives;
|
|
for (let i = 0; i < prims.length; i++) {
|
|
const p = prims.get(i);
|
|
if (p && p.constructor && p.constructor.name === 'Cesium3DTileset') {
|
|
return p.tilesLoaded === true;
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
{ timeout: timeoutMs }
|
|
).then(() => true).catch(() => false);
|
|
}
|
|
|
|
/** Enable traffic + teleport, then poll the layer until settled. */
|
|
function settleTraffic(page, view, { minCount = 100, timeoutS = 45 } = {}) {
|
|
return page.evaluate(async (v, minC, tS) => {
|
|
const gev = window.__godsEyeView;
|
|
await gev.dataManager.setEnabled('traffic', true);
|
|
const mod = gev.dataManager.layers.get('traffic').module;
|
|
try { gev.viewer.camera.cancelFlight(); } catch { /* no flight */ }
|
|
const ell = gev.viewer.scene.globe.ellipsoid;
|
|
const d2r = Math.PI / 180;
|
|
gev.viewer.camera.setView({
|
|
destination: ell.cartographicToCartesian({
|
|
longitude: v.lon * d2r, latitude: v.lat * d2r, height: v.height,
|
|
}),
|
|
orientation: {
|
|
heading: (v.heading || 0) * d2r, pitch: (v.pitch ?? -90) * d2r, roll: 0,
|
|
},
|
|
});
|
|
let s = null;
|
|
// `renderSettled` separates a real settle from a timeout returning the
|
|
// last (stale, possibly still-loading) sample — without it a capture of
|
|
// the previous view's dots passes.
|
|
let renderSettled = false;
|
|
for (let i = 0; i < tS; i++) {
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
s = mod.getStats();
|
|
if (s.count >= minC && !s.loading) {
|
|
renderSettled = true;
|
|
break;
|
|
}
|
|
}
|
|
return { ...s, renderSettled };
|
|
}, view, minCount, timeoutS);
|
|
}
|
|
|
|
async function main() {
|
|
console.log('\nPreset-aware traffic styling A/B capture');
|
|
console.log(` App URL : ${APP_URL}`);
|
|
console.log(` Out dir : ${OUT_DIR}\n`);
|
|
|
|
try {
|
|
const res = await fetch(APP_URL);
|
|
// Only the status matters; an unread body can hold the socket open.
|
|
await res.body?.cancel();
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
} catch (e) {
|
|
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
|
|
process.exitCode = 2;
|
|
return;
|
|
}
|
|
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
|
if (!status?.hasKey) {
|
|
console.error('Server has no TomTom key — the A/B needs live flow.');
|
|
process.exitCode = 2;
|
|
return;
|
|
}
|
|
|
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
|
const browser = await puppeteer.launch({
|
|
headless: 'new',
|
|
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
|
|
args: [
|
|
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
|
|
'--disable-dev-shm-usage', '--disable-web-security',
|
|
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
|
'--window-size=1600,900',
|
|
],
|
|
});
|
|
|
|
const manifest = [];
|
|
let exitCode = 0;
|
|
try {
|
|
const page = await browser.newPage();
|
|
await page.setViewport({ width: 1600, height: 900 });
|
|
console.log('Loading app...');
|
|
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
await page.waitForFunction(
|
|
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
|
{ timeout: 60000 },
|
|
);
|
|
await sleep(1500);
|
|
|
|
let firstView = true;
|
|
for (const view of VIEWS) {
|
|
console.log(`\n── ${view.label} (${view.id})`);
|
|
const settled = await settleTraffic(page, view);
|
|
// `mode` only says a TomTom key is configured; `error` says whether the
|
|
// flow feed is actually up. A degraded run renders simulated white dots
|
|
// and must not pass as a live styling capture — and a wait that timed
|
|
// out never rendered this view at all.
|
|
if (!settled || !settled.renderSettled || settled.count === 0
|
|
|| settled.mode !== 'live' || settled.error) {
|
|
console.error(' [FAIL] traffic never settled live '
|
|
+ `(count=${settled?.count} mode=${settled?.mode} `
|
|
+ `settled=${Boolean(settled?.renderSettled)} err=${settled?.error || 'none'})`);
|
|
exitCode = 1;
|
|
continue;
|
|
}
|
|
await waitForTilesLoaded(page, firstView ? 30000 : 15000);
|
|
firstView = false;
|
|
|
|
for (const style of STYLES) {
|
|
// Real user path: StyleManager.setStyle drives the post-FX stage AND
|
|
// the gev:style-change event the traffic layer listens to. The
|
|
// thermal palette uniform selects WHOT vs Ironbow.
|
|
await page.evaluate((s) => {
|
|
const sm = window.__godsEyeView.styleManager;
|
|
sm.setStyle(s.name, { applyPreset: true });
|
|
if (sm.stages.thermal) sm.stages.thermal.uniforms.palette = s.ironbow ? 1.0 : 0.0;
|
|
}, style);
|
|
await sleep(1800); // 500 ms intensity lerp + settle
|
|
|
|
// Round 2 legs: dots alone (DETECT off) vs dots + bucket-colored
|
|
// brackets (DENSE) — presetDots stays at its default 'on'.
|
|
for (const detect of ['off', 'on']) {
|
|
await page.evaluate((d) => {
|
|
window.__godsEyeView.styleManager._setDetectionMode(d === 'on' ? 'DENSE' : 'OFF');
|
|
}, detect);
|
|
await sleep(1200); // label-arbiter solve + a few rendered frames
|
|
|
|
const final = await page.evaluate(() => {
|
|
const mod = window.__godsEyeView.dataManager.layers.get('traffic').module;
|
|
return { stats: mod.getStats(), params: mod.getParams() };
|
|
});
|
|
const shot = `${view.id}--${style.id}--detect-${detect}.png`;
|
|
await page.screenshot({ path: path.join(OUT_DIR, shot) });
|
|
|
|
const b = final.stats.flowBuckets || {};
|
|
// Bucket colors are the whole point of this capture, so a degraded
|
|
// feed (mode 'live', flow down, every dot white) has to fail here.
|
|
const colored = (b.free || 0) + (b.slow || 0) + (b.jam || 0);
|
|
// A style swap can kick a re-render; capturing mid-load screenshots
|
|
// the previous style's dots.
|
|
const ok = !final.stats.loading
|
|
&& final.stats.mode === 'live' && !final.stats.error
|
|
&& final.stats.count > 0 && colored > 0
|
|
&& final.params.presetDots === 'on'
|
|
&& final.stats.stylePreset === style.name
|
|
&& final.stats.styleProfile === style.profile;
|
|
if (!ok) exitCode = 1;
|
|
console.log(` [${ok ? 'OK' : 'FAIL'}] ${shot} dots=${final.stats.count} `
|
|
+ `free=${b.free} slow=${b.slow} jam=${b.jam} sim=${b.sim} `
|
|
+ `stylePreset=${final.stats.stylePreset} profile=${final.stats.styleProfile} `
|
|
+ `err=${final.stats.error || 'none'} loading=${final.stats.loading}`);
|
|
manifest.push({
|
|
view: view.id, label: view.label,
|
|
style: style.id, styleLabel: style.label, detect, shot,
|
|
capturedAt: new Date().toISOString(),
|
|
count: final.stats.count, flowBuckets: b,
|
|
stylePreset: final.stats.stylePreset,
|
|
styleProfile: final.stats.styleProfile,
|
|
coveragePct: final.stats.flowCoveragePct,
|
|
// A rejected capture must look rejected in the manifest too, not
|
|
// just in the console verdict — record the gating fields.
|
|
ok,
|
|
loading: final.stats.loading,
|
|
error: final.stats.error || null,
|
|
renderSettled: final.renderSettled,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Leave the page in the shipped default state between views.
|
|
await page.evaluate(() => {
|
|
const sm = window.__godsEyeView.styleManager;
|
|
sm.setStyle('normal', { applyPreset: true });
|
|
sm._setDetectionMode('OFF');
|
|
if (sm.stages.thermal) sm.stages.thermal.uniforms.palette = 0.0;
|
|
});
|
|
await sleep(1000);
|
|
}
|
|
} catch (e) {
|
|
console.error('Harness error:', e);
|
|
exitCode = 3;
|
|
} finally {
|
|
fs.writeFileSync(path.join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
|
await browser.close();
|
|
}
|
|
console.log(`\nShots + manifest.json → ${OUT_DIR}`);
|
|
process.exit(exitCode);
|
|
}
|
|
|
|
main().catch((e) => { console.error(e); process.exit(3); });
|