mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Create the application once per page and switch engines through the scene host
createStandaloneApplication opens its first globe, attaches the scene host and returns attachScene/detachScene: a switch hands the session off, parks the outgoing renderer and attaches the incoming one with its catalog to the parts the phases registered once. A failed candidate releases what it attached; the working engine attaches again on the same shell, voice, tools and stores. ?switch=legacy keeps the route that builds an application per engine for one release. scripts/qa-app-once.mjs now asserts identity, UI, tool and listener continuity, the rollback and the legacy route.
This commit is contained in:
1 parent
2bac200c01
commit
a99b862580
6 files changed
+533
-81
No files matched your search
+122
-8
@@ -80,6 +80,59 @@ async function prepareUi(page) {
|
|||||||
}, FIELD);
|
}, FIELD);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The session's tools: a mark on the annotation board, an imported file and
|
||||||
|
// the Draw shape, set on Cesium before the switches.
|
||||||
|
async function prepareTools(page) {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const annotated = await window.__gevAnnotations.annotate([
|
||||||
|
{ type: 'pin', latitude: 42.3554, longitude: -71.0605, label: 'QA mark' },
|
||||||
|
]);
|
||||||
|
const file = new File(
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { name: 'QA area' },
|
||||||
|
geometry: {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [
|
||||||
|
[
|
||||||
|
[-71.07, 42.35],
|
||||||
|
[-71.05, 42.35],
|
||||||
|
[-71.05, 42.36],
|
||||||
|
[-71.07, 42.35],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
'qa-area.geojson',
|
||||||
|
{ type: 'application/geo+json' },
|
||||||
|
);
|
||||||
|
const imported = await window.__gevGeoImport.importFiles([file]);
|
||||||
|
window.__gevDrawTool.setShape('line');
|
||||||
|
return { annotated: annotated.drawn, imported: imported[0]?.ok };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const readTools = (page) =>
|
||||||
|
page.evaluate(() => ({
|
||||||
|
marks: window.__gevAnnotations?.count() ?? null,
|
||||||
|
marksDrawn: window.__gevAnnotations?.attached ?? null,
|
||||||
|
files: window.__gevGeoImport?.list().length ?? null,
|
||||||
|
filesDrawn: window.__gevGeoImport?.diagnostics().drawn ?? null,
|
||||||
|
drawShape: window.__gevDrawTool?.shape ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Listeners whose difference is the parked renderer's: the Navara engine
|
||||||
|
// keeps its own input, resize and attribution listeners while it is parked
|
||||||
|
// for a switch back (scripts that are not the session's).
|
||||||
|
const PARKED_RENDERER = /@navara-engine\.js$/;
|
||||||
|
|
||||||
const browser = await launch();
|
const browser = await launch();
|
||||||
try {
|
try {
|
||||||
const { page, errors } = await openSessionPage(
|
const { page, errors } = await openSessionPage(
|
||||||
@@ -90,8 +143,20 @@ try {
|
|||||||
// Let the first boot's deferred listeners land before counting.
|
// Let the first boot's deferred listeners land before counting.
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
const boot = await readSession(page);
|
const boot = await readSession(page);
|
||||||
const bootListeners = await listenerCounts(page);
|
const bootListeners = await listenerCounts(page, { bySource: true });
|
||||||
report.boot = boot;
|
report.boot = boot;
|
||||||
|
report.toolsPrepared = await prepareTools(page);
|
||||||
|
check(
|
||||||
|
report.toolsPrepared.annotated === 1 && report.toolsPrepared.imported,
|
||||||
|
'the tools could not be prepared',
|
||||||
|
report.toolsPrepared,
|
||||||
|
);
|
||||||
|
// Let the imported file finish drawing.
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__gevGeoImport.diagnostics().drawn === 1,
|
||||||
|
);
|
||||||
|
const toolsBefore = await readTools(page);
|
||||||
|
report.toolsBefore = toolsBefore;
|
||||||
|
|
||||||
await prepareUi(page);
|
await prepareUi(page);
|
||||||
const escapeBefore = await escapeEffect(page);
|
const escapeBefore = await escapeEffect(page);
|
||||||
@@ -110,17 +175,32 @@ try {
|
|||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.__qaParkedNavara = window.__godsEyeView.engine;
|
window.__qaParkedNavara = window.__godsEyeView.engine;
|
||||||
});
|
});
|
||||||
if (report.switches.length === 1)
|
if (report.switches.length === 1) {
|
||||||
report.firstRoundTripListeners = listenerDifference(
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
bootListeners,
|
report.firstRoundTrip = await listenerCounts(page, { bySource: true });
|
||||||
await listenerCounts(page),
|
}
|
||||||
|
if (kind === 'cesium')
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__gevGeoImport.diagnostics().drawn === 1,
|
||||||
|
{ timeout: 30_000 },
|
||||||
);
|
);
|
||||||
|
const tools = await readTools(page);
|
||||||
|
check(
|
||||||
|
tools.marks === toolsBefore.marks &&
|
||||||
|
tools.files === toolsBefore.files &&
|
||||||
|
tools.drawShape === toolsBefore.drawShape &&
|
||||||
|
tools.marksDrawn === (kind === 'cesium') &&
|
||||||
|
tools.filesDrawn === (kind === 'cesium' ? 1 : 0),
|
||||||
|
`the session tools changed on the switch to ${kind}`,
|
||||||
|
{ toolsBefore, tools },
|
||||||
|
);
|
||||||
report.switches.push({
|
report.switches.push({
|
||||||
kind,
|
kind,
|
||||||
ms: Date.now() - started,
|
ms: Date.now() - started,
|
||||||
ids: idDifference(boot.ids, state.ids),
|
ids: idDifference(boot.ids, state.ids),
|
||||||
counters: state.counters,
|
counters: state.counters,
|
||||||
ui,
|
ui,
|
||||||
|
tools,
|
||||||
});
|
});
|
||||||
check(
|
check(
|
||||||
Object.keys(idDifference(boot.ids, state.ids)).length === 0,
|
Object.keys(idDifference(boot.ids, state.ids)).length === 0,
|
||||||
@@ -141,14 +221,30 @@ try {
|
|||||||
}
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
const after = await readSession(page);
|
const after = await readSession(page);
|
||||||
const afterListeners = await listenerCounts(page);
|
const afterListeners = await listenerCounts(page, { bySource: true });
|
||||||
report.after = after;
|
report.after = after;
|
||||||
|
// After three round trips the page holds exactly the listeners it held
|
||||||
|
// after the first boot, apart from the parked Navara renderer's own (it
|
||||||
|
// stays alive for the switch back), which the first round trip added and
|
||||||
|
// the later ones did not grow.
|
||||||
report.listeners = listenerDifference(bootListeners, afterListeners);
|
report.listeners = listenerDifference(bootListeners, afterListeners);
|
||||||
|
report.listenersSinceFirstRoundTrip = listenerDifference(
|
||||||
|
report.firstRoundTrip,
|
||||||
|
afterListeners,
|
||||||
|
);
|
||||||
|
const sessionListeners = Object.keys(report.listeners).filter(
|
||||||
|
(key) => !PARKED_RENDERER.test(key),
|
||||||
|
);
|
||||||
check(
|
check(
|
||||||
Object.keys(report.listeners).length === 0,
|
sessionListeners.length === 0,
|
||||||
'window/document listeners changed after three round trips',
|
'session listeners on window/document changed after three round trips',
|
||||||
report.listeners,
|
report.listeners,
|
||||||
);
|
);
|
||||||
|
check(
|
||||||
|
Object.keys(report.listenersSinceFirstRoundTrip).length === 0,
|
||||||
|
'listeners on window/document grew after the first round trip',
|
||||||
|
report.listenersSinceFirstRoundTrip,
|
||||||
|
);
|
||||||
check(
|
check(
|
||||||
after.counters?.attachments === 1 + sequence.length &&
|
after.counters?.attachments === 1 + sequence.length &&
|
||||||
after.counters?.detaches === sequence.length,
|
after.counters?.detaches === sequence.length,
|
||||||
@@ -227,6 +323,24 @@ try {
|
|||||||
}
|
}
|
||||||
report.errors = errors;
|
report.errors = errors;
|
||||||
check(errors.length === 0, 'page errors', errors);
|
check(errors.length === 0, 'page errors', errors);
|
||||||
|
|
||||||
|
// The legacy route (?switch=legacy) still builds an application per
|
||||||
|
// engine, for one release.
|
||||||
|
const legacy = await openSessionPage(
|
||||||
|
browser,
|
||||||
|
sessionAddress(base, { engine: 'cesium', extra: { switch: 'legacy' } }),
|
||||||
|
);
|
||||||
|
await waitReady(legacy.page, 'cesium');
|
||||||
|
await switchEngine(legacy.page, 'navara');
|
||||||
|
await switchEngine(legacy.page, 'cesium');
|
||||||
|
report.legacy = await readSession(legacy.page);
|
||||||
|
check(
|
||||||
|
report.legacy.counters.applications === 3,
|
||||||
|
'the legacy route builds an application per engine',
|
||||||
|
report.legacy.counters,
|
||||||
|
);
|
||||||
|
check(legacy.errors.length === 0, 'legacy page errors', legacy.errors);
|
||||||
|
await legacy.page.close();
|
||||||
console.log(JSON.stringify(report, null, 2));
|
console.log(JSON.stringify(report, null, 2));
|
||||||
if (!reportOnly) console.log('PASS application created once; session kept');
|
if (!reportOnly) console.log('PASS application created once; session kept');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -288,10 +288,22 @@ export function readSession(page) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Live listeners on window and document, counted per event type. */
|
/**
|
||||||
export async function listenerCounts(page) {
|
* Live listeners on window and document, counted per event type. With
|
||||||
|
* `bySource`, each key also names the script that added the listener (its
|
||||||
|
* file name), so a listener held by the parked renderer can be told apart
|
||||||
|
* from one the session leaked.
|
||||||
|
*/
|
||||||
|
export async function listenerCounts(page, { bySource = false } = {}) {
|
||||||
const client = await page.createCDPSession();
|
const client = await page.createCDPSession();
|
||||||
try {
|
try {
|
||||||
|
const scripts = new Map();
|
||||||
|
if (bySource) {
|
||||||
|
client.on('Debugger.scriptParsed', ({ scriptId, url }) =>
|
||||||
|
scripts.set(scriptId, url),
|
||||||
|
);
|
||||||
|
await client.send('Debugger.enable');
|
||||||
|
}
|
||||||
const counts = {};
|
const counts = {};
|
||||||
for (const name of ['window', 'document']) {
|
for (const name of ['window', 'document']) {
|
||||||
const { result } = await client.send('Runtime.evaluate', {
|
const { result } = await client.send('Runtime.evaluate', {
|
||||||
@@ -301,13 +313,20 @@ export async function listenerCounts(page) {
|
|||||||
objectId: result.objectId,
|
objectId: result.objectId,
|
||||||
});
|
});
|
||||||
for (const listener of listeners) {
|
for (const listener of listeners) {
|
||||||
const key = `${name}:${listener.type}`;
|
const source = bySource
|
||||||
|
? `@${(scripts.get(listener.scriptId) ?? '')
|
||||||
|
.split('/')
|
||||||
|
.pop()
|
||||||
|
.replace(/-[\w-]{8}\.js$/, '.js')}`
|
||||||
|
: '';
|
||||||
|
const key = `${name}:${listener.type}${source}`;
|
||||||
counts[key] = (counts[key] ?? 0) + 1;
|
counts[key] = (counts[key] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
await client.send('Runtime.releaseObject', {
|
await client.send('Runtime.releaseObject', {
|
||||||
objectId: result.objectId,
|
objectId: result.objectId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (bySource) await client.send('Debugger.disable');
|
||||||
return counts;
|
return counts;
|
||||||
} finally {
|
} finally {
|
||||||
await client.detach();
|
await client.detach();
|
||||||
|
|||||||
+61
-21
@@ -97,11 +97,19 @@ function liveComponents() {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The page's application is created once; a switch detaches and attaches
|
||||||
|
// only its engine-bound parts. `?switch=legacy` keeps the previous route, one
|
||||||
|
// application per engine, for one release.
|
||||||
|
const legacySwitch =
|
||||||
|
new URLSearchParams(globalThis.location?.search ?? '').get('switch') ===
|
||||||
|
'legacy';
|
||||||
|
|
||||||
// Hand the session off and release the running engine. Its renderer is
|
// Hand the session off and release the running engine. Its renderer is
|
||||||
// parked for a later switch back.
|
// parked for a later switch back.
|
||||||
async function leave(from) {
|
async function leave(from) {
|
||||||
const components = liveComponents();
|
const components = liveComponents();
|
||||||
if (components) {
|
const attached = Boolean(components?.scene.engine);
|
||||||
|
if (attached) {
|
||||||
cover ??= keepGlobeFrame(components.scene.engine);
|
cover ??= keepGlobeFrame(components.scene.engine);
|
||||||
cockpit = components.controls.styleManager.getCockpitState();
|
cockpit = components.controls.styleManager.getCockpitState();
|
||||||
handOffSession(layerSession, {
|
handOffSession(layerSession, {
|
||||||
@@ -111,36 +119,68 @@ async function leave(from) {
|
|||||||
});
|
});
|
||||||
renderers.retain(from);
|
renderers.retain(from);
|
||||||
}
|
}
|
||||||
await application?.destroy();
|
if (legacySwitch) await application?.destroy();
|
||||||
|
else if (attached) await application.detachScene({ handoff: true });
|
||||||
session.count('detaches');
|
session.count('detaches');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start an engine on the retained session. Success means the engine runs
|
// 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
|
// with the session attached and the address's view restored; its first frame
|
||||||
// only decides when the cover goes (settle).
|
// only decides when the cover goes (settle).
|
||||||
async function enter(kind) {
|
async function enter(kind, { recovery = false } = {}) {
|
||||||
|
if (legacySwitch) return enterLegacy(kind);
|
||||||
|
if (!application || application.getState().status !== 'ready') {
|
||||||
|
// The first engine never started: there is no application to attach to.
|
||||||
|
return enterLegacy(kind);
|
||||||
|
}
|
||||||
|
layerSession.restoringView = true;
|
||||||
|
try {
|
||||||
|
await application.attachScene(kind);
|
||||||
|
signal.throwIfAborted();
|
||||||
|
await resume(application.getComponents().controls.styleManager);
|
||||||
|
if (recovery) session.count('rollbacks');
|
||||||
|
} catch (error) {
|
||||||
|
// pagehide owns the teardown once the lifetime is aborted. A failed
|
||||||
|
// candidate is detached; the session stays for the working engine.
|
||||||
|
if (!signal.aborted)
|
||||||
|
await application
|
||||||
|
.detachScene({ handoff: true })
|
||||||
|
.catch((cleanupError) =>
|
||||||
|
console.error('A failed globe could not be released:', cleanupError),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The session continues on the engine that attached: the view is restored,
|
||||||
|
// parked work resumes and Cockpit re-enters on the aircraft it had.
|
||||||
|
async function resume(style) {
|
||||||
|
await style.initialRestorePromise;
|
||||||
|
signal.throwIfAborted();
|
||||||
|
layerSession.restoringView = false;
|
||||||
|
session.count('attachments');
|
||||||
|
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();
|
||||||
|
if (
|
||||||
|
cockpit?.active &&
|
||||||
|
target &&
|
||||||
|
target?.id === cockpit.subject?.id &&
|
||||||
|
target?.layerId === cockpit.subject?.layerId &&
|
||||||
|
style.controlCockpit('enter').ok
|
||||||
|
) {
|
||||||
|
style.cockpitView.setVisionMode(cockpit.visionMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The legacy route: a new application on the retained session.
|
||||||
|
async function enterLegacy(kind) {
|
||||||
const app = createApplication(kind, true);
|
const app = createApplication(kind, true);
|
||||||
try {
|
try {
|
||||||
const { controls } = await app.start();
|
const { controls } = await app.start();
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
const style = controls.styleManager;
|
await resume(controls.styleManager);
|
||||||
await style.initialRestorePromise;
|
|
||||||
signal.throwIfAborted();
|
|
||||||
layerSession.restoringView = false;
|
|
||||||
session.count('attachments');
|
|
||||||
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();
|
|
||||||
if (
|
|
||||||
cockpit?.active &&
|
|
||||||
target &&
|
|
||||||
target?.id === cockpit.subject?.id &&
|
|
||||||
target?.layerId === cockpit.subject?.layerId &&
|
|
||||||
style.controlCockpit('enter').ok
|
|
||||||
) {
|
|
||||||
style.cockpitView.setVisionMode(cockpit.visionMode);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// pagehide owns the teardown once the lifetime is aborted.
|
// pagehide owns the teardown once the lifetime is aborted.
|
||||||
if (!signal.aborted)
|
if (!signal.aborted)
|
||||||
|
|||||||
+13
-2
@@ -1,6 +1,8 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { createSceneHost } from './app/sceneHost.js';
|
||||||
|
import { attachScenePart } from './app/sceneParts.js';
|
||||||
import vm from 'node:vm';
|
import vm from 'node:vm';
|
||||||
import { createGoogleTokenSource } from './maps/googleTokens.js';
|
import { createGoogleTokenSource } from './maps/googleTokens.js';
|
||||||
import {
|
import {
|
||||||
@@ -85,11 +87,20 @@ async function standaloneStartup(Cesium, credentials, fetchImpl) {
|
|||||||
...(await loadPhotorealisticTileset(Cesium, options)),
|
...(await loadPhotorealisticTileset(Cesium, options)),
|
||||||
operations: {},
|
operations: {},
|
||||||
}),
|
}),
|
||||||
|
createSceneHost,
|
||||||
|
attachScenePart,
|
||||||
|
AbortController,
|
||||||
|
AbortSignal,
|
||||||
});
|
});
|
||||||
vm.runInContext(source, context);
|
vm.runInContext(source, context);
|
||||||
const app = context.createStandaloneApplication(credentials);
|
const app = context.createStandaloneApplication(credentials);
|
||||||
// The application passes each constructor its signal and defer(cleanup).
|
// The application passes each constructor its signal and defer(cleanup);
|
||||||
return app.createScene({ signal: new AbortController().signal, defer() {} });
|
// the scene it returns reads the attached globe.
|
||||||
|
const scene = await app.createScene({
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
defer() {},
|
||||||
|
});
|
||||||
|
return scene.current();
|
||||||
}
|
}
|
||||||
|
|
||||||
test('standalone falls back to ion when the server token route is unavailable', async () => {
|
test('standalone falls back to ion when the server token route is unavailable', async () => {
|
||||||
|
|||||||
+205
-47
@@ -16,6 +16,8 @@ import {
|
|||||||
import { mountApplicationViews } from '../ui/views/index.js';
|
import { mountApplicationViews } from '../ui/views/index.js';
|
||||||
import { createGoogleTokenSource } from '../maps/googleTokens.js';
|
import { createGoogleTokenSource } from '../maps/googleTokens.js';
|
||||||
import { resolveEngineKind } from '../app/engineChoice.js';
|
import { resolveEngineKind } from '../app/engineChoice.js';
|
||||||
|
import { createSceneHost } from '../app/sceneHost.js';
|
||||||
|
import { attachScenePart } from '../app/sceneParts.js';
|
||||||
|
|
||||||
// Only one engine may own the page at a time.
|
// Only one engine may own the page at a time.
|
||||||
let owner = null;
|
let owner = null;
|
||||||
@@ -36,7 +38,14 @@ function sessionChrome(layerSession) {
|
|||||||
return (layerSession.chrome ??= {});
|
return (layerSession.chrome ??= {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compose one engine session. Destroy it before starting another. */
|
/**
|
||||||
|
* Compose the page's application, created once per page. Its first globe is
|
||||||
|
* `engineKind`; `attachScene(kind)` and `detachScene()` switch engines by
|
||||||
|
* detaching and attaching only the engine-bound parts (src/app/sceneHost.js)
|
||||||
|
* while the shell, stores, voice, tools and layer models stay. Destroy it
|
||||||
|
* before creating another (the legacy route, ?switch=legacy, still builds
|
||||||
|
* one per engine).
|
||||||
|
*/
|
||||||
export function createStandaloneApplication({
|
export function createStandaloneApplication({
|
||||||
googleApiKey,
|
googleApiKey,
|
||||||
cesiumToken,
|
cesiumToken,
|
||||||
@@ -66,9 +75,119 @@ export function createStandaloneApplication({
|
|||||||
const loadingScreen = document.getElementById('loading-screen');
|
const loadingScreen = document.getElementById('loading-screen');
|
||||||
const loaderStatus = loadingScreen.querySelector('.loader-status');
|
const loaderStatus = loadingScreen.querySelector('.loader-status');
|
||||||
let placeSearch;
|
let placeSearch;
|
||||||
let catalog;
|
let qaSources = {};
|
||||||
|
// The engine-bound parts of every phase attach through this host.
|
||||||
|
const sceneHost = createSceneHost({
|
||||||
|
onError: (error, part) =>
|
||||||
|
console.error(`The ${part} scene part failed:`, error),
|
||||||
|
});
|
||||||
|
// The attached globe (one renderer attachment with its catalog), or null
|
||||||
|
// between engines.
|
||||||
|
let current = null;
|
||||||
|
let lifetime = null;
|
||||||
|
let sessionContext = null;
|
||||||
|
|
||||||
|
/** Acquire `kind`'s renderer and build its catalog: one attachment. */
|
||||||
|
async function openScene(kind) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const releases = [];
|
||||||
|
const release = async () => {
|
||||||
|
controller.abort();
|
||||||
|
const errors = [];
|
||||||
|
while (releases.length) {
|
||||||
|
try {
|
||||||
|
await releases.pop()();
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.length)
|
||||||
|
throw new AggregateError(errors, 'The globe could not be released');
|
||||||
|
};
|
||||||
|
const signal = AbortSignal.any([controller.signal, lifetime]);
|
||||||
|
try {
|
||||||
|
const scene = await createStandaloneScene({
|
||||||
|
...sessionContext,
|
||||||
|
signal,
|
||||||
|
defer: (cleanup) => releases.push(cleanup),
|
||||||
|
googleApiKey,
|
||||||
|
// Without a key, Google 3D can still load with tokens from the
|
||||||
|
// app's server when it offers them.
|
||||||
|
googleTokens: googleApiKey ? null : createGoogleTokenSource(),
|
||||||
|
cesiumToken,
|
||||||
|
loaderStatus,
|
||||||
|
engineKind: kind,
|
||||||
|
rendererCache,
|
||||||
|
layerSession,
|
||||||
|
});
|
||||||
|
signal.throwIfAborted();
|
||||||
|
// The catalog of this engine: on Navara only the layers with a Navara
|
||||||
|
// renderer (src/app/layers/renderers.js), the others' state kept for
|
||||||
|
// links. The layer models are the session's (layerSession.models).
|
||||||
|
const sceneCatalog = createStandaloneCatalog({
|
||||||
|
engineKind: kind,
|
||||||
|
layerSession,
|
||||||
|
sources: { ...sources, ...qaSources },
|
||||||
|
streetLevelProviders,
|
||||||
|
nepalBoundaryResolver: (resolverSignal) =>
|
||||||
|
scene.operations.annotationResolver.resolveRegionRingForQuery(
|
||||||
|
'Nepal',
|
||||||
|
resolverSignal,
|
||||||
|
placeSearch,
|
||||||
|
// The locator draws the border whenever it arrives.
|
||||||
|
{ budgetMs: Infinity },
|
||||||
|
),
|
||||||
|
signal,
|
||||||
|
surface: scene.operations.surface,
|
||||||
|
});
|
||||||
|
return Object.freeze({
|
||||||
|
...scene,
|
||||||
|
kind,
|
||||||
|
catalog: sceneCatalog,
|
||||||
|
release,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await release().catch((cleanupError) =>
|
||||||
|
console.error('A failed globe could not be released:', cleanupError),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the phases and the page read as `scene`: the attached globe's
|
||||||
|
// handles (null between engines), the session's operations and the host.
|
||||||
|
const sceneView = Object.freeze({
|
||||||
|
get kind() {
|
||||||
|
return current?.kind ?? null;
|
||||||
|
},
|
||||||
|
get viewer() {
|
||||||
|
return current?.viewer ?? null;
|
||||||
|
},
|
||||||
|
get engine() {
|
||||||
|
return current?.engine ?? null;
|
||||||
|
},
|
||||||
|
get tileset() {
|
||||||
|
return current?.tileset ?? null;
|
||||||
|
},
|
||||||
|
get mapStackController() {
|
||||||
|
return current?.mapStackController ?? null;
|
||||||
|
},
|
||||||
|
get packRenderer() {
|
||||||
|
return current?.packRenderer ?? null;
|
||||||
|
},
|
||||||
|
get catalog() {
|
||||||
|
return current?.catalog ?? null;
|
||||||
|
},
|
||||||
|
get operations() {
|
||||||
|
return layerSession?.operations ?? current?.operations ?? null;
|
||||||
|
},
|
||||||
|
current: () => current,
|
||||||
|
sceneHost,
|
||||||
|
});
|
||||||
|
|
||||||
const application = createApplication({
|
const application = createApplication({
|
||||||
createScene: async (context) => {
|
createScene: async (context) => {
|
||||||
|
lifetime = context.signal;
|
||||||
// The page's language listener and place search belong to the page
|
// The page's language listener and place search belong to the page
|
||||||
// session when there is one (an engine switch keeps them), else to
|
// session when there is one (an engine switch keeps them), else to
|
||||||
// this application.
|
// this application.
|
||||||
@@ -101,26 +220,11 @@ export function createStandaloneApplication({
|
|||||||
layerSession.lifetime.signal,
|
layerSession.lifetime.signal,
|
||||||
))
|
))
|
||||||
: createPlaceSearch(context.signal);
|
: createPlaceSearch(context.signal);
|
||||||
const scene = await createStandaloneScene({
|
|
||||||
...context,
|
|
||||||
googleApiKey,
|
|
||||||
// Without a key, Google 3D can still load with tokens from the
|
|
||||||
// app's server when it offers them.
|
|
||||||
googleTokens: googleApiKey ? null : createGoogleTokenSource(),
|
|
||||||
cesiumToken,
|
|
||||||
loaderStatus,
|
|
||||||
engineKind,
|
|
||||||
rendererCache,
|
|
||||||
layerSession,
|
|
||||||
});
|
|
||||||
// The catalog of this engine: on Navara only the layers with a Navara
|
|
||||||
// renderer (src/app/layers/renderers.js; none yet), the others' state
|
|
||||||
// kept for links.
|
|
||||||
// QA fixture sources (?qaFeed=…) on the dev server only.
|
// QA fixture sources (?qaFeed=…) on the dev server only.
|
||||||
const search = globalThis.location?.search ?? '';
|
const search = globalThis.location?.search ?? '';
|
||||||
// import.meta.env.DEV at the import site, so production builds drop
|
// import.meta.env.DEV at the import site, so production builds drop
|
||||||
// the chunk instead of shipping it unreachable.
|
// the chunk instead of shipping it unreachable.
|
||||||
const qaSources =
|
qaSources =
|
||||||
import.meta.env?.DEV &&
|
import.meta.env?.DEV &&
|
||||||
allowQaRegistration &&
|
allowQaRegistration &&
|
||||||
/[?&]qaFeed=/.test(search)
|
/[?&]qaFeed=/.test(search)
|
||||||
@@ -129,23 +233,26 @@ export function createStandaloneApplication({
|
|||||||
createQaSources(parseQaFeed(search)),
|
createQaSources(parseQaFeed(search)),
|
||||||
)
|
)
|
||||||
: {};
|
: {};
|
||||||
catalog = createStandaloneCatalog({
|
// The session's own defer (its logo gaze and key when no layer
|
||||||
engineKind,
|
// session holds them) outlives every globe.
|
||||||
layerSession,
|
sessionContext = { defer: context.defer };
|
||||||
sources: { ...sources, ...qaSources },
|
current = await openScene(engineKind);
|
||||||
streetLevelProviders,
|
// The first globe: the phases build on it and register their parts.
|
||||||
nepalBoundaryResolver: (signal) =>
|
await sceneHost.attach(current);
|
||||||
scene.operations.annotationResolver.resolveRegionRingForQuery(
|
// After every part detached (the tools phase detaches them first),
|
||||||
'Nepal',
|
// the globe's renderer is parked or released.
|
||||||
signal,
|
context.defer(async () => {
|
||||||
placeSearch,
|
await sceneHost.detach({
|
||||||
// The locator draws the border whenever it arrives.
|
handoff: Boolean(
|
||||||
{ budgetMs: Infinity },
|
layerSession?.handoffLayers &&
|
||||||
|
!layerSession.lifetime.signal.aborted,
|
||||||
),
|
),
|
||||||
signal: context.signal,
|
});
|
||||||
surface: scene.operations.surface,
|
const scene = current;
|
||||||
|
current = null;
|
||||||
|
await scene?.release();
|
||||||
});
|
});
|
||||||
return scene;
|
return sceneView;
|
||||||
},
|
},
|
||||||
createControls: (context) =>
|
createControls: (context) =>
|
||||||
createStandaloneControls({
|
createStandaloneControls({
|
||||||
@@ -154,13 +261,13 @@ export function createStandaloneApplication({
|
|||||||
layerSession,
|
layerSession,
|
||||||
loaderStatus,
|
loaderStatus,
|
||||||
placeSearch,
|
placeSearch,
|
||||||
catalog,
|
catalog: current.catalog,
|
||||||
}),
|
}),
|
||||||
createData: (context) =>
|
createData: (context) =>
|
||||||
createStandaloneData({
|
createStandaloneData({
|
||||||
...context,
|
...context,
|
||||||
allowQaRegistration,
|
allowQaRegistration,
|
||||||
catalog,
|
catalog: current.catalog,
|
||||||
layerSession,
|
layerSession,
|
||||||
}),
|
}),
|
||||||
createTools: async (context) => {
|
createTools: async (context) => {
|
||||||
@@ -173,20 +280,29 @@ export function createStandaloneApplication({
|
|||||||
});
|
});
|
||||||
// The engine-neutral QA probe on the debug handle (src/app/qaProbe.js),
|
// The engine-neutral QA probe on the debug handle (src/app/qaProbe.js),
|
||||||
// on the dev server only: import.meta.env.DEV at the import site, so
|
// on the dev server only: import.meta.env.DEV at the import site, so
|
||||||
// production builds drop it.
|
// production builds drop it. It reads one engine: a part.
|
||||||
const debug = globalThis.window?.__godsEyeView;
|
const debug = globalThis.window?.__godsEyeView;
|
||||||
if (import.meta.env?.DEV && debug) {
|
if (import.meta.env?.DEV && debug) {
|
||||||
const { createQaProbe } = await import('../app/qaProbe.js');
|
const { createQaProbe } = await import('../app/qaProbe.js');
|
||||||
const probe = createQaProbe({
|
await attachScenePart(
|
||||||
engine: context.scene.engine,
|
context.scene,
|
||||||
dataManager: context.data.dataManager,
|
{
|
||||||
availability: catalog.availability,
|
name: 'qa-probe',
|
||||||
});
|
attachScene(next, { defer }) {
|
||||||
debug.layers = probe;
|
const probe = createQaProbe({
|
||||||
context.defer(() => {
|
engine: next.engine,
|
||||||
probe.destroy();
|
dataManager: context.data.dataManager,
|
||||||
if (debug.layers === probe) delete debug.layers;
|
availability: next.catalog.availability,
|
||||||
});
|
});
|
||||||
|
debug.layers = probe;
|
||||||
|
defer(() => {
|
||||||
|
probe.destroy();
|
||||||
|
if (debug.layers === probe) delete debug.layers;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ defer: context.defer },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return tools;
|
return tools;
|
||||||
},
|
},
|
||||||
@@ -199,5 +315,47 @@ export function createStandaloneApplication({
|
|||||||
)
|
)
|
||||||
owner = null;
|
owner = null;
|
||||||
});
|
});
|
||||||
return application;
|
|
||||||
|
/**
|
||||||
|
* Switch to `kind`: acquire its renderer and attach every part. A failure
|
||||||
|
* detaches what attached and releases the candidate; the session stays as
|
||||||
|
* it was, ready for the working engine to attach again.
|
||||||
|
*/
|
||||||
|
async function attachScene(kind) {
|
||||||
|
if (application.getState().status !== 'ready')
|
||||||
|
throw new Error('The application is not running');
|
||||||
|
if (current) throw new Error('A globe is attached; detach it first');
|
||||||
|
const next = await openScene(kind);
|
||||||
|
try {
|
||||||
|
await sceneHost.attach(next);
|
||||||
|
} catch (error) {
|
||||||
|
await next
|
||||||
|
.release()
|
||||||
|
.catch((cleanupError) =>
|
||||||
|
console.error('A failed globe could not be released:', cleanupError),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
current = next;
|
||||||
|
return sceneView;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detach every part from the attached globe and park or release it. */
|
||||||
|
async function detachScene(options = {}) {
|
||||||
|
const scene = current;
|
||||||
|
if (!scene) return;
|
||||||
|
try {
|
||||||
|
await sceneHost.detach(options);
|
||||||
|
} finally {
|
||||||
|
current = null;
|
||||||
|
await scene.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
...application,
|
||||||
|
sceneHost,
|
||||||
|
attachScene,
|
||||||
|
detachScene,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// The standalone application is created once; an engine switch detaches and
|
||||||
|
// attaches only its engine-bound parts. A candidate that fails releases its
|
||||||
|
// renderer, and the working engine attaches again on the same parts.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { createSceneHost } from '../app/sceneHost.js';
|
||||||
|
import { attachScenePart } from '../app/sceneParts.js';
|
||||||
|
|
||||||
|
function standalone({ failOpen = null } = {}) {
|
||||||
|
const source = readFileSync(
|
||||||
|
new URL('./application.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
.replace(/^import [^;]*;\n/gm, '')
|
||||||
|
.replace(/^export /gm, '')
|
||||||
|
.replace(/import\.meta\.env/g, '({}).env');
|
||||||
|
const opened = [];
|
||||||
|
const released = [];
|
||||||
|
const context = vm.createContext({
|
||||||
|
document: {
|
||||||
|
getElementById: () => ({ querySelector: () => ({}) }),
|
||||||
|
},
|
||||||
|
console,
|
||||||
|
resolveLocale: () => 'en',
|
||||||
|
setLocale() {},
|
||||||
|
getLocale: () => 'en',
|
||||||
|
onLocaleChange: () => () => {},
|
||||||
|
mountApplicationViews() {},
|
||||||
|
applyDocumentLanguage() {},
|
||||||
|
applyDocumentTranslations() {},
|
||||||
|
CITY_POIS: [],
|
||||||
|
createApplication: (options) => ({
|
||||||
|
...options,
|
||||||
|
subscribe() {},
|
||||||
|
getState: () => ({ status: 'ready' }),
|
||||||
|
}),
|
||||||
|
resolveEngineKind: () => 'cesium',
|
||||||
|
createStandalonePlaceSearch: () => ({ kind: 'search' }),
|
||||||
|
createStandaloneCatalog: ({ engineKind }) => ({ engineKind }),
|
||||||
|
createGoogleTokenSource: () => null,
|
||||||
|
createStandaloneScene: async ({ engineKind, defer }) => {
|
||||||
|
if (failOpen === engineKind) throw new Error(`${engineKind} refused`);
|
||||||
|
const engine = { kind: engineKind, id: opened.length };
|
||||||
|
opened.push(engine);
|
||||||
|
defer(() => released.push(engine));
|
||||||
|
return { viewer: null, engine, operations: { kind: 'operations' } };
|
||||||
|
},
|
||||||
|
createSceneHost,
|
||||||
|
attachScenePart,
|
||||||
|
AbortController,
|
||||||
|
AbortSignal,
|
||||||
|
});
|
||||||
|
vm.runInContext(source, context);
|
||||||
|
const app = context.createStandaloneApplication({});
|
||||||
|
return { app, opened, released };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the application switches engines on the parts it registered once', async () => {
|
||||||
|
const { app, opened, released } = standalone();
|
||||||
|
const scene = await app.createScene({
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
defer() {},
|
||||||
|
});
|
||||||
|
assert.equal(scene.engine.kind, 'cesium');
|
||||||
|
const log = [];
|
||||||
|
await scene.sceneHost.register({
|
||||||
|
name: 'part',
|
||||||
|
attachScene: (next) => log.push(`attach ${next.kind}`),
|
||||||
|
detachScene: () => log.push('detach'),
|
||||||
|
});
|
||||||
|
await app.detachScene({ handoff: true });
|
||||||
|
assert.equal(scene.engine, null, 'no globe between engines');
|
||||||
|
assert.deepEqual(released, [opened[0]]);
|
||||||
|
await app.attachScene('navara');
|
||||||
|
assert.equal(scene.engine.kind, 'navara');
|
||||||
|
assert.equal(scene.catalog.engineKind, 'navara');
|
||||||
|
assert.deepEqual(log, ['attach cesium', 'detach', 'attach navara']);
|
||||||
|
await assert.rejects(app.attachScene('cesium'), /detach it first/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed candidate releases what it attached and the working engine attaches again', async () => {
|
||||||
|
const { app, opened, released } = standalone();
|
||||||
|
const scene = await app.createScene({
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
defer() {},
|
||||||
|
});
|
||||||
|
const part = {
|
||||||
|
name: 'part',
|
||||||
|
attached: [],
|
||||||
|
attachScene(next) {
|
||||||
|
if (next.kind === 'navara') throw new Error('navara part failed');
|
||||||
|
part.attached.push(next.engine);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await scene.sceneHost.register(part);
|
||||||
|
await app.detachScene({ handoff: true });
|
||||||
|
await assert.rejects(app.attachScene('navara'), /navara part failed/);
|
||||||
|
assert.equal(scene.engine, null);
|
||||||
|
assert.equal(released.length, 2, 'the candidate renderer was released');
|
||||||
|
assert.equal(released[1].kind, 'navara');
|
||||||
|
await app.attachScene('cesium');
|
||||||
|
assert.equal(scene.engine.kind, 'cesium');
|
||||||
|
assert.deepEqual(
|
||||||
|
part.attached.map(({ kind }) => kind),
|
||||||
|
['cesium', 'cesium'],
|
||||||
|
);
|
||||||
|
assert.equal(opened.length, 3);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user