mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Port the layer probes in twenty-six browser scripts to engine primitive lists, drawn rows and layer diagnostics. Preserve Cesium as the reference and keep the supported Navara paths on the same observations. This changes QA observation only, with no application behaviour change. Review the interrupted infrastructure group and retain its readiness, drape, selection, label and floor checks. Match both installation outline buffers. Frame each CCTV city before reading its visible coverage. Read Radio colours and selected identities from actual mover writes. Retain transit's GPU upload budgets using engine-owned line primitives rather than substituting rebuild counts. Keep native inspection where a check specifically measures GPU uploads, loaded meshes or ground readiness. The preceding combined B/C commit owns all shared manifests, docs, locale catalogs and the coupling baseline; this commit changes only its own twenty-six QA scripts. Coupling remains unchanged at 344 Cesium renderer lines, 1294 Navara renderer lines, 9 Cesium import files, 1026 viewer lines, 2 Navara kind checks, 0 engine sniff calls, 9 extension reads, 1 ground pass, 3 SDK loads, 4 screen-handler files and 42 engine-name lines. The full unit suite and the boundary, format and coupling checks pass. Browser validation remains to be run. Browser checks to run GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4291 node scripts/qa-alpr-journey.mjs http://localhost:4291 node scripts/qa-alpr-journey.mjs 'http://localhost:4291/?engine=navara' node scripts/qa-cables-overlay.mjs --url http://localhost:4291 node scripts/qa-cctv-v2.mjs --url http://localhost:4291 --fixture --terrain-globe node scripts/qa-context-arrivals.mjs http://localhost:4291 node scripts/qa-directions.mjs --url http://localhost:4291 QA_BASE_URL=http://localhost:4291 node scripts/qa-draw-tool.mjs QA_BASE_URL=http://localhost:4291 node scripts/qa-floor-hold.mjs node scripts/qa-ghostmaps.mjs --url http://localhost:4291 node scripts/qa-gnss.mjs --url http://localhost:4291 node scripts/qa-height-datum.mjs --url http://localhost:4291 node scripts/qa-infra-lod.mjs --url http://localhost:4291 node scripts/qa-installation-navigation.mjs http://localhost:4291 node scripts/qa-military-names.mjs http://localhost:4291 --no-video node scripts/qa-overpass-offload.mjs --url http://localhost:4291 node scripts/qa-radio.mjs --url http://localhost:4291 node scripts/qa-traffic-motion.mjs http://localhost:4291 --mode=osm QA_BASE_URL=http://localhost:4291 node scripts/qa-transit-recovery.mjs QA_BASE_URL=http://localhost:4291 node scripts/qa-transit.mjs node scripts/qa-weather-teardown.mjs --url http://localhost:4291 --headless for engine in cesium navara; do node scripts/qa-enrich-ambient.mjs --url http://localhost:4291 --engine=$engine QA_BASE_URL=http://localhost:4291 node scripts/qa-floor-verify.mjs --engine=$engine node scripts/qa-heading-b3.mjs --url http://localhost:4291 --engine=$engine node scripts/qa-overlay-baseline.mjs --url http://localhost:4291 --engine=$engine node scripts/qa-sprites-b5.mjs --url http://localhost:4291 --engine=$engine node scripts/qa-vessel-datum.mjs --url 'http://localhost:4291/?qaFeed=vessels:fixture' --engine $engine done The military-names driver exercises installation-polish; the transit driver exercises transit-scenes. Use only the keyless server. Cases that require photoreal tiles or unavailable providers remain unverified.
405 lines
14 KiB
JavaScript
405 lines
14 KiB
JavaScript
/**
|
|
* Weather teardown and cross-layer regression probe.
|
|
*
|
|
* Against a running dev server: records a resource baseline, turns every
|
|
* weather layer on and exercises it (history playback, a wind reading, a
|
|
* storm selection), turns them all off, and asserts the scene is back to the
|
|
* baseline (imagery layers on the globe and the tileset, primitives, ground
|
|
* primitives, data sources, entities, the globe contract's mover sets,
|
|
* lines, ground sets and imagery layers, camera/scene listeners, the weather
|
|
* panel, the wind reading marker). Then it enables a set of non-weather
|
|
* layers one at a time and samples the render rate, long tasks and console
|
|
* errors for each, and repeats the same non-weather sequence in a control
|
|
* page that never had weather on. Writes <out>/<label>/results.json.
|
|
*
|
|
* node scripts/qa-weather-teardown.mjs --url http://localhost:4173 --label google
|
|
*/
|
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import puppeteer from 'puppeteer';
|
|
|
|
const argv = process.argv.slice(2);
|
|
const arg = (name, fallback) => {
|
|
const i = argv.indexOf(name);
|
|
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')
|
|
? argv[i + 1]
|
|
: fallback;
|
|
};
|
|
const flag = (name) => argv.includes(name);
|
|
const rawUrl = arg('--url', 'http://localhost:4173');
|
|
const url = rawUrl.includes('welcome=')
|
|
? rawUrl
|
|
: `${rawUrl}${rawUrl.includes('?') ? '&' : '?'}welcome=0`;
|
|
const label = arg('--label', 'teardown');
|
|
const outDir = path.resolve(arg('--out', 'qa-shots/weather-teardown'), label);
|
|
mkdirSync(outDir, { recursive: true });
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const WEATHER = [
|
|
'wind',
|
|
'weather-radar',
|
|
'weather-satellite',
|
|
'weather-lightning',
|
|
'weather-cyclones',
|
|
];
|
|
|
|
const browser = await puppeteer.launch({
|
|
headless: flag('--headless') ? 'new' : false,
|
|
executablePath:
|
|
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
|
['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((p) =>
|
|
existsSync(p),
|
|
),
|
|
protocolTimeout: 300_000,
|
|
defaultViewport: null,
|
|
args: [
|
|
'--no-sandbox',
|
|
'--window-size=1400,1000',
|
|
'--disable-backgrounding-occluded-windows',
|
|
'--disable-renderer-backgrounding',
|
|
'--disable-background-timer-throttling',
|
|
],
|
|
});
|
|
|
|
const boot = async () => {
|
|
const page = await browser.newPage();
|
|
// This probe measures active rendering; an OS-minimized window must not
|
|
// silently exercise the application's intentional hidden-page pause.
|
|
const focus = await page.createCDPSession();
|
|
await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
|
await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 1 });
|
|
const errors = [];
|
|
page.on('pageerror', (e) =>
|
|
errors.push(`pageerror: ${e.message}`.slice(0, 200)),
|
|
);
|
|
page.on('console', (m) => {
|
|
if (m.type() === 'error') errors.push(`console: ${m.text()}`.slice(0, 200));
|
|
});
|
|
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, {
|
|
timeout: 120_000,
|
|
});
|
|
await sleep(10_000);
|
|
await page.evaluate(async () => {
|
|
const gev = window.__godsEyeView;
|
|
const dm = gev.dataManager;
|
|
for (const [id, e] of dm.layers)
|
|
if (e.enabled) await dm.setEnabled(id, false, { origin: 'user' });
|
|
const v = gev.viewer;
|
|
const d = Math.PI / 180;
|
|
v.camera.setView({
|
|
destination: v.scene.ellipsoid.cartographicToCartesian({
|
|
longitude: -95 * d,
|
|
latitude: 32 * d,
|
|
height: 4_500_000,
|
|
}),
|
|
orientation: { heading: 0, pitch: -90 * d, roll: 0 },
|
|
});
|
|
});
|
|
await sleep(3000);
|
|
return { page, errors };
|
|
};
|
|
|
|
const snapshot = (page) =>
|
|
page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const v = gev.viewer;
|
|
const s = v.scene;
|
|
const t = gev.tileset;
|
|
const dm = gev.dataManager;
|
|
const weather = {};
|
|
for (const id of [
|
|
'weather-radar',
|
|
'weather-satellite',
|
|
'weather-lightning',
|
|
]) {
|
|
const d = dm.layers.get(id)?.module?.getDiagnostics?.();
|
|
if (d)
|
|
weather[id] = {
|
|
imagery: d.imageryCount,
|
|
mosaics: d.cache?.mosaics ?? 0,
|
|
prefetching: !!d.cache?.prefetching,
|
|
};
|
|
}
|
|
const wind = dm.layers.get('wind')?.module?.getDiagnostics?.() || {};
|
|
// What layers draw on Cesium directly, excluding collections and the contract's own
|
|
// batches: the engine keeps one PrimitiveCollection per primitive kind
|
|
// (src/globe/cesium/) for the scene's lifetime, and its mover, line and
|
|
// ground batches (tagged gevMoverSet, gevLine, gevGroundSet) are counted
|
|
// below through the contract.
|
|
const contractOwned = (item) =>
|
|
item?.gevMoverSet !== undefined ||
|
|
item?.gevLine !== undefined ||
|
|
item?.gevGroundSet !== undefined;
|
|
const drawn = (collection) => {
|
|
let count = 0;
|
|
for (let i = 0; i < collection.length; i++) {
|
|
const item = collection.get(i);
|
|
if (contractOwned(item)) continue;
|
|
if (typeof item?.raiseToTop === 'function') count += drawn(item);
|
|
else count++;
|
|
}
|
|
return count;
|
|
};
|
|
const { engine } = gev;
|
|
const sum = (rows, key) => rows.reduce((n, row) => n + (row[key] || 0), 0);
|
|
const movers = engine.movers.list();
|
|
const lines = engine.lines.list();
|
|
const ground = engine.ground.list();
|
|
return {
|
|
globeImagery: v.imageryLayers.length,
|
|
tilesetImagery: t?.imageryLayers?.length ?? null,
|
|
primitives: drawn(s.primitives),
|
|
groundPrimitives: drawn(s.groundPrimitives),
|
|
// The globe contract's own drawing (cyclone points and fills draw
|
|
// through it; render/display.js).
|
|
// Drawn instances, not sets: a disabled layer may keep an emptied set
|
|
// for its next enable (the cyclone points do), which draws nothing.
|
|
contract: {
|
|
moverInstances: sum(movers, 'count'),
|
|
lines: lines.filter((line) => line.shown && line.points > 0).length,
|
|
groundItems: sum(
|
|
ground.filter((set) => set.shown),
|
|
'count',
|
|
),
|
|
imageryLayers: engine.imagery.list().filter((layer) => layer.shown)
|
|
.length,
|
|
},
|
|
dataSources: v.dataSources.length,
|
|
entities: v.entities.values.length,
|
|
listeners: {
|
|
postRender: s.postRender.numberOfListeners,
|
|
preRender: s.preRender.numberOfListeners,
|
|
postUpdate: s.postUpdate.numberOfListeners,
|
|
moveEnd: v.camera.moveEnd.numberOfListeners,
|
|
moveStart: v.camera.moveStart.numberOfListeners,
|
|
changed: v.camera.changed.numberOfListeners,
|
|
tick: v.clock.onTick.numberOfListeners,
|
|
},
|
|
dom: {
|
|
weatherPanelHidden:
|
|
document.getElementById('weather-panel')?.hidden ?? null,
|
|
weatherCards: document.querySelectorAll('.weather-card').length,
|
|
windReading: !!document.querySelector(
|
|
'.gev-wind-reading:not([hidden])',
|
|
),
|
|
},
|
|
weather,
|
|
windCells: wind.gpu?.pathCount ?? wind.paths ?? 0,
|
|
enabled: [...dm.layers].filter(([, e]) => e.enabled).map(([id]) => id),
|
|
};
|
|
});
|
|
|
|
const sample = async (page, seconds) => {
|
|
await page.evaluate(() => {
|
|
window.__qaRenders = 0;
|
|
window.__qaLong = { count: 0, ms: 0 };
|
|
const s = window.__godsEyeView.viewer.scene;
|
|
window.__qaOff = s.postRender.addEventListener(() => window.__qaRenders++);
|
|
window.__qaObs = new PerformanceObserver((list) => {
|
|
for (const e of list.getEntries()) {
|
|
window.__qaLong.count++;
|
|
window.__qaLong.ms += e.duration;
|
|
}
|
|
});
|
|
window.__qaObs.observe({ entryTypes: ['longtask'] });
|
|
window.__qaRaf = () => {
|
|
s.requestRender();
|
|
window.__qaRafId = requestAnimationFrame(window.__qaRaf);
|
|
};
|
|
window.__qaRaf();
|
|
});
|
|
await sleep(seconds * 1000);
|
|
return page.evaluate((seconds) => {
|
|
cancelAnimationFrame(window.__qaRafId);
|
|
window.__qaOff();
|
|
window.__qaObs.disconnect();
|
|
return {
|
|
fps: +(window.__qaRenders / seconds).toFixed(1),
|
|
longTasks: window.__qaLong.count,
|
|
longMs: Math.round(window.__qaLong.ms),
|
|
};
|
|
}, seconds);
|
|
};
|
|
|
|
const setLayer = (page, id, on) =>
|
|
page.evaluate(
|
|
async ({ id, on }) => {
|
|
const dm = window.__godsEyeView.dataManager;
|
|
const e = dm.layers.get(id);
|
|
if (e && Boolean(e.enabled) !== on)
|
|
await dm.setEnabled(id, on, { origin: 'user' });
|
|
return !!e;
|
|
},
|
|
{ id, on },
|
|
);
|
|
|
|
const diff = (a, b) => {
|
|
const out = {};
|
|
const walk = (x, y, prefix) => {
|
|
for (const key of Object.keys(x)) {
|
|
const p = prefix ? `${prefix}.${key}` : key;
|
|
if (x[key] && typeof x[key] === 'object' && !Array.isArray(x[key]))
|
|
walk(x[key], y?.[key] || {}, p);
|
|
else if (JSON.stringify(x[key]) !== JSON.stringify(y?.[key]))
|
|
out[p] = [x[key], y?.[key]];
|
|
}
|
|
};
|
|
walk(a, b, '');
|
|
return out;
|
|
};
|
|
|
|
const results = { url: rawUrl, label, startedAt: new Date().toISOString() };
|
|
try {
|
|
// ── Run A: weather on → exercised → off → baseline check → other layers ──
|
|
const a = await boot();
|
|
const others = await a.page.evaluate(() =>
|
|
[...window.__godsEyeView.dataManager.layers.keys()].filter((id) =>
|
|
/^flights$|^satellites$|vessel|firms|earthquake|cables|^military$/.test(
|
|
id,
|
|
),
|
|
),
|
|
);
|
|
console.log('other layers under test:', others.join(', '));
|
|
const baseline = await snapshot(a.page);
|
|
console.log('baseline', JSON.stringify(baseline));
|
|
for (const id of WEATHER) await setLayer(a.page, id, true);
|
|
const weatherReady = await a.page
|
|
.waitForFunction(
|
|
() =>
|
|
['weather-radar', 'weather-satellite', 'weather-lightning'].every(
|
|
(id) => {
|
|
const d = window.__godsEyeView.dataManager.layers
|
|
.get(id)
|
|
?.module?.getDiagnostics?.();
|
|
return d && !d.loading && d.time;
|
|
},
|
|
),
|
|
{ timeout: 120_000 },
|
|
)
|
|
.then(
|
|
() => true,
|
|
() => false,
|
|
);
|
|
results.weatherReady = weatherReady;
|
|
if (!weatherReady)
|
|
console.log(' !! weather not ready — feature coverage incomplete');
|
|
await sleep(2000);
|
|
// exercise: history playback, wind reading, storm selection, product switch
|
|
await a.page.evaluate(() => {
|
|
const dm = window.__godsEyeView.dataManager;
|
|
dm.setLayerParams('weather-radar', { play: true }, { origin: 'user' });
|
|
dm.setLayerParams('wind', { overlay: 'speed' }, { origin: 'user' });
|
|
dm.setLayerParams('wind', { inspect: true }, { origin: 'user' });
|
|
dm.setLayerParams(
|
|
'weather-satellite',
|
|
{ product: 'clouds' },
|
|
{ origin: 'user' },
|
|
);
|
|
});
|
|
await sleep(8000);
|
|
await a.page.evaluate(() => {
|
|
const dm = window.__godsEyeView.dataManager;
|
|
const storms =
|
|
dm.layers.get('weather-cyclones')?.module?.getRowControls?.().list
|
|
?.items || [];
|
|
if (storms[1])
|
|
dm.setLayerParams('weather-cyclones', storms[1].params, {
|
|
origin: 'user',
|
|
});
|
|
dm.setLayerParams('weather-radar', { play: true }, { origin: 'user' });
|
|
});
|
|
await sleep(4000);
|
|
const weatherOn = await snapshot(a.page);
|
|
console.log('weather on', JSON.stringify(weatherOn));
|
|
results.weatherOnSample = await sample(a.page, 5);
|
|
for (const id of WEATHER) await setLayer(a.page, id, false);
|
|
await sleep(4000);
|
|
const afterOff = await snapshot(a.page);
|
|
const leak = diff(
|
|
{ ...baseline, enabled: undefined, weather: undefined },
|
|
{ ...afterOff, enabled: undefined, weather: undefined },
|
|
);
|
|
results.baseline = baseline;
|
|
results.afterOff = afterOff;
|
|
results.leak = leak;
|
|
console.log('after off', JSON.stringify(afterOff));
|
|
console.log(
|
|
Object.keys(leak).length
|
|
? `!! DIFFERENCES after teardown: ${JSON.stringify(leak)}`
|
|
: 'teardown: scene back to baseline',
|
|
);
|
|
results.afterWeather = [];
|
|
for (const id of others) {
|
|
const ok = await setLayer(a.page, id, true);
|
|
await sleep(9000);
|
|
const m = await sample(a.page, 5);
|
|
const snap = await snapshot(a.page);
|
|
results.afterWeather.push({
|
|
id,
|
|
ok,
|
|
...m,
|
|
primitives: snap.primitives,
|
|
dataSources: snap.dataSources,
|
|
});
|
|
console.log(
|
|
`after weather + ${id}: fps=${m.fps} long=${m.longTasks}/${m.longMs}ms`,
|
|
);
|
|
}
|
|
results.errorsA = a.errors;
|
|
await a.page.screenshot({ path: path.join(outDir, 'a-others-on.png') });
|
|
await a.page.close();
|
|
|
|
// ── Run B: control (same others, never had weather) ──
|
|
const b = await boot();
|
|
results.control = [];
|
|
for (const id of others) {
|
|
const ok = await setLayer(b.page, id, true);
|
|
await sleep(9000);
|
|
const m = await sample(b.page, 5);
|
|
const snap = await snapshot(b.page);
|
|
results.control.push({
|
|
id,
|
|
ok,
|
|
...m,
|
|
primitives: snap.primitives,
|
|
dataSources: snap.dataSources,
|
|
});
|
|
console.log(
|
|
`control + ${id}: fps=${m.fps} long=${m.longTasks}/${m.longMs}ms`,
|
|
);
|
|
}
|
|
results.errorsB = b.errors;
|
|
await b.page.screenshot({ path: path.join(outDir, 'b-control.png') });
|
|
await b.page.close();
|
|
|
|
console.log(
|
|
'\nerrors (weather run):',
|
|
results.errorsA.length ? results.errorsA : 'none',
|
|
);
|
|
console.log(
|
|
'errors (control run):',
|
|
results.errorsB.length ? results.errorsB : 'none',
|
|
);
|
|
// Resource ownership is an acceptance check; provider availability remains
|
|
// separately reported diagnostic evidence in both runs.
|
|
if (
|
|
!weatherReady ||
|
|
Object.keys(leak).length ||
|
|
[...a.errors, ...b.errors].some((e) => e.startsWith('pageerror:'))
|
|
) {
|
|
process.exitCode = 1;
|
|
}
|
|
} catch (error) {
|
|
results.failure = { name: error.name, message: error.message };
|
|
throw error;
|
|
} finally {
|
|
// Preserve completed resource evidence even if a later provider or browser
|
|
// phase aborts. A partial run still exits nonzero.
|
|
writeFileSync(
|
|
path.join(outDir, 'results.json'),
|
|
JSON.stringify(results, null, 2),
|
|
);
|
|
console.log(`wrote ${path.join(outDir, 'results.json')}`);
|
|
await browser.close();
|
|
}
|