gods-eye-view/scripts/qa-view-target-prewarm.mjs

350 lines
15 KiB
JavaScript
Raw Permalink 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
/**
* Regression proof for the view-target prewarm crash.
*
* `getViewTargetCartographic()` converts whatever `scene.pickPosition()` hands
* back into a Cartographic. Over empty sky the depth sample is degenerate, the
* pick yields a non-finite / zero-magnitude Cartesian, and
* `Cartographic.fromCartesian` throws `DeveloperError: normalized result is not
* a number`. Because the prewarm runs inside `requestIdleCallback`, the throw
* escapes as an UNCAUGHT page error on a plain camera flight — a red console
* error on the demo path, with no scene and no tracking involved.
*
* This harness flies the camera the way the demo does and asserts the flight
* produces zero uncaught errors and zero unhandled rejections.
*
* QA_BASE_URL=http://localhost:4173 node scripts/qa-view-target-prewarm.mjs
*
* `QA_LABEL=before` names the evidence files so a before/after pair can sit
* side by side in qa-shots/prewarm/.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const shotsDir = path.join(repoRoot, 'qa-shots', 'prewarm');
const appUrl = process.env.QA_BASE_URL || 'http://localhost:4173';
const label = process.env.QA_LABEL || 'after';
const headful = process.argv.includes('--headful');
fs.mkdirSync(shotsDir, { recursive: true });
const chromeCandidates = [
process.env.PUPPETEER_EXECUTABLE_PATH,
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
].filter(Boolean);
const executablePath = chromeCandidates.find((candidate) => fs.existsSync(candidate));
const browser = await puppeteer.launch({
headless: headful ? false : 'new',
...(executablePath ? { executablePath } : {}),
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
});
const page = await browser.newPage();
const failures = [];
const pageErrors = [];
const consoleErrors = [];
// Keep the message AND the stack: an ownerless promise rejection is reported
// here with an "Uncaught (in promise)" message but a stack that does not say
// so, while a Cesium DeveloperError puts the useful frames only in the stack.
page.on('pageerror', (error) => pageErrors.push(
`${String(error?.message || error)}\n${String(error?.stack || '')}`.trim(),
));
page.on('console', (message) => {
if (message.type() !== 'error') return;
const text = message.text();
if (/Failed to load resource/i.test(text)) return;
consoleErrors.push(text);
});
// Unhandled rejections do not surface through `pageerror`, so capture them in
// the page and read the buffer back after the flight.
await page.evaluateOnNewDocument(() => {
window.__qaPrewarmRejections = [];
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
window.__qaPrewarmRejections.push(String(reason?.stack || reason?.message || reason));
});
});
const check = (name, passed, detail) => {
console.log(` [${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? `${detail}` : ''}`);
if (!passed) failures.push(name);
};
/** High, straight-down, and horizon-grazing views all miss the rendered mesh. */
const FLIGHT_LEGS = [
{ name: 'austin-high', lon: -97.7431, lat: 30.2672, height: 900_000, pitch: -90 },
{ name: 'ocean-horizon', lon: -140.0, lat: 5.0, height: 2_500_000, pitch: -12 },
{ name: 'polar-limb', lon: 10.0, lat: 82.0, height: 6_000_000, pitch: -20 },
{ name: 'globe-out', lon: -30.0, lat: 12.0, height: 14_000_000, pitch: -90 },
];
let rejections = [];
try {
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 90_000 });
await page.waitForFunction(() => window.__godsEyeView?.viewer, { timeout: 90_000 });
await page.waitForFunction(
() => document.getElementById('loading-screen')?.classList.contains('hidden'),
{ timeout: 90_000 },
);
// Boot noise (tile / network warm-up) is not what this harness is about.
await new Promise((resolve) => setTimeout(resolve, 4_000));
pageErrors.length = 0;
consoleErrors.length = 0;
await page.evaluate(() => { window.__qaPrewarmRejections.length = 0; });
const legReports = [];
for (const leg of FLIGHT_LEGS) {
const report = await page.evaluate(async (spec) => {
const { viewer } = window.__godsEyeView;
// No window.Cesium global (qa-cctv-v2 / qa-height-datum precedent) —
// borrow Cartesian3's statics off a live camera-position instance.
const Cartesian3 = viewer.camera.position.constructor;
await new Promise((resolve) => {
viewer.camera.flyTo({
destination: Cartesian3.fromDegrees(spec.lon, spec.lat, spec.height),
orientation: { heading: 0, pitch: (spec.pitch * Math.PI) / 180, roll: 0 },
duration: 1.2,
complete: resolve,
cancel: resolve,
});
});
// Let moveEnd (120 ms debounce) + requestIdleCallback (500 ms timeout) run.
await new Promise((resolve) => setTimeout(resolve, 1_500));
// Record what the center pick actually yields so the root cause is
// visible in the evidence dump rather than merely asserted.
const scene = viewer.scene;
const canvas = scene.canvas;
const center = {
x: (canvas.clientWidth || canvas.width) / 2,
y: (canvas.clientHeight || canvas.height) / 2,
};
let picked;
try {
const raw = scene.pickPosition(center);
picked = raw ? { x: raw.x, y: raw.y, z: raw.z } : null;
} catch (error) {
picked = { threw: String(error?.message || error) };
}
return { leg: spec.name, picked };
}, leg);
legReports.push(report);
console.log(` · ${report.leg}: center pick = ${JSON.stringify(report.picked)}`);
}
await page.screenshot({ path: path.join(shotsDir, `${label}-final-view.png`) });
// ── Phase 2: the degenerate depth pick, injected ──────────────────────────
// A depth pick over empty sky can hand back a Cartesian that is not a place.
// Cesium produces those only under conditions this harness cannot schedule
// reliably, so the precondition is injected directly: replace
// `scene.pickPosition` with one that returns each degenerate shape in turn.
// Everything downstream — the moveEnd prewarm inside requestIdleCallback and
// the HUD summary interval — is the real shipped code.
const injection = await page.evaluate(async () => {
const { viewer, styleManager } = window.__godsEyeView;
const Cartesian3 = viewer.camera.position.constructor;
const shapes = [
// Throws inside Cesium's normalize.
{ name: 'nan', value: new Cartesian3(Number.NaN, Number.NaN, Number.NaN) },
{ name: 'origin', value: new Cartesian3(0, 0, 0) },
{ name: 'infinite', value: new Cartesian3(Number.POSITIVE_INFINITY, 0, 0) },
// Converts SILENTLY to a point 6,378 km underground — the quiet failure a
// bare non-zero check lets through, which then reverse-geocodes 0°, 0°.
{ name: 'core-interior', value: new Cartesian3(500, 0, 0) },
// Finite, but large enough to overflow the geodetic iteration into NaN.
{ name: 'absurd-magnitude', value: new Cartesian3(1e155, 0, 0) },
];
const scene = viewer.scene;
const originalPick = Object.getOwnPropertyDescriptor(scene, 'pickPosition');
let current = shapes[0].value;
// The counter proves the prewarm re-picked under injection, so a clean
// result cannot be clean merely because nothing ran.
let pickCalls = 0;
scene.pickPosition = () => { pickCalls += 1; return current; };
const hudErrors = [];
const nudge = (step) => {
const carto = viewer.camera.positionCartographic;
viewer.camera.setView({
destination: Cartesian3.fromRadians(
carto.longitude + 0.0015 * (step + 1),
carto.latitude,
carto.height,
),
orientation: { heading: viewer.camera.heading, pitch: viewer.camera.pitch, roll: 0 },
});
viewer.camera.moveEnd.raiseEvent();
};
try {
for (let step = 0; step < shapes.length; step += 1) {
current = shapes[step].value;
// Nudge the camera so the 2.5 s view-target cache misses and the
// moveEnd prewarm actually re-picks.
nudge(step);
// moveEnd debounce (120 ms) + requestIdleCallback timeout (500 ms).
await new Promise((resolve) => setTimeout(resolve, 900));
// The HUD summary walks the same view target. Enter through the REAL
// boundary the 15 s interval uses — `void this._updateSummary(true)`,
// with nobody owning the promise — so a rejection lands in the page's
// unhandledrejection buffer exactly as it would in production.
void styleManager.hud._updateSummary(true);
await new Promise((resolve) => setTimeout(resolve, 250));
// …and separately confirm the context itself is clean, with its error
// attributed to this shape rather than pooled into the page buffer.
try {
await styleManager.hud._summaryContext();
} catch (error) {
hudErrors.push(`${shapes[step].name}: ${String(error?.message || error)}`);
}
}
} finally {
if (originalPick) Object.defineProperty(scene, 'pickPosition', originalPick);
else delete scene.pickPosition;
}
return { hudErrors, shapes: shapes.length, pickCalls };
});
console.log(` · injected ${injection.shapes} degenerate pick shapes; ${injection.pickCalls} pick(s) served; HUD context threw ${injection.hudErrors.length}×`);
await new Promise((resolve) => setTimeout(resolve, 1_500));
check(
'the injected picks actually reached the shipped code',
injection.pickCalls > 0,
`${injection.pickCalls} pick call(s) served`,
);
check(
'a degenerate depth pick does not break the HUD view-target context',
injection.hudErrors.length === 0,
injection.hudErrors.length ? injection.hudErrors.slice(0, 5).join(' | ').slice(0, 500) : 'clean',
);
// ── Phase 3: the HUD summary's own async boundary ─────────────────────────
// Phase 2 proves the guard keeps the context healthy. This proves the
// boundary holds even when it is NOT — `_updateSummary` awaits the context
// before its own try, and every caller invokes it as `void`, so any rejection
// from anywhere in that walk is ownerless. Isolate the boundary by making the
// context reject, then enter through the interval's exact statement.
// The QA marker makes this round's rejection attributable no matter which
// channel reports it.
const boundaryMarker = 'QA: scene context unavailable';
const pageErrorsBeforeBoundary = pageErrors.length;
const rejectionsBeforeBoundary = await page.evaluate(() => window.__qaPrewarmRejections.length);
const boundary = await page.evaluate((marker) => {
const hud = window.__godsEyeView.styleManager.hud;
window.__qaPriorSummaryContext = hud._summaryContext;
window.__qaStubCalls = 0;
hud._summaryContext = async () => {
window.__qaStubCalls += 1;
throw new Error(marker);
};
// `_updateSummary` returns before the await unless metrics exist and the
// summary is dirty. Satisfy both, or this check passes vacuously.
hud._latestMetrics = hud._latestMetrics || {};
hud._summaryRequest = null;
hud._summaryDirty = true;
// Byte-for-byte the statement in the HUD's summary interval. Nothing waits
// inside this evaluate: an ownerless rejection must be observed from
// OUTSIDE the page, because the browser reports it to the driver rather
// than always reaching an in-page listener.
void hud._updateSummary(true);
return window.__qaStubCalls;
}, boundaryMarker);
await new Promise((resolve) => setTimeout(resolve, 1_500));
const boundaryStubCalls = await page.evaluate(() => {
const hud = window.__godsEyeView.styleManager.hud;
hud._summaryContext = window.__qaPriorSummaryContext;
return window.__qaStubCalls;
});
const boundaryRejections = [
...pageErrors.slice(pageErrorsBeforeBoundary),
...(await page.evaluate((n) => window.__qaPrewarmRejections.slice(n), rejectionsBeforeBoundary)),
].filter((text) => text.includes(boundaryMarker));
console.log(` · void _updateSummary() reached the context ${boundaryStubCalls}× (sync ${boundary}) → ${boundaryRejections.length} escaped rejection(s)`);
check(
'the void _updateSummary() call actually reached its context await',
boundaryStubCalls > 0,
`${boundaryStubCalls} call(s)`,
);
check(
'a rejecting summary context does not escape the void _updateSummary() call',
boundaryRejections.length === 0,
boundaryRejections.length ? boundaryRejections.join(' | ').slice(0, 400) : 'clean',
);
// An ownerless rejection reaches the driver as a page error prefixed
// "Uncaught (in promise)"; the in-page listener is a second, less reliable
// channel. Union both so neither can hide one.
const inPageRejections = await page.evaluate(() => window.__qaPrewarmRejections.slice());
rejections = [
...pageErrors.filter((text) => /Uncaught \(in promise\)/i.test(text)),
...inPageRejections,
];
// Cesium's DeveloperError stringifies as "DeveloperError: DeveloperError",
// so the diagnostic text only survives on the caught HUD errors — scan those
// too or this check reads clean while the page is throwing.
const allText = [...pageErrors, ...consoleErrors, ...rejections, ...injection.hudErrors];
const normalizeErrors = allText
.filter((text) => /normalized result is not a number/i.test(text));
check(
'no uncaught page errors across the flight and the degenerate picks',
pageErrors.length === 0,
pageErrors.length
? `${pageErrors.length}: ${pageErrors.slice(0, 3).join(' | ').slice(0, 400)}`
: 'clean',
);
check(
'no unhandled promise rejections across the flight and the degenerate picks',
rejections.length === 0,
rejections.length
? `${rejections.length}: ${rejections.slice(0, 3).join(' | ').slice(0, 400)}`
: 'clean',
);
check(
'no "normalized result is not a number" DeveloperError on the flight',
normalizeErrors.length === 0,
normalizeErrors.length ? `${normalizeErrors.length} occurrence(s)` : 'clean',
);
check(
'no console errors on the flight',
consoleErrors.length === 0,
consoleErrors.length
? `${consoleErrors.length}: ${consoleErrors.slice(0, 3).join(' | ').slice(0, 400)}`
: 'clean',
);
fs.writeFileSync(
path.join(shotsDir, `${label}-console.json`),
`${JSON.stringify({
appUrl,
label,
capturedAt: new Date().toISOString(),
legs: legReports,
injectedShapes: injection.shapes,
injectedPickCalls: injection.pickCalls,
hudContextErrors: injection.hudErrors,
voidBoundaryRejections: boundaryRejections,
pageErrors,
consoleErrors,
unhandledRejections: rejections,
normalizeErrors,
}, null, 2)}\n`,
);
} catch (error) {
check('harness ran to completion', false, String(error?.message || error));
} finally {
await browser.close();
}
console.log(`\n${failures.length ? `FAIL (${failures.length})` : 'PASS'} — evidence in ${path.relative(repoRoot, shotsDir)}/`);
process.exit(failures.length ? 1 : 0);