From 731eadc34a6d83e6848a8aa43b07f08116191e0b Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 16:43:58 +0400 Subject: [PATCH] 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. --- docs/CODE-BOUNDARIES.md | 11 +++++ scripts/qa/browser.mjs | 15 ++++++ scripts/qa/browser.test.mjs | 39 ++++++++++++++++ scripts/qa/geometry.mjs | 38 +++++++++++++++ scripts/qa/geometry.test.mjs | 24 ++++++++++ scripts/qa/ready.mjs | 91 ++++++++++++++++++++++++++++++++++++ scripts/qa/ready.test.mjs | 87 ++++++++++++++++++++++++++++++++++ 7 files changed, 305 insertions(+) create mode 100644 scripts/qa/browser.test.mjs create mode 100644 scripts/qa/geometry.mjs create mode 100644 scripts/qa/geometry.test.mjs create mode 100644 scripts/qa/ready.test.mjs diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index e5a9999ca..e275f04e0 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -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 diff --git a/scripts/qa/browser.mjs b/scripts/qa/browser.mjs index f91cf9695..cf4ceb528 100644 --- a/scripts/qa/browser.mjs +++ b/scripts/qa/browser.mjs @@ -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 } = {}) { diff --git a/scripts/qa/browser.test.mjs b/scripts/qa/browser.test.mjs new file mode 100644 index 000000000..9af359739 --- /dev/null +++ b/scripts/qa/browser.test.mjs @@ -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'); +}); diff --git a/scripts/qa/geometry.mjs b/scripts/qa/geometry.mjs new file mode 100644 index 000000000..c89910f50 --- /dev/null +++ b/scripts/qa/geometry.mjs @@ -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; +} diff --git a/scripts/qa/geometry.test.mjs b/scripts/qa/geometry.test.mjs new file mode 100644 index 000000000..bfa043150 --- /dev/null +++ b/scripts/qa/geometry.test.mjs @@ -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); +}); diff --git a/scripts/qa/ready.mjs b/scripts/qa/ready.mjs index 25814c04b..bcac3dc97 100644 --- a/scripts/qa/ready.mjs +++ b/scripts/qa/ready.mjs @@ -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}`); +} diff --git a/scripts/qa/ready.test.mjs b/scripts/qa/ready.test.mjs new file mode 100644 index 000000000..f82153e38 --- /dev/null +++ b/scripts/qa/ready.test.mjs @@ -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/, + ); +});