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);
|
||||
}
|
||||
|
||||
// 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();
|
||||
try {
|
||||
const { page, errors } = await openSessionPage(
|
||||
@@ -90,8 +143,20 @@ try {
|
||||
// Let the first boot's deferred listeners land before counting.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const boot = await readSession(page);
|
||||
const bootListeners = await listenerCounts(page);
|
||||
const bootListeners = await listenerCounts(page, { bySource: true });
|
||||
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);
|
||||
const escapeBefore = await escapeEffect(page);
|
||||
@@ -110,17 +175,32 @@ try {
|
||||
await page.evaluate(() => {
|
||||
window.__qaParkedNavara = window.__godsEyeView.engine;
|
||||
});
|
||||
if (report.switches.length === 1)
|
||||
report.firstRoundTripListeners = listenerDifference(
|
||||
bootListeners,
|
||||
await listenerCounts(page),
|
||||
if (report.switches.length === 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
report.firstRoundTrip = await listenerCounts(page, { bySource: true });
|
||||
}
|
||||
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({
|
||||
kind,
|
||||
ms: Date.now() - started,
|
||||
ids: idDifference(boot.ids, state.ids),
|
||||
counters: state.counters,
|
||||
ui,
|
||||
tools,
|
||||
});
|
||||
check(
|
||||
Object.keys(idDifference(boot.ids, state.ids)).length === 0,
|
||||
@@ -141,14 +221,30 @@ try {
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const after = await readSession(page);
|
||||
const afterListeners = await listenerCounts(page);
|
||||
const afterListeners = await listenerCounts(page, { bySource: true });
|
||||
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.listenersSinceFirstRoundTrip = listenerDifference(
|
||||
report.firstRoundTrip,
|
||||
afterListeners,
|
||||
);
|
||||
const sessionListeners = Object.keys(report.listeners).filter(
|
||||
(key) => !PARKED_RENDERER.test(key),
|
||||
);
|
||||
check(
|
||||
Object.keys(report.listeners).length === 0,
|
||||
'window/document listeners changed after three round trips',
|
||||
sessionListeners.length === 0,
|
||||
'session listeners on window/document changed after three round trips',
|
||||
report.listeners,
|
||||
);
|
||||
check(
|
||||
Object.keys(report.listenersSinceFirstRoundTrip).length === 0,
|
||||
'listeners on window/document grew after the first round trip',
|
||||
report.listenersSinceFirstRoundTrip,
|
||||
);
|
||||
check(
|
||||
after.counters?.attachments === 1 + sequence.length &&
|
||||
after.counters?.detaches === sequence.length,
|
||||
@@ -227,6 +323,24 @@ try {
|
||||
}
|
||||
report.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));
|
||||
if (!reportOnly) console.log('PASS application created once; session kept');
|
||||
} 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();
|
||||
try {
|
||||
const scripts = new Map();
|
||||
if (bySource) {
|
||||
client.on('Debugger.scriptParsed', ({ scriptId, url }) =>
|
||||
scripts.set(scriptId, url),
|
||||
);
|
||||
await client.send('Debugger.enable');
|
||||
}
|
||||
const counts = {};
|
||||
for (const name of ['window', 'document']) {
|
||||
const { result } = await client.send('Runtime.evaluate', {
|
||||
@@ -301,13 +313,20 @@ export async function listenerCounts(page) {
|
||||
objectId: result.objectId,
|
||||
});
|
||||
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;
|
||||
}
|
||||
await client.send('Runtime.releaseObject', {
|
||||
objectId: result.objectId,
|
||||
});
|
||||
}
|
||||
if (bySource) await client.send('Debugger.disable');
|
||||
return counts;
|
||||
} finally {
|
||||
await client.detach();
|
||||
|
||||
+61
-21
@@ -97,11 +97,19 @@ function liveComponents() {
|
||||
: 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
|
||||
// parked for a later switch back.
|
||||
async function leave(from) {
|
||||
const components = liveComponents();
|
||||
if (components) {
|
||||
const attached = Boolean(components?.scene.engine);
|
||||
if (attached) {
|
||||
cover ??= keepGlobeFrame(components.scene.engine);
|
||||
cockpit = components.controls.styleManager.getCockpitState();
|
||||
handOffSession(layerSession, {
|
||||
@@ -111,36 +119,68 @@ async function leave(from) {
|
||||
});
|
||||
renderers.retain(from);
|
||||
}
|
||||
await application?.destroy();
|
||||
if (legacySwitch) await application?.destroy();
|
||||
else if (attached) await application.detachScene({ handoff: true });
|
||||
session.count('detaches');
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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);
|
||||
try {
|
||||
const { controls } = await app.start();
|
||||
signal.throwIfAborted();
|
||||
const style = 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);
|
||||
}
|
||||
await resume(controls.styleManager);
|
||||
} catch (error) {
|
||||
// pagehide owns the teardown once the lifetime is aborted.
|
||||
if (!signal.aborted)
|
||||
|
||||
+13
-2
@@ -1,6 +1,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createSceneHost } from './app/sceneHost.js';
|
||||
import { attachScenePart } from './app/sceneParts.js';
|
||||
import vm from 'node:vm';
|
||||
import { createGoogleTokenSource } from './maps/googleTokens.js';
|
||||
import {
|
||||
@@ -85,11 +87,20 @@ async function standaloneStartup(Cesium, credentials, fetchImpl) {
|
||||
...(await loadPhotorealisticTileset(Cesium, options)),
|
||||
operations: {},
|
||||
}),
|
||||
createSceneHost,
|
||||
attachScenePart,
|
||||
AbortController,
|
||||
AbortSignal,
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
const app = context.createStandaloneApplication(credentials);
|
||||
// The application passes each constructor its signal and defer(cleanup).
|
||||
return app.createScene({ signal: new AbortController().signal, defer() {} });
|
||||
// The application passes each constructor its signal and defer(cleanup);
|
||||
// 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 () => {
|
||||
|
||||
+205
-47
@@ -16,6 +16,8 @@ import {
|
||||
import { mountApplicationViews } from '../ui/views/index.js';
|
||||
import { createGoogleTokenSource } from '../maps/googleTokens.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.
|
||||
let owner = null;
|
||||
@@ -36,7 +38,14 @@ function sessionChrome(layerSession) {
|
||||
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({
|
||||
googleApiKey,
|
||||
cesiumToken,
|
||||
@@ -66,9 +75,119 @@ export function createStandaloneApplication({
|
||||
const loadingScreen = document.getElementById('loading-screen');
|
||||
const loaderStatus = loadingScreen.querySelector('.loader-status');
|
||||
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({
|
||||
createScene: async (context) => {
|
||||
lifetime = context.signal;
|
||||
// The page's language listener and place search belong to the page
|
||||
// session when there is one (an engine switch keeps them), else to
|
||||
// this application.
|
||||
@@ -101,26 +220,11 @@ export function createStandaloneApplication({
|
||||
layerSession.lifetime.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.
|
||||
const search = globalThis.location?.search ?? '';
|
||||
// import.meta.env.DEV at the import site, so production builds drop
|
||||
// the chunk instead of shipping it unreachable.
|
||||
const qaSources =
|
||||
qaSources =
|
||||
import.meta.env?.DEV &&
|
||||
allowQaRegistration &&
|
||||
/[?&]qaFeed=/.test(search)
|
||||
@@ -129,23 +233,26 @@ export function createStandaloneApplication({
|
||||
createQaSources(parseQaFeed(search)),
|
||||
)
|
||||
: {};
|
||||
catalog = createStandaloneCatalog({
|
||||
engineKind,
|
||||
layerSession,
|
||||
sources: { ...sources, ...qaSources },
|
||||
streetLevelProviders,
|
||||
nepalBoundaryResolver: (signal) =>
|
||||
scene.operations.annotationResolver.resolveRegionRingForQuery(
|
||||
'Nepal',
|
||||
signal,
|
||||
placeSearch,
|
||||
// The locator draws the border whenever it arrives.
|
||||
{ budgetMs: Infinity },
|
||||
// The session's own defer (its logo gaze and key when no layer
|
||||
// session holds them) outlives every globe.
|
||||
sessionContext = { defer: context.defer };
|
||||
current = await openScene(engineKind);
|
||||
// The first globe: the phases build on it and register their parts.
|
||||
await sceneHost.attach(current);
|
||||
// After every part detached (the tools phase detaches them first),
|
||||
// the globe's renderer is parked or released.
|
||||
context.defer(async () => {
|
||||
await sceneHost.detach({
|
||||
handoff: Boolean(
|
||||
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) =>
|
||||
createStandaloneControls({
|
||||
@@ -154,13 +261,13 @@ export function createStandaloneApplication({
|
||||
layerSession,
|
||||
loaderStatus,
|
||||
placeSearch,
|
||||
catalog,
|
||||
catalog: current.catalog,
|
||||
}),
|
||||
createData: (context) =>
|
||||
createStandaloneData({
|
||||
...context,
|
||||
allowQaRegistration,
|
||||
catalog,
|
||||
catalog: current.catalog,
|
||||
layerSession,
|
||||
}),
|
||||
createTools: async (context) => {
|
||||
@@ -173,20 +280,29 @@ export function createStandaloneApplication({
|
||||
});
|
||||
// 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
|
||||
// production builds drop it.
|
||||
// production builds drop it. It reads one engine: a part.
|
||||
const debug = globalThis.window?.__godsEyeView;
|
||||
if (import.meta.env?.DEV && debug) {
|
||||
const { createQaProbe } = await import('../app/qaProbe.js');
|
||||
const probe = createQaProbe({
|
||||
engine: context.scene.engine,
|
||||
dataManager: context.data.dataManager,
|
||||
availability: catalog.availability,
|
||||
});
|
||||
debug.layers = probe;
|
||||
context.defer(() => {
|
||||
probe.destroy();
|
||||
if (debug.layers === probe) delete debug.layers;
|
||||
});
|
||||
await attachScenePart(
|
||||
context.scene,
|
||||
{
|
||||
name: 'qa-probe',
|
||||
attachScene(next, { defer }) {
|
||||
const probe = createQaProbe({
|
||||
engine: next.engine,
|
||||
dataManager: context.data.dataManager,
|
||||
availability: next.catalog.availability,
|
||||
});
|
||||
debug.layers = probe;
|
||||
defer(() => {
|
||||
probe.destroy();
|
||||
if (debug.layers === probe) delete debug.layers;
|
||||
});
|
||||
},
|
||||
},
|
||||
{ defer: context.defer },
|
||||
);
|
||||
}
|
||||
return tools;
|
||||
},
|
||||
@@ -199,5 +315,47 @@ export function createStandaloneApplication({
|
||||
)
|
||||
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