mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
- The CI gate builds the app and serves it with `vite preview`, with a dummy non-secret client token (a fork PR never gets secrets), and answers the Graph API, image tiles and thumbnails from fixtures: 41 photos on a line under the parked camera and one generated 640x320 JPEG (scripts/fixtures/street-level). All 7 photo steps now run in CI, plus a real click on a coverage line and on a cone, the framing flight stopping on close, the inert modal, the viewer returning into the panel, and a reload keeping a collapsed panel collapsed (share ui token t). - Fixture interception is limited to Mapillary hosts via CDP Fetch.enable: page-wide request interception stalled Cesium's workers, so the old fixture mode never drew anything on the globe. - Failure evidence (screenshots, UI state, console, failed requests, render errors) is written to qa-artifacts/ with keys and tokens redacted and uploaded on failure; qa-artifacts/ is gitignored. - Cesium render-loop errors fail the gate; Google 3D branches that cannot run print as skips, and --strict rejects any other skip. - Panel presses log their diagnostics, warn on a retry, and CI now fails on any retry (QA_FAIL_ON_RETRY=1) since the rail settles; a press with no pointerdown is a failure, not a retry. - Assertion-gating sleeps 27 -> 1; the rejected-key "no requests" check has a positive control. Verified locally: strict fixture gate on the production build 28 ok + 2 "no Google 3D" skips (twice, fail-on-retry on); panel gate 5/5 plus no render errors; live gate with a real token 25 ok + 3 fixture-only skips. Co-Authored-By: Claude Opus 5.5 <[email protected]>
190 lines
6.4 KiB
JavaScript
190 lines
6.4 KiB
JavaScript
/**
|
|
* Evidence the browser QA gates (qa-street-level, qa-panel-resize) collect
|
|
* while they run and save when they fail: every page's console (last 200
|
|
* messages), failed requests and HTTP errors, Cesium render-loop errors (the
|
|
* "An error occurred while rendering" console line and `scene.renderError`)
|
|
* and, on failure, a full-page screenshot of every open page plus the Street
|
|
* Level UI state, under `qa-artifacts/<gate>/` (CI uploads that directory).
|
|
*
|
|
* Keys never reach the evidence: URLs lose their `access_token`, `key` (and
|
|
* similar) values, and `MLY|…` client tokens and Google `AIza…` keys are
|
|
* masked wherever they appear.
|
|
*/
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
/** How many console messages and failed requests each page keeps. */
|
|
export const EVIDENCE_LIMIT = 200;
|
|
|
|
/** Cesium's console line when the render loop throws (CesiumWidget). */
|
|
export const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
|
|
|
|
/** Mask tokens in a URL or message before it is printed or saved. */
|
|
export function redact(text) {
|
|
return String(text ?? '')
|
|
.replace(
|
|
/([?&](?:access_token|token|key|api_key|apikey|client_secret|signature)=)[^&\s"']*/gi,
|
|
'$1REDACTED',
|
|
)
|
|
.replace(/MLY\|[^\s&"',)]+/g, 'MLY|REDACTED')
|
|
.replace(/AIza[\w-]{30,}/g, 'AIza…REDACTED');
|
|
}
|
|
|
|
function push(list, entry) {
|
|
list.push(entry);
|
|
if (list.length > EVIDENCE_LIMIT)
|
|
list.splice(0, list.length - EVIDENCE_LIMIT);
|
|
}
|
|
|
|
/**
|
|
* Subscribe to `viewer.scene.renderError` in every document the page loads
|
|
* (reloads included), into `window.__qaRenderErrors`. Call before `goto`.
|
|
*/
|
|
export function hookRenderErrors(page) {
|
|
return page.evaluateOnNewDocument(() => {
|
|
window.__qaRenderErrors = [];
|
|
const hook = () => {
|
|
const scene = window.__godsEyeView?.viewer?.scene;
|
|
if (!scene?.renderError) return false;
|
|
scene.renderError.addEventListener((_, error) =>
|
|
window.__qaRenderErrors.push(
|
|
String(error?.stack || error?.message || error),
|
|
),
|
|
);
|
|
return true;
|
|
};
|
|
const timer = setInterval(() => {
|
|
if (hook()) clearInterval(timer);
|
|
}, 250);
|
|
});
|
|
}
|
|
|
|
/** Render errors the in-page hook saw in the current document. */
|
|
export async function readRenderErrors(page) {
|
|
try {
|
|
return await page.evaluate(() => window.__qaRenderErrors ?? []);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Watch one page: console, page errors, failed requests and HTTP errors.
|
|
* `errors` (shared across pages) receives page errors, render-loop console
|
|
* errors and the extra `isError(text)` console lines a gate cares about.
|
|
* @returns {{name: string, page: object, console: Array<object>, failed: Array<object>, renderErrors: Array<string>}}
|
|
*/
|
|
export function watchPage(page, { name, errors, isError = () => false }) {
|
|
const monitor = { name, page, console: [], failed: [], renderErrors: [] };
|
|
page.on('console', (message) => {
|
|
const text = redact(message.text());
|
|
push(monitor.console, { type: message.type(), text });
|
|
if (message.type() === 'error' && RENDER_ERROR_PATTERN.test(text)) {
|
|
monitor.renderErrors.push(text);
|
|
errors.push(`[${name}] render loop: ${text}`);
|
|
} else if (isError(text)) errors.push(`[${name}] ${text}`);
|
|
});
|
|
page.on('pageerror', (error) => {
|
|
const text = redact(error?.stack || error?.message || error);
|
|
push(monitor.console, { type: 'pageerror', text });
|
|
errors.push(`[${name}] ${text}`);
|
|
});
|
|
page.on('requestfailed', (request) =>
|
|
push(monitor.failed, {
|
|
url: redact(request.url()),
|
|
method: request.method(),
|
|
failure: request.failure()?.errorText ?? 'failed',
|
|
}),
|
|
);
|
|
page.on('response', (response) => {
|
|
if (response.status() < 400) return;
|
|
push(monitor.failed, {
|
|
url: redact(response.url()),
|
|
method: response.request().method(),
|
|
status: response.status(),
|
|
});
|
|
});
|
|
return monitor;
|
|
}
|
|
|
|
const withTimeout = (promise, ms, label) =>
|
|
Promise.race([
|
|
promise,
|
|
new Promise((_, reject) =>
|
|
setTimeout(() => reject(new Error(`${label} timed out`)), ms),
|
|
),
|
|
]);
|
|
|
|
/** The Street Level layer's UI state on a page, or why it could not be read. */
|
|
function streetLevelState(page) {
|
|
return page.evaluate(() => {
|
|
const module =
|
|
window.__godsEyeView?.dataManager?.layers?.get('street-level')?.module;
|
|
return module?.getUIState ? module.getUIState() : null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Save the evidence of a failed run under `dir`: per page a full-page
|
|
* screenshot, the Street Level UI state, the console, failed requests and
|
|
* render errors; plus the error itself. Never throws: a page that cannot be
|
|
* captured records why instead.
|
|
* @returns {Promise<string>} the directory written
|
|
*/
|
|
export async function saveFailureArtifacts({ dir, browser, monitors, error }) {
|
|
await fs.mkdir(dir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(dir, 'error.txt'),
|
|
redact(error?.stack || error?.message || String(error)),
|
|
);
|
|
const known = new Map(monitors.map((monitor) => [monitor.page, monitor]));
|
|
let pages = [];
|
|
try {
|
|
pages = await browser.pages();
|
|
} catch {
|
|
pages = monitors.map((monitor) => monitor.page);
|
|
}
|
|
let index = 0;
|
|
for (const page of pages) {
|
|
const monitor = known.get(page);
|
|
// The browser's first blank tab holds nothing worth saving.
|
|
if (!monitor && page.url() === 'about:blank') continue;
|
|
const base = `${String(index++).padStart(2, '0')}-${monitor?.name ?? 'page'}`;
|
|
const record = { url: redact(page.url()) };
|
|
try {
|
|
await withTimeout(
|
|
page.screenshot({
|
|
path: path.join(dir, `${base}.png`),
|
|
fullPage: true,
|
|
}),
|
|
30_000,
|
|
'screenshot',
|
|
);
|
|
} catch (shotError) {
|
|
record.screenshotError = String(shotError?.message || shotError);
|
|
}
|
|
try {
|
|
record.streetLevel = await withTimeout(
|
|
streetLevelState(page),
|
|
10_000,
|
|
'getUIState',
|
|
);
|
|
} catch (stateError) {
|
|
record.streetLevelError = String(stateError?.message || stateError);
|
|
}
|
|
record.renderErrors = [
|
|
...(monitor?.renderErrors ?? []),
|
|
...(await withTimeout(readRenderErrors(page), 10_000, 'render errors')
|
|
.then((list) => list.map((text) => `scene.renderError: ${text}`))
|
|
.catch(() => [])),
|
|
].map(redact);
|
|
record.console = monitor?.console ?? [];
|
|
record.failedRequests = monitor?.failed ?? [];
|
|
await fs.writeFile(
|
|
path.join(dir, `${base}.json`),
|
|
redact(JSON.stringify(record, null, 2)),
|
|
);
|
|
}
|
|
return dir;
|
|
}
|