gods-eye-view/scripts/qa-perf.mjs

414 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* qa-perf — render-governor regression gate (perf wave 2).
*
* Asserts the governor's observable contract with RELATIVE frame-count
* assertions (SwiftShader-safe; no wall-clock GPU numbers):
*
* 1. Idle + zero layers + parked camera → the scene stops rendering
* (near-zero postRender fires over a settle-then-observe window).
* 2. A discrete mutation while idle (style slider write routed through
* governorRequestRender) → at least one render, then settles again.
* 3. Camera movement while idle → renders happen (Cesium-native path).
* 4. Flights enabled → continuous mode (postRender cadence ≈ rAF cadence,
* and ≥5× the idle count over the same window).
* 5. Flights disabled again → back to idle (near-zero fires).
* 6. Governor diagnostics agree with the mode at every step.
*
* ── WHAT THE IDLE WINDOWS MEASURE, AND WHY THEY WAIT ────────────────────────
*
* The idle checks (1 and 5) measure the STEADY-STATE floor: what a parked scene
* costs once it has settled. They do not measure how long settling takes, so the
* counted window has to begin AFTER settling — and "after" is a stronger
* condition than "nothing is happening right now".
*
* That distinction became load-bearing when detection stopped holding the render
* loop open (2026-08-22) and became a default. With a paint lane active, the
* world-overlay host honours its occluder observers with frames: chrome that
* changes SIZE resizes an occluder, `markLayoutDirty` fires, and that becomes a
* `requestRender`. While detection forced continuous mode this was invisible.
* Event-driven, it shows up as renders.
*
* The specific contaminant, pinned with stack traces on this tree, is triggered
* by THIS HARNESS'S OWN SETUP. Disabling every layer publishes a `visibility`
* change; the HUD subscribes to that and marks its semantic summary dirty
* (`src/hud.js`), and the summary refreshes on a 15-SECOND interval
* (`HUD_SUMMARY_INTERVAL_MS`). So up to 15 s after the teardown a tick fetches a
* new summary and TYPES it in with a typewriter animation; the growing text
* reflows `.hud-corner.hud-top-left`, an occluder, and the host turns that
* reflow into a handful of frames. Two instrumented runs (150 s and 180 s
* watches) agree to the second: the teardown lands at 15 s, the burst at 31 s —
* seven frames in that one second — which is 16 s after the teardown and 4 s
* INTO the window the old settle had already started counting. Neither run then
* saw a single further render, across 146 s and 176 s respectively. It is a
* one-shot: the tick that fetches also clears the summary's dirty flag and
* commits its context signature, so every later tick returns early.
*
* Waiting for "an empty second" cannot survive that, because the scene really IS
* empty for the ~15 s between the teardown and the tick that notices it. So the
* settle below requires a quiet RUN LONGER THAN ONE FULL SUMMARY TICK, and that
* run resets on any activity. Quiet then means something stronger and checkable:
* a complete refresh period has elapsed in which the app looked at its new state
* and found nothing to do. The length is derived from the app's own cadence
* rather than tuned against a flake rate — if that interval changes, this moves
* with it.
*
* The threshold inside the window (≤4 fires / 5 s) is untouched. The teeth live
* in the WAIT, not the threshold: a scene rendering every frame never produces an
* empty second at all, so the bounded wait expires and FAILS. Quiet that never
* arrives IS the failure, which is why the timeout is an assertion rather than a
* fall-through — and it is what keeps this honest if the churn ever stops being a
* one-shot. It would: when `/api/openai/hud-summary` is unreachable the summary
* stays dirty and re-types every 15 s, so that machine gets a loud repeatable
* failure here rather than a coin flip.
*
* The underlying fix belongs to the host, not to this harness: an active lane
* with nothing to place is not paint work and should not be honoured with a
* frame. That is worldOverlay surgery — see the post-launch ledger entry
* "world-overlay honours occluder churn as paint work" in
* the project roadmap.
*
* Usage: node scripts/qa-perf.mjs [--url http://localhost:4173]
* Requires a running dev server. Headless; flags disable occlusion
* throttling so rAF cadence is trustworthy (hidden-pane gotcha).
*/
import puppeteer from 'puppeteer';
const argv = process.argv;
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4173';
const results = [];
function check(name, pass, detail) {
results.push({ name, pass });
const tag = pass ? 'PASS' : 'FAIL';
console.log(` [${tag}] ${name}${detail ? `${JSON.stringify(detail)}` : ''}`);
}
const browser = await puppeteer.launch({
headless: 'new',
protocolTimeout: 300_000,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--window-size=1440,900',
// Never let background/occlusion throttling freeze rAF or timers — the
// measurements below depend on an honest frame clock.
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-background-timer-throttling',
],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 860 });
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!window.__godsEyeView?.viewer, { timeout: 90_000 });
// Boot flyTo + tile warm + all deferred init.
await new Promise((r) => setTimeout(r, 15_000));
// Park deterministically and disable every layer.
await page.evaluate(async () => {
const gev = window.__godsEyeView;
const v = gev.viewer;
v.camera.cancelFlight();
const ell = v.scene.globe.ellipsoid;
v.camera.setView({
destination: ell.cartographicToCartesian({
longitude: -97.74 * Math.PI / 180, latitude: 30.27 * Math.PI / 180, height: 60_000,
}),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
for (const [id, entry] of gev.dataManager.layers) {
if (entry.enabled) { try { await gev.dataManager.setEnabled(id, false, { origin: 'user' }); } catch { /* gate reports via counts */ } }
}
});
// Let tiles finish + fades settle + the settling frames drain.
await new Promise((r) => setTimeout(r, 12_000));
/** Count scene postRender fires and rAF ticks over windowMs. */
const countFrames = (windowMs) => page.evaluate((ms) => new Promise((resolve) => {
const scene = window.__godsEyeView.viewer.scene;
let renders = 0; let rafs = 0;
const remove = scene.postRender.addEventListener(() => { renders += 1; });
const t0 = performance.now();
const tick = () => {
rafs += 1;
if (performance.now() - t0 < ms) requestAnimationFrame(tick);
else { remove(); resolve({ renders, rafs }); }
};
requestAnimationFrame(tick);
}), windowMs);
const diag = () => page.evaluate(() => window.__godsEyeView.getRenderGovernorDiagnostics?.()
|| window.__gevRenderGovernor?.getDiagnostics?.() || null);
/**
* The HUD's semantic summary refreshes on this cadence (`src/hud.js`,
* `HUD_SUMMARY_INTERVAL_MS`). A layer-visibility change marks it dirty, and
* the tick that picks that up types the new text in — reflowing an occluder
* and, with a paint lane active, costing frames. Mirrored rather than
* imported because this harness runs against the built app, not the module.
*/
const HUD_SUMMARY_INTERVAL_MS = 15_000;
/** One full refresh period, plus a second of margin, in 1 s windows. */
const QUIET_RUN_WINDOWS = Math.ceil(HUD_SUMMARY_INTERVAL_MS / 1_000) + 1;
/**
* Wait until the scene is genuinely settled, and report whether it ever was.
* This is the START of the idle windows below — see the header.
*
* Requires a CONSECUTIVE RUN of empty one-second windows at least one full
* summary refresh period long, and resets that run on any activity. One empty
* window — or three — proves nothing here: the scene is genuinely idle for the
* whole gap between a teardown and the tick that notices it, so a short
* confirmation happily reports "quiet" moments before the typewriter fires.
* A run longer than the period cannot straddle that gap: it either contains
* the burst, and restarts, or it postdates it.
*
* `quiet: false` is a FAILURE, not a fall-through: a scene rendering every
* frame never produces an empty window at all, so this is what catches a real
* hot loop — and equally a contaminant that has stopped being a one-shot. The
* bound stays comfortably longer than settling takes and comfortably shorter
* than forever: worst case is one interrupted run, the burst itself, then a
* clean run (~55 s), against a 120 s ceiling.
*/
const settleUntilQuiet = async (maxMs = 120_000, requiredConsecutive = QUIET_RUN_WINDOWS) => {
const deadline = Date.now() + maxMs;
let windows = 0;
let consecutive = 0;
let busiest = 0;
let restarts = 0;
while (Date.now() < deadline) {
windows += 1;
const { renders } = await countFrames(1_000);
busiest = Math.max(busiest, renders);
if (renders === 0) consecutive += 1;
else { if (consecutive > 0) restarts += 1; consecutive = 0; }
if (consecutive >= requiredConsecutive) {
return { quiet: true, windows, busiest, restarts, ranFor: consecutive, needRun: requiredConsecutive };
}
}
// A failure here reads as "never held N empty seconds in a row, best run was
// M, restarted R times, busiest window was B" — enough to tell a hot loop
// from a contaminant that has stopped being a one-shot.
return { quiet: false, windows, busiest, restarts, ranFor: consecutive, needRun: requiredConsecutive };
};
// ── 1. idle: near-zero renders ────────────────────────────────────────
const idleSettle = await settleUntilQuiet();
const idle = await countFrames(5_000);
const d1 = await diag();
// Quiet that never arrives is the hot-loop failure — assert it, do not skip it.
check('the parked scene holds a settled quiet run before the idle window is counted', idleSettle.quiet, idleSettle);
check('governor reports idle mode with zero layers', d1?.mode === 'idle', d1);
check('idle parked scene stops rendering (≤4 fires / 5s)', idle.renders <= 4, idle);
// ── 1b. detection at its FIRST-RUN DEFAULT must not hold the parked scene ─
//
// Detection became ON by default on 2026-08-22. It used to take an
// unconditional continuous-render hold whenever it was on — nobody felt that
// while it defaulted OFF, but as a DEFAULT it would pin every idle first-run
// tab at 60 fps forever, defeating this governor outright. The hold is gone
// (`src/data/detectionRenderDemand.js`): detection repaints on change and asks
// for single frames only while a bounded animation is running.
//
// This is the gate for that. The scene above is parked with zero layers, so
// detection-on and detection-off must yield the SAME near-zero render count.
const detectionDefault = await page.evaluate(
() => window.__godsEyeView.styleManager.getDetectionState?.() || null,
);
check(
'precondition: detection is ON at its first-run default (Dense @ 75)',
detectionDefault?.detectionMode === 'DENSE' && detectionDefault?.densityPct === 75,
detectionDefault,
);
check(
'detection ON takes NO continuous-render hold',
d1?.mode === 'idle' && !d1.holds.includes('detection'),
d1,
);
// The control: the same window with detection explicitly OFF.
await page.evaluate(() => { window.__godsEyeView.styleManager._setDetectionMode('OFF'); });
await new Promise((r) => setTimeout(r, 1_500)); // let any fade chain terminate
const idleDetectOff = await countFrames(5_000);
check('idle baseline with detection OFF (≤4 fires / 5s)', idleDetectOff.renders <= 4, idleDetectOff);
// Back to the default. A regression here is the entire point of this gate: the
// old hold produced a full 60 fps window instead of near-zero.
await page.evaluate(() => { window.__godsEyeView.styleManager._setDetectionMode('DENSE'); });
await new Promise((r) => setTimeout(r, 1_500));
const idleDetectOn = await countFrames(5_000);
const dDetect = await diag();
check('idle parked scene with detection ON (≤4 fires / 5s)', idleDetectOn.renders <= 4, idleDetectOn);
check(
'detection ON costs no more idle frames than detection OFF',
idleDetectOn.renders <= idleDetectOff.renders + 2,
{ on: idleDetectOn.renders, off: idleDetectOff.renders },
);
check(
'governor still idle with detection ON',
dDetect?.mode === 'idle' && !dDetect.holds.includes('detection'),
dDetect,
);
// …and the overlay must still be LIVE, not merely quiet: with detection on, a
// camera nudge has to repaint promptly, or "idle" would only mean "stale".
const detectMove = await Promise.all([
countFrames(2_500),
page.evaluate(() => new Promise((resolve) => {
const v = window.__godsEyeView.viewer;
let steps = 0;
const id = setInterval(() => {
v.camera.moveForward(50);
steps += 1;
if (steps >= 20) { clearInterval(id); resolve(true); }
}, 60);
})),
]).then(([frames]) => frames);
check(
'detection ON still repaints promptly on camera motion (≥10 / 2.5s)',
detectMove.renders >= 10,
detectMove,
);
// A render count alone proves the SCENE rendered, not that the detection
// painter ran — a painter disabled outright would score a perfect idle and
// sail through every check above. Count the painter's own frames across the
// same kind of motion, so the teeth reach the thing this change touched.
const detectPainted = await page.evaluate(() => new Promise((resolve) => {
const gev = window.__godsEyeView;
const before = gev.styleManager.getDetectionDiagnostics?.()?.frameCount ?? null;
let paints = 0;
// The diagnostics object is rebuilt on every detection paint, so a fresh
// identity is one painted frame. Sampling it per scene frame is enough to
// tell "painting" from "silent" without reaching into module internals.
let last = gev.styleManager.getDetectionDiagnostics?.();
const remove = gev.viewer.scene.postRender.addEventListener(() => {
const now = gev.styleManager.getDetectionDiagnostics?.();
if (now && now !== last) { paints += 1; last = now; }
});
let steps = 0;
const id = setInterval(() => {
gev.viewer.camera.moveForward(50);
steps += 1;
if (steps >= 20) {
clearInterval(id);
setTimeout(() => {
remove();
resolve({ paints, before, after: gev.styleManager.getDetectionDiagnostics?.()?.frameCount ?? null });
}, 400);
}
}, 60);
}));
check(
'detection is still PAINTING, not merely quiet (≥5 painted frames on motion)',
detectPainted.paints >= 5,
detectPainted,
);
await new Promise((r) => setTimeout(r, 3_000)); // settle back to parked
// ── 2. a REAL slider mutation renders (full UI → uniform → request path) ─
const afterMutation = await Promise.all([
countFrames(2_500),
page.evaluate(() => new Promise((resolve) => setTimeout(() => {
// Drive the actual sharpen slider: input event → handler →
// _applySharpenIntensity → governorRequestRender. Proves the wiring,
// not just the facade.
const slider = document.getElementById('sharpen-intensity-slider');
if (!slider) { resolve({ ok: false }); return; }
slider.value = String(Math.min(100, Number(slider.value) + 7));
slider.dispatchEvent(new Event('input', { bubbles: true }));
resolve({ ok: true });
}, 500))),
]).then(([frames]) => frames);
check('real slider mutation while idle renders ≥1 and ≤10 frames', afterMutation.renders >= 1 && afterMutation.renders <= 10, afterMutation);
// ── 2b. animated style cycle: style-anim holds, then releases ─────────
await page.evaluate(() => { window.__godsEyeView.styleManager.setStyle('retro'); });
await new Promise((r) => setTimeout(r, 900)); // crossfade + first ticks
const dAnim = await diag();
check('animated style takes the style-anim hold (continuous)', dAnim?.mode === 'continuous' && dAnim.holds.includes('style-anim'), dAnim);
const animFrames = await countFrames(2_000);
check('animated style keeps frames flowing (≥50% rAF)', animFrames.renders >= animFrames.rafs * 0.5, animFrames);
// Returning to normal keeps detection ON by design (it is the default, and
// detection persists across style switches). Since 2026-08-22 detection holds
// nothing, so this asserts the strictly harder thing: the scene returns to
// idle with detection still ON — where before it could only go idle by also
// turning detection off.
await page.evaluate(() => { window.__godsEyeView.styleManager.setStyle('normal'); });
await new Promise((r) => setTimeout(r, 1_500)); // fade out + loop self-stop
const dAnimOff = await diag();
const detectionStillOn = await page.evaluate(
() => window.__godsEyeView.styleManager.getDetectionState?.()?.detectionMode || null,
);
check(
'style-anim hold releases and the scene goes idle with detection still ON',
dAnimOff?.mode === 'idle'
&& !dAnimOff.holds.includes('style-anim')
&& !dAnimOff.holds.includes('detection')
&& detectionStillOn !== 'OFF',
{ diag: dAnimOff, detection: detectionStillOn },
);
// ── 2c. satellites holder enters and leaves diagnostics ───────────────
await page.evaluate(async () => {
await window.__godsEyeView.dataManager.setEnabled('satellites', true, { origin: 'user' });
});
const dSat = await diag();
check('satellites enable registers its holder', dSat?.holds.includes('satellites'), dSat);
await page.evaluate(async () => {
await window.__godsEyeView.dataManager.setEnabled('satellites', false, { origin: 'user' });
});
await new Promise((r) => setTimeout(r, 2_000));
const dSatOff = await diag();
check('satellites disable releases its holder', !dSatOff?.holds.includes('satellites'), dSatOff);
// ── 3. camera movement renders (Cesium-native path) ───────────────────
const duringMove = await Promise.all([
countFrames(2_500),
page.evaluate(() => new Promise((resolve) => {
const v = window.__godsEyeView.viewer;
let steps = 0;
const id = setInterval(() => {
v.camera.moveForward(50);
steps += 1;
if (steps >= 20) { clearInterval(id); resolve(true); }
}, 60);
})),
]).then(([frames]) => frames);
check('camera movement while idle produces renders (≥10 / 2.5s)', duringMove.renders >= 10, duringMove);
await new Promise((r) => setTimeout(r, 3_000)); // settle
// ── 4. flights enabled → continuous ───────────────────────────────────
await page.evaluate(async () => {
await window.__godsEyeView.dataManager.setEnabled('flights', true, { origin: 'user' });
});
await new Promise((r) => setTimeout(r, 5_000));
const active = await countFrames(5_000);
const d4 = await diag();
check('governor reports continuous mode with flights on', d4?.mode === 'continuous', d4);
check('flights-on cadence ≈ rAF cadence (≥70%)', active.renders >= active.rafs * 0.7, active);
check('flights-on renders ≥5× idle renders', active.renders >= Math.max(1, idle.renders) * 5, { active: active.renders, idle: idle.renders });
// ── 5. flights disabled → idle again ──────────────────────────────────
await page.evaluate(async () => {
await window.__godsEyeView.dataManager.setEnabled('flights', false, { origin: 'user' });
});
// Deselect flows, fades, and the chrome churn the overlay host re-evaluates
// its occluders against all have to drain first — and this teardown, like the
// one in the setup above, marks the HUD summary dirty, so the same deferred
// typewriter is still ahead of us. Same rule: hold a quiet run longer than one
// refresh period before counting anything.
const teardownSettle = await settleUntilQuiet();
const idleAgain = await countFrames(5_000);
const d5 = await diag();
check('the scene holds a settled quiet run again before the second idle window', teardownSettle.quiet, teardownSettle);
check('governor returns to idle after disable', d5?.mode === 'idle', d5);
check('scene stops rendering again (≤4 fires / 5s)', idleAgain.renders <= 4, idleAgain);
} finally {
await browser.close();
}
const passed = results.filter((r) => r.pass).length;
console.log(`\nqa-perf: ${passed}/${results.length} passed`);
console.log(`RESULT: ${passed} passed, ${results.length - passed} failed, 0 skipped`);
process.exit(passed === results.length ? 0 : 1);