Files
gods-eye-view/scripts/qa/ready.mjs
T
German Khokhlov 731eadc34a Give QA scripts one launch, readiness and geometry helper
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.
2026-11-10 16:43:58 +04:00

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}`);
}