Files
gods-eye-view/scripts/qa-weather-teardown.mjs
T

366 lines
12 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, 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?.() || {};
return {
globeImagery: v.imageryLayers.length,
tilesetImagery: t?.imageryLayers?.length ?? null,
primitives: s.primitives.length,
groundPrimitives: s.groundPrimitives.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();
}