diff --git a/scripts/qa-engine-switch.mjs b/scripts/qa-engine-switch.mjs index be6a309c9..3d51bf19e 100644 --- a/scripts/qa-engine-switch.mjs +++ b/scripts/qa-engine-switch.mjs @@ -27,19 +27,49 @@ try { await page.evaluateOnNewDocument(() => { localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); const fetch = window.fetch.bind(window); - window.fetch = (url, options) => - String(url).startsWith('https://earthquake.usgs.gov/earthquakes/feed/') - ? Promise.resolve( - Response.json({ - type: 'FeatureCollection', - features: [{ - type: 'Feature', id: 'qa-boston', - geometry: { type: 'Point', coordinates: [-71.0605, 42.3554, 8] }, + // Two Boston vessels for the Contacts session; empty air and site feeds. + const fixtures = { + '/api/vessels': () => ({ + status: 'live', + rows: ['123456789', '223456789'].map((mmsi, i) => ({ + mmsi, + name: 'Boston ' + i, + lat: 42.3554, + lon: -71.0605 + i * 0.05, + last_position_epoch: Date.now() / 1000, + })), + }), + '/api/vessels/track': () => ({ samples: [] }), + '/api/flights': () => ({ time: Date.now() / 1000, states: [] }), + '/api/military': () => ({ now: Date.now(), ac: [] }), + '/api/military-installations': () => ({ elements: [], status: 'ok' }), + }; + window.fetch = (url, options) => { + if ( + String(url).startsWith('https://earthquake.usgs.gov/earthquakes/feed/') + ) + return Promise.resolve( + Response.json({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'qa-boston', + geometry: { + type: 'Point', + coordinates: [-71.0605, 42.3554, 8], + }, properties: { mag: 4, time: Date.now(), place: 'QA Boston' }, - }], - }), - ) + }, + ], + }), + ); + const fixture = + fixtures[new URL(url.url ?? url, document.baseURI).pathname]; + return fixture + ? Promise.resolve(Response.json(fixture())) : fetch(url, options); + }; }); await page.setViewport({ width: 1280, height: 800 }); const errors = []; @@ -65,19 +95,43 @@ try { const meanRed = async (enabled) => { await page.evaluate(async (enabled) => { const a = window.__godsEyeView; - await a.dataManager.setEnabled('earthquakes', enabled, { origin: 'user' }); + await a.dataManager.setEnabled('earthquakes', enabled, { + origin: 'user', + }); a.engine.render.request('qa-quake-surface'); }, enabled); - if (enabled) await page.waitForFunction(() => - window.__godsEyeView.dataManager.layers.get('earthquakes').module.getAnalystRecords().length === 1); - await new Promise(resolve => setTimeout(resolve, 1800)); - const bytes = await page.screenshot({ clip: { x: 675, y: 403, width: 20, height: 20 } }); + if (enabled) + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers + .get('earthquakes') + .module.getAnalystRecords().length === 1, + ); + await new Promise((resolve) => setTimeout(resolve, 1800)); + const bytes = await page.screenshot({ + clip: { x: 675, y: 403, width: 20, height: 20 }, + }); const { channels } = await sharp(bytes).stats(); return channels[0].mean - channels[1].mean; }; - const off = await meanRed(false), on = await meanRed(true); - assert.ok(on > off + 8, kind + ' must draw the shallow red earthquake footprint: ' + off + ' -> ' + on); - console.log('PASS', kind, 'earthquake footprint red minus green:', off, '->', on); + const off = await meanRed(false), + on = await meanRed(true); + assert.ok( + on > off + 8, + kind + + ' must draw the shallow red earthquake footprint: ' + + off + + ' -> ' + + on, + ); + console.log( + 'PASS', + kind, + 'earthquake footprint red minus green:', + off, + '->', + on, + ); } const stored = () => page.evaluate(() => localStorage.getItem('gev.engine')); async function switchTo(kind) { @@ -93,7 +147,8 @@ try { await page.waitForSelector('#globe-engine', { visible: true }); await page.evaluate(() => { window.__switchDocument = document; - window.__quakeEntry = window.__godsEyeView.dataManager.layers.get('earthquakes'); + window.__quakeEntry = + window.__godsEyeView.dataManager.layers.get('earthquakes'); window.__quakeTimer = window.__quakeEntry.intervalId; window.__renderers ??= new Map(); window.__renderers.set( @@ -121,7 +176,8 @@ try { const previous = window.__renderers.get(engine.kind); return ( !window.__switchSplash && - window.__quakeEntry === window.__godsEyeView.dataManager.layers.get('earthquakes') && + window.__quakeEntry === + window.__godsEyeView.dataManager.layers.get('earthquakes') && window.__quakeTimer === window.__quakeEntry.intervalId && (!previous || previous === engine) && !window.__outgoingEngine.isDestroyed() && @@ -199,12 +255,80 @@ try { ), ); assert.equal(await stored(), 'navara'); + // Back/Forward must hand the session off like the picker: the Contacts + // subject and its navigation history, the layers it owns and the Director + // survive, although the earlier entry carries an older view. + await page.evaluate(async () => { + const { dataManager, styleManager, sceneDirector } = window.__godsEyeView; + window.qaLayersBefore = [...dataManager.getEnabledLayerIds()].sort(); + await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' }); + const outcome = await styleManager.setContextMode('contacts'); + if (!outcome.ok) throw Error(JSON.stringify(outcome)); + window.qaSession = { + director: sceneDirector, + context: dataManager.layers.get('military-awareness').module, + vessels: dataManager.layers.get('ais-live-vessels'), + }; + if (!window.qaSession.vessels.module.selectById('123456789')) + throw Error('Missing first vessel'); + if ( + !window.qaSession.context.navigateNext({ + targetLayer: 'ais-live-vessels', + }) + ) + throw Error('Cannot navigate to the second vessel'); + }); + const session = () => + page.evaluate(() => { + const { dataManager, styleManager, sceneDirector } = window.__godsEyeView; + const { director, context, vessels } = window.qaSession; + return { + director: director === sceneDirector && !director._destroyed, + owners: + context === dataManager.layers.get('military-awareness').module && + vessels === dataManager.layers.get('ais-live-vessels'), + mode: styleManager.getContextModeState().mode, + subject: context.getContextSnapshot()?.subject?.id, + history: context._getAwarenessNavigationStateForTest().historyKeys, + enabled: [...dataManager.getEnabledLayerIds()].sort(), + }; + }); + const live = await session(); + assert.equal(live.mode, 'flights'); + assert.equal(live.subject, '223456789'); + assert.ok(live.history.includes('ais-live-vessels:123456789')); await page.goBack({ waitUntil: 'domcontentloaded' }); await ready('cesium'); + await page.waitForFunction( + () => !document.querySelector('.engine-switch-frame'), + ); assert.equal((await sharpen()).intensityPct, 49); + assert.deepEqual(await session(), live, 'Back keeps the session'); await page.goForward(); await ready('navara'); + await page.waitForFunction( + () => !document.querySelector('.engine-switch-frame'), + ); assert.equal((await sharpen()).intensityPct, 80); + assert.deepEqual(await session(), live, 'Forward keeps the session'); + assert.ok( + await page.evaluate(async () => { + const { dataManager, styleManager } = window.__godsEyeView; + const result = await styleManager.setContextMode('off'); + await dataManager.setEnabled('ais-live-vessels', false, { + origin: 'user', + }); + return ( + result.ok && + JSON.stringify([...dataManager.getEnabledLayerIds()].sort()) === + JSON.stringify(window.qaLayersBefore) + ); + }), + 'leaving Contacts after Back/Forward restores the layers it took', + ); + console.log( + 'PASS Back/Forward keep Contacts subject, history, layers and Director', + ); await page.evaluate(() => window.__godsEyeView.styleManager.setSharpen({ enabled: false, diff --git a/scripts/qa-layer-handoff.mjs b/scripts/qa-layer-handoff.mjs index 79c342c02..ea059b397 100644 --- a/scripts/qa-layer-handoff.mjs +++ b/scripts/qa-layer-handoff.mjs @@ -27,66 +27,91 @@ try { const errors = []; page.on('pageerror', (e) => errors.push(e.message)); await page.setViewport({ width: 1280, height: 720 }); - await page.evaluateOnNewDocument((layerId, aircraftId) => { - window.__qaAircraft = {layerId, aircraftId}; - const fetch = window.fetch.bind(window); - window.__flightPolls = 0; - const interval = window.setInterval.bind(window); - window.setInterval = function (callback, delay, ...args) { - if (typeof callback !== 'function') return interval(callback, delay, ...args); - const id = interval(function (...values) { - if (window.__handoff?.timer === id) window.__handoff.ticks++; - return callback.apply(this, values); - }, delay, ...args); - return id; - }; - window.fetch = (input, options) => { - const path = new URL(input.url ?? input, document.baseURI).pathname; - const now = Math.floor(Date.now() / 1000); - if (path === '/api/flights') { - if (layerId === 'flights') window.__flightPolls++; - return Promise.resolve( - Response.json({ - time: now, - states: [ - [ - '71be26', - 'GEV123', - 'QA', - now, - now, - -125.5779, - 40.2539, - 9700, - false, - 200, - 45, - 0, - null, - 9700, - null, - false, - 0, - ], - ], - }), + await page.evaluateOnNewDocument( + (layerId, aircraftId) => { + window.__qaAircraft = { layerId, aircraftId }; + const fetch = window.fetch.bind(window); + window.__flightPolls = 0; + const interval = window.setInterval.bind(window); + window.setInterval = function (callback, delay, ...args) { + if (typeof callback !== 'function') + return interval(callback, delay, ...args); + const id = interval( + function (...values) { + if (window.__handoff?.timer === id) window.__handoff.ticks++; + return callback.apply(this, values); + }, + delay, + ...args, ); - } - if (path === '/api/military') { - if (layerId === 'military') window.__flightPolls++; - return Promise.resolve(Response.json({now: Date.now(), ac: [{ - hex: 'aaa052', flight: 'GEVMIL', lon: -125.5779, lat: 40.2539, - alt_geom: 31825, alt_baro: 31825, gs: 389, track: 45, - seen: 0, seen_pos: 0, dbFlags: 1, type: 'adsb_icao', - }]})); - } - if (path === '/api/flights/track') - return Promise.resolve(Response.json({ path: [] })); - if (path.startsWith('/api/flights/') || path.startsWith('/api/openai/')) - return Promise.resolve(Response.json({})); - return fetch(input, options); - }; - }, layerId, aircraftId); + return id; + }; + window.fetch = (input, options) => { + const path = new URL(input.url ?? input, document.baseURI).pathname; + const now = Math.floor(Date.now() / 1000); + if (path === '/api/flights') { + if (layerId === 'flights') window.__flightPolls++; + return Promise.resolve( + Response.json({ + time: now, + states: [ + [ + '71be26', + 'GEV123', + 'QA', + now, + now, + -125.5779, + 40.2539, + 9700, + false, + 200, + 45, + 0, + null, + 9700, + null, + false, + 0, + ], + ], + }), + ); + } + if (path === '/api/military') { + if (layerId === 'military') window.__flightPolls++; + return Promise.resolve( + Response.json({ + now: Date.now(), + ac: [ + { + hex: 'aaa052', + flight: 'GEVMIL', + lon: -125.5779, + lat: 40.2539, + alt_geom: 31825, + alt_baro: 31825, + gs: 389, + track: 45, + seen: 0, + seen_pos: 0, + dbFlags: 1, + type: 'adsb_icao', + }, + ], + }), + ); + } + if (path === '/api/flights/track') + return Promise.resolve(Response.json({ path: [] })); + if (path.startsWith('/api/flights/') || path.startsWith('/api/openai/')) + return Promise.resolve(Response.json({})); + return fetch(input, options); + }; + }, + layerId, + aircraftId, + ); await page.goto(url.href, { waitUntil: 'domcontentloaded' }); const ready = (kind) => page.waitForFunction( @@ -116,7 +141,9 @@ try { window.__handoff = { dataManager, entry, - row: document.querySelector(`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`), + row: document.querySelector( + `[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`, + ), timer: entry.intervalId, ticks: 0, model: entry.module, @@ -139,19 +166,40 @@ try { HTMLCanvasElement.prototype.getContext = original; }; }); + const historyLength = await page.evaluate(() => history.length); await page.select('#globe-engine', 'cesium'); await page.waitForFunction(() => window.__refusedContext === true); await ready('navara'); - assert.ok(await page.evaluate(() => { - window.__restoreContext(); - const {dataManager, styleManager} = window.__godsEyeView; - return new URL(location.href).searchParams.get('engine') === 'navara' && - dataManager === window.__handoff.dataManager && - dataManager.layers.get(window.__qaAircraft.layerId) === window.__handoff.entry && - styleManager.getCockpitState().active && - styleManager.getAircraftTrackingTarget()?.id === window.__qaAircraft.aircraftId; - }), 'a failed scene must leave the live session usable'); - console.log('PASS failed renderer rolled back to the same flight and cockpit'); + // A failed switch leaves the session usable on the engine that worked and + // adds no history entry. + const survived = (kind) => + page.evaluate( + (kind, length) => { + const { dataManager, engine, styleManager } = window.__godsEyeView; + return ( + engine.kind === kind && + new URL(location.href).searchParams.get('engine') === kind && + history.length === length && + dataManager === window.__handoff.dataManager && + dataManager.layers.get(window.__qaAircraft.layerId) === + window.__handoff.entry && + styleManager.getCockpitState().active && + styleManager.getCockpitState().visionMode === 'thermal' && + styleManager.getAircraftTrackingTarget()?.id === + window.__qaAircraft.aircraftId + ); + }, + kind, + historyLength, + ); + await page.evaluate(() => window.__restoreContext()); + assert.ok( + await survived('navara'), + 'a failed scene must leave the live session usable', + ); + console.log( + 'PASS cold Cesium failure rolled back to Navara with the same flight, cockpit and history', + ); for (const kind of ['cesium', 'navara']) { await page.select('#globe-engine', kind); @@ -166,8 +214,12 @@ try { before.dataManager === dataManager && before.entry === entry && before.model === entry.module, - sameRow: Boolean(before.row) && - before.row === document.querySelector(`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`), + sameRow: + Boolean(before.row) && + before.row === + document.querySelector( + `[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`, + ), sameTimer: before.timer === entry.intervalId && entry.intervalId != null, sourcePolls: window.__flightPolls - before.pollCount, @@ -178,7 +230,9 @@ try { active: cockpit.active, mode: cockpit.visionMode, hud: !document.querySelector('#cockpit-hud').hidden, - drawn: entry.module.qaDrawn().some((row) => row.id === window.__qaAircraft.aircraftId), + drawn: entry.module + .qaDrawn() + .some((row) => row.id === window.__qaAircraft.aircraftId), height: engine.camera.height(), }; }); @@ -193,7 +247,10 @@ try { ); assert.equal(result.id, aircraftId); assert.equal(result.mode, 'thermal'); - assert.ok(result.sourcePolls <= result.timerTicks, 'switch must not add a source request'); + assert.ok( + result.sourcePolls <= result.timerTicks, + 'switch must not add a source request', + ); if (result.timerTicks === 0) { assert.equal(result.stamp, result.initialStamp); } @@ -204,6 +261,60 @@ try { `PASS ${kind} cockpit, aircraft, HUD, manager and timer (${result.sourcePolls} polls since entry)`, ); } + // The other direction, warm: the parked Navara renderer fails to resume on + // the way back from Cesium. The session rolls back to Cesium, then an + // ordinary retry starts Navara cold. + await page.evaluate(() => { + window.__parkedNavara = window.__godsEyeView.engine; + }); + await page.select('#globe-engine', 'cesium'); + await ready('cesium'); + const lengthOnCesium = await page.evaluate(() => { + const request = window.__parkedNavara.render.request; + window.__parkedNavara.render.request = function (...args) { + window.__parkedNavara.render.request = request; + window.__refusedResume = true; + throw new Error('QA parked renderer failure'); + }; + return history.length; + }); + await page.select('#globe-engine', 'navara'); + await page.waitForFunction(() => window.__refusedResume === true); + await ready('cesium'); + assert.ok( + (await page.evaluate( + (length) => history.length === length, + lengthOnCesium, + )) && + (await page.evaluate(() => { + const { styleManager } = window.__godsEyeView; + return ( + styleManager.getCockpitState().active && + styleManager.getAircraftTrackingTarget()?.id === + window.__qaAircraft.aircraftId && + new URL(location.href).searchParams.get('engine') === 'cesium' + ); + })), + 'a failed warm Navara resume must leave Cesium usable without a history entry', + ); + console.log( + 'PASS warm Navara failure rolled back to Cesium with the same flight and cockpit', + ); + await page.select('#globe-engine', 'navara'); + await ready('navara'); + assert.ok( + await page.evaluate(() => { + const { engine, styleManager } = window.__godsEyeView; + return ( + engine !== window.__parkedNavara && + styleManager.getCockpitState().active && + styleManager.getAircraftTrackingTarget()?.id === + window.__qaAircraft.aircraftId + ); + }), + 'the retry starts a new Navara renderer on the same session', + ); + console.log('PASS retry after the warm failure started Navara cold'); await page.evaluate(() => window.__godsEyeView.styleManager.controlCockpit('exit'), ); diff --git a/src/main.js b/src/main.js index 190ab1973..0af27e092 100644 --- a/src/main.js +++ b/src/main.js @@ -4,6 +4,13 @@ import { } from './standalone/application.js'; import { createRendererCache } from './standalone/rendererCache.js'; import { describeError } from './standalone/errors.js'; +import { + createEngineAddress, + createEngineSwitch, + handOffSession, + resumeSession, + waitForFirstFrame, +} from './standalone/engineSwitch.js'; import { ENGINE_STORAGE_KEY } from './app/engineChoice.js'; import { useDisplayStore } from './ui/stores/display.js'; import { applicationPinia } from './ui/vueRuntime.js'; @@ -11,37 +18,90 @@ import { t } from './i18n/index.js'; const display = useDisplayStore(applicationPinia()); const loading = document.getElementById('loading-screen'); -let kind = pageEngineKind(); let application; -let busy = false; +let engineSwitch = null; +let cover = null; +let cockpit = null; const renderers = createRendererCache(); const layerSession = { lifetime: new AbortController() }; +const { signal } = layerSession.lifetime; window.addEventListener('pagehide', (event) => { if (event.persisted) return; layerSession.lifetime.abort(); - void application?.destroy().catch(report).finally(() => renderers.clear()); + void application + ?.destroy() + .catch(report) + .finally(() => renderers.clear()); }); -async function start(next, engineSwitch = false, cockpit = null) { - kind = next; +// Registered before any engine starts, so these run ahead of the running +// session's own share-link listeners. A history visit that changes the +// engine belongs to the switch: the outgoing engine must not start restoring +// that entry's share (and release the followed subject) just before it hands +// the session off. The incoming engine reads the entry's view as it starts. +let swallowedHash = null; +window.addEventListener('popstate', (event) => { + if (!engineSwitch) return; + swallowedHash = null; + if (pageEngineKind() !== engineSwitch.state().engine) { + event.stopImmediatePropagation(); + swallowedHash = location.hash; + } + void engineSwitch.reconcile(); +}); +window.addEventListener('hashchange', (event) => { + // Back/Forward fires hashchange after popstate for the same entry. + if (swallowedHash !== null && location.hash === swallowedHash) + event.stopImmediatePropagation(); + swallowedHash = null; +}); + +function createApplication(kind, engineSwitch) { layerSession.restoringView = engineSwitch; application = createStandaloneApplication({ googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY, cesiumToken: import.meta.env.CESIUM_ION_TOKEN, allowQaRegistration: import.meta.env.DEV, - engineKind: next, + engineKind: kind, rendererCache: renderers, layerSession, engineSwitch, }); - const { scene, controls } = await application.start(); - if (engineSwitch) { + return application; +} + +function liveComponents() { + return application?.getState().status === 'ready' + ? application.getComponents() + : null; +} + +// 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) { + cover ??= keepGlobeFrame(components.scene.engine); + cockpit = components.controls.styleManager.getCockpitState(); + handOffSession(layerSession); + renderers.retain(from); + } + await application?.destroy(); +} + +// 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) { + 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; - for (const {module} of layerSession.manager.layers.values()) module.resumeViewport?.(); - if (layerSession.handoffLayers) layerSession.manager.layers.get('directions')?.module.resumeCamera?.(); - layerSession.director?.resumeScene(); + 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(); @@ -54,11 +114,32 @@ async function start(next, engineSwitch = false, cockpit = null) { ) { style.cockpitView.setVisionMode(cockpit.visionMode); } - await waitForGlobeFrame(scene.engine); + } catch (error) { + // pagehide owns the teardown once the lifetime is aborted. + if (!signal.aborted) + await app + .destroy() + .catch((cleanupError) => + console.error('A failed globe could not be released:', cleanupError), + ); + throw error; } } +async function settle() { + const engine = liveComponents()?.scene.engine; + if (cover && engine) await waitForFirstFrame(engine); + // A page left without an engine keeps the last handoff for the next try. + if (engine) { + layerSession.handoffLayers = null; + cockpit = null; + } + cover?.remove(); + cover = null; +} + function present() { + const { kind, busy } = engineSwitch.state(); display.presentGlobeEngine({ kind, busy, select }); } @@ -71,14 +152,7 @@ function report(error) { } function select(next) { - if (busy || kind === next || !['cesium', 'navara'].includes(next)) return; - application.getComponents().controls.styleManager.shareLinkManager.flush(); - const url = new URL(location.href); - url.searchParams.set('engine', kind); - history.replaceState(history.state, '', url); - url.searchParams.set('engine', next); - history.pushState(null, '', url); - void reconcile(true); + void engineSwitch?.select(next); } // Keep the last visible frame while the first visit initializes the other renderer. @@ -97,94 +171,40 @@ function keepGlobeFrame(engine) { return frame; } -// Hand off the visible globe only after the new camera has actually drawn. -function waitForGlobeFrame(engine) { - if (document.hidden) return; - return new Promise((resolve, reject) => { - const finish = () => { - remove(); - clearTimeout(timer); - document.removeEventListener('visibilitychange', done); - }; - const remove = engine.frames.postRender.addEventListener(done); - const timer = setTimeout(() => { - finish(); - reject(new Error('The globe did not draw a frame')); - }, 8000); - function done() { - finish(); - resolve(); - } - document.addEventListener('visibilitychange', done, { once: true }); - engine.render.request('engine-switch'); - }); -} - -// popstate can arrive again while an engine is starting. After teardown and -// after startup, read the actual address so the latest history entry wins. -async function reconcile(remember = false) { - if (busy) return; - busy = true; - present(); - const previous = kind; - let cover; - let cockpit; - const handoffHash = remember ? location.hash : null; - try { - while (pageEngineKind() !== kind) { - cover ??= keepGlobeFrame(application.getComponents().scene.engine); - cockpit = application - .getComponents() - .controls.styleManager.getCockpitState(); - const manager = application.getComponents().data.dataManager; - layerSession.handoffLayers = location.hash === handoffHash - ? new Map([...manager.layers].map(([id, entry]) => [ - id, { entry: entry.module.attachRenderer ? entry : null, enabled: manager.isEffectivelyEnabled(id) }, - ])) - : null; - if (layerSession.handoffLayers) { - manager.layers.get('directions')?.module.suspendCamera?.(); - manager.layers.get('satellites')?.module.suspendCamera?.(); - layerSession.director?.suspendScene(); - } - renderers.retain(kind); - await application.destroy(); - await start(pageEngineKind(), true, cockpit); - } - if (remember) { - try { - localStorage.setItem(ENGINE_STORAGE_KEY, kind); - } catch { - // The current URL still carries the choice when storage is blocked. - } - } - } catch (error) { - // Keep a failed renderer from stranding a previously working session. - const url = new URL(location.href); - url.searchParams.set('engine', previous); - history.replaceState(history.state, '', url); - try { - await application.destroy(); - await start(previous, true, cockpit); - application - .getComponents() - .controls.styleManager._showToast(describeError(error)); - } catch (recoveryError) { - report(recoveryError); - } - } finally { - layerSession.handoffLayers = null; - cover?.remove(); - busy = false; - present(); - } -} - -start(kind) +const initialKind = pageEngineKind(); +createApplication(initialKind, false) + .start() .then(() => { + engineSwitch = createEngineSwitch({ + kind: initialKind, + requested: pageEngineKind, + signal, + leave, + enter, + settle, + address: createEngineAddress({ + location, + history, + flush: () => + liveComponents()?.controls.styleManager.shareLinkManager?.flush(), + }), + remember(kind) { + try { + localStorage.setItem(ENGINE_STORAGE_KEY, kind); + } catch { + // The current URL still carries the choice when storage is blocked. + } + }, + onChange: present, + onFailure(error, { recovered }) { + const style = recovered && liveComponents()?.controls.styleManager; + if (style) style._showToast(describeError(error)); + else report(error); + }, + }); present(); - window.addEventListener('popstate', () => void reconcile()); - void reconcile(); + // A history visit made while the first engine started. + void engineSwitch.reconcile(); }) .catch(report); diff --git a/src/standalone/engineSwitch.js b/src/standalone/engineSwitch.js new file mode 100644 index 000000000..808c1d0b5 --- /dev/null +++ b/src/standalone/engineSwitch.js @@ -0,0 +1,308 @@ +// The page's engine switch: one layer session, one engine at a time. +// +// The address (?engine=) is the source of truth. A switch from the picker +// (select) and a history visit (Back/Forward, a pasted address) are the same +// operation: the running engine hands the session off and is released, the +// requested engine starts on the retained session. Requests that arrive while +// a switch or a recovery runs are never dropped: once the current attempt +// settles, the address is read again and reconciled. + +const ENGINES = Object.freeze(['cesium', 'navara']); + +/** A started engine whose first frame takes longer than this is shown anyway. */ +export const FIRST_FRAME_WAIT_MS = 10_000; + +/** + * Drive engine switches for one page session. + * + * Every attempt has a generation. A failed attempt rolls back to the last + * engine that actually started (never to the one that just failed) and, when + * that recovery fails too, leaves the page without an engine but still able + * to take the next request. A page lifetime abort (pagehide) stops the + * machine: nothing starts after it. + * + * @param {object} options + * @param {string} options.kind The engine already running. + * @param {() => string} options.requested The engine the address asks for now. + * @param {AbortSignal} options.signal The page lifetime. + * @param {(from: string, to: string) => Promise} options.leave Hand the + * session off from the running engine and release that engine. + * @param {(kind: string, attempt: {generation: number, recovery: boolean}) => Promise} options.enter + * Start an engine on the retained session. It resolves once the engine runs + * with the session attached; when it rejects it has already released what + * it started. + * @param {{select: (from: ?string, to: string) => *, commit: (token: *) => void, restore: (kind: string) => void}} options.address + * @param {(state: {failed: boolean}) => Promise|void} [options.settle] Runs + * once the address is reconciled, before the machine reports idle. + * @param {(kind: string) => void} [options.remember] Store a picked engine. + * @param {(state: object) => void} [options.onChange] + * @param {(error: unknown, outcome: {recovered: boolean, kind: ?string}) => void} [options.onFailure] + */ +export function createEngineSwitch({ + kind, + requested, + signal, + leave, + enter, + address, + settle, + remember, + onChange, + onFailure, +}) { + // The engine whose session is live, null while none is. + let current = kind; + // The engine whose application exists or is starting: what a history visit + // would leave. + let live = kind; + // The last engine that started successfully: the rollback target. + let working = kind; + let failed = false; + let busy = false; + let generation = 0; + // Every request (select or address visit) bumps this; a visit also bumps + // `visits`, which retires a select's pending history entry. + let requests = 0; + let visits = 0; + let queued = null; + let pending = null; + let running = null; + + const state = () => + Object.freeze({ + kind: current ?? working, + engine: live, + busy, + failed, + generation, + }); + const publish = () => onChange?.(state()); + + function prepare(next) { + pending = { to: next, visits, token: address.select(current, next) }; + } + + /** The picker's choice. While a switch runs it is queued, not dropped. */ + function select(next) { + if (!ENGINES.includes(next) || signal.aborted) return null; + requests++; + if (busy) { + queued = next; + return running; + } + if (next === current) return null; + prepare(next); + return run(); + } + + /** The address changed (history visit) or should be checked again. */ + function reconcile() { + if (signal.aborted) return Promise.resolve(); + requests++; + visits++; + // A later visit supersedes a select queued before it. + queued = null; + return busy ? running : run(); + } + + function run() { + busy = true; + publish(); + running = drive() + .catch((error) => onFailure?.(error, { recovered: false, kind: null })) + .finally(() => { + busy = false; + running = null; + publish(); + }); + return running; + } + + async function drive() { + for (;;) { + await reconcileAddress(); + if (signal.aborted) return; + const seen = requests; + await settle?.({ failed }); + if (signal.aborted || seen === requests) return; + } + } + + async function reconcileAddress() { + let handled = null; + for (;;) { + if (signal.aborted) return; + if (queued !== null) { + const next = queued; + queued = null; + if (next !== current) prepare(next); + } + const target = requested(); + if (target === current) return; + // After a failed recovery, wait for the person's next request instead + // of retrying the same address in a loop. + if (failed && handled === requests) return; + handled = requests; + await attempt(target); + } + } + + async function attempt(target) { + const id = ++generation; + const visitsAtStart = visits; + publish(); + try { + if (current !== null) { + const from = current; + try { + await leave(from, target); + } finally { + current = null; + } + } + if (signal.aborted) return; + live = target; + await enter(target, { generation: id, recovery: false }); + if (signal.aborted) return; + current = working = target; + failed = false; + if (pending?.to === target && pending.visits === visits) { + address.commit(pending.token); + remember?.(target); + } + pending = null; + } catch (error) { + if (signal.aborted) return; + pending = null; + // Point this history entry back at the working engine unless the + // person has since moved to another entry. Nothing is pushed, so a + // failed switch leaves no extra entry behind. + if (visits === visitsAtStart && requested() === target) + address.restore(working); + if (working === target) { + live = null; + failed = true; + onFailure?.(error, { recovered: false, kind: null }); + return; + } + live = working; + try { + await enter(working, { generation: id, recovery: true }); + if (signal.aborted) return; + current = working; + failed = false; + onFailure?.(error, { recovered: true, kind: working }); + } catch (recoveryError) { + if (signal.aborted) return; + live = null; + failed = true; + onFailure?.(recoveryError, { recovered: false, kind: null }); + } + } + } + + return Object.freeze({ select, reconcile, state }); +} + +/** + * The engine choice in the page address. A picked engine replaces the + * current entry while it starts; only a successful switch adds the history + * entry (the earlier entry keeps the outgoing engine and view), so Back + * returns to the previous engine and a failed switch adds nothing. + */ +export function createEngineAddress({ location, history, flush = () => {} }) { + const withEngine = (engine) => { + const url = new URL(location.href); + url.searchParams.set('engine', engine); + return url.href; + }; + return Object.freeze({ + select(from, to) { + flush(); + const previous = from ? withEngine(from) : null; + history.replaceState(history.state, '', withEngine(to)); + return previous; + }, + commit(previous) { + if (!previous) return; + const next = location.href; + history.replaceState(history.state, '', previous); + history.pushState(null, '', next); + }, + restore(engine) { + history.replaceState(history.state, '', withEngine(engine)); + }, + }); +} + +/** + * Hand the session off before the running engine is released: record which + * layers run so the next engine retains them, and park the cameras and the + * Director scene instead of ending them. Every engine change in this page + * hands off, whatever started it; the incoming engine reads the view + * (camera, style, map) from the address it starts with, which for a history + * visit may differ from the view that was showing. + */ +export function handOffSession(session) { + const manager = session.manager; + session.handoffLayers = new Map( + [...manager.layers].map(([id, entry]) => [ + id, + { + entry: entry.module.attachRenderer ? entry : null, + enabled: manager.isEffectivelyEnabled(id), + }, + ]), + ); + manager.layers.get('directions')?.module.suspendCamera?.(); + manager.layers.get('satellites')?.module.suspendCamera?.(); + session.director?.suspendScene(); +} + +/** Resume what handOffSession parked, once the incoming view is restored. */ +export function resumeSession(session) { + for (const { module } of session.manager.layers.values()) + module.resumeViewport?.(); + if (session.handoffLayers) + session.manager.layers.get('directions')?.module.resumeCamera?.(); + session.director?.resumeScene(); +} + +/** + * Wait for a started engine's first frame before the cover over it goes. + * + * A switch succeeds when the engine has started with the session attached; + * this wait never fails it. It resolves true on the first drawn frame and + * false when the tab is hidden (a hidden tab draws nothing and shows nothing, + * so there is nothing to wait for) or after `timeoutMs` of visible time + * without a frame, so a slow GPU leaves the new globe uncovered rather than + * rolling a healthy engine back. + */ +export function waitForFirstFrame( + engine, + { + document = globalThis.document, + timeoutMs = FIRST_FRAME_WAIT_MS, + setTimer = setTimeout, + clearTimer = clearTimeout, + } = {}, +) { + if (document?.hidden) return Promise.resolve(false); + return new Promise((resolve) => { + let timer = null; + let remove = () => {}; + const finish = (drawn) => { + remove(); + clearTimer(timer); + document?.removeEventListener?.('visibilitychange', hidden); + resolve(drawn); + }; + function hidden() { + if (document.hidden) finish(false); + } + remove = engine.frames.postRender.addEventListener(() => finish(true)); + timer = setTimer(() => finish(false), timeoutMs); + document?.addEventListener?.('visibilitychange', hidden); + engine.render.request('engine-switch'); + }); +} diff --git a/src/standalone/engineSwitch.test.mjs b/src/standalone/engineSwitch.test.mjs new file mode 100644 index 000000000..67928fff3 --- /dev/null +++ b/src/standalone/engineSwitch.test.mjs @@ -0,0 +1,556 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createEngineAddress, + createEngineSwitch, + handOffSession, + resumeSession, + waitForFirstFrame, +} from './engineSwitch.js'; + +const ORIGIN = 'https://gev.test/'; + +/** A browser history with entries, Back/Forward and the page address. */ +function fakeBrowser(urls) { + const entries = urls.map((url) => ({ + url: new URL(url, ORIGIN).href, + state: null, + })); + let index = entries.length - 1; + const location = { + get href() { + return entries[index].url; + }, + }; + const history = { + get state() { + return entries[index].state; + }, + replaceState(state, _title, url) { + entries[index] = { url: new URL(url, location.href).href, state }; + }, + pushState(state, _title, url) { + entries.splice(index + 1); + entries.push({ url: new URL(url, location.href).href, state }); + index += 1; + }, + }; + return { + location, + history, + entries, + go(delta) { + index += delta; + }, + get index() { + return index; + }, + engines: () => + entries.map(({ url }) => new URL(url).searchParams.get('engine')), + requested: () => + new URL(location.href).searchParams.get('engine') ?? 'cesium', + }; +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** A layer session with the parts a handoff parks and resumes. */ +function fakeSession(log) { + const module = (id, extra = {}) => ({ + attachRenderer() {}, + resumeViewport: () => log.push(`viewport:${id}`), + ...extra, + }); + const layers = new Map([ + ['flights', { module: module('flights') }], + [ + 'directions', + { + module: module('directions', { + suspendCamera: () => log.push('suspend:directions'), + resumeCamera: () => log.push('resume:directions'), + }), + }, + ], + [ + 'satellites', + { + module: module('satellites', { + suspendCamera: () => log.push('suspend:satellites'), + }), + }, + ], + ]); + return { + handoffLayers: null, + manager: { + layers, + isEffectivelyEnabled: (id) => id === 'flights', + }, + director: { + suspendScene: () => log.push('suspend:director'), + resumeScene: () => log.push('resume:director'), + destroy: () => log.push('destroy:director'), + }, + }; +} + +/** + * The machine wired to a fake browser, session and renderers. `outcome(kind, + * attempt)` may return a promise or throw to script each start. + */ +function harness({ + urls = ['/?engine=cesium'], + kind = 'cesium', + outcome, +} = {}) { + const browser = fakeBrowser(urls); + const lifetime = new AbortController(); + const log = []; + const remembered = []; + const failures = []; + const session = fakeSession(log); + // Parked renderers: a start of a parked engine is warm, any other cold. + const parked = new Set(); + let running = kind; + const machine = createEngineSwitch({ + kind, + requested: browser.requested, + signal: lifetime.signal, + async leave(from, to) { + log.push(`leave:${from}->${to}`); + handOffSession(session); + parked.add(from); + running = null; + }, + async enter(next, attempt) { + const warm = parked.delete(next); + log.push( + `enter:${next}:${warm ? 'warm' : 'cold'}${attempt.recovery ? ':recovery' : ''}`, + ); + await outcome?.(next, { ...attempt, warm }); + running = next; + resumeSession(session); + }, + address: createEngineAddress({ + location: browser.location, + history: browser.history, + flush: () => log.push('flush'), + }), + settle: () => { + if (running) session.handoffLayers = null; + }, + remember: (next) => remembered.push(next), + onFailure: (error, outcome) => + failures.push({ message: error.message, ...outcome }), + }); + return { + browser, + lifetime, + log, + remembered, + failures, + session, + parked, + machine, + }; +} + +test('a picked engine hands the session off and adds one history entry', async () => { + const h = harness({ urls: ['/?welcome=0#lat=1'] }); + await h.machine.select('navara'); + assert.deepEqual(h.browser.engines(), ['cesium', 'navara']); + assert.equal(new URL(h.browser.entries[0].url).hash, '#lat=1'); + assert.deepEqual(h.remembered, ['navara']); + assert.deepEqual(h.log, [ + 'flush', + 'leave:cesium->navara', + 'suspend:directions', + 'suspend:satellites', + 'suspend:director', + 'enter:navara:cold', + 'viewport:flights', + 'viewport:directions', + 'viewport:satellites', + 'resume:directions', + 'resume:director', + ]); + assert.equal(h.machine.state().kind, 'navara'); + assert.equal(h.machine.state().busy, false); +}); + +test('Back and Forward hand the session off exactly like the picker', async () => { + const h = harness(); + await h.machine.select('navara'); + const picked = h.log.splice(0).filter((line) => line !== 'flush'); + let captured; + h.session.director.suspendScene = () => { + captured = h.session.handoffLayers; + h.log.push('suspend:director'); + }; + h.browser.go(-1); + await h.machine.reconcile(); + assert.equal(h.machine.state().kind, 'cesium'); + // The same handoff, mirrored: layers retained, cameras and Director parked + // and resumed, nothing destroyed. + assert.deepEqual( + h.log, + picked.map((line) => + line + .replace('leave:cesium->navara', 'leave:navara->cesium') + .replace('enter:navara:cold', 'enter:cesium:warm'), + ), + ); + assert.ok(!h.log.includes('destroy:director')); + assert.deepEqual(captured.get('flights').enabled, true); + assert.equal( + captured.get('flights').entry, + h.session.manager.layers.get('flights'), + ); + h.browser.go(1); + await h.machine.reconcile(); + assert.equal(h.machine.state().kind, 'navara'); + assert.ok(h.log.includes('leave:cesium->navara')); + // History visits neither add entries nor change the stored choice. + assert.deepEqual(h.browser.engines(), ['cesium', 'navara']); + assert.deepEqual(h.remembered, ['navara']); +}); + +test('a visit during a switch is reconciled once the attempt settles', async () => { + const gate = deferred(); + const h = harness({ + urls: ['/?engine=cesium#before', '/?engine=cesium#now'], + outcome: (kind) => (kind === 'navara' ? gate.promise : undefined), + }); + const switching = h.machine.select('navara'); + assert.equal(h.machine.state().busy, true); + // Back while Navara starts: the earlier entry asks for Cesium. + h.browser.go(-1); + const visit = h.machine.reconcile(); + gate.resolve(); + await Promise.all([switching, visit]); + assert.equal(h.machine.state().kind, 'cesium'); + assert.deepEqual( + h.log.filter( + (line) => line.startsWith('enter') || line.startsWith('leave'), + ), + [ + 'leave:cesium->navara', + 'enter:navara:cold', + 'leave:navara->cesium', + 'enter:cesium:warm', + ], + ); + // The pick was superseded before it settled: no entry was pushed for it. + assert.equal(h.browser.entries.length, 2); + assert.deepEqual(h.remembered, []); +}); + +test('rollback goes to the last engine that worked, not the one that just failed', async () => { + const gate = deferred(); + const h = harness({ + urls: ['/?engine=cesium#before', '/?engine=cesium#now'], + outcome: (kind, { recovery }) => { + if (kind === 'navara' && !recovery) return gate.promise; + if (kind === 'cesium') throw new Error('Cesium refused'); + }, + }); + const switching = h.machine.select('navara'); + h.browser.go(-1); + void h.machine.reconcile(); + gate.resolve(); + await switching; + assert.deepEqual( + h.log.filter((line) => line.startsWith('enter')), + ['enter:navara:cold', 'enter:cesium:warm', 'enter:navara:warm:recovery'], + ); + assert.equal(h.machine.state().kind, 'navara'); + assert.equal(h.machine.state().failed, false); + assert.deepEqual(h.failures, [ + { message: 'Cesium refused', recovered: true, kind: 'navara' }, + ]); + // The visited entry now names the engine that runs; no entry was added. + assert.equal(h.browser.index, 0); + assert.deepEqual(h.browser.engines(), ['navara', 'navara']); +}); + +test('a pick made during a switch is queued, not dropped', async () => { + const gate = deferred(); + const h = harness({ + outcome: (kind, { warm }) => + kind === 'navara' && !warm ? gate.promise : undefined, + }); + const switching = h.machine.select('navara'); + assert.equal(h.machine.select('cesium'), switching); + gate.resolve(); + await switching; + assert.equal(h.machine.state().kind, 'cesium'); + assert.deepEqual(h.browser.engines(), ['cesium', 'navara', 'cesium']); + assert.deepEqual(h.remembered, ['navara', 'cesium']); +}); + +for (const [from, to] of [ + ['cesium', 'navara'], + ['navara', 'cesium'], +]) { + test(`a failed ${from} to ${to} switch rolls back without a history entry`, async () => { + let refuse = true; + const h = harness({ + urls: [`/?engine=${from}#view`], + kind: from, + outcome: (kind) => { + if (kind === to && refuse) throw new Error(`${to} refused`); + }, + }); + await h.machine.select(to); + assert.equal(h.machine.state().kind, from); + assert.deepEqual(h.browser.engines(), [from]); + assert.equal(new URL(h.browser.entries[0].url).hash, '#view'); + assert.deepEqual(h.remembered, []); + assert.deepEqual(h.failures, [ + { message: `${to} refused`, recovered: true, kind: from }, + ]); + // The recovery restarts the parked engine on the retained session. + assert.deepEqual( + h.log.filter( + (line) => + line.startsWith('enter') || line.startsWith('resume:director'), + ), + [`enter:${to}:cold`, `enter:${from}:warm:recovery`, 'resume:director'], + ); + // An ordinary retry afterwards works. + refuse = false; + await h.machine.select(to); + assert.equal(h.machine.state().kind, to); + assert.deepEqual(h.browser.engines(), [from, to]); + }); +} + +test('a warm start that fails rolls back warm and later starts cold', async () => { + let refuseWarmNavara = false; + const h = harness({ + outcome: (kind, { warm, recovery }) => { + if (kind === 'navara' && warm && refuseWarmNavara && !recovery) + throw new Error('parked Navara lost'); + }, + }); + await h.machine.select('navara'); + await h.machine.select('cesium'); + refuseWarmNavara = true; + await h.machine.select('navara'); + assert.equal(h.machine.state().kind, 'cesium'); + await h.machine.select('navara'); + assert.equal(h.machine.state().kind, 'navara'); + assert.deepEqual( + h.log.filter((line) => line.startsWith('enter')), + [ + 'enter:navara:cold', + 'enter:cesium:warm', + 'enter:navara:warm', + 'enter:cesium:warm:recovery', + 'enter:navara:cold', + ], + ); + assert.deepEqual(h.browser.engines(), [ + 'cesium', + 'navara', + 'cesium', + 'navara', + ]); +}); + +test('after a failed recovery the page stays usable and the next pick starts an engine', async () => { + let refuse = true; + const h = harness({ + outcome: () => { + if (refuse) throw new Error('no WebGL'); + }, + }); + await h.machine.select('navara'); + assert.equal(h.machine.state().failed, true); + assert.equal(h.machine.state().busy, false); + assert.deepEqual( + h.failures.map(({ recovered }) => recovered), + [false], + ); + assert.deepEqual(h.browser.engines(), ['cesium']); + refuse = false; + // No running engine to leave: the pick starts one directly. + await assert.doesNotReject(async () => h.machine.select('navara')); + assert.equal(h.machine.state().kind, 'navara'); + assert.equal(h.machine.state().failed, false); + assert.equal(h.log.filter((line) => line.startsWith('leave')).length, 1); +}); + +test('pagehide during a start neither rolls back nor starts another engine', async () => { + const gate = deferred(); + const entered = deferred(); + const h = harness({ + outcome: (kind) => { + if (kind !== 'navara') return undefined; + entered.resolve(); + return gate.promise; + }, + }); + const switching = h.machine.select('navara'); + await entered.promise; + h.lifetime.abort(); + gate.reject(new DOMException('aborted', 'AbortError')); + await switching; + assert.deepEqual( + h.log.filter((line) => line.startsWith('enter')), + ['enter:navara:cold'], + ); + assert.deepEqual(h.failures, []); + assert.equal(h.machine.select('cesium'), null); +}); + +test('pagehide while the outgoing engine is released starts nothing', async () => { + const gate = deferred(); + const h = harness(); + const enter = []; + const machine = createEngineSwitch({ + kind: 'cesium', + requested: h.browser.requested, + signal: h.lifetime.signal, + leave: () => gate.promise, + enter: async (kind) => enter.push(kind), + address: createEngineAddress({ + location: h.browser.location, + history: h.browser.history, + }), + }); + const switching = machine.select('navara'); + h.lifetime.abort(); + gate.resolve(); + await switching; + assert.deepEqual(enter, []); +}); + +test('a slow first frame never rolls back a started engine', async () => { + const timers = []; + const engine = { + frames: { postRender: { addEventListener: () => () => {} } }, + render: { request() {} }, + }; + const h = harness(); + let frame; + const machine = createEngineSwitch({ + kind: 'cesium', + requested: h.browser.requested, + signal: h.lifetime.signal, + leave: async () => {}, + enter: async () => {}, + settle: async () => { + frame = waitForFirstFrame(engine, { + document: { + hidden: false, + addEventListener() {}, + removeEventListener() {}, + }, + setTimer: (fn) => timers.push(fn), + clearTimer() {}, + }); + timers.shift()(); + await frame; + }, + address: createEngineAddress({ + location: h.browser.location, + history: h.browser.history, + }), + onFailure: () => assert.fail('a slow frame must not fail the switch'), + }); + await machine.select('navara'); + assert.equal(await frame, false); + assert.equal(machine.state().kind, 'navara'); + assert.deepEqual(h.browser.engines(), ['cesium', 'navara']); +}); + +test('the first-frame wait resolves on a frame, on a hidden tab and on timeout', async () => { + const listeners = new Set(); + const doc = { + hidden: false, + addEventListener: (_type, fn) => listeners.add(fn), + removeEventListener: (_type, fn) => listeners.delete(fn), + }; + let draw; + let requests = 0; + const engine = { + frames: { + postRender: { + addEventListener(fn) { + draw = fn; + return () => { + draw = null; + }; + }, + }, + }, + render: { + request: () => requests++, + }, + }; + const timers = new Map(); + const options = { + document: doc, + setTimer: (fn) => { + const id = Symbol('timer'); + timers.set(id, fn); + return id; + }, + clearTimer: (id) => timers.delete(id), + }; + // A drawn frame. + const drawn = waitForFirstFrame(engine, options); + draw(); + assert.equal(await drawn, true); + assert.equal(timers.size, 0); + assert.equal(listeners.size, 0); + // The tab hides while waiting. + const hiding = waitForFirstFrame(engine, options); + doc.hidden = true; + for (const fn of [...listeners]) fn(); + assert.equal(await hiding, false); + assert.equal(draw, null); + // Already hidden: nothing to wait for and no frame requested. + const before = requests; + assert.equal(await waitForFirstFrame(engine, options), false); + assert.equal(requests, before); + // No frame in time: resolved, never rejected. + doc.hidden = false; + const slow = waitForFirstFrame(engine, options); + [...timers.values()][0](); + assert.equal(await slow, false); +}); + +test('the address keeps the outgoing view on the earlier entry', () => { + const browser = fakeBrowser(['/?look=cinematic#lat=1']); + const address = createEngineAddress({ + location: browser.location, + history: browser.history, + }); + const token = address.select('cesium', 'navara'); + assert.deepEqual(browser.engines(), ['navara']); + // The incoming engine writes its own view while it starts. + browser.history.replaceState( + null, + '', + '/?look=cinematic&engine=navara#lat=2', + ); + address.commit(token); + assert.deepEqual( + browser.entries.map(({ url }) => url.replace(ORIGIN, '/')), + [ + '/?look=cinematic&engine=cesium#lat=1', + '/?look=cinematic&engine=navara#lat=2', + ], + ); +});