From a99b862580b44e4291dadc379cc8e7bb4fb1e17d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 18:19:51 +0400 Subject: [PATCH] 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. --- scripts/qa-app-once.mjs | 130 ++++++++++- scripts/qa/appSessionHarness.mjs | 25 ++- src/main.js | 82 +++++-- src/mapStartup.test.mjs | 15 +- src/standalone/application.js | 252 ++++++++++++++++++---- src/standalone/applicationSwitch.test.mjs | 110 ++++++++++ 6 files changed, 533 insertions(+), 81 deletions(-) create mode 100644 src/standalone/applicationSwitch.test.mjs diff --git a/scripts/qa-app-once.mjs b/scripts/qa-app-once.mjs index c255502b1..85c62bcf0 100644 --- a/scripts/qa-app-once.mjs +++ b/scripts/qa-app-once.mjs @@ -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 { diff --git a/scripts/qa/appSessionHarness.mjs b/scripts/qa/appSessionHarness.mjs index fb6d8c70f..032f45f80 100644 --- a/scripts/qa/appSessionHarness.mjs +++ b/scripts/qa/appSessionHarness.mjs @@ -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(); diff --git a/src/main.js b/src/main.js index 224d2dd69..1e32e9648 100644 --- a/src/main.js +++ b/src/main.js @@ -97,11 +97,19 @@ function liveComponents() { : 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 // parked for a later switch back. async function leave(from) { const components = liveComponents(); - if (components) { + const attached = Boolean(components?.scene.engine); + if (attached) { cover ??= keepGlobeFrame(components.scene.engine); cockpit = components.controls.styleManager.getCockpitState(); handOffSession(layerSession, { @@ -111,36 +119,68 @@ async function leave(from) { }); renderers.retain(from); } - await application?.destroy(); + if (legacySwitch) await application?.destroy(); + else if (attached) await application.detachScene({ handoff: true }); session.count('detaches'); } // 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 // only decides when the cover goes (settle). -async function enter(kind) { +async function enter(kind, { recovery = false } = {}) { + 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 { + await application.attachScene(kind); + signal.throwIfAborted(); + 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; + signal.throwIfAborted(); + layerSession.restoringView = false; + session.count('attachments'); + resumeSession(layerSession); + // Only resume the aircraft that actually survived the handoff. A history + // entry may select a different subject, and a stale track may disappear. + const target = style.getAircraftTrackingTarget(); + if ( + cockpit?.active && + target && + target?.id === cockpit.subject?.id && + target?.layerId === cockpit.subject?.layerId && + style.controlCockpit('enter').ok + ) { + 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(); - const style = controls.styleManager; - await style.initialRestorePromise; - signal.throwIfAborted(); - layerSession.restoringView = false; - session.count('attachments'); - resumeSession(layerSession); - // Only resume the aircraft that actually survived the handoff. A history - // entry may select a different subject, and a stale track may disappear. - const target = style.getAircraftTrackingTarget(); - if ( - cockpit?.active && - target && - target?.id === cockpit.subject?.id && - target?.layerId === cockpit.subject?.layerId && - style.controlCockpit('enter').ok - ) { - style.cockpitView.setVisionMode(cockpit.visionMode); - } + await resume(controls.styleManager); } catch (error) { // pagehide owns the teardown once the lifetime is aborted. if (!signal.aborted) diff --git a/src/mapStartup.test.mjs b/src/mapStartup.test.mjs index 63b928dfc..d9cbb2b3d 100644 --- a/src/mapStartup.test.mjs +++ b/src/mapStartup.test.mjs @@ -1,6 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +import { createSceneHost } from './app/sceneHost.js'; +import { attachScenePart } from './app/sceneParts.js'; import vm from 'node:vm'; import { createGoogleTokenSource } from './maps/googleTokens.js'; import { @@ -85,11 +87,20 @@ async function standaloneStartup(Cesium, credentials, fetchImpl) { ...(await loadPhotorealisticTileset(Cesium, options)), operations: {}, }), + createSceneHost, + attachScenePart, + AbortController, + AbortSignal, }); vm.runInContext(source, context); const app = context.createStandaloneApplication(credentials); - // The application passes each constructor its signal and defer(cleanup). - return app.createScene({ signal: new AbortController().signal, defer() {} }); + // The application passes each constructor its signal and defer(cleanup); + // 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 () => { diff --git a/src/standalone/application.js b/src/standalone/application.js index 2aac4918c..f45ce9f65 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -16,6 +16,8 @@ import { import { mountApplicationViews } from '../ui/views/index.js'; import { createGoogleTokenSource } from '../maps/googleTokens.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. let owner = null; @@ -36,7 +38,14 @@ function sessionChrome(layerSession) { 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({ googleApiKey, cesiumToken, @@ -66,9 +75,119 @@ export function createStandaloneApplication({ const loadingScreen = document.getElementById('loading-screen'); const loaderStatus = loadingScreen.querySelector('.loader-status'); 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({ createScene: async (context) => { + lifetime = context.signal; // The page's language listener and place search belong to the page // session when there is one (an engine switch keeps them), else to // this application. @@ -101,26 +220,11 @@ export function createStandaloneApplication({ layerSession.lifetime.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. const search = globalThis.location?.search ?? ''; // import.meta.env.DEV at the import site, so production builds drop // the chunk instead of shipping it unreachable. - const qaSources = + qaSources = import.meta.env?.DEV && allowQaRegistration && /[?&]qaFeed=/.test(search) @@ -129,23 +233,26 @@ export function createStandaloneApplication({ createQaSources(parseQaFeed(search)), ) : {}; - catalog = createStandaloneCatalog({ - engineKind, - layerSession, - sources: { ...sources, ...qaSources }, - streetLevelProviders, - nepalBoundaryResolver: (signal) => - scene.operations.annotationResolver.resolveRegionRingForQuery( - 'Nepal', - signal, - placeSearch, - // The locator draws the border whenever it arrives. - { budgetMs: Infinity }, + // The session's own defer (its logo gaze and key when no layer + // session holds them) outlives every globe. + sessionContext = { defer: context.defer }; + current = await openScene(engineKind); + // The first globe: the phases build on it and register their parts. + await sceneHost.attach(current); + // After every part detached (the tools phase detaches them first), + // the globe's renderer is parked or released. + context.defer(async () => { + await sceneHost.detach({ + handoff: Boolean( + layerSession?.handoffLayers && + !layerSession.lifetime.signal.aborted, ), - signal: context.signal, - surface: scene.operations.surface, + }); + const scene = current; + current = null; + await scene?.release(); }); - return scene; + return sceneView; }, createControls: (context) => createStandaloneControls({ @@ -154,13 +261,13 @@ export function createStandaloneApplication({ layerSession, loaderStatus, placeSearch, - catalog, + catalog: current.catalog, }), createData: (context) => createStandaloneData({ ...context, allowQaRegistration, - catalog, + catalog: current.catalog, layerSession, }), createTools: async (context) => { @@ -173,20 +280,29 @@ export function createStandaloneApplication({ }); // 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 - // production builds drop it. + // production builds drop it. It reads one engine: a part. const debug = globalThis.window?.__godsEyeView; if (import.meta.env?.DEV && debug) { const { createQaProbe } = await import('../app/qaProbe.js'); - const probe = createQaProbe({ - engine: context.scene.engine, - dataManager: context.data.dataManager, - availability: catalog.availability, - }); - debug.layers = probe; - context.defer(() => { - probe.destroy(); - if (debug.layers === probe) delete debug.layers; - }); + await attachScenePart( + context.scene, + { + name: 'qa-probe', + attachScene(next, { defer }) { + const probe = createQaProbe({ + engine: next.engine, + dataManager: context.data.dataManager, + availability: next.catalog.availability, + }); + debug.layers = probe; + defer(() => { + probe.destroy(); + if (debug.layers === probe) delete debug.layers; + }); + }, + }, + { defer: context.defer }, + ); } return tools; }, @@ -199,5 +315,47 @@ export function createStandaloneApplication({ ) 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, + }); } diff --git a/src/standalone/applicationSwitch.test.mjs b/src/standalone/applicationSwitch.test.mjs new file mode 100644 index 000000000..35d4346f2 --- /dev/null +++ b/src/standalone/applicationSwitch.test.mjs @@ -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); +});