gods-eye-view/scripts/qa-traffic.mjs

317 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* qa-traffic.mjs — headless proof for the TomTom live-flow traffic layer.
*
* Drives the REAL app in headless Chromium against a dev server that has a
* TomTom key (default :4410) and asserts the live-mode contract, then uses
* request interception to fabricate the keyless state deterministically
* (no key removal needed, no upstream traffic).
*
* (i) LIVE mode — stats.mode 'live', dots rendered, colored buckets
* non-empty (free+slow+jam > 0), coverage > 0, tiles fetched > 0.
* (ii) C4 oblique bounds — at 2.5 km / -20° pitch the road-fetch box
* center lands within 12 km of the camera (look-at point), never
* the pre-fix horizon-biased midpoint (>25 km).
* (iii) Budget honesty — /api/tomtom/status dailyCount grows by no more
* than the tiles the page actually fetched this run.
* (iv) Uncovered-roads param — 'hide' renders zero sim dots; 'sim'
* restores them (Mumbai has partial coverage; Austin may be 100%,
* so this asserts on whichever view has sim dots, else records
* INCONCLUSIVE rather than a false failure).
* (v) KEYLESS fallback — with /api/tomtom/status intercepted to
* {hasKey:false}: mode 'sim', every dot white (buckets.sim ===
* count), and ZERO /api/tomtom/flow requests issued.
*
* Visual proof saved to qa-shots/ (gitignored).
*
* Run: node scripts/qa-traffic.mjs --url http://localhost:4410
* Exits non-zero on any FAIL. Does not commit anything.
*/
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 SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
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:4410');
const HEADFUL = argv.includes('--headful');
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 { /* ignore */ }
}
return null;
}
const results = [];
function record(name, ok, detail) {
results.push({ name, ok, detail });
const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
console.log(` [${tag}] ${name}${detail ? `${detail}` : ''}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Enable traffic + teleport, then poll the layer until settled. */
async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) {
return page.evaluate(async (v, minC, tS) => {
const gev = window.__godsEyeView;
const dm = gev.dataManager;
await dm.setEnabled('traffic', true);
const mod = dm.layers.get('traffic').module;
const ell = gev.viewer.scene.globe.ellipsoid;
const d2r = Math.PI / 180;
// The app's intro flyTo animation clobbers a setView issued mid-flight —
// cancel any active tween before teleporting.
try { gev.viewer.camera.cancelFlight(); } catch { /* no flight active */ }
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;
for (let i = 0; i < tS; i++) {
await new Promise((r) => setTimeout(r, 1000));
s = mod.getStats();
if (s.count >= minC && !s.loading) break;
}
return s;
}, view, minCount, timeoutS);
}
async function main() {
console.log('\nTomTom Live-Flow Traffic Proof (qa-traffic)');
console.log(` App URL : ${APP_URL}\n`);
try {
const res = await fetch(APP_URL);
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`);
process.exit(2);
}
const statusBefore = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
if (!statusBefore?.hasKey) {
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
process.exit(2);
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
const browser = await puppeteer.launch({
headless: HEADFUL ? false : '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=1440,900',
],
});
let exitCode = 0;
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
// Track flow-tile requests + traffic console lines for (ii)/(iii)/(v).
const flowRequests = [];
page.on('request', (req) => {
if (req.url().includes('/api/tomtom/flow/')) flowRequests.push(req.url());
});
const trafficLogs = [];
page.on('console', (msg) => {
const t = msg.text();
if (t.includes('[Data:Traffic]')) trafficLogs.push(t);
});
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);
// ── (i) LIVE mode: San Antonio — fast Overpass extract, partial TomTom
// coverage (sim dots exist for (iv)). Mumbai proved too Overpass-cold for
// a deterministic harness; congestion colors assert the same either way.
console.log('\n(i) LIVE mode — San Antonio (partial coverage)...');
let mumbai = await settleTraffic(page, { lon: -98.4936, lat: 29.4241, height: 2800, heading: 23, pitch: -72 }, { minCount: 300, timeoutS: 45 });
if (!mumbai || mumbai.count === 0) {
// One retry with a nudged center to defeat the overlap gate (a slow
// first Overpass response can strand the initial load).
mumbai = await settleTraffic(page, { lon: -98.487, lat: 29.43, height: 2800, heading: 23, pitch: -72 }, { minCount: 300, timeoutS: 45 });
}
{
const b = mumbai.flowBuckets || {};
const colored = (b.free || 0) + (b.slow || 0) + (b.jam || 0);
record('LIVE: stats.mode === "live"', mumbai.mode === 'live', `mode=${mumbai.mode}`);
record('LIVE: dots rendered', mumbai.count > 0, `count=${mumbai.count}`);
record('LIVE: colored flow dots present (free+slow+jam > 0)', colored > 0,
`free=${b.free} slow=${b.slow} jam=${b.jam} sim=${b.sim}`);
record('LIVE: flow coverage > 0 and tiles fetched > 0',
mumbai.flowCoveragePct > 0 && mumbai.tilesFetched > 0,
`coverage=${mumbai.flowCoveragePct}% tiles=${mumbai.tilesFetched}`);
if (mumbai.mode !== 'live' || !(mumbai.count > 0) || !(colored > 0)) exitCode = 1;
await sleep(1200);
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-flow.png') });
}
// ── (iv) uncovered-roads param — hide vs sim ─────────────────────────────
console.log('\n(iv) uncoveredRoads param — hide vs sim...');
if ((mumbai.flowBuckets?.sim || 0) > 0) {
const hid = await page.evaluate(async () => {
const gev = window.__godsEyeView;
const mod = gev.dataManager.layers.get('traffic').module;
const before = mod.getStats().lastUpdate;
mod.setParams({ uncoveredRoads: 'hide' });
const ell = gev.viewer.scene.globe.ellipsoid;
const d2r = Math.PI / 180;
// Shift far enough to defeat the overlap gate and force a re-render.
gev.viewer.camera.setView({
destination: ell.cartographicToCartesian({ longitude: -98.51 * d2r, latitude: 29.435 * d2r, height: 2800 }),
orientation: { heading: 0.4, pitch: -1.25, roll: 0 },
});
// Poll for a NEW render (lastUpdate changes) — the pre-shift stats
// would otherwise satisfy a count>0 check instantly.
let s = null;
for (let i = 0; i < 40; i++) {
await new Promise((r) => setTimeout(r, 1000));
s = mod.getStats();
if (s.lastUpdate !== before && s.count > 0 && !s.loading) break;
}
mod.setParams({ uncoveredRoads: 'sim' });
return s;
});
record('PARAM: hide mode renders zero sim (white) dots',
(hid.flowBuckets?.sim || 0) === 0 && hid.count > 0,
`count=${hid.count} sim=${hid.flowBuckets?.sim}`);
if ((hid.flowBuckets?.sim || 0) !== 0) exitCode = 1;
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') });
} else {
record('PARAM: hide mode renders zero sim (white) dots', null,
'view had 100% coverage (no sim dots to hide) — inconclusive here, covered by unit tests');
}
// ── (ii) C4 oblique bounds ───────────────────────────────────────────────
console.log('\n(ii) C4 — oblique fetch bounds land at the look-at point...');
trafficLogs.length = 0;
await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 });
{
const fetchLine = trafficLogs.find((t) => t.includes('fetch') && t.includes('['));
let ok = false; let detail = 'no fetch log captured';
if (fetchLine) {
const m = fetchLine.match(/\[(-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\]/);
if (m) {
const [s, w, n, e] = m.slice(1).map(Number);
const cLat = (s + n) / 2; const cLon = (w + e) / 2;
const dKm = Math.sqrt(((cLat - 30.245) * 111) ** 2 + ((cLon - (-97.72)) * 111 * Math.cos(30.27 * Math.PI / 180)) ** 2);
ok = dKm > 1 && dKm <= 13; // look-at ~6.9 km ahead; clamp allows ≤12 (+margin)
detail = `box center (${cLat.toFixed(4)},${cLon.toFixed(4)}) is ${dKm.toFixed(1)} km from camera (want 113 km; pre-fix bug: 25+ km)`;
}
}
record('C4: oblique fetch box centers on the look-at point', ok, detail);
if (!ok) exitCode = 1;
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') });
}
// ── (iii) budget honesty ─────────────────────────────────────────────────
console.log('\n(iii) Budget — dailyCount grew by ≤ requests this run...');
{
const statusAfter = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json());
const grew = statusAfter.dailyCount - statusBefore.dailyCount;
const ok = grew >= 0 && grew <= flowRequests.length;
record('BUDGET: /api/tomtom/status growth ≤ page tile requests', ok,
`before=${statusBefore.dailyCount} after=${statusAfter.dailyCount} pageRequests=${flowRequests.length}`);
if (!ok) exitCode = 1;
}
// ── (v) KEYLESS fallback (intercepted — server key untouched) ────────────
console.log('\n(v) KEYLESS — intercepted status, expect pure simulation...');
await page.setRequestInterception(true);
const keylessFlowReqs = [];
page.on('request', (req) => {
const url = req.url();
if (url.includes('/api/tomtom/status')) {
req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ hasKey: false }) });
return;
}
if (url.includes('/api/tomtom/flow/')) {
keylessFlowReqs.push(url);
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
return;
}
try { req.continue(); } catch { /* already handled */ }
});
await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: 60000 },
);
await sleep(1500);
let simStats = await settleTraffic(page, { lon: -98.4936, lat: 29.4241, height: 3000 }, { minCount: 100, timeoutS: 45 });
if (!simStats || simStats.count === 0) {
// Public Overpass can throttle bursts across harness runs — one retry.
simStats = await settleTraffic(page, { lon: -98.487, lat: 29.43, height: 3000 }, { minCount: 100, timeoutS: 45 });
}
{
const b = simStats.flowBuckets || {};
const allWhite = simStats.count > 0 && b.sim === simStats.count && !b.free && !b.slow && !b.jam;
record('KEYLESS: stats.mode === "sim"', simStats.mode === 'sim', `mode=${simStats.mode}`);
record('KEYLESS: every dot is white simulation', allWhite,
`count=${simStats.count} sim=${b.sim} free=${b.free} slow=${b.slow} jam=${b.jam}`);
record('KEYLESS: zero flow-tile requests issued', keylessFlowReqs.length === 0,
`flowRequests=${keylessFlowReqs.length}`);
// Keyless is a designed fallback, not a fault: it must read SIMULATED
// without ever raising a layer error.
const keylessHonest = !simStats.error
&& String(simStats.loadingLabel || '').startsWith('SIMULATED');
record('KEYLESS: no error, and the label reads SIMULATED', keylessHonest,
`err=${simStats.error || 'none'} label="${simStats.loadingLabel}"`);
if (simStats.mode !== 'sim' || !allWhite || keylessFlowReqs.length !== 0 || !keylessHonest) {
exitCode = 1;
}
await sleep(1000);
await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') });
}
} catch (e) {
console.error('\x1b[31mHarness error:\x1b[0m', e);
exitCode = 3;
} finally {
await browser.close();
}
const pass = results.filter((r) => r.ok === true).length;
const fail = results.filter((r) => r.ok === false).length;
const inconclusive = results.filter((r) => r.ok === null).length;
console.log('\n' + '─'.repeat(60));
console.log(` RESULT: ${pass} passed, ${fail} failed, ${inconclusive} inconclusive`);
console.log(` Shots : ${SHOTS_DIR}/traffic-*.png`);
console.log('─'.repeat(60) + '\n');
process.exit(exitCode || (fail > 0 ? 1 : 0));
}
main().catch((e) => { console.error(e); process.exit(3); });