Create the application once per page and switch engines through the scene host

createStandaloneApplication opens its first globe, attaches the scene host and returns attachScene/detachScene: a switch hands the session off, parks the outgoing renderer and attaches the incoming one with its catalog to the parts the phases registered once. A failed candidate releases what it attached; the working engine attaches again on the same shell, voice, tools and stores. ?switch=legacy keeps the route that builds an application per engine for one release. scripts/qa-app-once.mjs now asserts identity, UI, tool and listener continuity, the rollback and the legacy route.
This commit is contained in:
German Khokhlov committed 2026-11-03 18:19:51 +04:00
1 parent 2bac200c01
commit a99b862580
6 files changed
+533 -81

No files matched your search

+122 -8
View File
@@ -80,6 +80,59 @@ async function prepareUi(page) {
}, 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(
@@ -90,8 +143,20 @@ try {
// 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);
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);
@@ -110,17 +175,32 @@ try {
await page.evaluate(() => {
window.__qaParkedNavara = window.__godsEyeView.engine;
});
if (report.switches.length === 1)
report.firstRoundTripListeners = listenerDifference(
bootListeners,
await listenerCounts(page),
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,
@@ -141,14 +221,30 @@ try {
}
await new Promise((resolve) => setTimeout(resolve, 2000));
const after = await readSession(page);
const afterListeners = await listenerCounts(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(
Object.keys(report.listeners).length === 0,
'window/document listeners changed after three round trips',
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,
@@ -227,6 +323,24 @@ try {
}
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 {
+22 -3
View File
@@ -288,10 +288,22 @@ export function readSession(page) {
}));
}
/** Live listeners on window and document, counted per event type. */
export async function listenerCounts(page) {
/**
* Live listeners on window and document, counted per event type. With
* `bySource`, each key also names the script that added the listener (its
* file name), so a listener held by the parked renderer can be told apart
* from one the session leaked.
*/
export async function listenerCounts(page, { bySource = false } = {}) {
const client = await page.createCDPSession();
try {
const scripts = new Map();
if (bySource) {
client.on('Debugger.scriptParsed', ({ scriptId, url }) =>
scripts.set(scriptId, url),
);
await client.send('Debugger.enable');
}
const counts = {};
for (const name of ['window', 'document']) {
const { result } = await client.send('Runtime.evaluate', {
@@ -301,13 +313,20 @@ export async function listenerCounts(page) {
objectId: result.objectId,
});
for (const listener of listeners) {
const key = `${name}:${listener.type}`;
const source = bySource
? `@${(scripts.get(listener.scriptId) ?? '')
.split('/')
.pop()
.replace(/-[\w-]{8}\.js$/, '.js')}`
: '';
const key = `${name}:${listener.type}${source}`;
counts[key] = (counts[key] ?? 0) + 1;
}
await client.send('Runtime.releaseObject', {
objectId: result.objectId,
});
}
if (bySource) await client.send('Debugger.disable');
return counts;
} finally {
await client.detach();