350 lines
15 KiB
JavaScript
350 lines
15 KiB
JavaScript
#!/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);
|