mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
The shell's scene part detaches it too, so a candidate failing in a later part (a layer's drawing, Cockpit clouds) leaves the shell free for the working engine instead of refusing every later attach. StyleManager.attachScene releases what it built when it throws, and one detach runs at a time. The controls phase awaits its parts, and a failed attachment no longer forces handoff off for the parts it releases. scripts/qa-app-once.mjs now also rolls back from a Navara candidate whose layer attachment fails.
389 lines
13 KiB
JavaScript
389 lines
13 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'];
|
|
for (const kind of sequence) {
|
|
const started = Date.now();
|
|
await switchEngine(page, kind);
|
|
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 });
|
|
}
|
|
if (kind === 'cesium')
|
|
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 === (kind === 'cesium') &&
|
|
tools.filesDrawn === (kind === 'cesium' ? 1 : 0),
|
|
`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,
|
|
);
|
|
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();
|
|
}
|