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
+511 -59

No files matched your search

+122 -8
View File
@@ -80,6 +80,59 @@ async function prepareUi(page) {
}, FIELD); }, 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(); const browser = await launch();
try { try {
const { page, errors } = await openSessionPage( const { page, errors } = await openSessionPage(
@@ -90,8 +143,20 @@ try {
// Let the first boot's deferred listeners land before counting. // Let the first boot's deferred listeners land before counting.
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
const boot = await readSession(page); const boot = await readSession(page);
const bootListeners = await listenerCounts(page); const bootListeners = await listenerCounts(page, { bySource: true });
report.boot = boot; 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); await prepareUi(page);
const escapeBefore = await escapeEffect(page); const escapeBefore = await escapeEffect(page);
@@ -110,10 +175,24 @@ try {
await page.evaluate(() => { await page.evaluate(() => {
window.__qaParkedNavara = window.__godsEyeView.engine; window.__qaParkedNavara = window.__godsEyeView.engine;
}); });
if (report.switches.length === 1) if (report.switches.length === 1) {
report.firstRoundTripListeners = listenerDifference( await new Promise((resolve) => setTimeout(resolve, 2000));
bootListeners, report.firstRoundTrip = await listenerCounts(page, { bySource: true });
await listenerCounts(page), }
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({ report.switches.push({
kind, kind,
@@ -121,6 +200,7 @@ try {
ids: idDifference(boot.ids, state.ids), ids: idDifference(boot.ids, state.ids),
counters: state.counters, counters: state.counters,
ui, ui,
tools,
}); });
check( check(
Object.keys(idDifference(boot.ids, state.ids)).length === 0, Object.keys(idDifference(boot.ids, state.ids)).length === 0,
@@ -141,14 +221,30 @@ try {
} }
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
const after = await readSession(page); const after = await readSession(page);
const afterListeners = await listenerCounts(page); const afterListeners = await listenerCounts(page, { bySource: true });
report.after = after; 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.listeners = listenerDifference(bootListeners, afterListeners);
report.listenersSinceFirstRoundTrip = listenerDifference(
report.firstRoundTrip,
afterListeners,
);
const sessionListeners = Object.keys(report.listeners).filter(
(key) => !PARKED_RENDERER.test(key),
);
check( check(
Object.keys(report.listeners).length === 0, sessionListeners.length === 0,
'window/document listeners changed after three round trips', 'session listeners on window/document changed after three round trips',
report.listeners, report.listeners,
); );
check(
Object.keys(report.listenersSinceFirstRoundTrip).length === 0,
'listeners on window/document grew after the first round trip',
report.listenersSinceFirstRoundTrip,
);
check( check(
after.counters?.attachments === 1 + sequence.length && after.counters?.attachments === 1 + sequence.length &&
after.counters?.detaches === sequence.length, after.counters?.detaches === sequence.length,
@@ -227,6 +323,24 @@ try {
} }
report.errors = errors; report.errors = errors;
check(errors.length === 0, 'page 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)); console.log(JSON.stringify(report, null, 2));
if (!reportOnly) console.log('PASS application created once; session kept'); if (!reportOnly) console.log('PASS application created once; session kept');
} finally { } 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(); const client = await page.createCDPSession();
try { try {
const scripts = new Map();
if (bySource) {
client.on('Debugger.scriptParsed', ({ scriptId, url }) =>
scripts.set(scriptId, url),
);
await client.send('Debugger.enable');
}
const counts = {}; const counts = {};
for (const name of ['window', 'document']) { for (const name of ['window', 'document']) {
const { result } = await client.send('Runtime.evaluate', { const { result } = await client.send('Runtime.evaluate', {
@@ -301,13 +313,20 @@ export async function listenerCounts(page) {
objectId: result.objectId, objectId: result.objectId,
}); });
for (const listener of listeners) { 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; counts[key] = (counts[key] ?? 0) + 1;
} }
await client.send('Runtime.releaseObject', { await client.send('Runtime.releaseObject', {
objectId: result.objectId, objectId: result.objectId,
}); });
} }
if (bySource) await client.send('Debugger.disable');
return counts; return counts;
} finally { } finally {
await client.detach(); await client.detach();
+46 -6
View File
@@ -97,11 +97,19 @@ function liveComponents() {
: null; : null;
} }
// The page's application is created once; a switch detaches and attaches
// only its engine-bound parts. `?switch=legacy` keeps the previous route, one
// application per engine, for one release.
const legacySwitch =
new URLSearchParams(globalThis.location?.search ?? '').get('switch') ===
'legacy';
// Hand the session off and release the running engine. Its renderer is // Hand the session off and release the running engine. Its renderer is
// parked for a later switch back. // parked for a later switch back.
async function leave(from) { async function leave(from) {
const components = liveComponents(); const components = liveComponents();
if (components) { const attached = Boolean(components?.scene.engine);
if (attached) {
cover ??= keepGlobeFrame(components.scene.engine); cover ??= keepGlobeFrame(components.scene.engine);
cockpit = components.controls.styleManager.getCockpitState(); cockpit = components.controls.styleManager.getCockpitState();
handOffSession(layerSession, { handOffSession(layerSession, {
@@ -111,19 +119,42 @@ async function leave(from) {
}); });
renderers.retain(from); renderers.retain(from);
} }
await application?.destroy(); if (legacySwitch) await application?.destroy();
else if (attached) await application.detachScene({ handoff: true });
session.count('detaches'); session.count('detaches');
} }
// Start an engine on the retained session. Success means the engine runs // Start an engine on the retained session. Success means the engine runs
// with the session attached and the address's view restored; its first frame // with the session attached and the address's view restored; its first frame
// only decides when the cover goes (settle). // only decides when the cover goes (settle).
async function enter(kind) { async function enter(kind, { recovery = false } = {}) {
const app = createApplication(kind, true); if (legacySwitch) return enterLegacy(kind);
if (!application || application.getState().status !== 'ready') {
// The first engine never started: there is no application to attach to.
return enterLegacy(kind);
}
layerSession.restoringView = true;
try { try {
const { controls } = await app.start(); await application.attachScene(kind);
signal.throwIfAborted(); signal.throwIfAborted();
const style = controls.styleManager; await resume(application.getComponents().controls.styleManager);
if (recovery) session.count('rollbacks');
} catch (error) {
// pagehide owns the teardown once the lifetime is aborted. A failed
// candidate is detached; the session stays for the working engine.
if (!signal.aborted)
await application
.detachScene({ handoff: true })
.catch((cleanupError) =>
console.error('A failed globe could not be released:', cleanupError),
);
throw error;
}
}
// The session continues on the engine that attached: the view is restored,
// parked work resumes and Cockpit re-enters on the aircraft it had.
async function resume(style) {
await style.initialRestorePromise; await style.initialRestorePromise;
signal.throwIfAborted(); signal.throwIfAborted();
layerSession.restoringView = false; layerSession.restoringView = false;
@@ -141,6 +172,15 @@ async function enter(kind) {
) { ) {
style.cockpitView.setVisionMode(cockpit.visionMode); style.cockpitView.setVisionMode(cockpit.visionMode);
} }
}
// The legacy route: a new application on the retained session.
async function enterLegacy(kind) {
const app = createApplication(kind, true);
try {
const { controls } = await app.start();
signal.throwIfAborted();
await resume(controls.styleManager);
} catch (error) { } catch (error) {
// pagehide owns the teardown once the lifetime is aborted. // pagehide owns the teardown once the lifetime is aborted.
if (!signal.aborted) if (!signal.aborted)
+13 -2
View File
@@ -1,6 +1,8 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { createSceneHost } from './app/sceneHost.js';
import { attachScenePart } from './app/sceneParts.js';
import vm from 'node:vm'; import vm from 'node:vm';
import { createGoogleTokenSource } from './maps/googleTokens.js'; import { createGoogleTokenSource } from './maps/googleTokens.js';
import { import {
@@ -85,11 +87,20 @@ async function standaloneStartup(Cesium, credentials, fetchImpl) {
...(await loadPhotorealisticTileset(Cesium, options)), ...(await loadPhotorealisticTileset(Cesium, options)),
operations: {}, operations: {},
}), }),
createSceneHost,
attachScenePart,
AbortController,
AbortSignal,
}); });
vm.runInContext(source, context); vm.runInContext(source, context);
const app = context.createStandaloneApplication(credentials); const app = context.createStandaloneApplication(credentials);
// The application passes each constructor its signal and defer(cleanup). // The application passes each constructor its signal and defer(cleanup);
return app.createScene({ signal: new AbortController().signal, defer() {} }); // the scene it returns reads the attached globe.
const scene = await app.createScene({
signal: new AbortController().signal,
defer() {},
});
return scene.current();
} }
test('standalone falls back to ion when the server token route is unavailable', async () => { test('standalone falls back to ion when the server token route is unavailable', async () => {
+198 -40
View File
@@ -16,6 +16,8 @@ import {
import { mountApplicationViews } from '../ui/views/index.js'; import { mountApplicationViews } from '../ui/views/index.js';
import { createGoogleTokenSource } from '../maps/googleTokens.js'; import { createGoogleTokenSource } from '../maps/googleTokens.js';
import { resolveEngineKind } from '../app/engineChoice.js'; import { resolveEngineKind } from '../app/engineChoice.js';
import { createSceneHost } from '../app/sceneHost.js';
import { attachScenePart } from '../app/sceneParts.js';
// Only one engine may own the page at a time. // Only one engine may own the page at a time.
let owner = null; let owner = null;
@@ -36,7 +38,14 @@ function sessionChrome(layerSession) {
return (layerSession.chrome ??= {}); return (layerSession.chrome ??= {});
} }
/** Compose one engine session. Destroy it before starting another. */ /**
* Compose the page's application, created once per page. Its first globe is
* `engineKind`; `attachScene(kind)` and `detachScene()` switch engines by
* detaching and attaching only the engine-bound parts (src/app/sceneHost.js)
* while the shell, stores, voice, tools and layer models stay. Destroy it
* before creating another (the legacy route, ?switch=legacy, still builds
* one per engine).
*/
export function createStandaloneApplication({ export function createStandaloneApplication({
googleApiKey, googleApiKey,
cesiumToken, cesiumToken,
@@ -66,9 +75,119 @@ export function createStandaloneApplication({
const loadingScreen = document.getElementById('loading-screen'); const loadingScreen = document.getElementById('loading-screen');
const loaderStatus = loadingScreen.querySelector('.loader-status'); const loaderStatus = loadingScreen.querySelector('.loader-status');
let placeSearch; let placeSearch;
let catalog; let qaSources = {};
// The engine-bound parts of every phase attach through this host.
const sceneHost = createSceneHost({
onError: (error, part) =>
console.error(`The ${part} scene part failed:`, error),
});
// The attached globe (one renderer attachment with its catalog), or null
// between engines.
let current = null;
let lifetime = null;
let sessionContext = null;
/** Acquire `kind`'s renderer and build its catalog: one attachment. */
async function openScene(kind) {
const controller = new AbortController();
const releases = [];
const release = async () => {
controller.abort();
const errors = [];
while (releases.length) {
try {
await releases.pop()();
} catch (error) {
errors.push(error);
}
}
if (errors.length)
throw new AggregateError(errors, 'The globe could not be released');
};
const signal = AbortSignal.any([controller.signal, lifetime]);
try {
const scene = await createStandaloneScene({
...sessionContext,
signal,
defer: (cleanup) => releases.push(cleanup),
googleApiKey,
// Without a key, Google 3D can still load with tokens from the
// app's server when it offers them.
googleTokens: googleApiKey ? null : createGoogleTokenSource(),
cesiumToken,
loaderStatus,
engineKind: kind,
rendererCache,
layerSession,
});
signal.throwIfAborted();
// The catalog of this engine: on Navara only the layers with a Navara
// renderer (src/app/layers/renderers.js), the others' state kept for
// links. The layer models are the session's (layerSession.models).
const sceneCatalog = createStandaloneCatalog({
engineKind: kind,
layerSession,
sources: { ...sources, ...qaSources },
streetLevelProviders,
nepalBoundaryResolver: (resolverSignal) =>
scene.operations.annotationResolver.resolveRegionRingForQuery(
'Nepal',
resolverSignal,
placeSearch,
// The locator draws the border whenever it arrives.
{ budgetMs: Infinity },
),
signal,
surface: scene.operations.surface,
});
return Object.freeze({
...scene,
kind,
catalog: sceneCatalog,
release,
});
} catch (error) {
await release().catch((cleanupError) =>
console.error('A failed globe could not be released:', cleanupError),
);
throw error;
}
}
// What the phases and the page read as `scene`: the attached globe's
// handles (null between engines), the session's operations and the host.
const sceneView = Object.freeze({
get kind() {
return current?.kind ?? null;
},
get viewer() {
return current?.viewer ?? null;
},
get engine() {
return current?.engine ?? null;
},
get tileset() {
return current?.tileset ?? null;
},
get mapStackController() {
return current?.mapStackController ?? null;
},
get packRenderer() {
return current?.packRenderer ?? null;
},
get catalog() {
return current?.catalog ?? null;
},
get operations() {
return layerSession?.operations ?? current?.operations ?? null;
},
current: () => current,
sceneHost,
});
const application = createApplication({ const application = createApplication({
createScene: async (context) => { createScene: async (context) => {
lifetime = context.signal;
// The page's language listener and place search belong to the page // The page's language listener and place search belong to the page
// session when there is one (an engine switch keeps them), else to // session when there is one (an engine switch keeps them), else to
// this application. // this application.
@@ -101,26 +220,11 @@ export function createStandaloneApplication({
layerSession.lifetime.signal, layerSession.lifetime.signal,
)) ))
: createPlaceSearch(context.signal); : createPlaceSearch(context.signal);
const scene = await createStandaloneScene({
...context,
googleApiKey,
// Without a key, Google 3D can still load with tokens from the
// app's server when it offers them.
googleTokens: googleApiKey ? null : createGoogleTokenSource(),
cesiumToken,
loaderStatus,
engineKind,
rendererCache,
layerSession,
});
// The catalog of this engine: on Navara only the layers with a Navara
// renderer (src/app/layers/renderers.js; none yet), the others' state
// kept for links.
// QA fixture sources (?qaFeed=…) on the dev server only. // QA fixture sources (?qaFeed=…) on the dev server only.
const search = globalThis.location?.search ?? ''; const search = globalThis.location?.search ?? '';
// import.meta.env.DEV at the import site, so production builds drop // import.meta.env.DEV at the import site, so production builds drop
// the chunk instead of shipping it unreachable. // the chunk instead of shipping it unreachable.
const qaSources = qaSources =
import.meta.env?.DEV && import.meta.env?.DEV &&
allowQaRegistration && allowQaRegistration &&
/[?&]qaFeed=/.test(search) /[?&]qaFeed=/.test(search)
@@ -129,23 +233,26 @@ export function createStandaloneApplication({
createQaSources(parseQaFeed(search)), createQaSources(parseQaFeed(search)),
) )
: {}; : {};
catalog = createStandaloneCatalog({ // The session's own defer (its logo gaze and key when no layer
engineKind, // session holds them) outlives every globe.
layerSession, sessionContext = { defer: context.defer };
sources: { ...sources, ...qaSources }, current = await openScene(engineKind);
streetLevelProviders, // The first globe: the phases build on it and register their parts.
nepalBoundaryResolver: (signal) => await sceneHost.attach(current);
scene.operations.annotationResolver.resolveRegionRingForQuery( // After every part detached (the tools phase detaches them first),
'Nepal', // the globe's renderer is parked or released.
signal, context.defer(async () => {
placeSearch, await sceneHost.detach({
// The locator draws the border whenever it arrives. handoff: Boolean(
{ budgetMs: Infinity }, layerSession?.handoffLayers &&
!layerSession.lifetime.signal.aborted,
), ),
signal: context.signal,
surface: scene.operations.surface,
}); });
return scene; const scene = current;
current = null;
await scene?.release();
});
return sceneView;
}, },
createControls: (context) => createControls: (context) =>
createStandaloneControls({ createStandaloneControls({
@@ -154,13 +261,13 @@ export function createStandaloneApplication({
layerSession, layerSession,
loaderStatus, loaderStatus,
placeSearch, placeSearch,
catalog, catalog: current.catalog,
}), }),
createData: (context) => createData: (context) =>
createStandaloneData({ createStandaloneData({
...context, ...context,
allowQaRegistration, allowQaRegistration,
catalog, catalog: current.catalog,
layerSession, layerSession,
}), }),
createTools: async (context) => { createTools: async (context) => {
@@ -173,20 +280,29 @@ export function createStandaloneApplication({
}); });
// The engine-neutral QA probe on the debug handle (src/app/qaProbe.js), // The engine-neutral QA probe on the debug handle (src/app/qaProbe.js),
// on the dev server only: import.meta.env.DEV at the import site, so // on the dev server only: import.meta.env.DEV at the import site, so
// production builds drop it. // production builds drop it. It reads one engine: a part.
const debug = globalThis.window?.__godsEyeView; const debug = globalThis.window?.__godsEyeView;
if (import.meta.env?.DEV && debug) { if (import.meta.env?.DEV && debug) {
const { createQaProbe } = await import('../app/qaProbe.js'); const { createQaProbe } = await import('../app/qaProbe.js');
await attachScenePart(
context.scene,
{
name: 'qa-probe',
attachScene(next, { defer }) {
const probe = createQaProbe({ const probe = createQaProbe({
engine: context.scene.engine, engine: next.engine,
dataManager: context.data.dataManager, dataManager: context.data.dataManager,
availability: catalog.availability, availability: next.catalog.availability,
}); });
debug.layers = probe; debug.layers = probe;
context.defer(() => { defer(() => {
probe.destroy(); probe.destroy();
if (debug.layers === probe) delete debug.layers; if (debug.layers === probe) delete debug.layers;
}); });
},
},
{ defer: context.defer },
);
} }
return tools; return tools;
}, },
@@ -199,5 +315,47 @@ export function createStandaloneApplication({
) )
owner = null; owner = null;
}); });
return application;
/**
* Switch to `kind`: acquire its renderer and attach every part. A failure
* detaches what attached and releases the candidate; the session stays as
* it was, ready for the working engine to attach again.
*/
async function attachScene(kind) {
if (application.getState().status !== 'ready')
throw new Error('The application is not running');
if (current) throw new Error('A globe is attached; detach it first');
const next = await openScene(kind);
try {
await sceneHost.attach(next);
} catch (error) {
await next
.release()
.catch((cleanupError) =>
console.error('A failed globe could not be released:', cleanupError),
);
throw error;
}
current = next;
return sceneView;
}
/** Detach every part from the attached globe and park or release it. */
async function detachScene(options = {}) {
const scene = current;
if (!scene) return;
try {
await sceneHost.detach(options);
} finally {
current = null;
await scene.release();
}
}
return Object.freeze({
...application,
sceneHost,
attachScene,
detachScene,
});
} }
+110
View File
@@ -0,0 +1,110 @@
// The standalone application is created once; an engine switch detaches and
// attaches only its engine-bound parts. A candidate that fails releases its
// renderer, and the working engine attaches again on the same parts.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { createSceneHost } from '../app/sceneHost.js';
import { attachScenePart } from '../app/sceneParts.js';
function standalone({ failOpen = null } = {}) {
const source = readFileSync(
new URL('./application.js', import.meta.url),
'utf8',
)
.replace(/^import [^;]*;\n/gm, '')
.replace(/^export /gm, '')
.replace(/import\.meta\.env/g, '({}).env');
const opened = [];
const released = [];
const context = vm.createContext({
document: {
getElementById: () => ({ querySelector: () => ({}) }),
},
console,
resolveLocale: () => 'en',
setLocale() {},
getLocale: () => 'en',
onLocaleChange: () => () => {},
mountApplicationViews() {},
applyDocumentLanguage() {},
applyDocumentTranslations() {},
CITY_POIS: [],
createApplication: (options) => ({
...options,
subscribe() {},
getState: () => ({ status: 'ready' }),
}),
resolveEngineKind: () => 'cesium',
createStandalonePlaceSearch: () => ({ kind: 'search' }),
createStandaloneCatalog: ({ engineKind }) => ({ engineKind }),
createGoogleTokenSource: () => null,
createStandaloneScene: async ({ engineKind, defer }) => {
if (failOpen === engineKind) throw new Error(`${engineKind} refused`);
const engine = { kind: engineKind, id: opened.length };
opened.push(engine);
defer(() => released.push(engine));
return { viewer: null, engine, operations: { kind: 'operations' } };
},
createSceneHost,
attachScenePart,
AbortController,
AbortSignal,
});
vm.runInContext(source, context);
const app = context.createStandaloneApplication({});
return { app, opened, released };
}
test('the application switches engines on the parts it registered once', async () => {
const { app, opened, released } = standalone();
const scene = await app.createScene({
signal: new AbortController().signal,
defer() {},
});
assert.equal(scene.engine.kind, 'cesium');
const log = [];
await scene.sceneHost.register({
name: 'part',
attachScene: (next) => log.push(`attach ${next.kind}`),
detachScene: () => log.push('detach'),
});
await app.detachScene({ handoff: true });
assert.equal(scene.engine, null, 'no globe between engines');
assert.deepEqual(released, [opened[0]]);
await app.attachScene('navara');
assert.equal(scene.engine.kind, 'navara');
assert.equal(scene.catalog.engineKind, 'navara');
assert.deepEqual(log, ['attach cesium', 'detach', 'attach navara']);
await assert.rejects(app.attachScene('cesium'), /detach it first/);
});
test('a failed candidate releases what it attached and the working engine attaches again', async () => {
const { app, opened, released } = standalone();
const scene = await app.createScene({
signal: new AbortController().signal,
defer() {},
});
const part = {
name: 'part',
attached: [],
attachScene(next) {
if (next.kind === 'navara') throw new Error('navara part failed');
part.attached.push(next.engine);
},
};
await scene.sceneHost.register(part);
await app.detachScene({ handoff: true });
await assert.rejects(app.attachScene('navara'), /navara part failed/);
assert.equal(scene.engine, null);
assert.equal(released.length, 2, 'the candidate renderer was released');
assert.equal(released[1].kind, 'navara');
await app.attachScene('cesium');
assert.equal(scene.engine.kind, 'cesium');
assert.deepEqual(
part.attached.map(({ kind }) => kind),
['cesium', 'cesium'],
);
assert.equal(opened.length, 3);
});