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