mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
437 lines
15 KiB
JavaScript
437 lines
15 KiB
JavaScript
// The application is created once per page: switching engines detaches and
|
|
// attaches only the engine-bound parts. This gate runs three Cesium→Navara→
|
|
// Cesium round trips and checks that every session object keeps its identity,
|
|
// an open panel, typed text and the focused field survive, Escape still does
|
|
// what it did before the switches, window/document listeners do not grow,
|
|
// and a failed candidate rolls back onto the same session objects.
|
|
//
|
|
// node scripts/qa-app-once.mjs http://127.0.0.1:<port>/ [--report]
|
|
// `--report` prints the measurements without asserting them.
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
idDifference,
|
|
launch,
|
|
listenerCounts,
|
|
listenerDifference,
|
|
openSessionPage,
|
|
previewBase,
|
|
readSession,
|
|
requestEngine,
|
|
sessionAddress,
|
|
switchEngine,
|
|
waitReady,
|
|
} from './qa/appSessionHarness.mjs';
|
|
|
|
const base = previewBase(process.argv[2]);
|
|
const reportOnly = process.argv.includes('--report');
|
|
const report = { switches: [], failures: [] };
|
|
const check = (condition, message, detail) => {
|
|
if (condition) return;
|
|
report.failures.push({ message, detail });
|
|
if (!reportOnly)
|
|
assert.fail(`${message}${detail ? ` ${JSON.stringify(detail)}` : ''}`);
|
|
};
|
|
|
|
const FIELD = '#location-search';
|
|
const PANEL = 'data-panel';
|
|
|
|
// The page's visible UI state the switches must leave alone.
|
|
const readUi = (page) =>
|
|
page.evaluate(
|
|
(field, panel) => ({
|
|
value: document.querySelector(field)?.value ?? null,
|
|
focused: document.activeElement?.id || null,
|
|
panelOpen: !document
|
|
.getElementById(panel)
|
|
?.classList.contains('collapsed'),
|
|
}),
|
|
FIELD,
|
|
PANEL,
|
|
);
|
|
|
|
// Focus the field, press Escape and read what changed; then put the state
|
|
// back. The same press before and after the switches must have the same
|
|
// effect.
|
|
async function escapeEffect(page) {
|
|
await page.focus(FIELD);
|
|
const before = await readUi(page);
|
|
await page.keyboard.press('Escape');
|
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
const after = await readUi(page);
|
|
return { before, after };
|
|
}
|
|
|
|
async function prepareUi(page) {
|
|
await page.evaluate((panel) => {
|
|
const style = window.__godsEyeView.styleManager;
|
|
style.setPanelCollapsed(panel, false, { explicit: true });
|
|
style.setPanelCollapsed('location-bar', false, { explicit: true });
|
|
}, PANEL);
|
|
await page.waitForFunction(
|
|
(field) => document.querySelector(field)?.offsetParent !== null,
|
|
{},
|
|
FIELD,
|
|
);
|
|
await page.evaluate((field) => {
|
|
const input = document.querySelector(field);
|
|
input.focus();
|
|
input.value = 'Reykjav';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}, FIELD);
|
|
}
|
|
|
|
// The session's tools: a mark on the annotation board, an imported file and
|
|
// the Draw shape, set on Cesium before the switches.
|
|
async function prepareTools(page) {
|
|
return page.evaluate(async () => {
|
|
const annotated = await window.__gevAnnotations.annotate([
|
|
{ type: 'pin', latitude: 42.3554, longitude: -71.0605, label: 'QA mark' },
|
|
]);
|
|
const file = new File(
|
|
[
|
|
JSON.stringify({
|
|
type: 'FeatureCollection',
|
|
features: [
|
|
{
|
|
type: 'Feature',
|
|
properties: { name: 'QA area' },
|
|
geometry: {
|
|
type: 'Polygon',
|
|
coordinates: [
|
|
[
|
|
[-71.07, 42.35],
|
|
[-71.05, 42.35],
|
|
[-71.05, 42.36],
|
|
[-71.07, 42.35],
|
|
],
|
|
],
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
],
|
|
'qa-area.geojson',
|
|
{ type: 'application/geo+json' },
|
|
);
|
|
const imported = await window.__gevGeoImport.importFiles([file]);
|
|
window.__gevDrawTool.setShape('line');
|
|
return { annotated: annotated.drawn, imported: imported[0]?.ok };
|
|
});
|
|
}
|
|
|
|
const readTools = (page) =>
|
|
page.evaluate(() => ({
|
|
marks: window.__gevAnnotations?.count() ?? null,
|
|
marksDrawn: window.__gevAnnotations?.attached ?? null,
|
|
files: window.__gevGeoImport?.list().length ?? null,
|
|
filesDrawn: window.__gevGeoImport?.diagnostics().drawn ?? null,
|
|
drawShape: window.__gevDrawTool?.shape ?? null,
|
|
}));
|
|
|
|
// Listeners whose difference is the parked renderer's: the Navara engine
|
|
// keeps its own input, resize and attribution listeners while it is parked
|
|
// for a switch back (scripts that are not the session's).
|
|
const PARKED_RENDERER = /@navara-engine\.js$/;
|
|
|
|
const browser = await launch();
|
|
try {
|
|
const { page, errors } = await openSessionPage(
|
|
browser,
|
|
sessionAddress(base, { engine: 'cesium' }),
|
|
);
|
|
await waitReady(page, 'cesium');
|
|
// Let the first boot's deferred listeners land before counting.
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
const boot = await readSession(page);
|
|
const bootListeners = await listenerCounts(page, { bySource: true });
|
|
report.boot = boot;
|
|
report.toolsPrepared = await prepareTools(page);
|
|
check(
|
|
report.toolsPrepared.annotated === 1 && report.toolsPrepared.imported,
|
|
'the tools could not be prepared',
|
|
report.toolsPrepared,
|
|
);
|
|
// Let the imported file finish drawing.
|
|
await page.waitForFunction(
|
|
() => window.__gevGeoImport.diagnostics().drawn === 1,
|
|
);
|
|
const toolsBefore = await readTools(page);
|
|
report.toolsBefore = toolsBefore;
|
|
|
|
await prepareUi(page);
|
|
const escapeBefore = await escapeEffect(page);
|
|
report.escapeBefore = escapeBefore;
|
|
await prepareUi(page);
|
|
const uiBefore = await readUi(page);
|
|
report.uiBefore = uiBefore;
|
|
|
|
const sequence = ['navara', 'cesium', 'navara', 'cesium', 'navara', 'cesium'];
|
|
// Dev guard: once an engine is parked, nothing may move its camera or
|
|
// ask it to draw until it is attached again (its own resume aside).
|
|
await page.evaluate(() => {
|
|
window.__qaParkedCalls = [];
|
|
window.__qaWatchParked = (engine) => {
|
|
if (!engine || engine.__qaWatched) return;
|
|
engine.__qaWatched = true;
|
|
const watch = (owner, names, label) => {
|
|
for (const name of names) {
|
|
const original = owner[name];
|
|
if (typeof original !== 'function') continue;
|
|
owner[name] = function (...args) {
|
|
const parked = window.__godsEyeView?.engine !== engine;
|
|
const resume = name === 'request' && args[0] === 'engine-resume';
|
|
if (parked && !resume && !window.__qaSwitching)
|
|
window.__qaParkedCalls.push({
|
|
call: `${engine.kind}.${label}.${name}`,
|
|
reason: typeof args[0] === 'string' ? args[0] : null,
|
|
stack: new Error().stack.split('\n').slice(2, 6).join(' | '),
|
|
});
|
|
return original.apply(this, args);
|
|
};
|
|
}
|
|
};
|
|
watch(
|
|
engine.camera,
|
|
['setView', 'flyTo', 'lookAt', 'flyToBoundingSphere', 'release'],
|
|
'camera',
|
|
);
|
|
watch(engine.render, ['request', 'setContinuous'], 'render');
|
|
};
|
|
window.__qaWatchParked(window.__godsEyeView.engine);
|
|
});
|
|
for (const kind of sequence) {
|
|
const started = Date.now();
|
|
// The switch itself parks and resumes engines; only the time an engine
|
|
// sits parked is watched.
|
|
await page.evaluate(() => {
|
|
window.__qaSwitching = true;
|
|
});
|
|
await switchEngine(page, kind);
|
|
await page.evaluate(() => {
|
|
window.__qaSwitching = false;
|
|
window.__qaWatchParked(window.__godsEyeView.engine);
|
|
});
|
|
const state = await readSession(page);
|
|
const ui = await readUi(page);
|
|
if (kind === 'navara')
|
|
await page.evaluate(() => {
|
|
window.__qaParkedNavara = window.__godsEyeView.engine;
|
|
});
|
|
if (report.switches.length === 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
report.firstRoundTrip = await listenerCounts(page, { bySource: true });
|
|
}
|
|
// Both engines draw the session's marks and files (Navara since Stage 6).
|
|
await page.waitForFunction(
|
|
() => window.__gevGeoImport.diagnostics().drawn === 1,
|
|
{ timeout: 30_000 },
|
|
);
|
|
const tools = await readTools(page);
|
|
check(
|
|
tools.marks === toolsBefore.marks &&
|
|
tools.files === toolsBefore.files &&
|
|
tools.drawShape === toolsBefore.drawShape &&
|
|
tools.marksDrawn === true &&
|
|
tools.filesDrawn === 1,
|
|
`the session tools changed on the switch to ${kind}`,
|
|
{ toolsBefore, tools },
|
|
);
|
|
report.switches.push({
|
|
kind,
|
|
ms: Date.now() - started,
|
|
ids: idDifference(boot.ids, state.ids),
|
|
counters: state.counters,
|
|
ui,
|
|
tools,
|
|
});
|
|
check(
|
|
Object.keys(idDifference(boot.ids, state.ids)).length === 0,
|
|
`session ids changed on the switch to ${kind}`,
|
|
idDifference(boot.ids, state.ids),
|
|
);
|
|
check(
|
|
ui.value === uiBefore.value,
|
|
`typed text lost on the switch to ${kind}`,
|
|
ui,
|
|
);
|
|
check(ui.panelOpen, `open panel closed on the switch to ${kind}`, ui);
|
|
check(
|
|
ui.focused === uiBefore.focused,
|
|
`focus moved on the switch to ${kind}`,
|
|
ui,
|
|
);
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
const after = await readSession(page);
|
|
const afterListeners = await listenerCounts(page, { bySource: true });
|
|
report.after = after;
|
|
// After three round trips the page holds exactly the listeners it held
|
|
// after the first boot, apart from the parked Navara renderer's own (it
|
|
// stays alive for the switch back), which the first round trip added and
|
|
// the later ones did not grow.
|
|
report.listeners = listenerDifference(bootListeners, afterListeners);
|
|
report.listenersSinceFirstRoundTrip = listenerDifference(
|
|
report.firstRoundTrip,
|
|
afterListeners,
|
|
);
|
|
const sessionListeners = Object.keys(report.listeners).filter(
|
|
(key) => !PARKED_RENDERER.test(key),
|
|
);
|
|
check(
|
|
sessionListeners.length === 0,
|
|
'session listeners on window/document changed after three round trips',
|
|
report.listeners,
|
|
);
|
|
check(
|
|
Object.keys(report.listenersSinceFirstRoundTrip).length === 0,
|
|
'listeners on window/document grew after the first round trip',
|
|
report.listenersSinceFirstRoundTrip,
|
|
);
|
|
check(
|
|
after.counters?.attachments === 1 + sequence.length &&
|
|
after.counters?.detaches === sequence.length,
|
|
'each switch detaches once and attaches once',
|
|
after.counters,
|
|
);
|
|
report.parkedEngineCalls = await page.evaluate(() => window.__qaParkedCalls);
|
|
check(
|
|
report.parkedEngineCalls.length === 0,
|
|
'a parked engine had its camera moved or a frame requested',
|
|
report.parkedEngineCalls,
|
|
);
|
|
check(
|
|
after.counters?.applications === 1,
|
|
'the application must be created once',
|
|
after.counters,
|
|
);
|
|
|
|
const escapeAfter = await escapeEffect(page);
|
|
report.escapeAfter = escapeAfter;
|
|
check(
|
|
JSON.stringify(escapeAfter) === JSON.stringify(escapeBefore),
|
|
'Escape behaves differently after the switches',
|
|
{ escapeBefore, escapeAfter },
|
|
);
|
|
|
|
// A failed candidate: the parked Navara renderer refuses to resume (as in
|
|
// qa-layer-handoff.mjs). The working Cesium attaches again on the same
|
|
// session objects.
|
|
await prepareUi(page);
|
|
const beforeRollback = await readSession(page);
|
|
const historyLength = await page.evaluate(() => history.length);
|
|
await page.evaluate(() => {
|
|
window.__qaRollback = false;
|
|
const parked = window.__qaParkedNavara;
|
|
const request = parked.render.request;
|
|
parked.render.request = function (...args) {
|
|
parked.render.request = request;
|
|
window.__qaRollback = true;
|
|
throw new Error('QA parked renderer failure');
|
|
};
|
|
});
|
|
const sabotaged = true;
|
|
if (sabotaged) {
|
|
await requestEngine(page, 'navara');
|
|
await page.waitForFunction(() => window.__qaRollback === true, {
|
|
timeout: 60_000,
|
|
});
|
|
await waitReady(page, 'cesium');
|
|
const rolledBack = await readSession(page);
|
|
const ui = await readUi(page);
|
|
report.rollback = {
|
|
ids: idDifference(beforeRollback.ids, rolledBack.ids),
|
|
counters: rolledBack.counters,
|
|
ui,
|
|
history: (await page.evaluate(() => history.length)) - historyLength,
|
|
};
|
|
check(
|
|
Object.keys(report.rollback.ids).length === 0,
|
|
'a rollback changed session ids',
|
|
report.rollback,
|
|
);
|
|
check(
|
|
report.rollback.history === 0,
|
|
'a rollback added a history entry',
|
|
report.rollback,
|
|
);
|
|
check(
|
|
ui.value === uiBefore.value && ui.panelOpen,
|
|
'a rollback lost the UI state',
|
|
ui,
|
|
);
|
|
// The page still switches after the rollback.
|
|
await switchEngine(page, 'navara');
|
|
await switchEngine(page, 'cesium');
|
|
const final = await readSession(page);
|
|
check(
|
|
Object.keys(idDifference(boot.ids, final.ids)).length === 0,
|
|
'ids changed after the rollback',
|
|
idDifference(boot.ids, final.ids),
|
|
);
|
|
|
|
// A candidate failing after the shell attached to it: a layer's
|
|
// drawing refuses to attach on Navara. The shell lets go of the failed
|
|
// globe and Cesium attaches again; the next switch works.
|
|
await page.evaluate(() => {
|
|
window.__qaDataRefused = false;
|
|
const module =
|
|
window.__godsEyeView.dataManager.layers.get('earthquakes').module;
|
|
const attach = module.attachRenderer;
|
|
module.attachRenderer = function () {
|
|
module.attachRenderer = attach;
|
|
window.__qaDataRefused = true;
|
|
return Promise.reject(new Error('QA data attach failure'));
|
|
};
|
|
});
|
|
const beforeDataFailure = await page.evaluate(() => history.length);
|
|
await requestEngine(page, 'navara');
|
|
await page.waitForFunction(() => window.__qaDataRefused === true, {
|
|
timeout: 60_000,
|
|
});
|
|
await waitReady(page, 'cesium');
|
|
const afterDataFailure = await readSession(page);
|
|
report.dataFailure = {
|
|
ids: idDifference(boot.ids, afterDataFailure.ids),
|
|
counters: afterDataFailure.counters,
|
|
history: (await page.evaluate(() => history.length)) - beforeDataFailure,
|
|
};
|
|
check(
|
|
Object.keys(report.dataFailure.ids).length === 0 &&
|
|
report.dataFailure.history === 0,
|
|
'a candidate failing in the data part changed the session',
|
|
report.dataFailure,
|
|
);
|
|
await switchEngine(page, 'navara');
|
|
await switchEngine(page, 'cesium');
|
|
check(
|
|
Object.keys(idDifference(boot.ids, (await readSession(page)).ids))
|
|
.length === 0,
|
|
'ids changed after the data-part failure',
|
|
);
|
|
}
|
|
report.errors = errors;
|
|
check(errors.length === 0, 'page errors', errors);
|
|
|
|
// The legacy route (?switch=legacy) still builds an application per
|
|
// engine, for one release.
|
|
const legacy = await openSessionPage(
|
|
browser,
|
|
sessionAddress(base, { engine: 'cesium', extra: { switch: 'legacy' } }),
|
|
);
|
|
await waitReady(legacy.page, 'cesium');
|
|
await switchEngine(legacy.page, 'navara');
|
|
await switchEngine(legacy.page, 'cesium');
|
|
report.legacy = await readSession(legacy.page);
|
|
check(
|
|
report.legacy.counters.applications === 3,
|
|
'the legacy route builds an application per engine',
|
|
report.legacy.counters,
|
|
);
|
|
check(legacy.errors.length === 0, 'legacy page errors', legacy.errors);
|
|
await legacy.page.close();
|
|
console.log(JSON.stringify(report, null, 2));
|
|
if (!reportOnly) console.log('PASS application created once; session kept');
|
|
} finally {
|
|
await browser.close();
|
|
}
|