Files
pulse-signage/test/player-page-playback.test.js
lzstealth 7295f83f04
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 4m34s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 57s
Release v2.15.2
2026-10-15 01:50:39 +01:00

1184 lines
37 KiB
JavaScript

const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
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: [],
scheduleSlideAdvance: []
};
const sandbox = {
window: null,
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Promise,
setTimeout,
clearTimeout,
console,
currentPlaylistSignature: 'old-signature',
currentPlaylistFadeBetweenSlides: false,
currentPlaylistSkipUnavailableRtmp: false,
slug: 'test2',
pendingPlaylistUpdate: null,
slides: [{ id: 1, duration_seconds: 12 }],
lastRenderedSlide: { id: 1, duration_seconds: 12 },
index: 0,
timer: null,
slideExpiresAt: null,
pausedRemainingMs: null,
app: null,
currentPlaylistEtag: '',
initialData: {
rssFeeds: [{ id: 1 }],
apiSources: [{ id: 2 }],
timetableGroups: [{ id: 3 }]
},
activeSlidesCacheKey: '',
activeSlidesCacheValue: [],
renderCacheViewportKey: '',
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() {},
addEventListener() {},
showCurrent() {
calls.showCurrent += 1;
},
sendCommandState() {},
scheduleSlideAdvance(delayMs) {
calls.scheduleSlideAdvance.push(delayMs);
},
logDebug(...args) {
calls.logDebug.push(args.join(' '));
}
};
sandbox.window = sandbox;
class XhrStub {
open(method, url) {
this.method = method;
this.url = url;
}
setRequestHeader() {}
getResponseHeader(name) {
return name === 'ETag' ? '"next-etag"' : '';
}
send() {
this.readyState = 4;
this.status = 200;
this.responseText = JSON.stringify({
signature: 'next-signature',
slides: [{ id: 1, duration_seconds: 12, disable_audio: false }],
rssFeeds: [{ id: 10 }],
apiSources: [{ id: 20 }],
timetableGroups: [{ id: 30 }],
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
});
if (typeof this.onreadystatechange === 'function') {
this.onreadystatechange();
}
}
}
sandbox.XMLHttpRequest = XhrStub;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
const script = fs.readFileSync(scriptPath, 'utf8');
const prelude = `
var pendingPlaylistUpdate = null;
var slides = [{ id: 1, duration_seconds: 12 }];
var currentPlaylistSignature = 'old-signature';
var currentPlaylistFadeBetweenSlides = false;
var currentPlaylistSkipUnavailableRtmp = false;
var currentPlaylistEtag = '';
var lastRenderedSlide = { id: 1, duration_seconds: 12 };
var index = 0;
var timer = null;
var slideExpiresAt = null;
var pausedRemainingMs = null;
var app = null;
var activeSlidesCacheKey = '';
var activeSlidesCacheValue = [];
var renderCacheViewportKey = '';
var slideMarkupCache = Object.create(null);
var templateLayoutCache = Object.create(null);
var templateRenderPlanCache = Object.create(null);
`;
vm.runInNewContext(prelude + '\n' + script, sandbox, { filename: scriptPath });
await sandbox.refresh(true);
assert.equal(calls.showCurrent, 0);
assert.equal(sandbox.currentPlaylistSignature, 'old-signature');
assert.equal(sandbox.slides[0].disable_audio, undefined);
assert.deepEqual(sandbox.initialData.rssFeeds, [{ id: 10 }]);
assert.deepEqual(sandbox.initialData.apiSources, [{ id: 20 }]);
assert.deepEqual(sandbox.initialData.timetableGroups, [{ id: 30 }]);
assert.equal(calls.logDebug.some((entry) => entry.includes('Unable to load screen playlist.')), false);
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 = {
window: null,
location: { origin: 'http://localhost' },
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Promise,
setTimeout,
clearTimeout,
console,
slug: 'test',
initialData: null,
currentPlaylistEtag: '"cached-etag"',
currentPlaylistSignature: 'cached-signature',
currentPlaylistFadeBetweenSlides: false,
currentPlaylistSkipUnavailableRtmp: false,
pendingPlaylistUpdate: null,
slides: [{ id: 1, duration_seconds: 10 }],
lastRenderedSlide: null,
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(method, url) {
this.method = method;
this.url = url;
requests.push(this);
}
setRequestHeader(name, value) {
this.headers = this.headers || {};
this.headers[name] = value;
}
getResponseHeader() { return ''; }
send() {
this.readyState = 4;
if (requests.length === 1) {
this.status = 304;
this.responseText = '';
} else {
this.status = 200;
this.responseText = JSON.stringify({
signature: 'fresh-signature',
slides: [{ id: 1, duration_seconds: 10 }],
rssFeeds: [],
apiSources: [{ id: 1 }],
timetableGroups: [],
weatherLocations: [{ id: 1 }],
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
});
}
this.onreadystatechange();
}
}
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 });
await sandbox.refresh();
assert.equal(requests.length, 2);
assert.equal(requests[1].headers && requests[1].headers['If-None-Match'], undefined);
assert.deepEqual(sandbox.initialData.apiSources, [{ id: 1 }]);
assert.deepEqual(sandbox.initialData.weatherLocations, [{ id: 1 }]);
});
test('runtime scripts have one slide timing owner and preserve configured fade timing', () => {
const sandbox = {
window: null,
currentPlaylistFadeBetweenSlides: false,
slideFadeLengthMs: 560,
slideFadeOffsetMs: 280,
getSlideHoldDelay(value) {
return value;
}
};
sandbox.window = sandbox;
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);
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12 }), 11720);
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12, use_video_duration: true }), 11440);
});
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
};
const timers = [];
const sandbox = {
window: null,
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Promise,
setTimeout(fn) {
timers.push(fn);
return timers.length;
},
clearTimeout() {},
console,
currentPlaylistSignature: 'old-signature',
currentPlaylistFadeBetweenSlides: false,
currentPlaylistSkipUnavailableRtmp: false,
slug: 'test2',
pendingPlaylistUpdate: null,
slides: [{ id: 1, duration_seconds: 12 }],
lastRenderedSlide: { id: 1, duration_seconds: 12 },
index: 0,
timer: null,
slideExpiresAt: null,
pausedRemainingMs: null,
currentPlaylistEtag: '',
activeSlidesCacheKey: '',
activeSlidesCacheValue: [],
renderCacheViewportKey: '',
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() {},
addEventListener() {},
clearActiveSlidesCache() {},
showCurrent() {},
sendCommandState() {},
refresh() {
calls.refresh += 1;
},
logDebug() {}
};
sandbox.window = sandbox;
class XhrStub {
open() {}
setRequestHeader() {}
getResponseHeader(name) {
return name === 'ETag' ? '"next-etag"' : '';
}
send() {
this.readyState = 4;
this.status = 200;
this.responseText = JSON.stringify({
signature: 'next-signature',
slides: [{ id: 2, duration_seconds: 12, disable_audio: false }],
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
});
if (typeof this.onreadystatechange === 'function') {
this.onreadystatechange();
}
}
}
sandbox.XMLHttpRequest = XhrStub;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.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 playbackScript = fs.readFileSync(scriptPath, 'utf8');
const transitionScript = fs.readFileSync(transitionPath, 'utf8');
const commandsScript = fs.readFileSync(commandsPath, 'utf8');
const prelude = `
var pendingPlaylistUpdate = null;
var slides = [{ id: 1, duration_seconds: 12 }];
var currentPlaylistSignature = 'old-signature';
var currentPlaylistFadeBetweenSlides = false;
var currentPlaylistSkipUnavailableRtmp = false;
var currentPlaylistEtag = '';
var lastRenderedSlide = { id: 1, duration_seconds: 12 };
var index = 0;
var timer = null;
var slideExpiresAt = null;
var pausedRemainingMs = null;
var app = null;
var activeSlidesCacheKey = '';
var activeSlidesCacheValue = [];
var renderCacheViewportKey = '';
var slideMarkupCache = Object.create(null);
var templateLayoutCache = Object.create(null);
var templateRenderPlanCache = Object.create(null);
`;
vm.runInNewContext(prelude + '\n' + transitionScript + '\n' + playbackScript + '\n' + commandsScript, sandbox, { filename: scriptPath });
sandbox.showCurrent = function () {
calls.showCurrent += 1;
};
sandbox.pendingPlaylistUpdate = {
slides: [{ id: 2, duration_seconds: 12 }],
signature: 'next-signature',
fadeBetweenSlides: false,
skipUnavailableRtmp: false
};
sandbox.scheduleSlideAdvance(100);
assert.equal(timers.length, 1);
timers.shift()();
assert.equal(calls.refresh, 0);
assert.equal(calls.showCurrent, 1);
assert.equal(sandbox.currentPlaylistSignature, 'next-signature');
assert.deepEqual(sandbox.slides, [{ id: 2, duration_seconds: 12 }]);
});
test('deferred playlist updates preserve the current slide index', async () => {
const sandbox = {
window: null,
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Promise,
setTimeout,
clearTimeout,
console,
currentPlaylistSignature: 'old-signature',
currentPlaylistFadeBetweenSlides: false,
currentPlaylistSkipUnavailableRtmp: false,
slug: 'test2',
pendingPlaylistUpdate: null,
slides: [
{ id: 1, duration_seconds: 12 },
{ id: 2, duration_seconds: 12 },
{ id: 3, duration_seconds: 12 }
],
lastRenderedSlide: { id: 2, duration_seconds: 12 },
index: 1,
timer: null,
slideExpiresAt: null,
pausedRemainingMs: null,
app: null,
currentPlaylistEtag: '',
activeSlidesCacheKey: '',
activeSlidesCacheValue: [],
renderCacheViewportKey: '',
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() {},
addEventListener() {},
clearActiveSlidesCache() {},
showCurrent() {},
sendCommandState() {},
logDebug() {}
};
sandbox.window = sandbox;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
const script = fs.readFileSync(scriptPath, 'utf8');
const prelude = `
var pendingPlaylistUpdate = null;
var slides = [
{ id: 1, duration_seconds: 12 },
{ id: 2, duration_seconds: 12 },
{ id: 3, duration_seconds: 12 }
];
var currentPlaylistSignature = 'old-signature';
var currentPlaylistFadeBetweenSlides = false;
var currentPlaylistSkipUnavailableRtmp = false;
var currentPlaylistEtag = '';
var lastRenderedSlide = { id: 2, duration_seconds: 12 };
var index = 1;
var timer = null;
var slideExpiresAt = null;
var pausedRemainingMs = null;
var app = null;
var activeSlidesCacheKey = '';
var activeSlidesCacheValue = [];
var renderCacheViewportKey = '';
var slideMarkupCache = Object.create(null);
var templateLayoutCache = Object.create(null);
var templateRenderPlanCache = Object.create(null);
`;
vm.runInNewContext(prelude + '\n' + script, sandbox, { filename: scriptPath });
sandbox.pendingPlaylistUpdate = {
slides: [
{ id: 10, duration_seconds: 12 },
{ id: 11, duration_seconds: 12 },
{ id: 12, duration_seconds: 12 }
],
signature: 'next-signature',
fadeBetweenSlides: false,
skipUnavailableRtmp: false
};
const applied = sandbox.applyPendingPlaylistUpdate();
assert.equal(applied, true);
assert.equal(sandbox.currentPlaylistSignature, 'next-signature');
assert.equal(sandbox.pendingPlaylistUpdate, null);
assert.deepEqual(sandbox.slides, [
{ id: 10, duration_seconds: 12 },
{ id: 11, duration_seconds: 12 },
{ id: 12, duration_seconds: 12 }
]);
assert.equal(sandbox.index, 1);
});
test('player page arrow keys move between slides', async () => {
const handlers = Object.create(null);
const calls = [];
const sandbox = {
window: null,
console,
Array,
Object,
String,
Boolean,
Number,
Math,
Date,
JSON,
Promise,
setTimeout,
clearTimeout,
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
addEventListener(type, handler) {
handlers[type] = handler;
},
slides: [{ id: 1 }, { id: 2 }, { id: 3 }],
lastRenderedSlide: { id: 2 },
index: 1,
timer: null,
slideOutroTimers: [],
getCurrentActiveSlides() {
return sandbox.slides;
},
clearSlideTimer() {},
applyPendingPlaylistUpdate() {},
renderSlideAtIndex(_sourceSlides, targetIndex) {
calls.push(targetIndex);
}
};
sandbox.window = sandbox;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
const script = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(script, sandbox, { filename: scriptPath });
assert.equal(typeof handlers.keydown, 'function');
let prevented = false;
handlers.keydown({
key: 'ArrowLeft',
target: {},
preventDefault() {
prevented = true;
}
});
assert.deepEqual(calls, [0]);
assert.equal(prevented, true);
sandbox.lastRenderedSlide = { id: 2 };
sandbox.index = 1;
prevented = false;
handlers.keydown({
key: 'ArrowRight',
target: {},
preventDefault() {
prevented = true;
}
});
assert.deepEqual(calls, [0, 2]);
assert.equal(prevented, true);
handlers.keydown({
key: 'ArrowLeft',
target: { tagName: 'INPUT' },
preventDefault() {
throw new Error('should not be called for editable targets');
}
});
assert.deepEqual(calls, [0, 2]);
});
test('manual forward wrap advances the playlist group pass', () => {
const handlers = Object.create(null);
const calls = [];
const firstPassSlides = [{ id: 1 }, { id: 2 }];
const nextPassSlides = [{ id: 3 }, { id: 4 }];
const sandbox = {
window: null,
console,
Array,
Object,
String,
Boolean,
Number,
Math,
Date,
JSON,
Promise,
setTimeout,
clearTimeout,
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
addEventListener(type, handler) { handlers[type] = handler; },
slides: firstPassSlides,
lastRenderedSlide: { id: 2 },
index: 1,
playlistPassNumber: 0,
timer: null,
slideOutroTimers: [],
getCurrentActiveSlides() { return sandbox.playlistPassNumber ? nextPassSlides : firstPassSlides; },
clearSlideTimer() {},
clearActiveSlidesCache() {},
applyPendingPlaylistUpdate() {},
renderSlideAtIndex(sourceSlides, targetIndex) { calls.push({ sourceSlides, targetIndex }); }
};
sandbox.window = sandbox;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.js');
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
sandbox.navigateSlides(1);
assert.equal(sandbox.playlistPassNumber, 1);
assert.deepEqual(calls, [{ sourceSlides: nextPassSlides, targetIndex: 0 }]);
const repeated = [{ id: 1 }, { id: 2 }, { id: 1 }, { id: 2 }, { id: 9 }];
sandbox.getCurrentActiveSlides = () => repeated;
sandbox.index = 3;
sandbox.lastRenderedSlide = { id: 2 };
sandbox.navigateSlides(1);
assert.equal(calls.at(-1).targetIndex, 4);
sandbox.navigateSlides(-1);
assert.equal(calls.at(-1).targetIndex, 2);
});
test('playlist removal of the current slide queues without rerendering it immediately', async () => {
const calls = {
showCurrent: 0,
logDebug: []
};
const sandbox = {
window: null,
location: { origin: 'http://localhost', href: 'http://localhost/screen/test2' },
Date,
JSON,
Math,
Number,
String,
Boolean,
Array,
Object,
Promise,
setTimeout,
clearTimeout,
console,
currentPlaylistSignature: 'old-signature',
currentPlaylistFadeBetweenSlides: false,
currentPlaylistSkipUnavailableRtmp: false,
slug: 'test2',
pendingPlaylistUpdate: null,
slides: [{ id: 1, duration_seconds: 12 }, { id: 2, duration_seconds: 12 }],
lastRenderedSlide: { id: 2, duration_seconds: 12 },
index: 1,
timer: null,
slideExpiresAt: null,
pausedRemainingMs: null,
app: null,
currentPlaylistEtag: '',
activeSlidesCacheKey: '',
activeSlidesCacheValue: [],
renderCacheViewportKey: '',
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() {},
addEventListener() {},
clearActiveSlidesCache() {},
showCurrent() {
calls.showCurrent += 1;
},
sendCommandState() {},
refresh() {},
logDebug(...args) {
calls.logDebug.push(args.join(' '));
}
};
sandbox.window = sandbox;
class XhrStub {
open() {}
setRequestHeader() {}
getResponseHeader(name) {
return name === 'ETag' ? '"next-etag"' : '';
}
send() {
this.readyState = 4;
this.status = 200;
this.responseText = JSON.stringify({
signature: 'next-signature',
slides: [{ id: 1, duration_seconds: 12, disable_audio: false }],
playlist: { fade_between_slides: false, skip_unavailable_rtmp: false }
});
if (typeof this.onreadystatechange === 'function') {
this.onreadystatechange();
}
}
}
sandbox.XMLHttpRequest = XhrStub;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
const playbackScript = fs.readFileSync(scriptPath, 'utf8');
const prelude = `
var pendingPlaylistUpdate = null;
var slides = [{ id: 1, duration_seconds: 12 }, { id: 2, duration_seconds: 12 }];
var currentPlaylistSignature = 'old-signature';
var currentPlaylistFadeBetweenSlides = false;
var currentPlaylistSkipUnavailableRtmp = false;
var currentPlaylistEtag = '';
var lastRenderedSlide = { id: 2, duration_seconds: 12 };
var index = 1;
var timer = null;
var slideExpiresAt = null;
var pausedRemainingMs = null;
var app = null;
var activeSlidesCacheKey = '';
var activeSlidesCacheValue = [];
var renderCacheViewportKey = '';
var slideMarkupCache = Object.create(null);
var templateLayoutCache = Object.create(null);
var templateRenderPlanCache = Object.create(null);
`;
vm.runInNewContext(prelude + '\n' + playbackScript, sandbox, { filename: scriptPath });
await sandbox.refresh();
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);
});