Files
gods-eye-view/scripts/qa-strict-soak.mjs
T
German Khokhlov 7abf74518f Cut duplicated and dead code across the engines, the layers and the QA scripts
- src/geo drops the ported debug argument checks and the tests that
  bitExact already covers; package boundaries accept a directory entry,
  so src/geo/ is listed once per group.
- Cesium no longer carries movers and lines nobody draws with: they are
  optional engine capabilities. The fake engine and the Cesium camera use
  the shared cameraMath; unused contract tables, clock.ticked, the
  'finest' surface detail and Navara click statistics are gone.
- ALPR keeps one overlay core and one presentation model for both
  engines, bikeshare one station bookkeeping, the Navara ground effects
  one definition helper; image atlases, view centres, mover banks and
  colours are shared.
- The legacy switch route, the clock offset and rate controls and other
  unused exports are removed.
- The acceptance runs the Navara draw, geo import and scope checks;
  scenarios use the harness switch and page helpers; the main chunk
  check keeps the size budget only and runs once in CI.
2026-11-05 13:32:39 +04:00

439 lines
16 KiB
JavaScript

// Soak and switch timing under a mixed workload: radio playing (750
// stations), earthquakes, 10 000 flights, 200 military aircraft, wind with
// its GPU particles, and the traffic simulation, all from fixtures.
//
// Each run starts a fresh browser on one engine and switches with real
// input on the picker. Run r starts on Navara when r is odd and on Cesium
// when it is even, so the first switch of each run is a cold one in either
// direction.
//
// "First frame" of a switch: the first postRender of the incoming engine
// whose picture, read back from its canvas in that callback, is not blank,
// then the next animation frame (presented). Timed in the page from the
// picker's change event, so no CDP round trip is included.
//
// Checked on every switch: the same session (manager and every layer
// model), the radio still playing the same station with one play() call.
// With --assert (the long run), the flights' fix history fills first (see
// below), then after the warm-up (switch 20): live WebGL
// objects per engine context grow by at most 0.1 per switch (least-squares
// slope over the checkpoints) and by at most 64 overall; no new WebGL
// context; the JS objects grow by at most 10 KiB per switch (heap snapshots
// at the warm-up and the end, V8's compiled code and native memory left out
// and reported apart, with the used heap's slope); no new document; the
// webglcontextlost/restored listener counts on both engine canvases stay as
// they were (each canvas keeps its webglcontextlost listener).
//
// node scripts/qa-strict-soak.mjs http://127.0.0.1:4208 --runs=6 --swaps=50
// node scripts/qa-strict-soak.mjs http://127.0.0.1:4208 --runs=1 --swaps=500 --assert
import assert from 'node:assert/strict';
import {
createStrict,
localPreview,
waitForEngine,
} from './qa-strict-harness.mjs';
import {
installFirstFrameProbe,
installSessionFixtures,
installWebGlCounters,
openApp,
quantile,
rememberEngine,
slope,
startSession,
} from './qa-strict-session.mjs';
import { installRadioFixture, stations } from './fixtures/w2/radio.mjs';
import { trafficFixtureResponse } from './traffic-fixtures.mjs';
import { POSITION_HISTORY_LIMIT } from '../src/layers/flights/recordPolicy.js';
import { heapSizesByType } from './qa/heapSnapshotSizes.mjs';
const base = localPreview(process.argv[2]);
const option = (name, fallback) => {
const arg = process.argv.find((a) => a.startsWith(`--${name}=`));
return arg ? Number(arg.slice(name.length + 3)) : fallback;
};
const runs = option('runs', 6);
const swaps = option('swaps', 50);
const strictMode = process.argv.includes('--assert');
assert.ok(
Number.isInteger(swaps) && swaps >= 4 && swaps % 2 === 0,
'even --swaps',
);
const WARM_UP = 20;
const CHECKPOINTS = [
2, 10, 20, 50, 100, 150, 200, 250, 300, 350, 400, 450, 500,
];
const HASH =
'v=2&lat=30.2670&lon=-97.7440&alt=3000&heading=0&pitch=-90&map=osm&l=';
const LAYERS = [
'earthquakes',
'flights',
'military',
'wind',
'traffic',
'radio',
];
const strict = createStrict({
origin: base.origin,
layers: [],
// Road tiles of the traffic simulation, from scripts/traffic-fixtures.mjs.
fixtures: ['https://tiles.openfreemap.org/*'],
});
/** Wind and traffic fixtures on their own CDP Fetch patterns. */
async function installWorkloadFixtures(page) {
const session = await page.createCDPSession();
const grid = (u) => {
const kind = u.searchParams.get('overlay');
const count = 36 * 19;
const values = new Float32Array(count * (kind ? 3 : 2));
values.fill(8, 0, count);
values.fill(6, count, count * 2);
if (kind) values.fill(40, count * 2);
return Buffer.from(values.buffer);
};
session.on('Fetch.requestPaused', async ({ requestId, request }) => {
const u = new URL(request.url);
let answer;
try {
if (u.pathname.startsWith('/api/wind/')) {
const model = u.searchParams.get('model');
answer = u.pathname.endsWith('/manifest')
? {
status: 200,
contentType: 'application/json',
body: JSON.stringify({
model,
overlay: 'none',
grid: { nx: 36, ny: 19, lo1: 0, la1: 90, dx: 10, dy: 10 },
gridUrl: '/api/wind/grid/' + model + '-qa.bin' + u.search,
cycle: {
validIso: '2026-11-02T12:00:00Z',
runIso: '2026-11-02T06:00:00Z',
},
}),
}
: {
status: 200,
contentType: 'application/octet-stream',
body: grid(u),
};
} else if (u.pathname === '/api/tomtom/status')
answer = {
status: 200,
contentType: 'application/json',
body: '{"hasKey":false}',
};
else answer = trafficFixtureResponse({ url: () => request.url });
await session.send('Fetch.fulfillRequest', {
requestId,
responseCode: answer.status,
responseHeaders: [
{ name: 'Content-Type', value: answer.contentType },
{ name: 'Access-Control-Allow-Origin', value: '*' },
],
body: Buffer.from(answer.body).toString('base64'),
});
} catch {
await session
.send('Fetch.failRequest', { requestId, errorReason: 'Failed' })
.catch(() => {});
}
});
await session.send('Fetch.enable', {
patterns: [
{ urlPattern: `${base.origin}/api/wind/*` },
{ urlPattern: `${base.origin}/api/tomtom/*` },
{ urlPattern: 'https://tiles.openfreemap.org/*' },
],
});
}
const report = { runs: [], swaps, strict: strictMode };
strict.extra.soak = report;
for (let run = 1; run <= runs; run++) {
const start = run % 2 ? 'navara' : 'cesium';
const browser = await strict.launch();
const row = { run, start, swaps: [], checkpoints: [], errors: [] };
report.runs.push(row);
try {
const p = await browser.newPage();
await p.setViewport({ width: 1280, height: 800 });
p.on('pageerror', (e) => row.errors.push(e.message.slice(0, 300)));
await installRadioFixture(p);
await installSessionFixtures(p, { flights: 10000, military: 200 });
await installWebGlCounters(p);
await installFirstFrameProbe(p);
await installWorkloadFixtures(p);
const cdp = await p.createCDPSession();
await openApp(p, base.origin, start, HASH);
await startSession(p, LAYERS);
await p.waitForFunction(
() => {
const layers = window.__godsEyeView.dataManager.layers;
const stat = (id) => layers.get(id).module.getStats();
return (
stat('radio').count === 750 &&
stat('flights').count > 0 &&
stat('military').count > 0 &&
stat('traffic').count >= 10 &&
layers.get('wind').module.getDiagnostics().gpu?.ready
);
},
{ timeout: 90000, polling: 500 },
);
await p.evaluate((id) => {
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
radio.selectStation(id, { autoplay: true, focus: false, origin: 'user' });
window.__qaRadio.station = id;
}, stations[10].id);
await p.waitForFunction(
() =>
window.__qaRadio.plays.length === 1 &&
window.__godsEyeView.dataManager.layers.get('radio').module.getUIState()
.audioState === 'playing',
);
// Each aircraft keeps its last POSITION_HISTORY_LIMIT fixes for dead
// reckoning, so the first polls of the flights feed grow the heap by
// itself (about 14 MiB here, over 2.5 min; a run without switches then
// levels off). The long run lets that history fill before it switches,
// so the heap slope it asserts is the switches' own.
if (strictMode) {
const filling = Date.now();
await p.waitForFunction(
(polls) => window.__qaReads.flights > polls,
{ timeout: 300000, polling: 1000 },
POSITION_HISTORY_LIMIT,
);
row.historyFillSeconds = Math.round((Date.now() - filling) / 1000);
}
row.workload = await p.evaluate(() => {
const layers = window.__godsEyeView.dataManager.layers;
return Object.fromEntries(
['flights', 'military', 'traffic', 'radio', 'earthquakes'].map((id) => [
id,
layers.get(id).module.getStats().count,
]),
);
});
const listeners = async () => {
const { result } = await cdp.send('Runtime.evaluate', {
includeCommandLineAPI: true,
returnByValue: true,
expression: `(() => Object.fromEntries([...window.__qaEngines].map(([kind, engine]) => {
const events = getEventListeners(engine.host.canvas);
return [kind, [events.webglcontextlost?.length ?? 0, events.webglcontextrestored?.length ?? 0]];
})))()`,
});
return result.value;
};
const seen = new Set([start]);
let kind = start;
// The switch after a heap snapshot is slower; timings leave it out.
let afterSnapshot = false;
for (let i = 1; i <= swaps; i++) {
kind = kind === 'navara' ? 'cesium' : 'navara';
const cold = !seen.has(kind);
seen.add(kind);
await p.evaluate((kind) => {
window.__qaFrame = window.__qaFirstFrame(kind);
}, kind);
const pick = await strict.pick(p, kind);
await waitForEngine(p, kind);
const frame = await p.evaluate(() => window.__qaFrame);
await rememberEngine(p);
const state = await p.evaluate(() => {
const { dataManager } = window.__godsEyeView;
const session = window.__qaSession;
const radio = dataManager.layers.get('radio').module;
return {
session:
dataManager === session.manager &&
[...session.models].every(
([id, model]) => dataManager.layers.get(id)?.module === model,
),
radio:
window.__qaRadio.plays.length === 1 &&
radio.getUIState().audioState === 'playing' &&
radio.getUIState().selected?.id === window.__qaRadio.station,
};
});
assert.ok(
state.session && state.radio,
`swap ${i}: ${JSON.stringify(state)}`,
);
row.swaps.push({
i,
to: kind,
cold,
firstFrameMs: Math.round((frame.presented - pick.at) * 10) / 10,
drawnMs: Math.round((frame.drawn - pick.at) * 10) / 10,
settledMs:
pick.idleAt == null
? null
: Math.round((pick.idleAt - pick.at) * 10) / 10,
...(afterSnapshot ? { afterSnapshot } : {}),
});
afterSnapshot = false;
if (CHECKPOINTS.includes(i) || i === swaps) {
await cdp.send('HeapProfiler.collectGarbage');
const heap = await cdp.send('Runtime.getHeapUsage');
// The heap by snapshot node type, at the first checkpoint after the
// warm-up and at the last: the asserted growth is the JS objects'
// (V8's compiled code grows with tier-ups over a long run; native
// memory, array buffer data included, is reported apart). The switch
// after a snapshot is left out of the timings.
let typesMiB = null;
if (strictMode && (i === WARM_UP || i === swaps)) {
const sizes = await heapSizesByType(cdp);
typesMiB = Object.fromEntries(
Object.entries(sizes).map(([type, bytes]) => [
type,
Math.round((bytes / 1048576) * 100) / 100,
]),
);
afterSnapshot = true;
}
const dom = await cdp.send('Memory.getDOMCounters');
row.checkpoints.push({
swap: i,
engine: kind,
heapMiB: Math.round((heap.usedSize / 1048576) * 100) / 100,
typesMiB,
...dom,
listeners: await listeners(),
webgl: await p.evaluate(() => window.__qaGlCounts()),
});
}
}
} finally {
await browser.close().catch(() => {});
}
const warm = (to) =>
row.swaps
.filter((s) => !s.cold && !s.afterSnapshot && s.to === to)
.map((s) => s.firstFrameMs);
row.summary = Object.fromEntries(
['navara', 'cesium'].map((to) => [
to,
{
cold:
row.swaps.find((s) => s.cold && s.to === to)?.firstFrameMs ?? null,
warmP50: quantile(warm(to), 0.5),
warmP95: quantile(warm(to), 0.95),
},
]),
);
console.log(
'RUN',
JSON.stringify({ run, start, ...row.summary, workload: row.workload }),
);
}
// Pooled over the runs, per direction.
const pooled = (to, cold) =>
report.runs.flatMap((r) =>
r.swaps
.filter((s) => s.to === to && s.cold === cold && !s.afterSnapshot)
.map((s) => s.firstFrameMs),
);
report.summary = Object.fromEntries(
['navara', 'cesium'].map((to) => [
to === 'navara' ? 'cesium→navara' : 'navara→cesium',
{
cold: pooled(to, true),
warmP50: quantile(pooled(to, false), 0.5),
warmP95: quantile(pooled(to, false), 0.95),
warmSwitches: pooled(to, false).length,
},
]),
);
console.log('SOAK', JSON.stringify(report.summary));
strict.timings.push(
...report.runs.flatMap((r) => r.swaps.map((s) => ({ run: r.run, ...s }))),
);
if (strictMode) {
const checks = [];
for (const row of report.runs) {
const late = row.checkpoints.filter((c) => c.swap >= WARM_UP);
assert.ok(late.length >= 3, 'enough checkpoints after the warm-up');
const first = late[0];
const last = late.at(-1);
for (const owner of ['navara', 'cesium']) {
const totals = late.map((c) => [
c.swap,
c.webgl
.filter((g) => g.owner === owner && !g.lost)
.reduce((s, g) => s + g.total, 0),
]);
checks.push({
check: `${owner} live WebGL objects`,
slopePerSwap: Math.round(slope(totals) * 1000) / 1000,
from: totals[0][1],
to: totals.at(-1)[1],
pass: slope(totals) <= 0.1 && totals.at(-1)[1] - totals[0][1] <= 64,
});
}
checks.push({
check: 'WebGL contexts',
from: first.webgl.length,
to: last.webgl.length,
pass: last.webgl.length === first.webgl.length,
});
const heap = late.map((c) => [c.swap, c.heapMiB * 1024]);
const typed = late.filter((c) => c.typesMiB);
assert.equal(typed.length, 2, 'heap snapshots at the warm-up and the end');
const [from, to] = typed;
const outside = new Set(['code', 'native', 'synthetic']);
const objects = (c) =>
Object.entries(c.typesMiB)
.filter(([type]) => !outside.has(type))
.reduce((sum, [, mib]) => sum + mib, 0);
const perSwitch = (mib) =>
Math.round(((mib * 1024) / (to.swap - from.swap)) * 100) / 100;
const objectGrowth = perSwitch(objects(to) - objects(from));
checks.push({
check: 'JS heap KiB per switch',
objectsKiBPerSwitch: objectGrowth,
fromMiB: Math.round(objects(from) * 100) / 100,
toMiB: Math.round(objects(to) * 100) / 100,
// Reported, not asserted: the used heap's slope (compiled code
// included), compiled code's and native memory's growth.
usedSlopePerSwap: Math.round(slope(heap) * 100) / 100,
codeKiBPerSwitch: perSwitch(
(to.typesMiB.code ?? 0) - (from.typesMiB.code ?? 0),
),
nativeKiBPerSwitch: perSwitch(
(to.typesMiB.native ?? 0) - (from.typesMiB.native ?? 0),
),
pass: objectGrowth <= 10,
});
checks.push({
check: 'documents',
from: first.documents,
to: last.documents,
pass: last.documents <= first.documents,
});
checks.push({
check: 'context-loss listeners on both canvases',
from: first.listeners,
to: last.listeners,
pass:
JSON.stringify(last.listeners) === JSON.stringify(first.listeners) &&
Object.keys(last.listeners).length === 2 &&
Object.values(last.listeners).every(([lost]) => lost > 0),
});
}
report.checks = checks;
for (const check of checks)
console.log(check.pass ? 'PASS' : 'FAIL', JSON.stringify(check));
await strict.finish({
failed: checks.some((c) => !c.pass) ? 'soak checks failed' : null,
});
if (checks.some((c) => !c.pass)) process.exitCode = 1;
} else await strict.finish();