Files
pulse-signage/test/player-preload-helpers.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

635 lines
21 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 loadScript(scriptPath, sandbox) {
const source = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(source, sandbox, { filename: scriptPath });
}
function loadHtmlScript(scriptPath, sandbox) {
const source = fs.readFileSync(scriptPath, 'utf8').match(/<script>([\s\S]*)<\/script>/);
if (!source) {
throw new Error(`Unable to extract script body from ${scriptPath}`);
}
const script = source[1].trim();
vm.runInNewContext(script, sandbox, { filename: scriptPath });
}
test('media preload cleanup releases unpromoted video elements on every early exit', () => {
for (const reason of ['empty', 'single', 'missing-slide', 'missing-renderer', 'missing-document']) {
const calls = [];
const sandbox = {
window: {},
mediaPreloadSignature: 'old-signature',
mediaPreloadElements: [{
tagName: 'VIDEO',
pause() { calls.push('pause'); },
removeAttribute(name) { calls.push('remove:' + name); },
load() { calls.push('load'); }
}],
document: {},
primeSlideMarkup() {}
};
const slides = reason === 'empty' ? [] : reason === 'single' ? [{ id: 1 }] : [{ id: 1 }, { id: 2 }];
if (reason === 'missing-renderer') delete sandbox.primeSlideMarkup;
if (reason === 'missing-document') delete sandbox.document;
loadScript(path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js'), sandbox);
sandbox.syncSlideMediaPreloads(slides, reason === 'missing-slide' ? 0.5 : 0);
assert.deepEqual(calls, ['pause', 'remove:src', 'load'], reason);
assert.equal(sandbox.mediaPreloadSignature, '');
assert.equal(sandbox.mediaPreloadElements.length, 0);
sandbox.syncSlideMediaPreloads([], 0);
assert.equal(calls.length, 3);
}
});
test('playlist groups rotate in order, fill every pass, and optionally shuffle', () => {
const sandbox = {
window: { pulsePlayerRenderingShared: new Proxy({}, { get() { return function () {}; } }) },
playlistPassNumber: 0
};
const renderingScriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-rendering.js');
const playlistScriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js');
loadScript(renderingScriptPath, sandbox);
loadScript(playlistScriptPath, sandbox);
const groupSlides = Array.from({ length: 5 }, function (_value, index) {
return { id: index + 1, groupKey: 'news', groupPickCount: 2 };
});
sandbox.slides = groupSlides.concat([{ id: 99 }]);
function selectedGroupIds() {
return Array.from(sandbox.getActiveSlidesFrom(sandbox.slides)
.filter(function (slide) { return slide.groupKey; })
.map(function (slide) { return slide.id; }));
}
const firstPass = selectedGroupIds();
assert.equal(firstPass.length, 2);
assert.deepEqual(selectedGroupIds(), firstPass);
sandbox.playlistPassNumber = 1;
const secondPass = selectedGroupIds();
assert.equal(secondPass.length, 2);
assert.equal(firstPass.some(function (id) { return secondPass.includes(id); }), false);
sandbox.playlistPassNumber = 2;
const thirdPass = selectedGroupIds();
assert.equal(thirdPass.length, 2);
assert.equal(new Set(firstPass.concat(secondPass, thirdPass)).size, 5);
assert.deepEqual(firstPass, [1, 2]);
assert.deepEqual(secondPass, [3, 4]);
assert.deepEqual(thirdPass, [5, 1]);
groupSlides.forEach(slide => { slide.groupShuffle = true; });
const shuffled = selectedGroupIds();
assert.equal(shuffled.length, 2);
assert.equal(new Set(shuffled).size, 2);
assert.deepEqual(selectedGroupIds(), shuffled);
const selections = new Set();
for (let pass = 0; pass < 10; pass += 1) {
sandbox.playlistPassNumber = pass;
const selection = selectedGroupIds();
assert.equal(selection.length, 2);
assert.equal(new Set(selection).size, 2);
selections.add(selection.join(','));
}
assert.ok(selections.size > 1);
sandbox.slides = groupSlides.slice(0, 3);
sandbox.slides.forEach(slide => { slide.groupShuffle = false; });
for (const [pass, expected] of [[0, [1, 2]], [1, [3, 1]], [2, [2, 3]]]) {
sandbox.playlistPassNumber = pass;
assert.deepEqual(selectedGroupIds(), expected);
}
sandbox.slides[0].scheduleRules = [{ start_datetime: '2000-01-01T00:00:00Z', end_datetime: '2000-01-02T00:00:00Z' }];
assert.deepEqual(selectedGroupIds(), [2, 3]);
sandbox.slides[1].groupPickCount = 99;
assert.equal(selectedGroupIds().length, 2);
sandbox.slides[2].scheduleRules = sandbox.slides[0].scheduleRules;
assert.deepEqual(selectedGroupIds(), [2]);
const now = new Date(2026, 9, 14, 10, 30);
const gatedSlide = {
groupKey: 'weekday',
groupScheduleRules: [{ start_time: '10:00', end_time: '11:00', days: [now.getDay()] }]
};
assert.equal(sandbox.isSlideActive(gatedSlide, now), true);
assert.equal(sandbox.isSlideActive(gatedSlide, new Date(2026, 9, 14, 12, 30)), false);
assert.equal(sandbox.isSlideActive(gatedSlide, new Date(2026, 9, 15, 10, 30)), false);
});
test('repeat groups play all eligible members in order and ignore Pick and shuffle', () => {
const sandbox = {
window: { pulsePlayerRenderingShared: new Proxy({}, { get() { return function () {}; } }) },
playlistPassNumber: 0
};
loadScript(path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-rendering.js'), sandbox);
loadScript(path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js'), sandbox);
sandbox.slides = [{ id: 9 }].concat([1, 2, 3].map(id => ({
id, groupKey: 'news', groupMode: 'repeat', groupRepeatCount: 2, groupPickCount: 1, groupShuffle: true
})), [{ id: 10 }]);
const ids = () => Array.from(sandbox.getActiveSlidesFrom(sandbox.slides), slide => slide.id);
assert.deepEqual(ids(), [9, 1, 2, 3, 1, 2, 3, 10]);
sandbox.playlistPassNumber = 1;
assert.deepEqual(ids(), [9, 1, 2, 3, 1, 2, 3, 10]);
assert.equal(sandbox.hasSelectableGroupVariants(), false);
sandbox.slides[2].scheduleRules = [{ start_datetime: '2000-01-01T00:00:00Z', end_datetime: '2000-01-02T00:00:00Z' }];
assert.deepEqual(ids(), [9, 1, 3, 1, 3, 10]);
sandbox.slides.forEach(slide => { if (slide.groupKey) slide.groupRepeatCount = 1; });
assert.deepEqual(ids(), [9, 1, 3, 10]);
});
test('warmed video node is promoted into its slide instead of fetched again', () => {
const createdVideos = [];
let nextSource = '';
const holder = {
innerHTML: '',
querySelectorAll() {
return [{ tagName: 'VIDEO', getAttribute(name) { return name === 'data-pulse-video-src' ? nextSource : null; } }];
}
};
const sandbox = {
window: { innerWidth: 1920, innerHeight: 1080, location: { origin: 'http://localhost' } },
URL,
currentPlaylistSignature: 'playlist',
mediaPreloadSignature: '',
mediaPreloadElements: [],
primeSlideMarkup(slide) {
nextSource = '/media/slide-' + slide.id + '.mp4';
return '<video src="' + nextSource + '"></video>';
},
document: {
createElement(tag) {
if (tag !== 'video') return holder;
const attributes = new Map();
const video = {
tagName: 'VIDEO',
preload: '',
muted: false,
src: '',
loadCalls: 0,
attributes,
setAttribute(name, value) { attributes.set(name, String(value)); },
getAttribute(name) { return attributes.get(name) || null; },
removeAttribute(name) { attributes.delete(name); },
load() { this.loadCalls += 1; },
pause() {}
};
createdVideos.push(video);
return video;
}
}
};
loadScript(path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js'), sandbox);
const slides = [{ id: 1 }, { id: 2 }, { id: 3 }];
sandbox.syncSlideMediaPreloads(slides, 0);
const warmedVideo = createdVideos[0];
assert.equal(warmedVideo.src, 'http://localhost/media/slide-2.mp4');
let promotedNode = null;
const activeVideo = {
tagName: 'VIDEO',
src: 'http://localhost/media/slide-2.mp4',
attributes: [{ name: 'class', value: 'video' }, { name: 'data-pulse-video-src', value: '/media/slide-2.mp4' }],
getAttribute(name) { return name === 'data-pulse-video-src' ? '/media/slide-2.mp4' : null; },
parentNode: {
replaceChild(replacement, oldNode) {
assert.equal(oldNode, activeVideo);
promotedNode = replacement;
}
}
};
const root = { querySelectorAll() { return [activeVideo]; } };
assert.equal(sandbox.promotePreloadedVideoElements(root, 2), 1);
assert.equal(promotedNode, warmedVideo);
assert.equal(warmedVideo.loadCalls, 1);
sandbox.syncSlideMediaPreloads(slides, 1);
assert.equal(createdVideos[1].src, 'http://localhost/media/slide-3.mp4');
assert.notEqual(sandbox.mediaPreloadElements[0], warmedVideo);
});
test('media preloads next-slide images without creating duplicate video loads', () => {
const calls = [];
const nextImage = {};
Object.defineProperty(nextImage, 'src', { set(value) { calls.push(value); } });
const holder = {
innerHTML: '',
querySelectorAll(selector) {
assert.equal(selector, 'img[src], video[data-pulse-video-src]');
return [{ tagName: 'IMG', getAttribute() { return '/media/next.jpg'; } }];
}
};
const sandbox = {
window: { innerWidth: 1920, innerHeight: 1080, location: { origin: 'http://localhost' } },
URL,
currentPlaylistSignature: 'playlist',
mediaPreloadSignature: '',
mediaPreloadElements: [],
primeSlideMarkup() { return '<img src="/media/next.jpg"><video src="/media/next.mp4"></video>'; },
Image: function () { return nextImage; },
document: {
createElement() { return holder; }
}
};
loadScript(path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js'), sandbox);
const slides = [{ id: 1 }, { id: 2 }];
sandbox.syncSlideMediaPreloads(slides, 0);
assert.deepEqual(calls, ['http://localhost/media/next.jpg']);
assert.equal(sandbox.mediaPreloadElements[0], nextImage);
sandbox.syncSlideMediaPreloads(slides, 0);
assert.equal(calls.length, 1);
});
test('media preloader keeps a warmed next-slide video node for promotion', () => {
const preloadedVideo = {
tagName: 'VIDEO',
loadCalls: 0,
load() { this.loadCalls += 1; }
};
const sourceVideo = { tagName: 'VIDEO', getAttribute() { return '/media/next.mp4'; } };
const holder = {
innerHTML: '',
querySelectorAll(selector) {
assert.equal(selector, 'img[src], video[data-pulse-video-src]');
return [sourceVideo];
}
};
const sandbox = {
window: { innerWidth: 1920, innerHeight: 1080, location: { origin: 'http://localhost' } },
URL,
currentPlaylistSignature: 'playlist',
mediaPreloadSignature: '',
mediaPreloadElements: [],
primeSlideMarkup() { return '<video src="/media/next.mp4"></video>'; },
document: {
createElement(tag) { return tag === 'video' ? preloadedVideo : holder; }
}
};
loadScript(path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js'), sandbox);
sandbox.syncSlideMediaPreloads([{ id: 1 }, { id: 2 }], 0);
assert.equal(preloadedVideo.src, 'http://localhost/media/next.mp4');
assert.equal(preloadedVideo.preload, 'auto');
assert.equal(preloadedVideo.loadCalls, 1);
assert.equal(sandbox.mediaPreloadElements[0], preloadedVideo);
assert.equal(preloadedVideo.__pulsePreloadSlideId, '2');
});
test('offline playlist snapshots preserve and apply the RTMP skip option', () => {
const values = new Map();
const sandbox = {
window: { localStorage: {
setItem(key, value) { values.set(key, value); },
getItem(key) { return values.get(key) || null; }
} },
playlistSnapshotStorageKey: 'snapshot',
normalizeSlide(slide) { return slide; },
slides: [],
currentPlaylistSignature: '',
currentPlaylistFadeBetweenSlides: false,
currentPlaylistSkipUnavailableRtmp: false,
currentPlaylistEtag: '',
pendingPlaylistUpdate: null,
activeSlidesCacheKey: '', activeSlidesCacheValue: [],
slideMarkupCache: {}, templateLayoutCache: {}, templateRenderPlanCache: {},
renderCacheViewportKey: '', index: 0
};
loadScript(path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js'), sandbox);
for (const skipUnavailableRtmp of [true, false]) {
sandbox.savePlaylistSnapshot({
slides: [{ id: 1 }], signature: 'revision', fadeBetweenSlides: true,
skipUnavailableRtmp, etag: '"etag"'
});
const snapshot = sandbox.loadPlaylistSnapshot();
assert.equal(snapshot.skipUnavailableRtmp, skipUnavailableRtmp);
assert.equal(sandbox.applyPlaylistSnapshot(snapshot), true);
assert.equal(sandbox.currentPlaylistSkipUnavailableRtmp, skipUnavailableRtmp);
assert.equal(sandbox.currentPlaylistFadeBetweenSlides, true);
assert.equal(sandbox.currentPlaylistEtag, '"etag"');
}
values.set('snapshot', JSON.stringify({ slides: [{ id: 1 }] }));
assert.equal(sandbox.loadPlaylistSnapshot().skipUnavailableRtmp, false);
});
test('webpage preloading only targets the next slide', () => {
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console,
escapeHtml(value) {
return String(value || '');
},
currentPlaylistSignature: 'signature',
slides: [],
index: 0,
activeSlidesCacheKey: '',
activeSlidesCacheValue: [],
renderCacheViewportKey: '',
preloadSignature: '',
preloadContainer: null
};
sandbox.window = sandbox;
loadScript(path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js'), sandbox);
const current = { id: 1 };
const next = { id: 2 };
const later = { id: 3 };
assert.equal(sandbox.getWebpagePreloadSlides([current, next, later], 0).map((slide) => slide.id).join(','), '2');
assert.equal(sandbox.getWebpagePreloadSlides([current, next, later], 1).map((slide) => slide.id).join(','), '3');
assert.equal(sandbox.getWebpagePreloadSlides([current, next, later], 2).map((slide) => slide.id).join(','), '');
});
test('rtmp warmups only target the next slide', () => {
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console,
fetch() {
throw new Error('not expected');
},
currentPlaylistSkipUnavailableRtmp: false,
videoRegionRenderVersion: 0,
slideMarkupCache: Object.create(null),
slides: [],
index: 0,
setTimeout,
clearTimeout,
document: {
createElement() {
return {
className: '',
setAttribute() {},
appendChild() {},
parentNode: null,
addEventListener() {}
};
},
body: {
appendChild() {}
}
},
pulsePlayerRegionTypes: {
register() {}
}
};
sandbox.window = sandbox;
loadScript(path.join(__dirname, '..', 'src', 'player', 'regions', 'rtmp.js'), sandbox);
const current = { id: 1 };
const next = { id: 2 };
const later = { id: 3 };
assert.equal(sandbox.getRtmpWarmupSlides([current, next, later], 0).map((slide) => slide.id).join(','), '2');
assert.equal(sandbox.getRtmpWarmupSlides([current, next, later], 1).map((slide) => slide.id).join(','), '3');
assert.equal(sandbox.getRtmpWarmupSlides([current, next, later], 2).map((slide) => slide.id).join(','), '');
});
test('command client ids stay scoped to the screen session', () => {
const sessionStorage = (() => {
const values = new Map();
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const localStorage = (() => {
const values = new Map();
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console,
crypto: {
randomUUID() {
return 'tab-session-client-id';
}
},
localStorage,
sessionStorage,
commandClientId: null,
commandClientStorageKey: 'pulse-command-client-id',
currentPlaylistSignature: 'signature',
slides: [],
index: 0,
activeSlidesCacheKey: '',
activeSlidesCacheValue: [],
renderCacheViewportKey: '',
preloadSignature: '',
preloadContainer: null
};
sandbox.window = sandbox;
loadScript(path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playlist.js'), sandbox);
assert.equal(sandbox.getCommandClientId(), 'tab-session-client-id');
assert.equal(sessionStorage.getItem('pulse-command-client-id'), 'tab-session-client-id');
assert.equal(localStorage.getItem('pulse-command-client-id'), null);
});
test('player client names stay scoped to the tab session', () => {
const sessionStorage = (() => {
const values = new Map([['pulse-signage-player-client-name', 'tab-only-name']]);
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const localStorage = (() => {
const values = new Map([['pulse-signage-player-client-name', 'shared-name']]);
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console,
sessionStorage,
localStorage,
WebSocket: { OPEN: 1 },
sendCommandState() {}
};
sandbox.window = sandbox;
loadHtmlScript(path.join(__dirname, '..', 'src', 'player', 'player-client-name.script.html'), sandbox);
assert.equal(sandbox.getOnboardingClientName(), 'tab-only-name');
assert.equal(localStorage.getItem('pulse-signage-player-client-name'), 'shared-name');
sandbox.applyOnboardingClientName('Renamed Tab Client', null);
assert.equal(sessionStorage.getItem('pulse-signage-player-client-name'), 'Renamed Tab Client');
assert.equal(localStorage.getItem('pulse-signage-player-client-name'), 'shared-name');
});
test('onboarding device ids stay scoped to the tab session', () => {
const sessionStorage = (() => {
const values = new Map();
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const localStorage = (() => {
const values = new Map([['pulse-signage-player-device-id', 'shared-device-id']]);
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console,
sessionStorage,
localStorage,
crypto: {
randomUUID() {
return 'tab-device-id';
}
},
fetch() {
return Promise.resolve({
ok: true,
json() {
return Promise.resolve({ onboarded: false, screens: [] });
},
text() {
return Promise.resolve('');
}
});
},
document: {
getElementById() {
return null;
},
createElement() {
return { appendChild() {}, removeChild() {} };
}
},
location: {
replace() {}
},
setInterval() {
return 1;
},
clearInterval() {}
};
sandbox.window = sandbox;
loadHtmlScript(path.join(__dirname, '..', 'src', 'player', 'onboarding', 'player-onboarding-landing.script.html'), sandbox);
assert.equal(sessionStorage.getItem('pulse-signage-player-device-id'), 'tab-device-id');
assert.equal(localStorage.getItem('pulse-signage-player-device-id'), 'shared-device-id');
});
test('player onboarding device ids are generated when missing', () => {
const sessionStorage = (() => {
const values = new Map();
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(String(key), String(value));
}
};
})();
const sandbox = {
window: null,
Date,
Array,
Number,
String,
Boolean,
Object,
Math,
console,
sessionStorage,
localStorage: sessionStorage,
crypto: {
randomUUID() {
return 'generated-device-id';
}
},
WebSocket: { OPEN: 1 },
sendCommandState() {}
};
sandbox.window = sandbox;
loadHtmlScript(path.join(__dirname, '..', 'src', 'player', 'player-client-name.script.html'), sandbox);
assert.equal(sandbox.getOnboardingDeviceId(), 'generated-device-id');
assert.equal(sessionStorage.getItem('pulse-signage-player-device-id'), 'generated-device-id');
});