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

347 lines
18 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
* Add --fixtures for synthetic road/flow tiles and a flat terrain surface without a key;
* that mode does not qualify geographic terrain, live source access or quota accounting.
* Exits non-zero on any FAIL. Does not commit anything.
*/
import puppeteer from 'puppeteer';
import { captureQaScreenshot } from './qa-chrome.mjs';
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 appUrl = new URL(getOpt('--url', 'http://localhost:4410'));
appUrl.searchParams.set('welcome', '0');
const APP_URL = appUrl.href;
const HEADFUL = argv.includes('--headful');
const FIXTURES = argv.includes('--fixtures');
const { installTrafficRoutes } = await import('./traffic-fixtures.mjs');
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);
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 } = {}) {
// The app deliberately stops rendering hidden pages. Programmatic camera
// changes do not activate a headful window the way a real UI click does.
await page.bringToFront();
await page.waitForFunction(() => document.visibilityState === 'visible');
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 beforeUpdate = mod.getStats().lastUpdate;
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++) {
if (document.visibilityState !== 'visible') throw new Error('Traffic QA page became hidden; rendering is intentionally paused');
await new Promise((r) => setTimeout(r, 1000));
s = mod.getStats();
// Retained roads from the previous city can satisfy count/loading before
// the move debounce starts its request. Require the destination render.
if (s.lastUpdate !== beforeUpdate && 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);
// 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(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
process.exitCode = 2;
return;
}
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
if (FIXTURES) console.log('Source mode: synthetic road/flow tiles and a flat terrain surface; live quota is not qualified.');
if (!statusBefore?.hasKey) {
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
process.exitCode = 2;
return;
}
fs.mkdirSync(SHOTS_DIR, { recursive: true });
const browser = await puppeteer.launch({
headless: HEADFUL ? false : 'new',
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
args: [
'--no-sandbox', '--disable-setuid-sandbox',
...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []),
'--disable-backgrounding-occluded-windows',
'--disable-dev-shm-usage',
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
'--window-size=1440,900',
],
});
let exitCode = 0;
try {
let page = await browser.newPage();
// Keep this visible-page regression deterministic even if the OS starts
// Chrome minimized. Use browser focus emulation, not patched DOM getters.
const focusSession = await page.createCDPSession();
await focusSession.send('Emulation.setFocusEmulationEnabled', { enabled: true });
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 = [];
const pageErrors = [];
if (FIXTURES) await installTrafficRoutes(page, { origin: appUrl.origin, fixtures: true, onError: error => pageErrors.push(error.message) });
page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); });
page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); });
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(5000);
await page.keyboard.press('Escape');
// ── (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 captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }, { headful: HEADFUL });
}
// ── (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 captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') }, { headful: HEADFUL });
} 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;
const oblique = await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 });
{
const bounds = oblique?.roadBounds;
let ok = false; let detail = 'no road bounds published';
if (bounds) {
const cLat = (bounds.south + bounds.north) / 2;
const cLon = (bounds.west + bounds.east) / 2;
const dKm = Math.hypot((cLat - 30.245) * 111, (cLon + 97.72) * 111 * Math.cos(30.27 * Math.PI / 180));
ok = dKm > 1 && dKm <= 13;
detail = `road box center is ${dKm.toFixed(1)} km from camera (want 1–13 km)`;
}
record('C4: oblique fetch box centers on the look-at point', ok, detail);
if (!ok) exitCode = 1;
await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') }, { headful: HEADFUL });
}
// ── (iii) budget honesty ─────────────────────────────────────────────────
console.log('\n(iii) Budget — dailyCount grew by ≤ requests this run...');
{
const statusAfter = FIXTURES ? statusBefore : await fetch(`${appUrl.origin}/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', FIXTURES ? null : ok,
FIXTURES ? 'Synthetic source mode; live quota was not exercised' : `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...');
// Keyless is an independent fresh-session contract. Close the keyed page
// so its workers, terrain work and intercepted cache cannot leak into it.
await page.close();
page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
const keylessFocus = await page.createCDPSession();
await keylessFocus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); });
page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); });
const keylessFlowReqs = [];
await installTrafficRoutes(page, {
origin: appUrl.origin, fixtures: FIXTURES, keyless: true,
onFlow: url => keylessFlowReqs.push(url),
onError: error => pageErrors.push(error.message),
});
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForFunction(
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
{ timeout: 60000 },
);
await sleep(5000);
await page.keyboard.press('Escape');
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 captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }, { headful: HEADFUL });
}
record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; '));
} 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); });