Share one render loop fixture across the render failure tests

Four test files built the same widget on CesiumWidget.prototype, viewer
proxy, store and notice; renderLoopFixture.mjs now does it once. The notice
test drives Cesium.Event instead of a hand-made double. frameEvents() drops
tileset.tileLoad, which Cesium raises inside its own error handling, and the
notice no longer guards a viewer without a canvas or requestRender.
This commit is contained in:
German Khokhlov committed 2026-11-13 20:16:08 +04:00
1 parent df41c0affb
commit 73bd952736
8 files changed
+112 -237

No files matched your search

+1
View File
@@ -563,6 +563,7 @@
"src/ui/renderFailureEventRetry.test.mjs", "src/ui/renderFailureEventRetry.test.mjs",
"src/app/renderFailureInlineRetry.test.mjs", "src/app/renderFailureInlineRetry.test.mjs",
"src/ui/renderFailureSecondLoop.test.mjs", "src/ui/renderFailureSecondLoop.test.mjs",
"src/ui/renderLoopFixture.mjs",
"src/layers/cctv/lifecycle.js", "src/layers/cctv/lifecycle.js",
"src/layers/cctv/controls.js", "src/layers/cctv/controls.js",
"src/layers/cctv/geometry.js", "src/layers/cctv/geometry.js",
+14 -50
View File
@@ -5,10 +5,9 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { keepPanelRendering } from './embed.js'; import { keepPanelRendering } from './embed.js';
import { installRenderFailureNotice } from '../ui/renderFailure.js'; import { renderLoopFixture } from '../ui/renderLoopFixture.mjs';
function setup() { function setup() {
const frames = [];
let tick = null; let tick = null;
let clock = 0; let clock = 0;
// The host reports the panel hidden: its own animation frames never arrive. // The host reports the panel hidden: its own animation frames never arrive.
@@ -18,8 +17,6 @@ function setup() {
setInterval: (fn) => ((tick = fn), 1), setInterval: (fn) => ((tick = fn), 1),
clearInterval() {}, clearInterval() {},
}; };
const previousRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (fn) => frames.push(fn);
const previousError = console.error; const previousError = console.error;
console.error = () => {}; console.error = () => {};
const state = { failure: null, drawn: 0 }; const state = { failure: null, drawn: 0 };
@@ -40,48 +37,14 @@ function setup() {
this.postRender.raiseEvent(this); this.postRender.raiseEvent(this);
}, },
}; };
const widget = Object.create(Cesium.CesiumWidget.prototype); const { frames, widget, viewer, store, runFrames, restore } =
Object.assign(widget, { renderLoopFixture(scene, {
_scene: scene, viewer: {
_clock: { tick() {} },
_canRender: true,
_useDefaultRenderLoop: false,
_renderLoopRunning: false,
_showRenderLoopErrors: false,
_targetFrameRate: undefined,
isDestroyed: () => false,
resize() {},
});
// CesiumWidget's own renderError handler, which marks the loop stopped.
scene.renderError.addEventListener(() => {
widget._useDefaultRenderLoop = false;
widget._renderLoopRunning = false;
});
const viewer = {
scene,
cesiumWidget: widget,
canvas: { addEventListener() {}, removeEventListener() {} },
get useDefaultRenderLoop() {
return widget.useDefaultRenderLoop;
},
set useDefaultRenderLoop(value) {
widget.useDefaultRenderLoop = value;
},
isDestroyed: () => false, isDestroyed: () => false,
render: () => widget.render(), render: () => widget.render(),
resize: () => widget.resize(), resize: () => widget.resize(),
};
const store = {
renderFailure: null,
publishRenderFailure(next) {
this.renderFailure = next;
}, },
}; notice: { allowReload: false },
const uninstall = installRenderFailureNotice(viewer, {
store,
allowReload: false,
locationRef: { href: 'https://gev.example/', reload() {}, assign() {} },
logError() {},
}); });
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
viewer.useDefaultRenderLoop = true; viewer.useDefaultRenderLoop = true;
@@ -89,17 +52,18 @@ function setup() {
clock += 400; clock += 400;
tick(); tick();
}; };
const animationFrames = (n) => { return {
for (let frame = 1; frame <= n; frame += 1) frames,
for (const render of frames.splice(0)) render(frame * 16); state,
}; store,
const restore = () => { timerFrame,
animationFrames: runFrames,
restore: () => {
stop(); stop();
uninstall(); restore();
globalThis.requestAnimationFrame = previousRaf;
console.error = previousError; console.error = previousError;
},
}; };
return { frames, state, store, timerFrame, animationFrames, restore };
} }
for (const failure of ['caught', 'thrown']) { for (const failure of ['caught', 'thrown']) {
+4 -5
View File
@@ -21,7 +21,7 @@ export function installRenderFailureNotice(
} = {}, } = {},
) { ) {
const { scene } = viewer; const { scene } = viewer;
const canvas = viewer.canvas ?? scene.canvas; const { canvas } = viewer;
let notice = null; let notice = null;
let contextLost = false; let contextLost = false;
// With rethrowRenderErrors, Scene throws the error it has just raised; // With rethrowRenderErrors, Scene throws the error it has just raised;
@@ -36,7 +36,7 @@ export function installRenderFailureNotice(
return; return;
} }
viewer.useDefaultRenderLoop = true; viewer.useDefaultRenderLoop = true;
scene.requestRender?.(); scene.requestRender();
} }
function reset() { function reset() {
@@ -113,11 +113,11 @@ export function installRenderFailureNotice(
raisedByScene = null; raisedByScene = null;
}), }),
); );
canvas?.addEventListener?.('webglcontextlost', onContextLost); canvas.addEventListener('webglcontextlost', onContextLost);
return () => { return () => {
removeRenderError(); removeRenderError();
unguard.forEach((restore) => restore()); unguard.forEach((restore) => restore());
canvas?.removeEventListener?.('webglcontextlost', onContextLost); canvas.removeEventListener('webglcontextlost', onContextLost);
clear(); clear();
}; };
} }
@@ -147,7 +147,6 @@ function frameEvents(viewer) {
tileset?.loadProgress, tileset?.loadProgress,
tileset?.allTilesLoaded, tileset?.allTilesLoaded,
tileset?.initialTilesLoaded, tileset?.initialTilesLoaded,
tileset?.tileLoad,
); );
} }
return events; return events;
+12 -25
View File
@@ -1,27 +1,14 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { installRenderFailureNotice } from './renderFailure.js'; import { installRenderFailureNotice } from './renderFailure.js';
function event() {
const listeners = new Set();
return {
listeners,
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
raise(...args) {
for (const listener of [...listeners]) listener(...args);
},
};
}
function fixture(t, options = {}) { function fixture(t, options = {}) {
const canvasListeners = new Map(); const canvasListeners = new Map();
const scene = { const scene = {
renderError: event(), renderError: new Cesium.Event(),
preUpdate: event(), preUpdate: new Cesium.Event(),
postRender: event(), postRender: new Cesium.Event(),
requestRender() { requestRender() {
this.requests = (this.requests ?? 0) + 1; this.requests = (this.requests ?? 0) + 1;
}, },
@@ -62,12 +49,12 @@ function fixture(t, options = {}) {
// One Cesium frame: preUpdate, an optional render error, then postRender, // One Cesium frame: preUpdate, an optional render error, then postRender,
// which Cesium raises after a failed frame too. // which Cesium raises after a failed frame too.
const frame = (error) => { const frame = (error) => {
scene.preUpdate.raise(scene); scene.preUpdate.raiseEvent(scene);
if (error) { if (error) {
viewer.useDefaultRenderLoop = false; viewer.useDefaultRenderLoop = false;
scene.renderError.raise(scene, error); scene.renderError.raiseEvent(scene, error);
} }
scene.postRender.raise(scene); scene.postRender.raiseEvent(scene);
}; };
return { return {
viewer, viewer,
@@ -105,8 +92,8 @@ test('Retry restarts the render loop and a clean frame clears the notice', (t) =
f.frame(); f.frame();
assert.equal(f.store.renderFailure, null); assert.equal(f.store.renderFailure, null);
// Clearing stops the per-frame listeners. // Clearing stops the per-frame listeners.
assert.equal(f.scene.preUpdate.listeners.size, 0); assert.equal(f.scene.preUpdate.numberOfListeners, 0);
assert.equal(f.scene.postRender.listeners.size, 0); assert.equal(f.scene.postRender.numberOfListeners, 0);
}); });
test('a loop restarted into the same error keeps one notice', (t) => { test('a loop restarted into the same error keeps one notice', (t) => {
@@ -137,7 +124,7 @@ test('a new failure after recovery is a new appearance', (t) => {
test('a render error without a value shows no detail', (t) => { test('a render error without a value shows no detail', (t) => {
const f = fixture(t); const f = fixture(t);
f.viewer.useDefaultRenderLoop = false; f.viewer.useDefaultRenderLoop = false;
f.scene.renderError.raise(f.scene, undefined); f.scene.renderError.raiseEvent(f.scene, undefined);
assert.equal(f.store.renderFailure.reason, 'render-error'); assert.equal(f.store.renderFailure.reason, 'render-error');
assert.equal(f.store.renderFailure.detail, ''); assert.equal(f.store.renderFailure.detail, '');
}); });
@@ -179,8 +166,8 @@ test('uninstall removes the listeners and the notice', (t) => {
f.frame(new Error('boom')); f.frame(new Error('boom'));
f.uninstall(); f.uninstall();
assert.equal(f.store.renderFailure, null); assert.equal(f.store.renderFailure, null);
assert.equal(f.scene.renderError.listeners.size, 0); assert.equal(f.scene.renderError.numberOfListeners, 0);
assert.equal(f.scene.postRender.listeners.size, 0); assert.equal(f.scene.postRender.numberOfListeners, 0);
assert.equal(f.canvasListeners.size, 0); assert.equal(f.canvasListeners.size, 0);
f.frame(new Error('later')); f.frame(new Error('later'));
assert.equal(f.store.renderFailure, null); assert.equal(f.store.renderFailure, null);
+2 -52
View File
@@ -5,11 +5,9 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { installRenderFailureNotice } from './renderFailure.js'; import { installRenderFailureNotice } from './renderFailure.js';
import { renderLoopFixture } from './renderLoopFixture.mjs';
function setup() { function setup() {
const frames = [];
const previousRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (fn) => frames.push(fn);
// Scene.render as in Scene.js:4369: preUpdate always, preRender/postRender // Scene.render as in Scene.js:4369: preUpdate always, preRender/postRender
// only when a frame is rendered (requestRenderMode idle skips them). // only when a frame is rendered (requestRenderMode idle skips them).
const scene = { const scene = {
@@ -34,53 +32,7 @@ function setup() {
this.postRender.raiseEvent(this, time); this.postRender.raiseEvent(this, time);
}, },
}; };
const widget = Object.create(Cesium.CesiumWidget.prototype); return { scene, ...renderLoopFixture(scene) };
Object.assign(widget, {
_scene: scene,
_clock: { tick() {} },
_canRender: true,
_useDefaultRenderLoop: false,
_renderLoopRunning: false,
_showRenderLoopErrors: false,
_targetFrameRate: undefined,
isDestroyed: () => false,
resize() {},
});
scene.renderError.addEventListener(() => {
widget._useDefaultRenderLoop = false;
widget._renderLoopRunning = false;
});
const viewer = {
scene,
cesiumWidget: widget,
canvas: { addEventListener() {}, removeEventListener() {} },
get useDefaultRenderLoop() {
return widget.useDefaultRenderLoop;
},
set useDefaultRenderLoop(value) {
widget.useDefaultRenderLoop = value;
},
};
const store = {
renderFailure: null,
publishRenderFailure(next) {
this.renderFailure = next;
},
};
const uninstall = installRenderFailureNotice(viewer, {
store,
locationRef: { href: 'https://gev.example/', reload() {}, assign() {} },
logError() {},
});
const runFrames = (n) => {
for (let i = 0; i < n; i += 1)
for (const fn of frames.splice(0)) fn(16 * (i + 1));
};
const restore = () => {
uninstall();
globalThis.requestAnimationFrame = previousRaf;
};
return { frames, scene, widget, viewer, store, runFrames, restore };
} }
for (const eventName of ['postRender', 'preUpdate']) { for (const eventName of ['postRender', 'preUpdate']) {
@@ -200,7 +152,6 @@ const FRAME_EVENTS = {
'tileset.allTilesLoaded': (v) => v.scene.primitives.get(0).allTilesLoaded, 'tileset.allTilesLoaded': (v) => v.scene.primitives.get(0).allTilesLoaded,
'tileset.initialTilesLoaded': (v) => 'tileset.initialTilesLoaded': (v) =>
v.scene.primitives.get(0).initialTilesLoaded, v.scene.primitives.get(0).initialTilesLoaded,
'tileset.tileLoad': (v) => v.scene.primitives.get(0).tileLoad,
}; };
for (const [name, pick] of Object.entries(FRAME_EVENTS)) { for (const [name, pick] of Object.entries(FRAME_EVENTS)) {
@@ -214,7 +165,6 @@ for (const [name, pick] of Object.entries(FRAME_EVENTS)) {
loadProgress: new Cesium.Event(), loadProgress: new Cesium.Event(),
allTilesLoaded: new Cesium.Event(), allTilesLoaded: new Cesium.Event(),
initialTilesLoaded: new Cesium.Event(), initialTilesLoaded: new Cesium.Event(),
tileLoad: new Cesium.Event(),
}; };
const viewer = { const viewer = {
scene: { scene: {
+5 -50
View File
@@ -3,16 +3,9 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { installRenderFailureNotice } from './renderFailure.js'; import { renderLoopFixture } from './renderLoopFixture.mjs';
// What src/app/scene.js passes to the viewer (showRenderLoopErrors).
const SHOW_RENDER_LOOP_ERRORS = false;
function runLoop(render) { function runLoop(render) {
const frames = [];
const previousRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (fn) => frames.push(fn);
try {
const scene = { const scene = {
renderError: new Cesium.Event(), renderError: new Cesium.Event(),
preUpdate: new Cesium.Event(), preUpdate: new Cesium.Event(),
@@ -21,48 +14,10 @@ function runLoop(render) {
initializeFrame() {}, initializeFrame() {},
render, render,
}; };
const widget = Object.create(Cesium.CesiumWidget.prototype); const fixture = renderLoopFixture(scene);
Object.assign(widget, { fixture.viewer.useDefaultRenderLoop = true;
_scene: scene, fixture.runFrames(3);
_clock: { tick() {} }, return { ...fixture, uninstall: fixture.restore };
_canRender: true,
_useDefaultRenderLoop: false,
_renderLoopRunning: false,
_showRenderLoopErrors: SHOW_RENDER_LOOP_ERRORS,
_targetFrameRate: undefined,
isDestroyed: () => false,
resize() {},
showErrorPanel() {},
});
const viewer = {
scene,
cesiumWidget: widget,
canvas: { addEventListener() {}, removeEventListener() {} },
get useDefaultRenderLoop() {
return widget.useDefaultRenderLoop;
},
set useDefaultRenderLoop(value) {
widget.useDefaultRenderLoop = value;
},
};
const store = {
renderFailure: null,
publishRenderFailure(next) {
this.renderFailure = next;
},
};
const logged = [];
const uninstall = installRenderFailureNotice(viewer, {
store,
locationRef: { href: 'https://gev.example/', reload() {}, assign() {} },
logError: (error) => logged.push(error),
});
viewer.useDefaultRenderLoop = true;
while (frames.length) frames.shift()(16);
return { widget, store, logged, uninstall };
} finally {
globalThis.requestAnimationFrame = previousRaf;
}
} }
test('an error thrown outside tryAndCatchError still shows the notice', () => { test('an error thrown outside tryAndCatchError still shows the notice', () => {
+2 -55
View File
@@ -4,12 +4,9 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { installRenderFailureNotice } from './renderFailure.js'; import { renderLoopFixture } from './renderLoopFixture.mjs';
function setup({ targetFrameRate } = {}) { function setup({ targetFrameRate } = {}) {
const frames = [];
const previousRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (fn) => frames.push(fn);
const scene = { const scene = {
renderError: new Cesium.Event(), renderError: new Cesium.Event(),
preUpdate: new Cesium.Event(), preUpdate: new Cesium.Event(),
@@ -29,57 +26,7 @@ function setup({ targetFrameRate } = {}) {
this.postRender.raiseEvent(this, time); this.postRender.raiseEvent(this, time);
}, },
}; };
const widget = Object.create(Cesium.CesiumWidget.prototype); return { scene, ...renderLoopFixture(scene, { targetFrameRate }) };
Object.assign(widget, {
_scene: scene,
_clock: { tick() {} },
_canRender: true,
_useDefaultRenderLoop: false,
_renderLoopRunning: false,
_showRenderLoopErrors: false,
_targetFrameRate: targetFrameRate,
isDestroyed: () => false,
resize() {},
});
// CesiumWidget's own renderError handler (CesiumWidget.js:414).
scene.renderError.addEventListener(() => {
widget._useDefaultRenderLoop = false;
widget._renderLoopRunning = false;
});
const viewer = {
scene,
cesiumWidget: widget,
canvas: { addEventListener() {}, removeEventListener() {} },
get useDefaultRenderLoop() {
return widget.useDefaultRenderLoop;
},
set useDefaultRenderLoop(value) {
widget.useDefaultRenderLoop = value;
},
};
const store = {
renderFailure: null,
publishRenderFailure(next) {
this.renderFailure = next;
},
};
const uninstall = installRenderFailureNotice(viewer, {
store,
locationRef: { href: 'https://gev.example/', reload() {}, assign() {} },
logError() {},
});
let time = 0;
const runFrames = (n) => {
for (let i = 0; i < n; i += 1) {
time += 17;
for (const fn of frames.splice(0)) fn(time);
}
};
const restore = () => {
uninstall();
globalThis.requestAnimationFrame = previousRaf;
};
return { frames, scene, viewer, store, runFrames, restore };
} }
for (const targetFrameRate of [undefined, 60]) { for (const targetFrameRate of [undefined, 60]) {
+72
View File
@@ -0,0 +1,72 @@
// A widget on Cesium's real CesiumWidget.prototype (startRenderLoop, the
// useDefaultRenderLoop setter, render) around a scene double, with animation
// frames queued by hand and the render failure notice installed.
import * as Cesium from 'cesium';
import { installRenderFailureNotice } from './renderFailure.js';
export function renderLoopFixture(
scene,
{ targetFrameRate, viewer: viewerExtras = {}, notice = {} } = {},
) {
const frames = [];
const previousRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (fn) => frames.push(fn);
const widget = Object.create(Cesium.CesiumWidget.prototype);
Object.assign(widget, {
_scene: scene,
_clock: { tick() {} },
_canRender: true,
_useDefaultRenderLoop: false,
_renderLoopRunning: false,
// What src/app/scene.js passes to the viewer.
_showRenderLoopErrors: false,
_targetFrameRate: targetFrameRate,
isDestroyed: () => false,
resize() {},
});
// CesiumWidget's own renderError handler, which marks the loop stopped.
scene.renderError.addEventListener(() => {
widget._useDefaultRenderLoop = false;
widget._renderLoopRunning = false;
});
const viewer = Object.assign(
{
scene,
cesiumWidget: widget,
canvas: { addEventListener() {}, removeEventListener() {} },
get useDefaultRenderLoop() {
return widget.useDefaultRenderLoop;
},
set useDefaultRenderLoop(value) {
widget.useDefaultRenderLoop = value;
},
},
viewerExtras,
);
const store = {
renderFailure: null,
publishRenderFailure(next) {
this.renderFailure = next;
},
};
const logged = [];
const uninstall = installRenderFailureNotice(viewer, {
store,
locationRef: { href: 'https://gev.example/', reload() {}, assign() {} },
logError: (error) => logged.push(error),
...notice,
});
let time = 0;
const runFrames = (n) => {
for (let i = 0; i < n; i += 1) {
// Above a 60 fps target's interval, so every frame draws.
time += 17;
for (const fn of frames.splice(0)) fn(time);
}
};
const restore = () => {
uninstall();
globalThis.requestAnimationFrame = previousRaf;
};
return { frames, widget, viewer, store, logged, runFrames, restore };
}