mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
scripts/qa/browser.mjs launches headless Chrome with QA_ANGLE and the shared Chrome lookup; scripts/qa/ready.mjs adds frame, tile and transition readiness and bounded polling; scripts/qa/geometry.mjs hands page evaluations the engine-neutral geometry without dev-only source URLs. CODE-BOUNDARIES gains the rule for new QA scripts.
214 lines
6.6 KiB
JavaScript
214 lines
6.6 KiB
JavaScript
import { createSettledState } from '../../src/app/qaProbe.js';
|
|
|
|
/** A CPU-ready pick can precede the GPU hit delivered for a real click. */
|
|
export async function clickUntilSelected(page, click, selected) {
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
await click();
|
|
let handle;
|
|
try {
|
|
handle = await page.waitForFunction(selected, { timeout: 2000 });
|
|
return;
|
|
} catch (error) {
|
|
if (error.name !== 'TimeoutError') throw error;
|
|
if (attempt === 4)
|
|
throw new Error('Readiness timeout: selection after 5 clicks', {
|
|
cause: error,
|
|
});
|
|
} finally {
|
|
await handle?.dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Viewport CSS coordinates (or a page-side locator); predicate runs beside the picker. */
|
|
export async function whenPickable(
|
|
page,
|
|
windowPosition,
|
|
predicate,
|
|
{ timeoutMs = 30000 } = {},
|
|
) {
|
|
const position =
|
|
typeof windowPosition === 'function'
|
|
? `(${windowPosition})()`
|
|
: JSON.stringify(windowPosition);
|
|
let handle;
|
|
try {
|
|
handle = await page.waitForFunction(
|
|
`(() => {
|
|
const engine = window.__godsEyeView?.engine;
|
|
if (!engine || engine.isDestroyed()) return false;
|
|
engine.render.request('qa-pick-readiness');
|
|
const position = ${position};
|
|
if (!position) return false;
|
|
const box = engine.host.canvas.getBoundingClientRect();
|
|
const picked = engine.picker.pick({
|
|
x: position.x - box.left,
|
|
y: position.y - box.top,
|
|
});
|
|
return (${predicate})(picked, position) ? position : false;
|
|
})()`,
|
|
{ polling: 50, timeout: timeoutMs },
|
|
);
|
|
return await handle.jsonValue();
|
|
} catch (error) {
|
|
if (error.name === 'TimeoutError')
|
|
throw new Error('Readiness timeout: pickable target', { cause: error });
|
|
throw error;
|
|
} finally {
|
|
await handle?.dispose();
|
|
}
|
|
}
|
|
|
|
/** Wait for the restored camera over drawn frames, including request-mode globes. */
|
|
export async function whenSettled(
|
|
page,
|
|
{ frames = 3, timeoutMs = 120000 } = {},
|
|
) {
|
|
const started = Date.now();
|
|
try {
|
|
await page.waitForFunction(() => window.__godsEyeView?.engine, {
|
|
timeout: timeoutMs,
|
|
polling: 50,
|
|
});
|
|
} catch {
|
|
throw new Error(
|
|
'Readiness timeout: engineStarted, initialViewRestored, frameDrawn, noFlight, cameraInputsEnabled, cameraPoseStable',
|
|
);
|
|
}
|
|
return page.evaluate(
|
|
`(${waitForSettled})(${createSettledState}, ${JSON.stringify({ frames, timeoutMs: Math.max(1, timeoutMs - (Date.now() - started)) })})`,
|
|
);
|
|
}
|
|
|
|
/** Self-contained so production pages need no QA module. */
|
|
export function waitForSettled(createState, { frames, timeoutMs }) {
|
|
return new Promise((resolve, reject) => {
|
|
const { engine } = window.__godsEyeView;
|
|
const state = createState(engine);
|
|
let finished = false;
|
|
let remove = () => {};
|
|
const finish = (error, result) => {
|
|
if (finished) return;
|
|
finished = true;
|
|
clearTimeout(timer);
|
|
remove();
|
|
state.destroy();
|
|
if (error) reject(error);
|
|
else resolve(result);
|
|
};
|
|
const timer = setTimeout(() => {
|
|
const status = state.settled({ frames });
|
|
if (window.__godsEyeView?.engine !== engine)
|
|
status.unmet.push('engineUnchanged');
|
|
finish(
|
|
new Error(
|
|
`Readiness timeout: ${status.unmet.join(', ')}${status.restoreError ? ': ' + status.restoreError : ''}`,
|
|
),
|
|
);
|
|
}, timeoutMs);
|
|
remove = engine.frames.postRender.addEventListener(() => {
|
|
const status = state.settled({ frames });
|
|
if (window.__godsEyeView?.engine !== engine)
|
|
return finish(new Error('Readiness interrupted: engineChanged'));
|
|
if (status.settled) finish(null, status);
|
|
else
|
|
queueMicrotask(() => {
|
|
if (!finished && !engine.isDestroyed())
|
|
engine.render.request('qa-readiness');
|
|
});
|
|
});
|
|
engine.render.request('qa-readiness');
|
|
});
|
|
}
|
|
/** Wait for drawn frames when the scenario deliberately keeps a follow or orbit moving. */
|
|
export async function whenFramesDrawn(
|
|
page,
|
|
{ frames = 3, timeoutMs = 30000 } = {},
|
|
) {
|
|
return page.evaluate(
|
|
({ frames, timeoutMs }) =>
|
|
new Promise((resolve, reject) => {
|
|
const { engine } = window.__godsEyeView;
|
|
let drawn = 0;
|
|
const timer = setTimeout(() => {
|
|
remove();
|
|
reject(new Error('Readiness timeout: drawn frames'));
|
|
}, timeoutMs);
|
|
const remove = engine.frames.postRender.addEventListener(() => {
|
|
if (++drawn >= frames) {
|
|
clearTimeout(timer);
|
|
remove();
|
|
resolve();
|
|
} else {
|
|
queueMicrotask(() => engine.render.request('qa-drawn-frames'));
|
|
}
|
|
});
|
|
engine.render.request('qa-drawn-frames');
|
|
}),
|
|
{ frames, timeoutMs },
|
|
);
|
|
}
|
|
/** Tile readiness is independent of camera rest, including a keyless globe. */
|
|
export async function whenTilesLoaded(page, { timeoutMs = 30000 } = {}) {
|
|
const handle = await page.waitForFunction(
|
|
() => window.__godsEyeView?.engine?.surface.tilesLoaded(),
|
|
{ timeout: timeoutMs, polling: 100 },
|
|
);
|
|
await handle.dispose();
|
|
}
|
|
/** Wait for finite panel transitions without waiting on decorative looping animations. */
|
|
export async function whenUiSettled(page) {
|
|
const handle = await page.waitForFunction(
|
|
() => {
|
|
const app = window.__godsEyeView;
|
|
app?.engine?.render.request('qa-ui-readiness');
|
|
return (
|
|
!app?.styleManager?.transitions?.size &&
|
|
document
|
|
.getAnimations()
|
|
.every(
|
|
(animation) =>
|
|
animation.playState !== 'running' ||
|
|
animation.effect.getTiming().iterations === Infinity,
|
|
)
|
|
);
|
|
},
|
|
{ polling: 'raf', timeout: 15000 },
|
|
);
|
|
await handle.dispose();
|
|
}
|
|
/** Predicate polling keeps timeout-as-evidence scenarios explicit. */
|
|
export async function pollPage(
|
|
page,
|
|
predicate,
|
|
{ timeoutMs, intervalMs = 250, args = [] },
|
|
) {
|
|
let handle;
|
|
try {
|
|
handle = await page.waitForFunction(
|
|
predicate,
|
|
{ timeout: timeoutMs, polling: intervalMs },
|
|
...args,
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
if (error.name === 'TimeoutError') return false;
|
|
throw error;
|
|
} finally {
|
|
await handle?.dispose();
|
|
}
|
|
}
|
|
/** Poll scenario state outside the page without duplicating timeout loops. */
|
|
export async function until(
|
|
predicate,
|
|
{ timeoutMs = 30000, intervalMs = 100, label = 'scenario state' } = {},
|
|
) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
do {
|
|
const value = await predicate();
|
|
if (value) return value;
|
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
} while (Date.now() < deadline);
|
|
throw new Error(`Readiness timeout: ${label}`);
|
|
}
|