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
+127 -252

No files matched your search

+1
View File
@@ -563,6 +563,7 @@
"src/ui/renderFailureEventRetry.test.mjs",
"src/app/renderFailureInlineRetry.test.mjs",
"src/ui/renderFailureSecondLoop.test.mjs",
"src/ui/renderLoopFixture.mjs",
"src/layers/cctv/lifecycle.js",
"src/layers/cctv/controls.js",
"src/layers/cctv/geometry.js",
+21 -57
View File
@@ -5,10 +5,9 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { keepPanelRendering } from './embed.js';
import { installRenderFailureNotice } from '../ui/renderFailure.js';
import { renderLoopFixture } from '../ui/renderLoopFixture.mjs';
function setup() {
const frames = [];
let tick = null;
let clock = 0;
// The host reports the panel hidden: its own animation frames never arrive.
@@ -18,8 +17,6 @@ function setup() {
setInterval: (fn) => ((tick = fn), 1),
clearInterval() {},
};
const previousRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (fn) => frames.push(fn);
const previousError = console.error;
console.error = () => {};
const state = { failure: null, drawn: 0 };
@@ -40,66 +37,33 @@ function setup() {
this.postRender.raiseEvent(this);
},
};
const widget = Object.create(Cesium.CesiumWidget.prototype);
Object.assign(widget, {
_scene: scene,
_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,
render: () => widget.render(),
resize: () => widget.resize(),
};
const store = {
renderFailure: null,
publishRenderFailure(next) {
this.renderFailure = next;
},
};
const uninstall = installRenderFailureNotice(viewer, {
store,
allowReload: false,
locationRef: { href: 'https://gev.example/', reload() {}, assign() {} },
logError() {},
});
const { frames, widget, viewer, store, runFrames, restore } =
renderLoopFixture(scene, {
viewer: {
isDestroyed: () => false,
render: () => widget.render(),
resize: () => widget.resize(),
},
notice: { allowReload: false },
});
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
viewer.useDefaultRenderLoop = true;
const timerFrame = () => {
clock += 400;
tick();
};
const animationFrames = (n) => {
for (let frame = 1; frame <= n; frame += 1)
for (const render of frames.splice(0)) render(frame * 16);
return {
frames,
state,
store,
timerFrame,
animationFrames: runFrames,
restore: () => {
stop();
restore();
console.error = previousError;
},
};
const restore = () => {
stop();
uninstall();
globalThis.requestAnimationFrame = previousRaf;
console.error = previousError;
};
return { frames, state, store, timerFrame, animationFrames, restore };
}
for (const failure of ['caught', 'thrown']) {
+4 -5
View File
@@ -21,7 +21,7 @@ export function installRenderFailureNotice(
} = {},
) {
const { scene } = viewer;
const canvas = viewer.canvas ?? scene.canvas;
const { canvas } = viewer;
let notice = null;
let contextLost = false;
// With rethrowRenderErrors, Scene throws the error it has just raised;
@@ -36,7 +36,7 @@ export function installRenderFailureNotice(
return;
}
viewer.useDefaultRenderLoop = true;
scene.requestRender?.();
scene.requestRender();
}
function reset() {
@@ -113,11 +113,11 @@ export function installRenderFailureNotice(
raisedByScene = null;
}),
);
canvas?.addEventListener?.('webglcontextlost', onContextLost);
canvas.addEventListener('webglcontextlost', onContextLost);
return () => {
removeRenderError();
unguard.forEach((restore) => restore());
canvas?.removeEventListener?.('webglcontextlost', onContextLost);
canvas.removeEventListener('webglcontextlost', onContextLost);
clear();
};
}
@@ -147,7 +147,6 @@ function frameEvents(viewer) {
tileset?.loadProgress,
tileset?.allTilesLoaded,
tileset?.initialTilesLoaded,
tileset?.tileLoad,
);
}
return events;
+12 -25
View File
@@ -1,27 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
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 = {}) {
const canvasListeners = new Map();
const scene = {
renderError: event(),
preUpdate: event(),
postRender: event(),
renderError: new Cesium.Event(),
preUpdate: new Cesium.Event(),
postRender: new Cesium.Event(),
requestRender() {
this.requests = (this.requests ?? 0) + 1;
},
@@ -62,12 +49,12 @@ function fixture(t, options = {}) {
// One Cesium frame: preUpdate, an optional render error, then postRender,
// which Cesium raises after a failed frame too.
const frame = (error) => {
scene.preUpdate.raise(scene);
scene.preUpdate.raiseEvent(scene);
if (error) {
viewer.useDefaultRenderLoop = false;
scene.renderError.raise(scene, error);
scene.renderError.raiseEvent(scene, error);
}
scene.postRender.raise(scene);
scene.postRender.raiseEvent(scene);
};
return {
viewer,
@@ -105,8 +92,8 @@ test('Retry restarts the render loop and a clean frame clears the notice', (t) =
f.frame();
assert.equal(f.store.renderFailure, null);
// Clearing stops the per-frame listeners.
assert.equal(f.scene.preUpdate.listeners.size, 0);
assert.equal(f.scene.postRender.listeners.size, 0);
assert.equal(f.scene.preUpdate.numberOfListeners, 0);
assert.equal(f.scene.postRender.numberOfListeners, 0);
});
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) => {
const f = fixture(t);
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.detail, '');
});
@@ -179,8 +166,8 @@ test('uninstall removes the listeners and the notice', (t) => {
f.frame(new Error('boom'));
f.uninstall();
assert.equal(f.store.renderFailure, null);
assert.equal(f.scene.renderError.listeners.size, 0);
assert.equal(f.scene.postRender.listeners.size, 0);
assert.equal(f.scene.renderError.numberOfListeners, 0);
assert.equal(f.scene.postRender.numberOfListeners, 0);
assert.equal(f.canvasListeners.size, 0);
f.frame(new Error('later'));
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 * as Cesium from 'cesium';
import { installRenderFailureNotice } from './renderFailure.js';
import { renderLoopFixture } from './renderLoopFixture.mjs';
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
// only when a frame is rendered (requestRenderMode idle skips them).
const scene = {
@@ -34,53 +32,7 @@ function setup() {
this.postRender.raiseEvent(this, time);
},
};
const widget = Object.create(Cesium.CesiumWidget.prototype);
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 };
return { scene, ...renderLoopFixture(scene) };
}
for (const eventName of ['postRender', 'preUpdate']) {
@@ -200,7 +152,6 @@ const FRAME_EVENTS = {
'tileset.allTilesLoaded': (v) => v.scene.primitives.get(0).allTilesLoaded,
'tileset.initialTilesLoaded': (v) =>
v.scene.primitives.get(0).initialTilesLoaded,
'tileset.tileLoad': (v) => v.scene.primitives.get(0).tileLoad,
};
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(),
allTilesLoaded: new Cesium.Event(),
initialTilesLoaded: new Cesium.Event(),
tileLoad: new Cesium.Event(),
};
const viewer = {
scene: {
+13 -58
View File
@@ -3,66 +3,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { installRenderFailureNotice } from './renderFailure.js';
// What src/app/scene.js passes to the viewer (showRenderLoopErrors).
const SHOW_RENDER_LOOP_ERRORS = false;
import { renderLoopFixture } from './renderLoopFixture.mjs';
function runLoop(render) {
const frames = [];
const previousRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (fn) => frames.push(fn);
try {
const scene = {
renderError: new Cesium.Event(),
preUpdate: new Cesium.Event(),
postRender: new Cesium.Event(),
requestRender() {},
initializeFrame() {},
render,
};
const widget = Object.create(Cesium.CesiumWidget.prototype);
Object.assign(widget, {
_scene: scene,
_clock: { tick() {} },
_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;
}
const scene = {
renderError: new Cesium.Event(),
preUpdate: new Cesium.Event(),
postRender: new Cesium.Event(),
requestRender() {},
initializeFrame() {},
render,
};
const fixture = renderLoopFixture(scene);
fixture.viewer.useDefaultRenderLoop = true;
fixture.runFrames(3);
return { ...fixture, uninstall: fixture.restore };
}
test('an error thrown outside tryAndCatchError still shows the notice', () => {
+2 -55
View File
@@ -4,12 +4,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { installRenderFailureNotice } from './renderFailure.js';
import { renderLoopFixture } from './renderLoopFixture.mjs';
function setup({ targetFrameRate } = {}) {
const frames = [];
const previousRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (fn) => frames.push(fn);
const scene = {
renderError: new Cesium.Event(),
preUpdate: new Cesium.Event(),
@@ -29,57 +26,7 @@ function setup({ targetFrameRate } = {}) {
this.postRender.raiseEvent(this, time);
},
};
const widget = Object.create(Cesium.CesiumWidget.prototype);
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 };
return { scene, ...renderLoopFixture(scene, { targetFrameRate }) };
}
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 };
}