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(() => {
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,