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('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); });