317 lines
15 KiB
JavaScript
317 lines
15 KiB
JavaScript
#!/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 1–13 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); });
|