Files
gods-eye-view/scripts/qa-spatial-workspace.mjs

511 lines
16 KiB
JavaScript

#!/usr/bin/env node
/** Rendered spatial acceptance. Optional real-OSM replay isolates interaction regressions. */
import fs from 'node:fs/promises';
import puppeteer from 'puppeteer';
import sharp from 'sharp';
const base = process.env.QA_BASE_URL || 'http://localhost:4173';
const out = process.env.QA_SPATIAL_OUT || 'output/spatial-review/browser';
await fs.mkdir(out, { recursive: true });
const fixture = process.env.QA_OSM_FIXTURE
? await fs.readFile(process.env.QA_OSM_FIXTURE, 'utf8')
: null;
const headful = process.env.QA_HEADFUL === '1';
const browser = await puppeteer.launch({
headless: !headful,
...(headful
? {
executablePath:
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
}
: {}),
args: [
'--no-sandbox',
'--use-gl=angle',
headful ? '--use-angle=metal' : '--use-angle=swiftshader',
'--enable-gpu',
'--enable-unsafe-swiftshader',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
],
});
const page = await browser.newPage();
const checks = [],
errors = [];
let recorder;
const check = (name, pass, detail) => {
checks.push({ name, pass, detail });
console.log(
`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' ' + JSON.stringify(detail) : ''}`,
);
};
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
page.on('pageerror', (e) => errors.push(e.message));
if (fixture) {
// Intercept only this source at the fetch boundary. Global Chrome request
// interception stalls Cesium terrain workers and produces a blank globe.
await page.evaluateOnNewDocument((payload) => {
const original = window.fetch;
window.fetch = function (input, init) {
if (
String(input).includes('/api/overpass') &&
String(init?.body).includes('building')
)
return Promise.resolve(
new Response(payload, {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
return original.call(this, input, init);
};
}, fixture);
}
const state = () =>
page.evaluate(() => window.__godsEyeView.spatialWorkspace.diagnostics());
try {
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
await page.bringToFront();
await page.goto(
`${base}/?welcome=0#v=2&lat=30.2618&lon=-97.7431&alt=1400&heading=0&pitch=-65&roll=0&style=normal&bloom=0&sharpen=0&bi=0&bv=2&sc=0&map=esri-imagery`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() =>
window.__godsEyeView?.spatialWorkspace &&
document.querySelector('#loading-screen')?.classList.contains('hidden'),
{ timeout: 90000 },
);
await page.waitForFunction(
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
{ timeout: 45000 },
);
await wait(1500);
await page.click('.spatial-launch');
await page.click('[data-action="load"]');
await page.waitForFunction(
() =>
window.__godsEyeView.spatialWorkspace.diagnostics().loaded > 0 ||
/unavailable|timed out/.test(
document.querySelector('.spatial-status').textContent,
),
{ timeout: 50000 },
);
const loaded = await state();
check(
'real OSM buildings load within bounded count',
loaded.loaded > 0 && loaded.loaded <= 500,
loaded,
);
if (!loaded.loaded) throw new Error('No buildings loaded');
await wait(3500);
await page.screenshot({ path: `${out}/01-loaded.png` });
const pixels = await sharp(
await sharp(`${out}/01-loaded.png`)
.extract({ left: 300, top: 220, width: 600, height: 430 })
.toBuffer(),
).stats();
check(
'globe pixels actually render',
pixels.channels
.slice(0, 3)
.every((c) => c.mean > 20 && c.mean < 230 && c.stdev > 15),
pixels.channels.slice(0, 3).map((c) => ({ mean: c.mean, stdev: c.stdev })),
);
if (!checks.at(-1).pass)
throw new Error(
'Blank globe capture: do not accept recording or performance result',
);
if (process.env.QA_RECORD === '1')
recorder = await page.screencast({
path: `${out}/spatial-demo.webm`,
ffmpegPath: '/opt/homebrew/bin/ffmpeg',
fps: 24,
quality: 24,
});
await page.click('[data-action="all"]');
await page.click('[data-question="use"]');
await wait(1500);
check(
'category colors and canonical count',
(await page.$eval('.spatial-legend', (e) => !e.hidden)) &&
(await state()).selected === loaded.loaded,
);
await page.screenshot({ path: `${out}/02-categories.png` });
await page.click('.spatial-evidence summary');
check(
'evidence DOM is capped at 60 rows',
await page.$$eval('.spatial-evidence-row', (rows) => rows.length <= 60),
);
// Inspect then frame one real record, through its visible evidence button.
await page.click('.spatial-evidence-row button');
await wait(1800);
await page.screenshot({ path: `${out}/03-evidence-focus.png` });
check(
'camera focus retains selection',
(await state()).selected === loaded.loaded,
);
check(
'focus stays above loaded roofline',
await page.evaluate(() => {
const v = window.__godsEyeView.viewer;
const maximum = Math.max(
...v.dataSources
.getByName('gev-spatial-buildings')[0]
.entities.values.filter((e) => e.polygon)
.map((e) => e.polygon.extrudedHeight.getValue(v.clock.currentTime)),
);
return (
v.camera.positionCartographic.height > maximum + 100 &&
v.camera.pitch < -1.5
);
}),
);
await page.click('.spatial-evidence summary');
// Return to an overhead view with room for a drawn polygon.
await page.evaluate(() => {
const { viewer } = window.__godsEyeView;
const C = window.__CESIUM__;
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(-97.7431, 30.2672, 1200),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
viewer.scene.requestRender();
});
await wait(1200);
await page.click('[data-action="draw"]');
for (const [x, y] of [
[470, 270],
[760, 270],
[760, 570],
[470, 570],
])
await page.mouse.click(x, y);
await page.click('[data-action="finish"]');
const drawn = await state();
check(
'category colors remain enabled after drawing',
await page.$eval('.spatial-legend', (e) => !e.hidden),
);
check(
'drawn area selects a subset',
!drawn.drawing && drawn.selected > 0 && drawn.selected < loaded.loaded,
drawn,
);
check(
'previous answer marked stale',
await page.$eval('.spatial-answer', (e) =>
e.classList.contains('is-stale'),
),
);
await page.click('[data-question="overview"]');
await wait(1400);
check(
'refresh attaches calculated answer to current revision',
(await state()).answerRevision === (await state()).revision,
);
await page.screenshot({ path: `${out}/04-drawn-answer.png` });
await page.click('[data-question="height"]');
await wait(1400);
check(
'height result is explicitly recorded',
await page.$eval('.spatial-answer-text', (e) =>
/recorded|unrecorded/.test(e.textContent),
),
);
if (recorder) {
await recorder.stop();
recorder = null;
}
// Real canvas picking and additive selection, not a debug selection setter.
if (process.env.QA_RECORD === '1')
recorder = await page.screencast({
path: `${out}/spatial-conversation.webm`,
ffmpegPath: '/opt/homebrew/bin/ffmpeg',
fps: 24,
quality: 24,
});
const pickable = await page.evaluate(() => {
const v = window.__godsEyeView.viewer,
C = window.__CESIUM__,
result = [];
for (const e of v.dataSources.getByName('gev-spatial-buildings')[0].entities
.values) {
if (!e.polygon) continue;
const center = C.Cartographic.fromCartesian(
C.BoundingSphere.fromPoints(e.polygon.hierarchy.getValue().positions)
.center,
);
center.height =
(v.scene.globe.getHeight(center) || 0) +
e.polygon.extrudedHeight.getValue(v.clock.currentTime) +
0.5;
const p = C.SceneTransforms.worldToWindowCoordinates(
v.scene,
C.Cartesian3.fromRadians(
center.longitude,
center.latitude,
center.height,
),
);
if (
p &&
p.x > 280 &&
p.x < 880 &&
p.y > 180 &&
p.y < 680 &&
v.scene.pick(p)?.id?.__gevBuildingId === e.__gevBuildingId
)
result.push({ x: p.x, y: p.y, id: e.__gevBuildingId });
if (result.length === 2) break;
}
return result;
});
check(
'visible building volumes are independently pickable',
pickable.length === 2,
pickable,
);
if (pickable.length !== 2)
throw new Error('No pair of pickable building roofs');
await page.mouse.click(pickable[0].x, pickable[0].y);
await page.keyboard.down('Shift');
await page.mouse.click(pickable[1].x, pickable[1].y);
await page.keyboard.up('Shift');
check(
'Shift-click compares exactly two buildings',
(await state()).selected === 2,
);
await page.type('#spatial-question-input', 'How far apart are these?');
await page.click('.spatial-question button');
check(
'natural distance question gives a calculated measurement',
await page.$eval('.spatial-answer-text', (e) =>
/approximately .* m apart/.test(e.textContent),
),
);
await wait(1800);
await page.screenshot({ path: `${out}/06-building-comparison.png` });
if (process.env.QA_MODEL === '1') {
await page.$eval('#spatial-question-input', (e) => (e.value = ''));
await page.type(
'#spatial-question-input',
'Compare what we know about these two buildings. What cannot be established from this evidence?',
);
await page.click('.spatial-question button');
await page.waitForFunction(
() => !document.querySelector('.spatial-question button').disabled,
{ timeout: 50000 },
);
check(
'live model explains supplied spatial evidence',
await page.$eval('.spatial-answer-meta', (e) =>
e.textContent.startsWith('AI explanation'),
),
);
await wait(1800);
await page.screenshot({ path: `${out}/07-live-model-answer.png` });
}
if (recorder) {
await recorder.stop();
recorder = null;
}
// Deliberately late response must never overwrite a new selection.
await page.evaluate(() => {
const original = window.fetch;
window.fetch = function (input, init) {
if (String(input).includes('/api/openai/spatial-answer'))
return new Promise((resolve) =>
setTimeout(
() =>
resolve(
new Response(
JSON.stringify({ answer: 'STALE RESPONSE SENTINEL' }),
{ headers: { 'Content-Type': 'application/json' } },
),
),
900,
),
);
return original.call(this, input, init);
};
});
await page.$eval(
'#spatial-question-input',
(e) => (e.value = 'Explain this selection'),
);
await page.click('.spatial-question button');
await page.click('[data-action="all"]');
await wait(1100);
check(
'late model response cannot replace a new selection',
await page.$eval(
'.spatial-answer-text',
(e) => !e.textContent.includes('STALE RESPONSE SENTINEL'),
),
);
await page.click('[data-action="draw"]');
await page.mouse.move(730, 420);
await page.mouse.down();
for (let i = 1; i <= 20; i++) {
const a = (i / 20) * Math.PI * 2;
await page.mouse.move(600 + 130 * Math.cos(a), 420 + 100 * Math.sin(a));
await wait(40);
}
await page.mouse.up();
check(
'freehand drag closes a usable world-space area',
!(await state()).drawing && (await state()).selected > 0,
);
await page.screenshot({ path: `${out}/09-freehand-area.png` });
// Recovery and keyboard checks are kept out of the presentation clip.
await page.click('[data-action="draw"]');
for (const [x, y] of [
[460, 280],
[780, 550],
[470, 570],
[770, 260],
])
await page.mouse.click(x, y);
await page.click('[data-action="finish"]');
check(
'invalid outline restores camera',
!(await state()).drawing &&
(await page.evaluate(
() =>
window.__godsEyeView.viewer.scene.screenSpaceCameraController
.enableInputs,
)),
);
await page.click('[data-action="draw"]');
await page.focus('[data-action="cancel"]');
await page.keyboard.press('Enter');
check(
'Enter activates Cancel rather than finishing',
!(await state()).drawing,
);
// Measure actual browser-side selection/query costs already instrumented by the app.
await page.click('[data-action="all"]');
for (let i = 0; i < 10; i++) {
await page.click('[data-question="overview"]');
await page.click('[data-question="use"]');
}
const diagnostics = await state();
const selectionMax = Math.max(...diagnostics.metrics.selections),
localQueryMax = Math.max(
...diagnostics.metrics.queries
.filter((q) => q.kind !== 'model')
.map((q) => q.durationMs),
);
check('selection feedback below 100 ms', selectionMax < 100, {
selectionMaxMs: selectionMax,
});
check('local query below 250 ms', localQueryMax < 250, {
localQueryMaxMs: localQueryMax,
});
await page.setViewport({ width: 390, height: 844 });
await wait(700);
await page.screenshot({ path: `${out}/05-mobile.png` });
check(
'mobile spatial panel fits viewport',
await page.$eval('.spatial-panel', (e) => {
const b = e.getBoundingClientRect();
return (
b.left >= 0 &&
b.right <= innerWidth &&
b.top >= 0 &&
b.bottom <= innerHeight
);
}),
);
check(
'mobile panel leaves meaningful map space',
await page.$eval(
'.spatial-panel',
(e) => e.getBoundingClientRect().height < innerHeight * 0.65,
),
);
check(
'spatial controls leave attribution unobscured',
await page.evaluate(() => {
const credit = document
.querySelector('#cesium-credits')
.getBoundingClientRect();
return ['.spatial-panel', '.spatial-launch'].every((s) => {
const b = document.querySelector(s).getBoundingClientRect();
return (
b.bottom <= credit.top ||
b.top >= credit.bottom ||
b.right <= credit.left ||
b.left >= credit.right
);
});
}),
);
await page.click('[data-action="draw"]');
await page.screenshot({ path: `${out}/08-mobile-drawing.png` });
check(
'mobile drawing exposes the map',
await page.$eval(
'.spatial-panel',
(e) => e.getBoundingClientRect().height < 180,
),
);
check(
'mobile drawing removes unrelated controls',
await page.$eval(
'#command-dock',
(e) => getComputedStyle(e).display === 'none',
),
);
await page.click('[data-action="cancel"]');
await page.click('.spatial-close');
check(
'closing releases camera control',
!(await state()).open &&
(await page.evaluate(
() =>
window.__godsEyeView.viewer.scene.screenSpaceCameraController
.enableInputs,
)),
);
await page.evaluate(() => window.__godsEyeView.spatialWorkspace.destroy());
await wait(100);
check(
'teardown removes data source and UI',
await page.evaluate(
() =>
!document.querySelector('#spatial-workspace') &&
!Array.from(
{ length: window.__godsEyeView.viewer.dataSources.length },
(_, i) => window.__godsEyeView.viewer.dataSources.get(i).name,
).includes('gev-spatial-buildings'),
),
);
check('no browser exceptions', errors.length === 0, errors);
await fs.writeFile(
`${out}/report.json`,
JSON.stringify(
{
fixture: process.env.QA_OSM_FIXTURE || null,
browser: await browser.version(),
checks,
errors,
diagnostics,
},
null,
2,
),
);
} catch (error) {
check('harness completed', false, error.message);
await page.screenshot({ path: `${out}/failure.png` }).catch(() => {});
await fs.writeFile(
`${out}/report.json`,
JSON.stringify({ checks, errors, error: error.stack }, null, 2),
);
} finally {
if (recorder) await recorder.stop().catch(() => {});
await browser.close();
}
if (checks.some((c) => !c.pass)) process.exitCode = 1;