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