mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
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:
1 parent
37263a3e2b
commit
731eadc34a
7 files changed
+305
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 } = {}) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user