255 lines
10 KiB
JavaScript
255 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.
|
|
(() => { 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);
|
|
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);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
} catch (e) {
|
|
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
|
|
process.exit(2);
|
|
}
|
|
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.exit(2);
|
|
}
|
|
|
|
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); });
|