Check the globe contract by behaviour on every engine

The contract suite is split into one module per area and grows from 87
to 114 checks, each named after the member it exercises: a gesture during
a flight does not cut it, a render request inside a frame draws the next
one, a pure rotation raises changed, ground corridors are as wide in CSS
pixels at device scale 2, imagery replans on resize, drapes draw only on
their surface, sampleTerrainMostDetailed keeps order and passes on a
rejection, terrainRevision changes on a new terrain and on fallback,
follows raise followChanged on retarget, and a mesh, a ground fill and an
imagery pixel draw the colour asked for. In the browser Cesium passes all
113 it runs at device scale 1 and 2; Navara passes 110 of 111 and reports
its single-colour mesh, drawn before tone mapping, as a known failure.

scripts/contract-mutants.test.mjs breaks one promise of the fake engine
at a time, 30 mutants in all, and names the check that catches each; the
list of contract members the suite never calls is down to five.

maps.errors reports tile and terrain errors where an engine has them
(the tileErrors capability: Cesium yes, Navara no), and the map
controller falls back to another map only where it does; movers.list()
rows tell whether a set is shown, as the other families do; FollowHandle
.name and PickResult.primitive are marked deprecated. qa-contract-suite
takes --dpr and the ANGLE backend from QA_ANGLE.
This commit is contained in:
German Khokhlov committed 2026-11-10 15:26:55 +04:00
1 parent 9c2a2d6951
commit b03edaaf2b
39 files changed
+7247 -5194

No files matched your search

