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

+405 -12
View File
@@ -4,7 +4,306 @@ const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
test('playlist refresh queues updates until the next slide transition', async () => {
function createRenderSandbox() {
const calls = { rendered: [], scheduled: [], reported: [], saved: 0, progress: 0 };
const sandbox = {
window: null,
slides: [{ id: 1, duration_seconds: 10 }, { id: 2, duration_seconds: 12 }],
slideRenderRequestId: 0,
currentPlaylistSkipUnavailableRtmp: false,
currentPlaylistFadeBetweenSlides: false,
isPaused: false,
index: 0,
timer: null,
app: null,
lastRenderedViewKey: '',
getCurrentActiveSlides() { return sandbox.slides; },
getCurrentRenderKey() { return String(sandbox.index); },
buildSlideMarkup(slide) { return slide.id; },
renderEmpty() { calls.rendered.push('empty'); },
savePlaybackState() { calls.saved += 1; },
sendCommandState(slide) { calls.reported.push(slide.id); },
getSlideHoldDelay(delayMs) { return delayMs; },
clearSlideTimer() {},
syncWebpagePreloads() {}
};
sandbox.window = sandbox;
const transitionPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-transition.js');
vm.runInNewContext(fs.readFileSync(transitionPath, 'utf8'), sandbox, { filename: transitionPath });
sandbox.renderSlideMarkup = function (markup) { calls.rendered.push(markup); };
sandbox.scheduleSlideAdvance = function (delayMs) { calls.scheduled.push(delayMs); };
sandbox.markPlayerPlaybackProgress = function () { calls.progress += 1; };
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
return { sandbox, calls };
}
test('playlist refreshes coalesce startup bursts but forced refreshes run after the debounce window', () => {
const requests = [];
class XhrStub {
open(_method, url) { this.url = url; }
setRequestHeader() {}
send() {
requests.push(this);
this.readyState = 4;
this.status = 304;
this.onreadystatechange();
}
}
const sandbox = {
window: null,
location: { origin: 'http://localhost' },
slug: 'test',
currentPlaylistEtag: '"etag"',
initialData: { apiSources: [], weatherLocations: [] },
playlistRefreshInFlight: false,
XMLHttpRequest: XhrStub,
markRefreshHealthy() {},
setOfflineBannerVisible() {},
logDebug() {}
};
sandbox.window = sandbox;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
sandbox.refresh();
sandbox.refresh();
assert.equal(requests.length, 1);
sandbox.refresh(true);
assert.equal(requests.length, 1);
sandbox.lastPlaylistRefreshAt = Date.now() - 1001;
sandbox.refresh(true);
assert.equal(requests.length, 2);
});
test('playlist refresh sends cached etags only after source metadata is hydrated', () => {
function captureHeaders(initialData) {
let request = null;
class XhrStub {
constructor() { this.headers = {}; request = this; }
open() {}
setRequestHeader(name, value) { this.headers[name] = value; }
send() {}
}
const sandbox = {
window: null,
location: { origin: 'http://localhost' },
slug: 'test',
initialData: initialData,
currentPlaylistEtag: '"cached-etag"',
playlistRefreshInFlight: false,
XMLHttpRequest: XhrStub
};
sandbox.window = sandbox;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
sandbox.refresh();
return request.headers['If-None-Match'];
}
assert.equal(captureHeaders(null), undefined);
assert.equal(captureHeaders({ apiSources: [], weatherLocations: [] }), '"cached-etag"');
});
test('command client ID conflict reconnects in place without navigating through onboarding', () => {
const sockets = [];
const storedValues = new Map([['pulse-signage-player-client-id', 'client-old']]);
const reconnectTimers = [];
let redirects = 0;
class MockWebSocket {
constructor(url) {
this.url = url;
this.readyState = MockWebSocket.CONNECTING;
this.sent = [];
sockets.push(this);
}
send(payload) { this.sent.push(payload); }
}
MockWebSocket.CONNECTING = 0;
MockWebSocket.OPEN = 1;
MockWebSocket.CLOSED = 3;
const window = {
location: {
origin: 'http://localhost:8081',
href: 'http://localhost:8081/screen/test',
replace() { redirects += 1; }
},
navigator: { userAgent: 'test' },
crypto: { randomUUID() { return 'client-new'; } },
sessionStorage: {
getItem(key) { return storedValues.get(key) || null; },
setItem(key, value) { storedValues.set(key, String(value)); },
removeItem(key) { storedValues.delete(key); }
},
WebSocket: MockWebSocket,
addEventListener() {},
setTimeout(callback) { reconnectTimers.push(callback); return reconnectTimers.length; },
clearTimeout() {},
setInterval() { return 1; },
clearInterval() {}
};
const sandbox = {
window: window,
WebSocket: MockWebSocket,
URL,
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Promise,
commandSocketPath: '/ws/screens/test',
commandSocket: null,
commandClientId: null,
commandClientStorageKey: 'pulse-signage-player-client-id',
commandReconnectTimer: null,
commandHeartbeatTimer: null,
currentPlaylistSignature: 'playlist-revision',
getOnboardingClientName() { return ''; },
getOnboardingDeviceId() { return 'device-1'; },
getCurrentViewport() { return { width: 1920, height: 1080 }; },
lastRenderedSlide: null,
isPaused: false,
isBlackout: false
};
const playlistScriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js');
const commandsScriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
vm.runInNewContext(fs.readFileSync(playlistScriptPath, 'utf8'), sandbox, { filename: playlistScriptPath });
vm.runInNewContext(fs.readFileSync(commandsScriptPath, 'utf8'), sandbox, { filename: commandsScriptPath });
sandbox.connectCommandSocket();
const socket = sockets[0];
socket.readyState = MockWebSocket.OPEN;
socket.onopen();
socket.onmessage({ data: JSON.stringify({ type: 'client-id-conflict' }) });
socket.onclose({ code: 4009, reason: 'Client ID is already in use.' });
assert.equal(redirects, 0);
assert.equal(storedValues.get('pulse-signage-player-client-id'), 'client-new');
assert.equal(reconnectTimers.length, 1);
});
test('invalid slide indexes fall back to the first available slide', async () => {
const { sandbox, calls } = createRenderSandbox();
for (const targetIndex of [-1, Infinity, NaN, 0.5, sandbox.slides.length]) {
assert.equal(await sandbox.renderSlideAtIndex(sandbox.slides, targetIndex), true);
assert.equal(sandbox.index, 0);
}
assert.deepEqual(calls.rendered, [1, 1, 1, 1, 1]);
});
test('obsolete RTMP probes cannot overwrite a newer render or its side effects', async () => {
for (const olderAvailable of [true, false]) {
const { sandbox, calls } = createRenderSandbox();
const pending = new Map();
sandbox.currentPlaylistSkipUnavailableRtmp = true;
sandbox.probeRtmpSlideAvailability = function (slide) {
return new Promise(resolve => pending.set(slide.id, resolve));
};
const older = sandbox.renderSlideAtIndex(sandbox.slides, 0);
const newer = sandbox.renderSlideAtIndex(sandbox.slides, 1);
pending.get(2)(true);
assert.equal(await newer, true);
pending.get(1)(olderAvailable);
assert.equal(await older, false);
assert.deepEqual(calls.rendered, [2]);
assert.deepEqual(calls.reported, [2]);
assert.deepEqual(calls.scheduled, [12000]);
assert.equal(calls.saved, 1);
assert.equal(calls.progress, 1);
assert.equal(sandbox.index, 1);
}
});
test('an empty active playlist cancels a pending RTMP render', async () => {
const { sandbox, calls } = createRenderSandbox();
let resolveProbe;
sandbox.currentPlaylistSkipUnavailableRtmp = true;
sandbox.probeRtmpSlideAvailability = function () {
return new Promise(resolve => { resolveProbe = resolve; });
};
const pendingRender = sandbox.renderSlideAtIndex(sandbox.slides, 0);
sandbox.slides = [];
sandbox.showCurrent();
resolveProbe(true);
assert.equal(await pendingRender, false);
assert.deepEqual(calls.rendered, ['empty']);
assert.deepEqual(calls.scheduled, []);
assert.equal(calls.saved, 0);
});
test('playlist refreshes preserve single-slide deadlines and paused remaining time', () => {
for (const outcome of ['304', 'unchanged', 'network-error', 'timeout', '503', 'invalid-json']) {
for (const paused of [false, true]) {
const requests = [];
let advances = 0;
const sandbox = {
window: null,
location: { origin: 'http://localhost' },
slug: 'test',
isPaused: paused,
timer: paused ? null : 42,
slideExpiresAt: paused ? null : Date.now() + 90000,
pausedRemainingMs: paused ? 45000 : null,
lastRenderedSlide: { id: 1, duration_seconds: 120 },
currentPlaylistSignature: 'revision',
currentPlaylistEtag: '"etag"',
initialData: { apiSources: [], weatherLocations: [] },
pendingPlaylistUpdate: null,
getCurrentActiveSlides() { return [sandbox.lastRenderedSlide]; },
normalizeSlide(slide) { return slide; },
getActiveSlidesFrom(slides) { return slides; },
getPlaylistRevision() { return 'revision'; },
savePlaylistSnapshot() {},
markRefreshHealthy() {},
setOfflineBannerVisible() {},
scheduleRefreshRetry() {},
logDebug() {},
getSlideAdvanceDelay() { return 120000; },
scheduleSlideAdvance(delayMs) {
advances += 1;
sandbox.timer = 99;
sandbox.slideExpiresAt = Date.now() + delayMs;
},
XMLHttpRequest: class {
open() { requests.push(this); }
setRequestHeader() {}
getResponseHeader() { return '"etag"'; }
send() {
if (outcome === 'network-error') { this.onerror(); return; }
if (outcome === 'timeout') { this.ontimeout(); return; }
this.readyState = 4;
this.status = outcome === '304' ? 304 : outcome === '503' ? 503 : 200;
this.responseText = outcome === 'invalid-json' ? '{' : JSON.stringify({
slides: [{ id: 1, duration_seconds: 120 }], apiSources: [], weatherLocations: [], playlist: {}
});
this.onreadystatechange();
}
}
};
sandbox.window = sandbox;
const deadline = sandbox.slideExpiresAt;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
sandbox.refresh();
sandbox.refresh();
assert.equal(requests.length, 1);
assert.equal(advances, 0, outcome + ', paused=' + paused);
assert.equal(sandbox.timer, paused ? null : 42);
assert.equal(sandbox.slideExpiresAt, deadline);
assert.equal(sandbox.pausedRemainingMs, paused ? 45000 : null);
}
}
});
test('forced playlist refresh queues updates until the next slide transition', async () => {
const calls = {
showCurrent: 0,
logDebug: [],
@@ -137,7 +436,7 @@ test('playlist refresh queues updates until the next slide transition', async ()
`;
vm.runInNewContext(prelude + '\n' + script, sandbox, { filename: scriptPath });
await sandbox.refresh();
await sandbox.refresh(true);
assert.equal(calls.showCurrent, 0);
assert.equal(sandbox.currentPlaylistSignature, 'old-signature');
@@ -149,6 +448,74 @@ test('playlist refresh queues updates until the next slide transition', async ()
assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), true);
});
test('playlist refresh does not overlap an in-flight request', () => {
const requests = [];
const sandbox = {
window: null,
location: { origin: 'http://localhost' },
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Promise,
setTimeout,
clearTimeout,
console,
slug: 'test',
initialData: {},
currentPlaylistEtag: '',
currentPlaylistSignature: 'cached-signature',
currentPlaylistFadeBetweenSlides: false,
currentPlaylistSkipUnavailableRtmp: false,
pendingPlaylistUpdate: null,
slides: [{ id: 1, duration_seconds: 10 }],
lastRenderedSlide: { id: 1, duration_seconds: 10 },
activeSlidesCacheKey: '',
activeSlidesCacheValue: [],
slideMarkupCache: Object.create(null),
templateLayoutCache: Object.create(null),
templateRenderPlanCache: Object.create(null),
getCurrentActiveSlides() { return sandbox.slides; },
getPlaylistRevision(data) { return data.signature; },
normalizeSlide(slide) { return slide; },
getActiveSlidesFrom(slideList) { return slideList; },
savePlaylistSnapshot() {},
markRefreshHealthy() {},
setOfflineBannerVisible() {},
scheduleRefreshRetry() {},
syncWebpagePreloads() {},
syncRtmpWarmups() {},
showCurrent() {},
sendCommandState() {},
scheduleSlideAdvance() {},
logDebug() {}
};
sandbox.window = sandbox;
class XhrStub {
open() {
requests.push(this);
}
setRequestHeader() {}
getResponseHeader() { return ''; }
send() {}
}
sandbox.XMLHttpRequest = XhrStub;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
sandbox.refresh();
sandbox.refresh();
assert.equal(requests.length, 1);
});
test('playlist refresh reloads source data after a cached snapshot receives 304', async () => {
const requests = [];
const sandbox = {
@@ -244,7 +611,7 @@ test('playlist refresh reloads source data after a cached snapshot receives 304'
assert.deepEqual(sandbox.initialData.weatherLocations, [{ id: 1 }]);
});
test('centralized slide advance timing preserves the configured slide duration', () => {
test('runtime scripts have one slide timing owner and preserve configured fade timing', () => {
const sandbox = {
window: null,
currentPlaylistFadeBetweenSlides: false,
@@ -256,11 +623,19 @@ test('centralized slide advance timing preserves the configured slide duration',
};
sandbox.window = sandbox;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
const script = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(script, sandbox, { filename: scriptPath });
require('../src/common');
const { getPlayerRuntimeScripts } = require('../src/player/render-helpers');
const runtimeScripts = getPlayerRuntimeScripts();
const declarations = runtimeScripts.flatMap(([, source]) => source.match(/function getSlideAdvanceDelay\s*\(/g) || []);
assert.equal(declarations.length, 1);
for (const [name, source] of runtimeScripts) {
if (name === 'transition' || name === 'playback') {
vm.runInNewContext(source, sandbox, { filename: name });
}
}
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12 }), 12000);
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12, use_video_duration: true }), 12000);
sandbox.currentPlaylistFadeBetweenSlides = true;
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 10 }), 9720);
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 10, use_video_duration: true }), 9440);
@@ -268,7 +643,24 @@ test('centralized slide advance timing preserves the configured slide duration',
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12, use_video_duration: true }), 11440);
});
test('single-slide playlists re-render the active slide instead of refreshing after a queued update', async () => {
test('runtime scripts keep DOM transitions and region lifecycle outside command handling', () => {
require('../src/common');
const { getPlayerRuntimeScripts } = require('../src/player/render-helpers');
const scripts = getPlayerRuntimeScripts();
for (const [symbol, owner] of [
['renderSlideMarkup', 'transition'],
['runRegionLifecycle', 'region-registry'],
['initializeRegionInstances', 'region-registry'],
['destroyRegionInstances', 'region-registry']
]) {
const declaration = new RegExp('function ' + symbol + '\\s*\\(');
const owners = scripts.filter(([, source]) => declaration.test(source)).map(([name]) => name);
assert.deepEqual(owners, [owner]);
assert.ok(scripts.findIndex(([name]) => name === owner) < scripts.findIndex(([name]) => name === 'commands'));
}
});
test('playlist removal of the current slide waits until its transition before applying', async () => {
const calls = {
showCurrent: 0,
refresh: 0
@@ -611,7 +1003,7 @@ test('player page arrow keys move between slides', async () => {
assert.deepEqual(calls, [0, 2]);
});
test('removing the currently visible slide from a two-slide playlist applies the one-slide update immediately', async () => {
test('playlist removal of the current slide queues without rerendering it immediately', async () => {
const calls = {
showCurrent: 0,
logDebug: []
@@ -733,8 +1125,9 @@ test('removing the currently visible slide from a two-slide playlist applies the
await sandbox.refresh();
assert.equal(sandbox.currentPlaylistSignature, 'next-signature');
assert.equal(sandbox.pendingPlaylistUpdate, null);
assert.deepEqual(sandbox.slides, [{ id: 1, duration_seconds: 12, disable_audio: false }]);
assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), false);
assert.equal(sandbox.currentPlaylistSignature, 'old-signature');
assert.ok(sandbox.pendingPlaylistUpdate);
assert.equal(JSON.stringify(sandbox.slides), JSON.stringify([{ id: 1, duration_seconds: 12 }, { id: 2, duration_seconds: 12 }]));
assert.equal(calls.showCurrent, 0);
assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), true);
});