diff --git a/src/app/debugHandleSwitch.test.mjs b/src/app/debugHandleSwitch.test.mjs new file mode 100644 index 000000000..952103cea --- /dev/null +++ b/src/app/debugHandleSwitch.test.mjs @@ -0,0 +1,89 @@ +// The debug handle is one object for the page; it is on window only while a +// globe is attached, as when every engine built its own application, so +// scripts reading `__godsEyeView?.engine.kind` never see a null engine. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApplicationTools } from './tools.js'; +import { createSceneHost } from './sceneHost.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { _resetRenderGovernorForTest } from '../renderGovernor.js'; + +test('the debug handle leaves window between engines and comes back the same', async (t) => { + const saved = { + window: globalThis.window, + document: globalThis.document, + localStorage: globalThis.localStorage, + }; + globalThis.window = {}; + globalThis.localStorage = { + getItem: () => null, + setItem() {}, + removeItem() {}, + }; + globalThis.document = { + hidden: false, + getElementById: () => null, + querySelector: () => null, + body: { + classList: { add() {}, remove() {}, toggle() {}, contains: () => false }, + appendChild() {}, + }, + addEventListener() {}, + removeEventListener() {}, + }; + t.after(() => { + _resetRenderGovernorForTest(); + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete globalThis[name]; + else globalThis[name] = value; + } + }); + const sceneHost = createSceneHost(); + const navara = (kind) => ({ + kind, + viewer: null, + engine: createFakeEngine({ kind, canvas: new EventTarget() }), + tileset: null, + mapStackController: { kind: 'maps' }, + operations: { surface: {} }, + }); + let current = navara('navara'); + await sceneHost.attach(current); + const scene = { + operations: { surface: {} }, + sceneHost, + current: () => current, + }; + const cleanups = []; + await createApplicationTools({ + scene, + voice: { + createControl: () => ({ update() {}, on: () => () => {}, remove() {} }), + createCard: () => null, + createSession: () => ({ + async start() {}, + stop() {}, + sendText() {}, + sendMapEvent() {}, + }), + }, + controls: { styleManager: {}, weatherEffects: null }, + data: { + dataManager: { layers: new Map(), getAll: () => [] }, + presentation: { flushVisible() {} }, + }, + signal: new AbortController().signal, + defer: (dispose) => cleanups.push(dispose), + }); + const handle = window.__godsEyeView; + assert.equal(handle.engine, current.engine); + await sceneHost.detach({ handoff: true }); + assert.equal(window.__godsEyeView, undefined); + assert.equal(handle.engine, null); + current = navara('navara'); + await sceneHost.attach(current); + assert.equal(window.__godsEyeView, handle); + assert.equal(handle.engine, current.engine); + while (cleanups.length) await cleanups.pop()(); + assert.equal(window.__godsEyeView, undefined); +}); diff --git a/src/app/tools.js b/src/app/tools.js index f3e5b9f24..fc6a0f782 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -162,7 +162,8 @@ export async function createApplicationTools({ { defer }, ); - // The application's debug handle; its globe fields follow the engine. + // The application's debug handle, the same object on every globe; its + // globe fields follow the engine. const debug = { viewer: null, engine: null, @@ -179,7 +180,6 @@ export async function createApplicationTools({ surfaceServices: operations.surface, requestRender: governorRequestRender, }; - window.__godsEyeView = debug; defer(() => { if (window.__godsEyeView === debug) delete window.__godsEyeView; }); @@ -232,8 +232,12 @@ export async function createApplicationTools({ cockpitCloudEffects: controls.cockpitCloudEffects ?? null, voiceCommands: attached.voiceCommands, }); + // Present only while a globe is attached, as when every engine + // built its own application: scripts read `engine.kind` from it. + window.__godsEyeView = debug; }, detachScene() { + if (window.__godsEyeView === debug) delete window.__godsEyeView; Object.assign(debug, { viewer: null, engine: null, diff --git a/src/main.js b/src/main.js index 1e32e9648..8ef0f6f39 100644 --- a/src/main.js +++ b/src/main.js @@ -91,10 +91,12 @@ function createApplication(kind, engineSwitch) { return application; } +// The running application while a globe is attached to it; null while the +// page has no engine (between engines, or after a failed recovery). function liveComponents() { - return application?.getState().status === 'ready' - ? application.getComponents() - : null; + if (application?.getState().status !== 'ready') return null; + const components = application.getComponents(); + return components.scene?.engine ? components : null; } // The page's application is created once; a switch detaches and attaches @@ -108,7 +110,7 @@ const legacySwitch = // parked for a later switch back. async function leave(from) { const components = liveComponents(); - const attached = Boolean(components?.scene.engine); + const attached = Boolean(components); if (attached) { cover ??= keepGlobeFrame(components.scene.engine); cockpit = components.controls.styleManager.getCockpitState(); @@ -206,6 +208,9 @@ async function settle() { layerSession.handoffLayers = null; cockpit = null; withdrawEngineRetry(); + // An engine runs again after a failed recovery showed its error: the + // application, built once, does not start its loading chrome again. + loading.classList.add('hidden'); // The running engine owns the address again, including an entry visited // before its share link listened or while it was held. components.controls.styleManager.shareLinkManager?.resumeNavigation?.();