260 lines
12 KiB
JavaScript
260 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* scripts/qa-floor-hold.mjs — a grounded contact holds its floor through a
|
|
* terrain-proxy outage (owner incident, 2026-08-21).
|
|
*
|
|
* Reproduces the incident end to end against the RENDERED mesh:
|
|
*
|
|
* phase A the terrain proxy is healthy. A GROUNDED contact reporting no
|
|
* altitude at all (the AXEL21 profile — a few knots, alt unknown)
|
|
* settles onto its resolved floor.
|
|
* phase B every `/api/terrain/heights` request answers 504 — the same
|
|
* "[terrain-heights-proxy] refresh incomplete" the server logged —
|
|
* and the contact taxis into cells that were never warmed.
|
|
*
|
|
* The contact must stay ON the mesh through phase B. Without the hold it falls
|
|
* to the geoid, which at an inland field is ~150 m of burial.
|
|
*
|
|
* node scripts/qa-floor-hold.mjs # real GPU (ANGLE/Metal)
|
|
* node scripts/qa-floor-hold.mjs --headful # watch it
|
|
* QA_BASE_URL=http://localhost:4257 node scripts/qa-floor-hold.mjs
|
|
*
|
|
* GPU BACKEND MATTERS. `scene.sampleHeight` answers on both backends, but
|
|
* software rasterization streams a coarser LOD. Measured directly, one probe
|
|
* point on the Austin apron read ~20.9 m under swiftshader against ~168.4 m
|
|
* under Metal — a backend comparison at one point, not to be confused with the
|
|
* same-backend run-to-run spread (122.1 m vs 20.6 m on a cell centre) that
|
|
* retired the hold chain's unvalidated tier. The
|
|
* default is the real GPU. Under `--angle=swiftshader` the rendered-mesh oracle
|
|
* is reported as unavailable rather than trusted, and the run falls back to the
|
|
* bare-earth DEM (fetched server-side, so the simulated outage cannot reach
|
|
* it) — a weaker but still decisive check, since the geoid sits ~150 m below it.
|
|
*/
|
|
import puppeteer from 'puppeteer';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
const APP_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
|
const argv = Object.fromEntries(process.argv.slice(2)
|
|
.map((a) => a.replace(/^--/, '').split('=')).map(([k, v]) => [k, v ?? true]));
|
|
const ANGLE = String(argv.angle || 'metal');
|
|
const HEADFUL = !!argv.headful;
|
|
// Austin airport apron by default — the site qa-floor-verify pins, where the
|
|
// mesh sits ~150 m above the geoid and burial is unmistakable.
|
|
const SITE = {
|
|
lat: Number.isFinite(Number(argv.lat)) ? Number(argv.lat) : 30.1975,
|
|
lon: Number.isFinite(Number(argv.lon)) ? Number(argv.lon) : -97.6660,
|
|
};
|
|
const ICAO = 'aa0f01';
|
|
const OUT_DIR = 'qa-shots/floorhold';
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
const results = [];
|
|
function record(name, ok, detail) {
|
|
results.push({ name, ok, detail });
|
|
console.log(` [${ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'}] ${name}${detail ? ` — ${detail}` : ''}`);
|
|
}
|
|
|
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
|
const chrome = (() => { try { return puppeteer.executablePath(); } catch { return undefined; } })();
|
|
const browser = await puppeteer.launch({
|
|
headless: HEADFUL ? false : 'new',
|
|
executablePath: chrome,
|
|
args: ['--use-gl=angle', `--use-angle=${ANGLE}`, '--enable-webgl',
|
|
'--ignore-gpu-blocklist', '--no-sandbox'],
|
|
});
|
|
const page = await browser.newPage();
|
|
await page.setViewport({ width: 1400, height: 900 });
|
|
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
|
|
|
|
await page.evaluateOnNewDocument((site, icao) => {
|
|
window.__CONTACT = { icao, lat: site.lat, lon: site.lon };
|
|
window.__TERRAIN = { ok: 0, failed: 0 };
|
|
window.__TERRAIN_FAIL = false;
|
|
const realFetch = window.fetch.bind(window);
|
|
const json = (o) => new Response(JSON.stringify(o), {
|
|
status: 200, headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
window.fetch = (input, init) => {
|
|
const raw = typeof input === 'string' || input instanceof URL ? String(input) : input?.url;
|
|
let url; try { url = new URL(raw, window.location.href); } catch { return realFetch(input, init); }
|
|
const app = url.origin === window.location.origin;
|
|
if (app && url.pathname === '/api/terrain/heights') {
|
|
if (window.__TERRAIN_FAIL) {
|
|
window.__TERRAIN.failed += 1;
|
|
return Promise.resolve(new Response('upstream timeout', { status: 504 }));
|
|
}
|
|
window.__TERRAIN.ok += 1;
|
|
return realFetch(input, init);
|
|
}
|
|
if (app && url.pathname === '/api/opensky') {
|
|
const c = window.__CONTACT;
|
|
const t = Math.floor(Date.now() / 1000);
|
|
// baro_altitude (7) and geo_altitude (13) both null, on_ground true.
|
|
return Promise.resolve(json({
|
|
time: t,
|
|
states: [[c.icao, 'AXEL21', 'Synthetica', t, t, c.lon, c.lat,
|
|
null, true, 1.5, 90, 0, null, null, null, false, 0]],
|
|
}));
|
|
}
|
|
if (app && url.pathname === '/api/opensky-track') return Promise.resolve(json({ path: [] }));
|
|
if (app && /^\/api\/adsbdb\//.test(url.pathname)) return Promise.resolve(json({}));
|
|
return realFetch(input, init);
|
|
};
|
|
}, SITE, ICAO);
|
|
|
|
console.log(`\nFloor hold through a terrain outage\n App : ${APP_URL}\n GPU : ANGLE/${ANGLE}\n Site : ${SITE.lat}, ${SITE.lon}\n`);
|
|
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 120000 });
|
|
await page.waitForFunction(() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
|
{ timeout: 150000 });
|
|
await sleep(12000); // let the boot fly-to settle before pinning
|
|
|
|
await page.evaluate((site) => { window.__SITE = site; }, SITE);
|
|
const pin = () => page.evaluate(() => {
|
|
const v = window.__godsEyeView.viewer;
|
|
const C = v.camera.positionCartographic.constructor;
|
|
v.camera.cancelFlight();
|
|
v.camera.setView({
|
|
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
|
|
C.fromDegrees(window.__SITE.lon, window.__SITE.lat - 0.008, 900),
|
|
),
|
|
orientation: { heading: 0, pitch: -0.45, roll: 0 },
|
|
});
|
|
v.camera.moveEnd.raiseEvent();
|
|
});
|
|
await pin();
|
|
await page.evaluate(async () => { await window.__godsEyeView.dataManager.toggle('flights'); });
|
|
|
|
/** Reads the contact's rendered height and the rendered mesh beneath it. */
|
|
const measure = () => page.evaluate(async (icao) => {
|
|
const v = window.__godsEyeView.viewer;
|
|
const C = v.camera.positionCartographic.constructor;
|
|
const sprites = []; const models = []; let bb = null;
|
|
const walk = (coll) => {
|
|
for (let i = 0; i < coll.length; i++) {
|
|
let p; try { p = coll.get(i); } catch { continue; }
|
|
if (!p) continue;
|
|
if (typeof p.length === 'number' && typeof p.get === 'function') { walk(p); continue; }
|
|
if (p.image !== undefined && p.alignedAxis !== undefined) {
|
|
sprites.push(p); if (p.id === icao) bb = p;
|
|
} else if (p.boundingSphere && p.modelMatrix) models.push(p);
|
|
}
|
|
};
|
|
try { walk(v.scene.primitives); } catch { /* mid-teardown */ }
|
|
if (!bb?.position) return { error: 'contact billboard not found' };
|
|
const carto = C.fromCartesian(bb.position);
|
|
const lat = carto.latitude * 180 / Math.PI;
|
|
const lon = carto.longitude * 180 / Math.PI;
|
|
// `sampleHeight` runs an offscreen PICK pass, so it needs the scene to be
|
|
// rendering. This app parks its render loop when idle (the wave-2 governor),
|
|
// and a probe fired against a parked scene — or one whose tiles are still
|
|
// streaming after a camera move — reads `undefined`. Pump a frame through the
|
|
// app's own governor hook before each attempt, exactly as the fly_route
|
|
// harness does, and accept the coarse cell centre as a fallback point.
|
|
const pump = () => new Promise((resolve) => {
|
|
try { window.__godsEyeView.requestRender?.(); } catch { /* no governor */ }
|
|
try { v.scene.requestRender(); } catch { /* explicit-render off */ }
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
|
});
|
|
let mesh = null;
|
|
for (let attempt = 0; attempt < 10 && mesh == null; attempt += 1) {
|
|
await pump();
|
|
for (const [pLat, pLon] of [[lat, lon], [+lat.toFixed(3), +lon.toFixed(3)]]) {
|
|
for (const exclude of [[...sprites, ...models], []]) {
|
|
try {
|
|
const h = v.scene.sampleHeight(C.fromDegrees(pLon, pLat, 0), exclude);
|
|
if (Number.isFinite(h)) { mesh = h; break; }
|
|
} catch { /* mid-stream */ }
|
|
}
|
|
if (mesh != null) break;
|
|
}
|
|
if (mesh == null) await new Promise((r) => setTimeout(r, 1200));
|
|
}
|
|
return {
|
|
spriteH: carto.height, meshH: mesh, shown: bb.show === true,
|
|
lat: +lat.toFixed(5), lon: +lon.toFixed(5), terrain: { ...window.__TERRAIN },
|
|
};
|
|
}, ICAO);
|
|
|
|
/** Bare-earth ground truth, fetched SERVER-SIDE so the page's simulated outage
|
|
* cannot touch it. The photoreal mesh sits ABOVE bare earth, so this is the
|
|
* weaker of the two oracles — but the geoid is ~150 m below it, so burial is
|
|
* still unmistakable. */
|
|
const truth = new Map();
|
|
async function demTruth(lat, lon) {
|
|
const key = `${lat.toFixed(4)},${lon.toFixed(4)}`;
|
|
if (truth.has(key)) return truth.get(key);
|
|
let h = null;
|
|
try {
|
|
const res = await fetch(`${APP_URL}/api/terrain/heights?points=${lon.toFixed(5)},${lat.toFixed(5)}`);
|
|
if (res.ok) {
|
|
const v = Number((await res.json())?.results?.[0]?.ellipsoid);
|
|
if (Number.isFinite(v)) h = v;
|
|
}
|
|
} catch { /* the oracle is best-effort too */ }
|
|
truth.set(key, h);
|
|
return h;
|
|
}
|
|
|
|
const samples = [];
|
|
async function sample(phase, note) {
|
|
const m = await measure();
|
|
if (m.error) { console.log(` ${phase} ${note}: ${m.error}`); return null; }
|
|
const dem = await demTruth(m.lat, m.lon);
|
|
const row = {
|
|
phase, note, ...m, demH: dem,
|
|
meshClearM: m.meshH != null ? +(m.spriteH - m.meshH).toFixed(2) : null,
|
|
demClearM: dem != null ? +(m.spriteH - dem).toFixed(2) : null,
|
|
};
|
|
samples.push(row);
|
|
console.log(` ${phase} ${note}: @${m.lat},${m.lon} sprite=${m.spriteH.toFixed(1)}`
|
|
+ ` mesh=${m.meshH?.toFixed?.(1) ?? 'n/a'} (${row.meshClearM})`
|
|
+ ` DEM=${dem?.toFixed?.(1) ?? 'n/a'} (${row.demClearM})`
|
|
+ ` terrain=${JSON.stringify(m.terrain)}`);
|
|
return row;
|
|
}
|
|
|
|
console.log('\nphase A — terrain healthy, warming cells (3 polls)');
|
|
for (let i = 0; i < 3; i++) { await sleep(32000); await pin(); }
|
|
await sample('A', 'warm');
|
|
fs.writeFileSync(path.join(OUT_DIR, `hold-A-warm-${ANGLE}.png`), await page.screenshot({ type: 'png' }));
|
|
|
|
console.log('\nphase B — every terrain request answers 504 from here');
|
|
await page.evaluate(() => { window.__TERRAIN_FAIL = true; });
|
|
for (let i = 1; i <= 4; i++) {
|
|
await page.evaluate(() => { window.__CONTACT.lon += 0.0016; }); // ~150 m east per poll
|
|
await sleep(32000);
|
|
await pin();
|
|
await sample('B', `poll+${i}`);
|
|
}
|
|
fs.writeFileSync(path.join(OUT_DIR, `hold-B-outage-${ANGLE}.png`), await page.screenshot({ type: 'png' }));
|
|
await browser.close();
|
|
|
|
console.log('');
|
|
const outage = samples.filter((s) => s.phase === 'B');
|
|
const meshRows = outage.filter((s) => s.meshClearM != null);
|
|
const demRows = outage.filter((s) => s.demClearM != null);
|
|
record('the terrain proxy really went down mid-run',
|
|
outage.length > 0 && outage[outage.length - 1].terrain.failed > 0,
|
|
`${outage[outage.length - 1]?.terrain?.failed ?? 0} requests answered 504`);
|
|
record('the contact stayed visible throughout', outage.every((s) => s.shown === true));
|
|
if (meshRows.length) {
|
|
const worst = Math.min(...meshRows.map((s) => s.meshClearM));
|
|
record('the contact never rendered below the RENDERED MESH', worst >= -0.5,
|
|
`worst clearance ${worst} m across ${meshRows.length} samples`);
|
|
} else {
|
|
record('rendered-mesh oracle available', false,
|
|
`sampleHeight never answered on ANGLE/${ANGLE} — falling back to the bare-earth DEM`);
|
|
}
|
|
if (demRows.length) {
|
|
const worst = Math.min(...demRows.map((s) => s.demClearM));
|
|
record('the contact never fell to the geoid (bare-earth DEM oracle)', worst >= -2,
|
|
`worst clearance ${worst} m above bare earth; the geoid here is ~150 m below it`);
|
|
}
|
|
fs.writeFileSync(path.join(OUT_DIR, `hold-${ANGLE}.json`), JSON.stringify(samples, null, 2));
|
|
console.log(`\n evidence: ${OUT_DIR}/hold-*-${ANGLE}.png, ${OUT_DIR}/hold-${ANGLE}.json`);
|
|
|
|
const failed = results.filter((r) => r.ok === false).length;
|
|
console.log(`\n RESULT: ${results.length - failed} passed, ${failed} failed\n`);
|
|
process.exit(failed ? 1 : 0);
|