Hand the session off on Back/Forward and roll failed globe switches back to the last working engine

The engine switch moves out of main.js into src/standalone/engineSwitch.js: every
engine change in the page hands the session off (layers, Director, Contacts,
directions and satellite cameras), whether it came from the picker or from
history. Each attempt has a generation; a failure rolls back to the last engine
that started, requests made during a switch or recovery are reconciled once it
settles, and a picked engine adds its history entry only after it starts. The
first-frame wait only lifts the cover and never fails a started engine; a
hidden tab does not wait. Nothing starts after pagehide, and a failed recovery
leaves the picker usable.
This commit is contained in:
German Khokhlov committed 2026-11-03 14:51:17 +04:00
1 parent 64c5cd25e6
commit 4d2b061e3b
5 files changed
+1320 -201

No files matched your search

+145 -21
View File
@@ -27,19 +27,49 @@ try {
await page.evaluateOnNewDocument(() => { await page.evaluateOnNewDocument(() => {
localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
const fetch = window.fetch.bind(window); const fetch = window.fetch.bind(window);
window.fetch = (url, options) => // Two Boston vessels for the Contacts session; empty air and site feeds.
String(url).startsWith('https://earthquake.usgs.gov/earthquakes/feed/') const fixtures = {
? Promise.resolve( '/api/vessels': () => ({
Response.json({ status: 'live',
type: 'FeatureCollection', rows: ['123456789', '223456789'].map((mmsi, i) => ({
features: [{ mmsi,
type: 'Feature', id: 'qa-boston', name: 'Boston ' + i,
geometry: { type: 'Point', coordinates: [-71.0605, 42.3554, 8] }, 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' }, 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); : fetch(url, options);
};
}); });
await page.setViewport({ width: 1280, height: 800 }); await page.setViewport({ width: 1280, height: 800 });
const errors = []; const errors = [];
@@ -65,19 +95,43 @@ try {
const meanRed = async (enabled) => { const meanRed = async (enabled) => {
await page.evaluate(async (enabled) => { await page.evaluate(async (enabled) => {
const a = window.__godsEyeView; 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'); a.engine.render.request('qa-quake-surface');
}, enabled); }, enabled);
if (enabled) await page.waitForFunction(() => if (enabled)
window.__godsEyeView.dataManager.layers.get('earthquakes').module.getAnalystRecords().length === 1); await page.waitForFunction(
await new Promise(resolve => setTimeout(resolve, 1800)); () =>
const bytes = await page.screenshot({ clip: { x: 675, y: 403, width: 20, height: 20 } }); 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(); const { channels } = await sharp(bytes).stats();
return channels[0].mean - channels[1].mean; return channels[0].mean - channels[1].mean;
}; };
const off = await meanRed(false), on = await meanRed(true); const off = await meanRed(false),
assert.ok(on > off + 8, kind + ' must draw the shallow red earthquake footprint: ' + off + ' -> ' + on); on = await meanRed(true);
console.log('PASS', kind, 'earthquake footprint red minus green:', off, '->', on); 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')); const stored = () => page.evaluate(() => localStorage.getItem('gev.engine'));
async function switchTo(kind) { async function switchTo(kind) {
@@ -93,7 +147,8 @@ try {
await page.waitForSelector('#globe-engine', { visible: true }); await page.waitForSelector('#globe-engine', { visible: true });
await page.evaluate(() => { await page.evaluate(() => {
window.__switchDocument = document; 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.__quakeTimer = window.__quakeEntry.intervalId;
window.__renderers ??= new Map(); window.__renderers ??= new Map();
window.__renderers.set( window.__renderers.set(
@@ -121,7 +176,8 @@ try {
const previous = window.__renderers.get(engine.kind); const previous = window.__renderers.get(engine.kind);
return ( return (
!window.__switchSplash && !window.__switchSplash &&
window.__quakeEntry === window.__godsEyeView.dataManager.layers.get('earthquakes') && window.__quakeEntry ===
window.__godsEyeView.dataManager.layers.get('earthquakes') &&
window.__quakeTimer === window.__quakeEntry.intervalId && window.__quakeTimer === window.__quakeEntry.intervalId &&
(!previous || previous === engine) && (!previous || previous === engine) &&
!window.__outgoingEngine.isDestroyed() && !window.__outgoingEngine.isDestroyed() &&
@@ -199,12 +255,80 @@ try {
), ),
); );
assert.equal(await stored(), 'navara'); 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 page.goBack({ waitUntil: 'domcontentloaded' });
await ready('cesium'); await ready('cesium');
await page.waitForFunction(
() => !document.querySelector('.engine-switch-frame'),
);
assert.equal((await sharpen()).intensityPct, 49); assert.equal((await sharpen()).intensityPct, 49);
assert.deepEqual(await session(), live, 'Back keeps the session');
await page.goForward(); await page.goForward();
await ready('navara'); await ready('navara');
await page.waitForFunction(
() => !document.querySelector('.engine-switch-frame'),
);
assert.equal((await sharpen()).intensityPct, 80); 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(() => await page.evaluate(() =>
window.__godsEyeView.styleManager.setSharpen({ window.__godsEyeView.styleManager.setSharpen({
enabled: false, enabled: false,
+185 -74
View File
@@ -27,66 +27,91 @@ try {
const errors = []; const errors = [];
page.on('pageerror', (e) => errors.push(e.message)); page.on('pageerror', (e) => errors.push(e.message));
await page.setViewport({ width: 1280, height: 720 }); await page.setViewport({ width: 1280, height: 720 });
await page.evaluateOnNewDocument((layerId, aircraftId) => { await page.evaluateOnNewDocument(
window.__qaAircraft = {layerId, aircraftId}; (layerId, aircraftId) => {
const fetch = window.fetch.bind(window); window.__qaAircraft = { layerId, aircraftId };
window.__flightPolls = 0; const fetch = window.fetch.bind(window);
const interval = window.setInterval.bind(window); window.__flightPolls = 0;
window.setInterval = function (callback, delay, ...args) { const interval = window.setInterval.bind(window);
if (typeof callback !== 'function') return interval(callback, delay, ...args); window.setInterval = function (callback, delay, ...args) {
const id = interval(function (...values) { if (typeof callback !== 'function')
if (window.__handoff?.timer === id) window.__handoff.ticks++; return interval(callback, delay, ...args);
return callback.apply(this, values); const id = interval(
}, delay, ...args); function (...values) {
return id; if (window.__handoff?.timer === id) window.__handoff.ticks++;
}; return callback.apply(this, values);
window.fetch = (input, options) => { },
const path = new URL(input.url ?? input, document.baseURI).pathname; delay,
const now = Math.floor(Date.now() / 1000); ...args,
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,
],
],
}),
); );
} return id;
if (path === '/api/military') { };
if (layerId === 'military') window.__flightPolls++; window.fetch = (input, options) => {
return Promise.resolve(Response.json({now: Date.now(), ac: [{ const path = new URL(input.url ?? input, document.baseURI).pathname;
hex: 'aaa052', flight: 'GEVMIL', lon: -125.5779, lat: 40.2539, const now = Math.floor(Date.now() / 1000);
alt_geom: 31825, alt_baro: 31825, gs: 389, track: 45, if (path === '/api/flights') {
seen: 0, seen_pos: 0, dbFlags: 1, type: 'adsb_icao', if (layerId === 'flights') window.__flightPolls++;
}]})); return Promise.resolve(
} Response.json({
if (path === '/api/flights/track') time: now,
return Promise.resolve(Response.json({ path: [] })); states: [
if (path.startsWith('/api/flights/') || path.startsWith('/api/openai/')) [
return Promise.resolve(Response.json({})); '71be26',
return fetch(input, options); 'GEV123',
}; 'QA',
}, layerId, aircraftId); 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' }); await page.goto(url.href, { waitUntil: 'domcontentloaded' });
const ready = (kind) => const ready = (kind) =>
page.waitForFunction( page.waitForFunction(
@@ -116,7 +141,9 @@ try {
window.__handoff = { window.__handoff = {
dataManager, dataManager,
entry, 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, timer: entry.intervalId,
ticks: 0, ticks: 0,
model: entry.module, model: entry.module,
@@ -139,19 +166,40 @@ try {
HTMLCanvasElement.prototype.getContext = original; HTMLCanvasElement.prototype.getContext = original;
}; };
}); });
const historyLength = await page.evaluate(() => history.length);
await page.select('#globe-engine', 'cesium'); await page.select('#globe-engine', 'cesium');
await page.waitForFunction(() => window.__refusedContext === true); await page.waitForFunction(() => window.__refusedContext === true);
await ready('navara'); await ready('navara');
assert.ok(await page.evaluate(() => { // A failed switch leaves the session usable on the engine that worked and
window.__restoreContext(); // adds no history entry.
const {dataManager, styleManager} = window.__godsEyeView; const survived = (kind) =>
return new URL(location.href).searchParams.get('engine') === 'navara' && page.evaluate(
dataManager === window.__handoff.dataManager && (kind, length) => {
dataManager.layers.get(window.__qaAircraft.layerId) === window.__handoff.entry && const { dataManager, engine, styleManager } = window.__godsEyeView;
styleManager.getCockpitState().active && return (
styleManager.getAircraftTrackingTarget()?.id === window.__qaAircraft.aircraftId; engine.kind === kind &&
}), 'a failed scene must leave the live session usable'); new URL(location.href).searchParams.get('engine') === kind &&
console.log('PASS failed renderer rolled back to the same flight and cockpit'); 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']) { for (const kind of ['cesium', 'navara']) {
await page.select('#globe-engine', kind); await page.select('#globe-engine', kind);
@@ -166,8 +214,12 @@ try {
before.dataManager === dataManager && before.dataManager === dataManager &&
before.entry === entry && before.entry === entry &&
before.model === entry.module, before.model === entry.module,
sameRow: Boolean(before.row) && sameRow:
before.row === document.querySelector(`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`), Boolean(before.row) &&
before.row ===
document.querySelector(
`[data-layer-id="${window.__qaAircraft.layerId}"] .data-toggle-btn`,
),
sameTimer: sameTimer:
before.timer === entry.intervalId && entry.intervalId != null, before.timer === entry.intervalId && entry.intervalId != null,
sourcePolls: window.__flightPolls - before.pollCount, sourcePolls: window.__flightPolls - before.pollCount,
@@ -178,7 +230,9 @@ try {
active: cockpit.active, active: cockpit.active,
mode: cockpit.visionMode, mode: cockpit.visionMode,
hud: !document.querySelector('#cockpit-hud').hidden, 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(), height: engine.camera.height(),
}; };
}); });
@@ -193,7 +247,10 @@ try {
); );
assert.equal(result.id, aircraftId); assert.equal(result.id, aircraftId);
assert.equal(result.mode, 'thermal'); 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) { if (result.timerTicks === 0) {
assert.equal(result.stamp, result.initialStamp); assert.equal(result.stamp, result.initialStamp);
} }
@@ -204,6 +261,60 @@ try {
`PASS ${kind} cockpit, aircraft, HUD, manager and timer (${result.sourcePolls} polls since entry)`, `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(() => await page.evaluate(() =>
window.__godsEyeView.styleManager.controlCockpit('exit'), window.__godsEyeView.styleManager.controlCockpit('exit'),
); );
+126 -106
View File
@@ -4,6 +4,13 @@ import {
} from './standalone/application.js'; } from './standalone/application.js';
import { createRendererCache } from './standalone/rendererCache.js'; import { createRendererCache } from './standalone/rendererCache.js';
import { describeError } from './standalone/errors.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 { ENGINE_STORAGE_KEY } from './app/engineChoice.js';
import { useDisplayStore } from './ui/stores/display.js'; import { useDisplayStore } from './ui/stores/display.js';
import { applicationPinia } from './ui/vueRuntime.js'; import { applicationPinia } from './ui/vueRuntime.js';
@@ -11,37 +18,90 @@ import { t } from './i18n/index.js';
const display = useDisplayStore(applicationPinia()); const display = useDisplayStore(applicationPinia());
const loading = document.getElementById('loading-screen'); const loading = document.getElementById('loading-screen');
let kind = pageEngineKind();
let application; let application;
let busy = false; let engineSwitch = null;
let cover = null;
let cockpit = null;
const renderers = createRendererCache(); const renderers = createRendererCache();
const layerSession = { lifetime: new AbortController() }; const layerSession = { lifetime: new AbortController() };
const { signal } = layerSession.lifetime;
window.addEventListener('pagehide', (event) => { window.addEventListener('pagehide', (event) => {
if (event.persisted) return; if (event.persisted) return;
layerSession.lifetime.abort(); 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) { // Registered before any engine starts, so these run ahead of the running
kind = next; // 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; layerSession.restoringView = engineSwitch;
application = createStandaloneApplication({ application = createStandaloneApplication({
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY, googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
cesiumToken: import.meta.env.CESIUM_ION_TOKEN, cesiumToken: import.meta.env.CESIUM_ION_TOKEN,
allowQaRegistration: import.meta.env.DEV, allowQaRegistration: import.meta.env.DEV,
engineKind: next, engineKind: kind,
rendererCache: renderers, rendererCache: renderers,
layerSession, layerSession,
engineSwitch, engineSwitch,
}); });
const { scene, controls } = await application.start(); return application;
if (engineSwitch) { }
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; const style = controls.styleManager;
await style.initialRestorePromise; await style.initialRestorePromise;
signal.throwIfAborted();
layerSession.restoringView = false; layerSession.restoringView = false;
for (const {module} of layerSession.manager.layers.values()) module.resumeViewport?.(); resumeSession(layerSession);
if (layerSession.handoffLayers) layerSession.manager.layers.get('directions')?.module.resumeCamera?.();
layerSession.director?.resumeScene();
// Only resume the aircraft that actually survived the handoff. A history // Only resume the aircraft that actually survived the handoff. A history
// entry may select a different subject, and a stale track may disappear. // entry may select a different subject, and a stale track may disappear.
const target = style.getAircraftTrackingTarget(); const target = style.getAircraftTrackingTarget();
@@ -54,11 +114,32 @@ async function start(next, engineSwitch = false, cockpit = null) {
) { ) {
style.cockpitView.setVisionMode(cockpit.visionMode); 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() { function present() {
const { kind, busy } = engineSwitch.state();
display.presentGlobeEngine({ kind, busy, select }); display.presentGlobeEngine({ kind, busy, select });
} }
@@ -71,14 +152,7 @@ function report(error) {
} }
function select(next) { function select(next) {
if (busy || kind === next || !['cesium', 'navara'].includes(next)) return; void engineSwitch?.select(next);
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);
} }
// Keep the last visible frame while the first visit initializes the other renderer. // Keep the last visible frame while the first visit initializes the other renderer.
@@ -97,94 +171,40 @@ function keepGlobeFrame(engine) {
return frame; return frame;
} }
// Hand off the visible globe only after the new camera has actually drawn. const initialKind = pageEngineKind();
function waitForGlobeFrame(engine) { createApplication(initialKind, false)
if (document.hidden) return; .start()
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)
.then(() => { .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(); present();
window.addEventListener('popstate', () => void reconcile()); // A history visit made while the first engine started.
void reconcile(); void engineSwitch.reconcile();
}) })
.catch(report); .catch(report);
+308
View File
@@ -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<void>} options.leave Hand the
* session off from the running engine and release that engine.
* @param {(kind: string, attempt: {generation: number, recovery: boolean}) => Promise<void>} 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>|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');
});
}
+556
View File
@@ -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',
],
);
});