+73 -12
View File
@@ -26,13 +26,22 @@ const option = (name) =>
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
if (!origin) {
console.error(
'Usage: node scripts/qa-contract-suite.mjs <origin> [--engine=cesium,navara] [--only=prefix,...] [--json=FILE]',
'Usage: node scripts/qa-contract-suite.mjs <origin> [--engine=cesium,navara] [--only=prefix,...] [--dpr=1|2] [--json=FILE]',
);
process.exit(2);
}
const engines = (option('engine') ?? 'cesium,navara').split(',');
// --only=prefix,prefix runs just the checks whose names start with one.
const only = option('only')?.split(',') ?? null;
const only =
option('only')
?.split(',')
.map((prefix) =>
/^(ground|imagery|movers|lines|meshes|maps):$/.test(prefix)
? prefix.replace(':', '.')
: prefix,
) ?? null;
const dpr = Number(option('dpr') ?? 1);
if (![1, 2].includes(dpr)) throw new Error('--dpr must be 1 or 2');
// Checks of state the running app drives itself, by name prefix, with the
// owner. The app sets contact sonar on every frame (src/cyberSonarEngine.js),
@@ -43,13 +52,15 @@ const APP_OWNED = Object.freeze([
async function runOn(browser, kind) {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.setViewport({ width: 1280, height: 800, deviceScaleFactor: dpr });
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
await page.setRequestInterception(true);
page.on('request', (request) => {
const { hostname } = new URL(request.url());
if (BLOCKED_HOSTS.test(hostname)) request.abort();
if (new URL(request.url()).pathname === '/__contract_missing_model.glb')
request.respond({ status: 404, body: 'Model unavailable' });
else if (BLOCKED_HOSTS.test(hostname)) request.abort();
else request.continue();
});
// A view in the link: the app starts there instead of flying to a city.
@@ -139,25 +150,53 @@ async function runOn(browser, kind) {
await page.mouse.move(...at);
}
});
await page.exposeFunction('__contractDrag', async (x, y, endX, endY) => {
const box = await page.evaluate(() => {
const rect =
window.__godsEyeView.engine.host.canvas.getBoundingClientRect();
return { left: rect.left, top: rect.top };
});
await page.mouse.move(box.left + x, box.top + y);
await page.mouse.down();
await page.mouse.move(box.left + endX, box.top + endY, { steps: 4 });
await page.mouse.up();
});
await page.exposeFunction('__contractResize', async (width, height) => {
await page.setViewport({
width: Math.round(width),
height: Math.round(height),
deviceScaleFactor: dpr,
});
});
const results = await page.evaluate(
async (appOwned, only) => {
const { engine } = window.__godsEyeView;
const waitFrames = (count) =>
new Promise((resolve) => {
new Promise((resolve, reject) => {
const timer = setTimeout(() => {
remove();
reject(new Error('Timed out waiting for a rendered frame'));
}, 10_000);
let left = count;
const remove = engine.frames.postRender.addEventListener(() => {
if (--left > 0) return;
remove();
clearTimeout(timer);
resolve();
});
engine.render.request('contract-suite');
});
const pointer = {
click: (x, y) => window.__contractPointer('click', x, y),
move: (x, y) => window.__contractPointer('move', x, y),
drag: (x, y, endX, endY) => window.__contractDrag(x, y, endX, endY),
};
const { createContractSuite } =
await import('/src/globe/testing/contractSuite.mjs');
const fixtureModule =
engine.kind === 'cesium'
? await import('/src/globe/cesium/testing/contractFixtures.js')
: await import('/src/globe/navara/testing/contractFixtures.js');
const fixtures = await fixtureModule.createContractFixtures();
const outcome = [];
// A real 2D canvas: a sprite atlas the engines can draw and pick.
const makeCanvas = (width, height) =>
@@ -189,6 +228,8 @@ async function runOn(browser, kind) {
pointer,
readRegion,
makeCanvas,
fixtures,
resize: (width, height) => window.__contractResize(width, height),
})) {
if (only && !only.some((prefix) => name.startsWith(prefix))) continue;
const owner = appOwned.find(([prefix]) => name.startsWith(prefix));
@@ -196,6 +237,12 @@ async function runOn(browser, kind) {
outcome.push({ name, skipped: owner[1] });
continue;
}
const knownFailure =
engine.kind === 'navara' &&
name ===
'meshes.createMesh: a single colour draws as asked within 10 per channel'
? 'Meshes are drawn before tone mapping'
: null;
try {
await run();
outcome.push({ name, ok: true });
@@ -203,24 +250,33 @@ async function runOn(browser, kind) {
outcome.push({
name,
ok: false,
...(knownFailure &&
String(error?.message).startsWith(
'mesh colour within 10 per channel',
) && { knownFailure }),
error: String(error?.message ?? error),
});
}
}
fixtures.destroy?.();
return outcome;
},
APP_OWNED,
only,
);
await page.close();
return { engine: kind, results, pageErrors };
return { engine: kind, dpr, results, pageErrors };
}
const browser = await puppeteer.launch({
headless: true,
// A Navara page can hold its main thread for minutes while it starts.
protocolTimeout: 600_000,
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
args: [
`--use-angle=${process.env.QA_ANGLE ?? (process.platform === 'darwin' ? 'metal' : 'swiftshader')}`,
'--enable-gpu',
'--no-sandbox',
],
});
const report = [];
try {
@@ -229,13 +285,16 @@ try {
report.push(run);
const ran = run.results.filter(({ skipped }) => !skipped);
const failed = ran.filter(({ ok }) => !ok);
const known = failed.filter(({ knownFailure }) => knownFailure);
console.log(
`${kind}: ${ran.length - failed.length}/${ran.length} checks pass`,
`${kind} DPR ${dpr}: ${ran.length - failed.length}/${ran.length} checks pass; ${known.length} known failures; ${run.results.length - ran.length} skipped`,
);
for (const { name, skipped } of run.results)
if (skipped) console.log(` skipped ${name}: ${skipped}`);
for (const { name, error } of failed)
console.log(` FAIL ${name}: ${error}`);
for (const { name, error, knownFailure } of failed)
console.log(
` ${knownFailure ? 'KNOWN FAIL' : 'FAIL'} ${name}: ${error}`,
);
for (const message of run.pageErrors)
console.log(` page error: ${message}`);
}
@@ -246,7 +305,9 @@ const json = option('json');
if (json) fs.writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`);
process.exit(
report.every(({ results }) =>
results.every(({ ok, skipped }) => ok || skipped),
results.every(
({ ok, skipped, knownFailure }) => ok || skipped || knownFailure,
),
)
? 0
: 1,