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,
+185 -74
View File
@@ -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'),
);