Release v2.14.1
This commit is contained in:
1 parent
635e1a9942
commit
c3299abaac
174 files changed
+4483
-1687
No files matched your search
@@ -4,26 +4,53 @@ const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
function loadCommandsScript(sandbox) {
|
||||
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 commandsPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-commands.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');
|
||||
const commandsScript = fs.readFileSync(commandsPath, 'utf8');
|
||||
vm.runInNewContext(animationScript + '\n' + mediaScript + '\n' + transitionScript + '\n' + commandsScript, sandbox, { filename: commandsPath });
|
||||
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,
|
||||
playRegionAnimations: 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,
|
||||
@@ -35,7 +62,10 @@ function createSandbox() {
|
||||
},
|
||||
set innerHTML(value) {
|
||||
this.innerHTMLValue = String(value || '');
|
||||
this.firstElementChild = this.innerHTMLValue ? { isConnected: true } : null;
|
||||
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;
|
||||
@@ -43,11 +73,18 @@ function createSandbox() {
|
||||
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 [];
|
||||
}
|
||||
@@ -64,14 +101,19 @@ function createSandbox() {
|
||||
Array,
|
||||
Object,
|
||||
Promise,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
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',
|
||||
@@ -89,29 +131,17 @@ function createSandbox() {
|
||||
pausedRemainingMs: null,
|
||||
timer: null,
|
||||
lastRenderedSlide: null,
|
||||
destroyRtmpRegions() {},
|
||||
destroyRtmpRegions() { calls.destroyRtmpRegions += 1; },
|
||||
syncRtmpRegions() {
|
||||
calls.syncRtmpRegions += 1;
|
||||
},
|
||||
initializeRegionInstances() {
|
||||
calls.initializeRegionInstances += 1;
|
||||
},
|
||||
playRegionAnimations() {
|
||||
calls.playRegionAnimations += 1;
|
||||
},
|
||||
addEventListener() {},
|
||||
pulsePlayerRegionTypes: {
|
||||
list() {
|
||||
return [
|
||||
{
|
||||
definition: {
|
||||
initRegion() {
|
||||
calls.initRegion += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
regionDefinition: {
|
||||
initRegion(root) { calls.initRegion += 1; calls.lifecycle.push(['init', root]); },
|
||||
destroyRegion(root) { calls.destroyRegion += 1; calls.lifecycle.push(['destroy', root]); }
|
||||
},
|
||||
isThumbnailPreview() {
|
||||
return false;
|
||||
@@ -120,12 +150,12 @@ function createSandbox() {
|
||||
|
||||
sandbox.window = sandbox;
|
||||
sandbox.window.requestAnimationFrame = sandbox.requestAnimationFrame;
|
||||
return { sandbox, calls, rafCallbacks, app };
|
||||
return { sandbox, calls, rafCallbacks, app, timers };
|
||||
}
|
||||
|
||||
test('renderSlideMarkup runs post-render setup immediately', () => {
|
||||
test('renderSlideMarkup initializes immediately without loading command handling', () => {
|
||||
const { sandbox, calls, rafCallbacks, app } = createSandbox();
|
||||
loadCommandsScript(sandbox);
|
||||
loadRendererScripts(sandbox);
|
||||
sandbox.playRegionAnimations = function () {
|
||||
calls.playRegionAnimations += 1;
|
||||
};
|
||||
@@ -138,4 +168,190 @@ test('renderSlideMarkup runs post-render setup immediately', () => {
|
||||
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('<div>first</div>', true);
|
||||
assert.equal(calls.initRegion, 1);
|
||||
const next = sandbox.renderSlideMarkup('<div>second</div>', 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('<div>video</div>', true);
|
||||
previous.querySelectorAll = function () { return [video]; };
|
||||
app.querySelectorAll = function () { return app.children.includes(previous) ? [video] : []; };
|
||||
sandbox.initializeSlideMedia(previous, 280);
|
||||
sandbox.renderSlideMarkup('<div>next</div>', 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('<div>first</div>', true);
|
||||
const second = sandbox.renderSlideMarkup('<div>second</div>', true);
|
||||
const staleCleanup = timers.get(sandbox.slideTransitionTimer);
|
||||
const third = sandbox.renderSlideMarkup('<div>third</div>', 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);
|
||||
});
|
||||
Reference in new issue
Block a user