Release v2.14.1
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m53s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s

This commit is contained in:
lzstealth committed 2026-10-11 18:54:43 +01:00
1 parent 635e1a9942
commit c3299abaac
174 files changed
+4483 -1687

No files matched your search

+243 -27
View File
@@ -4,26 +4,53 @@ const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
function loadCommandsScript(sandbox) {
function loadRendererScripts(sandbox) {
const registryPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-region-registry.js');
const animationPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-animation.js');
const mediaPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-media.js');
const transitionPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-transition.js');
const commandsPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
const registryScript = fs.readFileSync(registryPath, 'utf8');
const animationScript = fs.readFileSync(animationPath, 'utf8');
const mediaScript = fs.readFileSync(mediaPath, 'utf8');
const transitionScript = fs.readFileSync(transitionPath, 'utf8');
const commandsScript = fs.readFileSync(commandsPath, 'utf8');
vm.runInNewContext(animationScript + '\n' + mediaScript + '\n' + transitionScript + '\n' + commandsScript, sandbox, { filename: commandsPath });
vm.runInNewContext(registryScript + '\n' + animationScript + '\n' + mediaScript + '\n' + transitionScript, sandbox, { filename: transitionPath });
sandbox.pulsePlayerRegionTypes.register('test-region', sandbox.regionDefinition);
}
function createSandbox() {
const rafCallbacks = [];
const timers = new Map();
let nextTimerId = 0;
const calls = {
syncRtmpRegions: 0,
initRegion: 0,
playRegionAnimations: 0
destroyRegion: 0,
destroyRtmpRegions: 0,
playRegionAnimations: 0,
lifecycle: []
};
function createElement() {
const classes = new Set();
return {
style: {},
isConnected: false,
parentNode: null,
innerHTML: '',
get className() { return Array.from(classes).join(' '); },
set className(value) {
classes.clear();
String(value).split(/\s+/).filter(Boolean).forEach(name => classes.add(name));
},
classList: {
contains(name) { return classes.has(name); },
add(name) { classes.add(name); },
remove(name) { classes.delete(name); }
},
querySelectorAll() { return []; }
};
}
const app = {
children: [],
firstElementChild: null,
@@ -35,7 +62,10 @@ function createSandbox() {
},
set innerHTML(value) {
this.innerHTMLValue = String(value || '');
this.firstElementChild = this.innerHTMLValue ? { isConnected: true } : null;
this.children.forEach(node => { node.isConnected = false; node.parentNode = null; });
this.children = [];
this.firstElementChild = null;
if (this.innerHTMLValue) this.appendChild(createElement());
},
get innerHTML() {
return this.innerHTMLValue;
@@ -43,11 +73,18 @@ function createSandbox() {
appendChild(node) {
if (node) {
node.isConnected = true;
node.parentNode = this;
this.children.push(node);
this.firstElementChild = this.firstElementChild || node;
}
return node;
},
removeChild(node) {
this.children = this.children.filter(child => child !== node);
node.parentNode = null;
node.isConnected = false;
this.firstElementChild = this.children[0] || null;
},
querySelectorAll() {
return [];
}
@@ -64,14 +101,19 @@ function createSandbox() {
Array,
Object,
Promise,
setTimeout,
clearTimeout,
setTimeout(callback) {
const timerId = ++nextTimerId;
timers.set(timerId, callback);
return timerId;
},
clearTimeout(timerId) { timers.delete(timerId); },
console,
requestAnimationFrame(callback) {
rafCallbacks.push(callback);
return rafCallbacks.length;
},
app,
document: { createElement },
slides: [],
index: 0,
currentPlaylistSignature: 'signature',
@@ -89,29 +131,17 @@ function createSandbox() {
pausedRemainingMs: null,
timer: null,
lastRenderedSlide: null,
destroyRtmpRegions() {},
destroyRtmpRegions() { calls.destroyRtmpRegions += 1; },
syncRtmpRegions() {
calls.syncRtmpRegions += 1;
},
initializeRegionInstances() {
calls.initializeRegionInstances += 1;
},
playRegionAnimations() {
calls.playRegionAnimations += 1;
},
addEventListener() {},
pulsePlayerRegionTypes: {
list() {
return [
{
definition: {
initRegion() {
calls.initRegion += 1;
}
}
}
];
}
regionDefinition: {
initRegion(root) { calls.initRegion += 1; calls.lifecycle.push(['init', root]); },
destroyRegion(root) { calls.destroyRegion += 1; calls.lifecycle.push(['destroy', root]); }
},
isThumbnailPreview() {
return false;
@@ -120,12 +150,12 @@ function createSandbox() {
sandbox.window = sandbox;
sandbox.window.requestAnimationFrame = sandbox.requestAnimationFrame;
return { sandbox, calls, rafCallbacks, app };
return { sandbox, calls, rafCallbacks, app, timers };
}
test('renderSlideMarkup runs post-render setup immediately', () => {
test('renderSlideMarkup initializes immediately without loading command handling', () => {
const { sandbox, calls, rafCallbacks, app } = createSandbox();
loadCommandsScript(sandbox);
loadRendererScripts(sandbox);
sandbox.playRegionAnimations = function () {
calls.playRegionAnimations += 1;
};
@@ -138,4 +168,190 @@ test('renderSlideMarkup runs post-render setup immediately', () => {
assert.equal(calls.playRegionAnimations, 1);
assert.equal(rafCallbacks.length, 0);
assert.equal(returned, app.firstElementChild);
assert.equal(calls.destroyRegion, 1);
assert.equal(calls.destroyRtmpRegions, 1);
assert.equal(typeof sandbox.sendCommandState, 'undefined');
});
test('fade rendering destroys old regions, initializes on the next frame, and removes the old shell', () => {
const { sandbox, calls, rafCallbacks, app, timers } = createSandbox();
loadRendererScripts(sandbox);
const previous = sandbox.renderSlideMarkup('<div>first</div>', true);
assert.equal(calls.initRegion, 1);
const next = sandbox.renderSlideMarkup('<div>second</div>', true, 0);
assert.equal(calls.initRegion, 1);
assert.equal(calls.destroyRegion, 2);
assert.equal(calls.destroyRtmpRegions, 2);
assert.equal(app.children.length, 2);
assert.equal(next.style.opacity, '0');
assert.equal(rafCallbacks.length, 1);
rafCallbacks.shift()();
assert.equal(calls.initRegion, 2);
assert.equal(calls.lifecycle.at(-1)[1], next);
assert.equal(next.style.opacity, '1');
assert.equal(previous.style.opacity, '0');
assert.equal(timers.size, 1);
timers.get(sandbox.slideTransitionTimer)();
assert.equal(app.children.length, 1);
assert.equal(app.firstElementChild, next);
assert.equal(previous.isConnected, false);
assert.equal(sandbox.slideTransitionTimer, null);
});
test('region lifecycle dispatch isolates failing hooks and tolerates absent modules', () => {
const { sandbox, calls, app } = createSandbox();
loadRendererScripts(sandbox);
sandbox.pulsePlayerRegionTypes.register('broken', {
initRegion() { throw new Error('broken init'); },
destroyRegion() { throw new Error('broken destroy'); }
});
let healthyCalls = 0;
sandbox.pulsePlayerRegionTypes.register('healthy', {
initRegion() { healthyCalls += 1; },
destroyRegion() { healthyCalls += 1; }
});
sandbox.pulsePlayerRegionTypes.register('no-hooks', {});
sandbox.initializeRegionInstances(app);
sandbox.destroyRegionInstances(app);
assert.equal(healthyCalls, 2);
assert.equal(calls.initRegion, 1);
assert.equal(calls.destroyRegion, 1);
sandbox.initializeRegionInstances(null);
sandbox.pulsePlayerRegionTypes = null;
assert.doesNotThrow(() => sandbox.destroyRegionInstances(app));
});
function createVideoSandbox(readyState = 2) {
const fixture = createSandbox();
loadRendererScripts(fixture.sandbox);
const listeners = new Map();
const mediaCalls = { plays: 0, pauses: 0, loads: 0, removed: [] };
const video = {
readyState,
dataset: { loop: '0', disableAudio: '0' },
isConnected: true,
src: '/media/video.mp4',
play() { mediaCalls.plays += 1; return Promise.resolve(); },
pause() { mediaCalls.pauses += 1; },
load() { mediaCalls.loads += 1; },
removeAttribute(name) { mediaCalls.removed.push(name); if (name === 'src') this.src = ''; },
addEventListener(name, callback) { listeners.set(name, callback); },
removeEventListener(name, callback) {
if (listeners.get(name) === callback) listeners.delete(name);
}
};
const root = { isConnected: true, querySelectorAll() { return [video]; } };
return { ...fixture, video, root, listeners, mediaCalls };
}
test('video teardown cancels delayed starts and releases the source', () => {
const { sandbox, root, video, timers, mediaCalls } = createVideoSandbox();
sandbox.initializeSlideMedia(root, 280);
assert.equal(timers.size, 1);
const staleStart = Array.from(timers.values())[0];
sandbox.destroySlideMedia(root);
root.isConnected = false;
video.isConnected = false;
assert.equal(timers.size, 0);
staleStart();
assert.equal(mediaCalls.plays, 0);
assert.equal(mediaCalls.pauses, 1);
assert.deepEqual(mediaCalls.removed, ['src']);
assert.equal(mediaCalls.loads, 1);
});
test('video teardown removes readiness listeners and ignores late readiness events', () => {
const { sandbox, root, video, timers, listeners, mediaCalls } = createVideoSandbox(0);
sandbox.initializeSlideMedia(root, 280);
const lateCanPlay = listeners.get('canplay');
const lateMetadata = listeners.get('loadedmetadata');
assert.equal(listeners.size, 2);
sandbox.destroySlideMedia(root);
assert.equal(listeners.size, 0);
video.readyState = 2;
lateCanPlay();
lateMetadata();
assert.equal(timers.size, 0);
assert.equal(mediaCalls.plays, 0);
});
test('video readiness schedules one start and reinitialization cancels the previous start', () => {
const { sandbox, root, video, timers, listeners, mediaCalls } = createVideoSandbox(0);
sandbox.initializeSlideMedia(root, 280);
const metadata = listeners.get('loadedmetadata');
const canPlay = listeners.get('canplay');
metadata();
assert.equal(timers.size, 0);
video.readyState = 2;
canPlay();
metadata();
assert.equal(timers.size, 1);
assert.equal(listeners.size, 0);
const staleStart = Array.from(timers.values())[0];
sandbox.initializeSlideMedia(root, 280);
assert.equal(timers.size, 1);
staleStart();
assert.equal(mediaCalls.plays, 0);
const [timerId, start] = Array.from(timers.entries())[0];
timers.delete(timerId);
start();
assert.equal(mediaCalls.plays, 1);
assert.equal(video.loop, false);
assert.equal(video.muted, false);
assert.equal(video.autoplay, false);
sandbox.destroySlideMedia(root);
assert.equal(mediaCalls.loads, 1);
});
test('video teardown can preserve the outgoing frame until crossfade removal', () => {
const { sandbox, root, timers, mediaCalls } = createVideoSandbox();
sandbox.initializeSlideMedia(root, 280);
sandbox.destroySlideMedia(root, { preserveVideoFrame: true });
assert.equal(timers.size, 0);
assert.equal(mediaCalls.pauses, 1);
assert.deepEqual(mediaCalls.removed, []);
assert.equal(mediaCalls.loads, 0);
sandbox.destroySlideMedia(root);
assert.deepEqual(mediaCalls.removed, ['src']);
assert.equal(mediaCalls.loads, 1);
});
test('crossfade cancels outgoing video startup but releases its source only at shell removal', () => {
const { sandbox, app, video, timers, rafCallbacks, mediaCalls } = createVideoSandbox();
const previous = sandbox.renderSlideMarkup('<div>video</div>', true);
previous.querySelectorAll = function () { return [video]; };
app.querySelectorAll = function () { return app.children.includes(previous) ? [video] : []; };
sandbox.initializeSlideMedia(previous, 280);
sandbox.renderSlideMarkup('<div>next</div>', true);
assert.equal(mediaCalls.pauses, 1);
assert.equal(mediaCalls.loads, 0);
assert.equal(timers.size, 1);
rafCallbacks.shift()();
timers.get(sandbox.slideTransitionTimer)();
assert.equal(mediaCalls.plays, 0);
assert.deepEqual(mediaCalls.removed, ['src']);
assert.equal(mediaCalls.loads, 1);
assert.equal(previous.isConnected, false);
});
test('rapid crossfades ignore obsolete media setup and cleanup callbacks', () => {
const { sandbox, app, timers, rafCallbacks, calls } = createSandbox();
loadRendererScripts(sandbox);
const first = sandbox.renderSlideMarkup('<div>first</div>', true);
const second = sandbox.renderSlideMarkup('<div>second</div>', true);
const staleCleanup = timers.get(sandbox.slideTransitionTimer);
const third = sandbox.renderSlideMarkup('<div>third</div>', true);
const activeTimerId = sandbox.slideTransitionTimer;
assert.equal(first.isConnected, false);
assert.equal(app.children.length, 2);
staleCleanup();
assert.equal(sandbox.slideTransitionTimer, activeTimerId);
rafCallbacks.shift()();
assert.equal(calls.initRegion, 1);
rafCallbacks.shift()();
assert.equal(calls.initRegion, 2);
assert.equal(calls.lifecycle.at(-1)[1], third);
timers.get(activeTimerId)();
assert.equal(second.isConnected, false);
assert.equal(app.firstElementChild, third);
});