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.
This commit is contained in:
German Khokhlov committed 2026-11-10 16:43:58 +04:00
1 parent 37263a3e2b
commit 731eadc34a
7 files changed
+305

No files matched your search

+11
View File
@@ -19,6 +19,17 @@ excluded. The formatter validates every entry before writing any file.
New world text labels must use the world-overlay host; Cesium text labels are forbidden.
## QA scripts
New `scripts/qa-*.mjs` scenarios use `scripts/qa/browser.mjs` for headless launch
and bounded shutdown, and `scripts/qa/ready.mjs` for application, camera and
frame readiness. Select the GPU backend with `QA_ANGLE`. Keep timed pauses only
when elapsed time is part of the assertion, and explain that measurement.
Read scene state through the globe contract or the QA probe; native reads belong
only in explicit engine-adapter checks. The counters in
`node scripts/report-qa-harness.mjs` must not grow; lower the baseline with
`--write-baseline` after removing duplication.
## Current component ownership
Package imports use `gods-eye-view`; `package.json` is the authoritative
+15
View File
@@ -1,4 +1,19 @@
import { processTree, readProcesses } from './process.mjs';
import puppeteer from 'puppeteer';
import { chromeExecutablePath } from '../qa-chrome.mjs';
/** Keep every scenario on the same headless GPU path. */
export function launchBrowser({ args = [], ...options } = {}) {
return puppeteer.launch({
executablePath: chromeExecutablePath(),
...options,
headless: true,
args: [
`--use-angle=${process.env.QA_ANGLE || 'metal'}`,
'--enable-gpu',
...args,
],
});
}
/** Bound shutdown even when Chrome leaves descendants holding its pipes. */
export async function closeBrowser(browser, { deadlineMs = 5000 } = {}) {
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import puppeteer from 'puppeteer';
import { launchBrowser } from './browser.mjs';
test('scenario launch uses the selected ANGLE backend and stays headless', async (t) => {
const prior = process.env.QA_ANGLE;
t.after(() => {
if (prior === undefined) delete process.env.QA_ANGLE;
else process.env.QA_ANGLE = prior;
});
process.env.QA_ANGLE = 'swiftshader';
const browser = {};
let options;
t.mock.method(puppeteer, 'launch', async (value) => {
options = value;
return browser;
});
assert.equal(
await launchBrowser({
headless: false,
args: ['--no-sandbox'],
defaultViewport: { width: 800, height: 600 },
protocolTimeout: 120000,
}),
browser,
);
assert.equal(options.headless, true);
assert.deepEqual(options.args, [
'--use-angle=swiftshader',
'--enable-gpu',
'--no-sandbox',
]);
assert.deepEqual(options.defaultViewport, { width: 800, height: 600 });
assert.equal(options.protocolTimeout, 120000);
delete process.env.QA_ANGLE;
await launchBrowser();
assert.equal(options.args[0], '--use-angle=metal');
});
+38
View File
@@ -0,0 +1,38 @@
import { build } from 'vite';
import { fileURLToPath } from 'node:url';
let bundle;
/** Supply geometry to page callbacks without requiring a dev server's /src routes. */
export async function evaluateWithGeo(page, callback, ...args) {
const evaluate = new Function(
'...args',
`${await geometryBundle()}\nreturn (${callback})(qaGeo, ...args);`,
);
return page.evaluate(evaluate, ...args);
}
/** A self-contained predicate for page-side polling as well as evaluation. */
export async function withGeo(callback, ...args) {
return `(() => { ${await geometryBundle()}\nreturn (${callback})(qaGeo, ...${JSON.stringify(args)}); })()`;
}
function geometryBundle() {
bundle ??= build({
configFile: false,
envFile: false,
logLevel: 'silent',
build: {
write: false,
minify: false,
lib: {
entry: fileURLToPath(
new URL('../../src/geo/index.js', import.meta.url),
),
name: 'qaGeo',
formats: ['iife'],
},
},
}).then((result) => result[0].output[0].code);
return bundle;
}
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import { evaluateWithGeo } from './geometry.mjs';
test('page geometry works without source routes or native engine constructors', async () => {
const page = {
evaluate: (callback, ...args) =>
vm.runInNewContext(`(${callback})(...${JSON.stringify(args)})`),
};
const result = await evaluateWithGeo(
page,
(geo, lon, lat, height) => {
const point = geo.cartesian3.fromDegrees(lon, lat, height);
return geo.cartographic.fromCartesian(point);
},
23,
-12,
650,
);
assert.ok(Math.abs(result.longitude - (23 * Math.PI) / 180) < 1e-12);
assert.ok(Math.abs(result.latitude + (12 * Math.PI) / 180) < 1e-12);
assert.ok(Math.abs(result.height - 650) < 1e-6);
});
+91
View File
@@ -120,3 +120,94 @@ export function waitForSettled(createState, { frames, timeoutMs }) {
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}`);
}
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFakeEngine } from '../../src/globe/testing/fakeEngine.js';
import {
whenFramesDrawn,
whenTilesLoaded,
whenUiSettled,
pollPage,
until,
} from './ready.mjs';
test('frame readiness advances a request-mode engine and removes its listener', async (t) => {
const engine = createFakeEngine();
const previous = globalThis.window;
globalThis.window = { __godsEyeView: { engine } };
t.after(() => {
if (previous === undefined) delete globalThis.window;
else globalThis.window = previous;
engine.destroy();
});
let frames = 0;
t.mock.method(engine.render, 'request', () =>
queueMicrotask(() => {
frames++;
engine.fake.frame();
}),
);
const page = { evaluate: (fn, value) => fn(value) };
await whenFramesDrawn(page, { frames: 3, timeoutMs: 1000 });
assert.equal(frames, 3);
engine.fake.frame();
await Promise.resolve();
assert.equal(frames, 3, 'completion does not leave a render pump');
t.mock.method(engine.render, 'request', () => {});
await assert.rejects(
whenFramesDrawn(page, { timeoutMs: 5 }),
/Readiness timeout: drawn frames/,
);
});
test('page readiness disposes polling handles and only treats timeouts as absence', async () => {
let disposed = 0;
const page = {
waitForFunction: async () => ({
dispose: async () => {
disposed++;
},
}),
};
await whenTilesLoaded(page);
await whenUiSettled(page);
assert.equal(await pollPage(page, () => true, { timeoutMs: 10 }), true);
assert.equal(disposed, 3);
page.waitForFunction = async () => {
throw Object.assign(new Error('late'), { name: 'TimeoutError' });
};
assert.equal(await pollPage(page, () => false, { timeoutMs: 10 }), false);
page.waitForFunction = async () => {
throw new Error('page closed');
};
await assert.rejects(
pollPage(page, () => true, { timeoutMs: 10 }),
/page closed/,
);
});
test('scenario polling waits for evidence and identifies an unmet condition', async () => {
let calls = 0;
assert.equal(
await until(() => ++calls === 3 && 'ready', { intervalMs: 1 }),
'ready',
);
await assert.rejects(
until(() => false, {
timeoutMs: 5,
intervalMs: 1,
label: 'fixture response',
}),
/Readiness timeout: fixture response/,
);
await assert.rejects(
until(() => {
throw new Error('broken predicate');
}),
/broken predicate/,
);
});