mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +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]>
259 lines
10 KiB
JavaScript
259 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* qa-traffic-jamviz-ab.mjs — A/B screenshot capture for the jam-viz
|
|
* congestion prototypes (feat/traffic-jam-viz).
|
|
*
|
|
* For each view, renders the SAME camera framing under each jamViz mode
|
|
* (none = shipped main behavior / density / heatline / both), forcing a
|
|
* fresh camera-driven re-render between modes via an away-and-back teleport
|
|
* (setParams applies on the next load, same semantics as uncoveredRoads).
|
|
* Shots + per-shot layer stats land in --out (default qa-shots/jamviz).
|
|
*
|
|
* Views: Austin I-35 downtown corridor (owner's requested A/B target) at
|
|
* city scale + low detail, plus Mumbai Western Express Hwy — captured
|
|
* because Austin is off-peak at 22:00 CT while Mumbai is in live morning
|
|
* rush, guaranteeing jam-bucket coverage in the drama shots.
|
|
*
|
|
* Run: node scripts/qa-traffic-jamviz-ab.mjs --url http://localhost:4412
|
|
* Exits non-zero on harness failure (missing live mode, zero dots, etc.).
|
|
*/
|
|
|
|
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:4412');
|
|
const OUT_DIR = path.resolve(REPO_ROOT, getOpt('--out', 'qa-shots/jamviz'));
|
|
|
|
const VIEWS = [
|
|
{
|
|
id: 'i35-city',
|
|
label: 'Austin I-35 corridor — city scale',
|
|
lon: -97.7365, lat: 30.235, height: 3000, heading: 8, pitch: -38,
|
|
},
|
|
{
|
|
id: 'i35-detail',
|
|
label: 'Austin I-35 at downtown — queue detail',
|
|
lon: -97.735, lat: 30.262, height: 1100, heading: 10, pitch: -45,
|
|
},
|
|
{
|
|
id: 'mumbai-wexp',
|
|
label: 'Mumbai Western Express Hwy — live morning rush',
|
|
lon: 72.851, lat: 19.115, height: 3200, heading: 0, pitch: -40,
|
|
},
|
|
];
|
|
const MODES = ['none', 'density', 'heatline', 'both'];
|
|
|
|
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-cctv-v2: wait for the 3D tileset to settle for crisp shots. */
|
|
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);
|
|
}
|
|
|
|
/** Teleport to a view (cancelling any camera flight first). */
|
|
function setView(page, v) {
|
|
return page.evaluate((view) => {
|
|
const gev = window.__godsEyeView;
|
|
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: view.lon * d2r, latitude: view.lat * d2r, height: view.height,
|
|
}),
|
|
orientation: {
|
|
heading: (view.heading || 0) * d2r,
|
|
pitch: (view.pitch ?? -90) * d2r,
|
|
roll: 0,
|
|
},
|
|
});
|
|
}, v);
|
|
}
|
|
|
|
/**
|
|
* Poll layer stats until a render newer than `sinceLastUpdate` settles.
|
|
* Returns the last sample with `renderSettled` reporting whether the
|
|
* freshness predicate was actually met — on timeout the sample is stale
|
|
* (possibly still-colored buckets from the previous view), and a caller that
|
|
* cannot tell the difference will pass a capture that never rendered.
|
|
*/
|
|
function waitForFreshRender(page, sinceLastUpdate, timeoutS = 40) {
|
|
return page.evaluate(async (since, tS) => {
|
|
const mod = window.__godsEyeView.dataManager.layers.get('traffic').module;
|
|
let s = null;
|
|
let renderSettled = false;
|
|
for (let i = 0; i < tS; i++) {
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
s = mod.getStats();
|
|
if (s.lastUpdate && s.lastUpdate !== since && s.count > 0 && !s.loading) {
|
|
renderSettled = true;
|
|
break;
|
|
}
|
|
}
|
|
return { ...s, renderSettled };
|
|
}, sinceLastUpdate, timeoutS);
|
|
}
|
|
|
|
async function main() {
|
|
console.log('\nJam-viz 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 page.evaluate(() => window.__godsEyeView.dataManager.setEnabled('traffic', true));
|
|
await sleep(1500);
|
|
|
|
let firstView = true;
|
|
for (const view of VIEWS) {
|
|
console.log(`\n── ${view.label} (${view.id})`);
|
|
for (const mode of MODES) {
|
|
// Apply the mode, then force a re-render AT THE EXACT SAME framing:
|
|
// hop ~8.6 km east (defeats the overlap/center-shift gate — adjacent
|
|
// views alone can sit inside it), WAIT for the away render to commit
|
|
// (otherwise its late commit satisfies the freshness poll and the
|
|
// shot captures the away render), then return — the comeback load
|
|
// re-spawns with the new mode active.
|
|
await page.evaluate((m) => {
|
|
window.__godsEyeView.dataManager.layers.get('traffic').module.setParams({ jamViz: m });
|
|
}, mode);
|
|
const preHop = await page.evaluate(
|
|
() => window.__godsEyeView.dataManager.layers.get('traffic').module.getStats().lastUpdate,
|
|
);
|
|
await setView(page, { ...view, lon: view.lon + 0.09 });
|
|
const hop = await waitForFreshRender(page, preHop, 30);
|
|
const since = await page.evaluate(
|
|
() => window.__godsEyeView.dataManager.layers.get('traffic').module.getStats().lastUpdate,
|
|
);
|
|
await setView(page, view);
|
|
const stats = await waitForFreshRender(page, since);
|
|
await waitForTilesLoaded(page, firstView ? 30000 : 15000);
|
|
await sleep(1400); // flow-recolor race + heat-line pulse mid-bright
|
|
|
|
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}--${mode}.png`;
|
|
await page.screenshot({ path: path.join(OUT_DIR, shot) });
|
|
|
|
const b = final.stats.flowBuckets || {};
|
|
// `mode` is the CONFIGURED source, not feed health: a keyed run whose
|
|
// TomTom flow died still reports mode 'live' while every dot renders
|
|
// simulated white. Health is `!error` + actually-colored dots.
|
|
const colored = (b.free || 0) + (b.slow || 0) + (b.jam || 0);
|
|
// A capture whose waits timed out shows the PREVIOUS view's dots.
|
|
// That is a failed capture, not a footnote in the manifest.
|
|
const renderSettled = Boolean(hop.renderSettled && stats.renderSettled)
|
|
&& !final.stats.loading;
|
|
const ok = renderSettled && final.stats.mode === 'live' && !final.stats.error
|
|
&& final.stats.count > 0 && colored > 0
|
|
&& final.params.jamViz === mode
|
|
&& (mode !== 'none' || final.stats.heatLines === 0);
|
|
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} `
|
|
+ `heatLines=${final.stats.heatLines} cov=${final.stats.flowCoveragePct}% `
|
|
+ `err=${final.stats.error || 'none'} settled=${renderSettled}`);
|
|
manifest.push({
|
|
view: view.id, label: view.label, mode, shot,
|
|
capturedAt: new Date().toISOString(),
|
|
count: final.stats.count, flowBuckets: b,
|
|
heatLines: final.stats.heatLines,
|
|
closedRoads: final.stats.closedRoads,
|
|
coveragePct: final.stats.flowCoveragePct,
|
|
error: final.stats.error || null,
|
|
renderSettled,
|
|
});
|
|
firstView = false;
|
|
}
|
|
}
|
|
} 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); });
|