Release v2.14.1
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m53s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s

This commit is contained in:
lzstealth committed 2026-10-11 18:54:43 +01:00
1 parent 635e1a9942
commit c3299abaac
174 files changed
+4483 -1687

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
function loadChartData() {
const context = { window: {} };
vm.runInNewContext(fs.readFileSync('src/web/public/js/regions/chart-data.js', 'utf8'), context);
return context.window.pulseChartData;
}
function loadChartEditor() {
const definition = {};
const context = {
window: {
pulseChartData: loadChartData(),
pulseRegionTypes: { register: (_type, value) => Object.assign(definition, value) },
pulseRegionUtils: { escapeHtml: (value) => String(value ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') }
}
};
vm.runInNewContext(fs.readFileSync('src/web/public/js/regions/type/chart.js', 'utf8'), context);
return definition;
}
test('normalizes manual chart rows into Chart.js data', () => {
const chartData = loadChartData();
const result = chartData.fromContent({
chart_type: 'line',
dataset_label: 'Visitors',
label_path: 'day',
value_path: 'count',
manual_rows: [
{ day: 'Mon', count: '12' },
{ day: 'Tue', count: 18 }
]
}, []);
assert.equal(result.type, 'line');
assert.deepEqual(Array.from(result.labels), ['Mon', 'Tue']);
assert.deepEqual(Array.from(result.datasets[0].data), [12, 18]);
assert.equal(result.datasets[0].label, 'Visitors');
});
test('uses an API source default items path and ignores non-numeric rows', () => {
const chartData = loadChartData();
const result = chartData.fromContent({
source_kind: 'api',
source_id: 4,
label_path: 'name',
value_path: 'total'
}, [{
id: 4,
items_path: 'payload.rows',
responseJson: {
payload: { rows: [
{ name: 'A', total: '1,200' },
{ name: 'Missing', total: 'n/a' },
{ name: 'B', total: 4 }
] }
}
}]);
assert.deepEqual(Array.from(result.labels), ['A', 'B']);
assert.deepEqual(Array.from(result.datasets[0].data), [1200, 4]);
});
test('normalizes multiple series for line charts', () => {
const chartData = loadChartData();
const result = chartData.fromContent({
chart_type: 'line',
label_path: 'label',
colors: ['#111111', '#222222'],
datasets: [
{ label: 'Sales', value_path: 'sales' },
{ label: 'Returns', value_path: 'returns', color: '#333333' }
],
manual_rows: [
{ label: 'A', sales: 10, returns: 2 },
{ label: 'B', sales: 20, returns: 3, color: '#abcdef' },
{ label: 'C', sales: 30, returns: 4 }
]
}, []);
assert.deepEqual(Array.from(result.labels), ['A', 'B', 'C']);
assert.deepEqual(Array.from(result.datasets[0].data), [10, 20, 30]);
assert.deepEqual(Array.from(result.datasets[1].data), [2, 3, 4]);
assert.equal(result.datasets[0].backgroundColor, '#111111');
assert.equal(result.datasets[1].backgroundColor, '#333333');
});
test('uses per-item colors for bar values', () => {
const chartData = loadChartData();
const result = chartData.fromContent({
chart_type: 'bar',
label_path: 'label',
value_path: 'value',
manual_rows: [
{ label: 'A', value: 10 },
{ label: 'B', value: 20 },
{ label: 'C', value: 30 }
]
}, []);
assert.deepEqual(Array.from(result.datasets[0].backgroundColor), ['#36a269', '#3b82f6', '#f59e0b']);
assert.deepEqual(Array.from(result.datasets[0].borderColor), ['#36a269', '#3b82f6', '#f59e0b']);
});
test('applies source ordering, limits, and missing-value settings', () => {
const chartData = loadChartData();
const result = chartData.fromContent({
chart_type: 'line',
label_path: 'label',
source_options: { sort: 'value-descending', limit: 2, missing: 'zero' },
datasets: [
{ label: 'Sales', value_path: 'sales' },
{ label: 'Returns', value_path: 'returns' }
],
manual_rows: [
{ label: 'B', sales: 20 },
{ label: 'A', sales: 10, returns: 1 },
{ label: 'C', sales: 30, returns: 3 }
]
}, []);
assert.deepEqual(Array.from(result.labels), ['C', 'B']);
assert.deepEqual(Array.from(result.datasets[0].data), [30, 20]);
assert.deepEqual(Array.from(result.datasets[1].data), [3, 0]);
});
test('supports reverse original source order', () => {
const chartData = loadChartData();
const result = chartData.fromContent({
chart_type: 'bar',
label_path: 'label',
value_path: 'value',
source_options: { sort: 'reverse-original' },
manual_rows: [
{ label: 'A', value: 10 },
{ label: 'B', value: 20 },
{ label: 'C', value: 30 }
]
}, []);
assert.deepEqual(Array.from(result.labels), ['C', 'B', 'A']);
});
test('builds chart options from chart settings', () => {
const chartData = loadChartData();
const data = chartData.fromContent({
chart_type: 'bar',
chart_options: { legend: 'show', bar_orientation: 'horizontal', y_axis_min: 10, y_axis_max: 100, grid_color: '#123456', text_color: '#654321', text_size: 20 },
value_path: 'value',
manual_rows: [{ label: 'A', value: 20 }]
}, []);
const options = chartData.getChartOptions(data);
assert.equal(options.indexAxis, 'y');
assert.equal(options.plugins.legend.display, true);
assert.equal(options.scales.x.min, 10);
assert.equal(options.scales.x.max, 100);
assert.equal(options.scales.x.grid.color, '#123456');
assert.equal(options.scales.y.grid.color, '#123456');
assert.equal(options.scales.x.ticks.color, '#654321');
assert.equal(options.plugins.legend.labels.color, '#654321');
assert.equal(options.font.size, 20);
assert.equal(options.scales.x.ticks.font.size, 20);
assert.equal(options.plugins.legend.labels.font.size, 20);
assert.equal(options.plugins.tooltip.bodyFont.size, 20);
assert.equal(options.plugins.tooltip.bodyColor, '#654321');
});
test('clamps chart text size to supported bounds', () => {
const chartData = loadChartData();
assert.equal(chartData.normalizeConfig({ chart_options: { text_size: 64 } }).chart_options.text_size, 48);
assert.equal(chartData.normalizeConfig({ chart_options: { text_size: 4 } }).chart_options.text_size, 8);
assert.equal(chartData.normalizeConfig({}).chart_options.text_size, 12);
});
test('renders repeatable dataset controls instead of a JSON series editor', () => {
const editor = loadChartEditor();
const html = editor.renderEditorCard({
region: { id: 7, label: 'Revenue' },
config: {
type: 'bar',
source_kind: 'api',
datasets: [{ label: 'Sales', value_path: 'sales', color: '' }],
dataset_label: 'Value',
value_path: 'value',
colors_text: '#111111, #222222',
manual_rows_json: '[]'
},
sourceOptions: '<option value="1">Metrics</option>'
});
assert.match(html, /data-chart-datasets/);
assert.match(html, /data-chart-dataset-add/);
assert.match(html, /data-chart-dataset-remove/);
assert.match(html, /data-chart-source-panel="api"/);
assert.match(html, /data-chart-source-panel="manual"/);
assert.match(html, /data-chart-dataset-editor hidden/);
assert.match(html, /Series color/);
assert.match(html, /data-chart-dataset-color[^>]*type="color"/);
assert.doesNotMatch(html, /data-chart-dataset-color-auto/);
assert.doesNotMatch(html, /Default series label/);
assert.doesNotMatch(html, /Color palette/);
assert.doesNotMatch(html, /Series mappings \(JSON\)/);
assert.match(html, /Chart options/);
assert.match(html, /Source options/);
assert.match(html, /region_chart_value_format_7/);
assert.match(html, /region_chart_text_size_7/);
assert.match(html, /region_chart_source_missing_7/);
assert.match(html, /data-chart-option-types="line bar"/);
assert.match(html, />Increasing values</);
assert.match(html, />Decreasing values</);
});
+52
View File
@@ -3,8 +3,13 @@ const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const sharp = require('sharp');
const {
normalizeMediaPath,
createImageVariants,
removeImageVariants,
optimizeUploadedImage,
registerMediaAssets,
syncMediaAssetsFromDirectory,
countMediaAssetReferences
@@ -16,6 +21,53 @@ test('media library normalizes only media URLs', () => {
assert.equal(normalizeMediaPath('/assets/photo.png'), null);
});
test('media library optimizes large raster uploads in place', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-optimize-'));
const filePath = path.join(uploadDir, 'large.jpg');
const sourceBuffer = await sharp(crypto.randomBytes(5000 * 100 * 3), {
raw: { width: 5000, height: 100, channels: 3 }
}).jpeg({ quality: 100 }).toBuffer();
await fs.promises.writeFile(filePath, sourceBuffer);
try {
const file = { path: filePath, size: sourceBuffer.length };
await optimizeUploadedImage(file, { maxDimension: 8192 });
const optimizedStats = await fs.promises.stat(filePath);
assert.equal(file.path, filePath);
assert.equal(file.size, optimizedStats.size);
assert.ok(optimizedStats.size < sourceBuffer.length);
const metadata = await sharp(filePath).metadata();
assert.equal(metadata.format, 'jpeg');
assert.equal(metadata.width, 5000);
} finally {
await fs.promises.rm(uploadDir, { recursive: true, force: true });
}
});
test('media library creates only smaller image variants and preserves the original', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-variants-'));
const filePath = path.join(uploadDir, 'large.jpg');
const sourceBuffer = await sharp(crypto.randomBytes(5000 * 100 * 3), {
raw: { width: 5000, height: 100, channels: 3 }
}).jpeg({ quality: 100 }).toBuffer();
await fs.promises.writeFile(filePath, sourceBuffer);
try {
const variants = await createImageVariants({ path: filePath });
assert.deepEqual(variants.map(function (variant) { return variant.width; }), [2048, 4096]);
assert.equal((await fs.promises.stat(filePath)).size, sourceBuffer.length);
for (const variant of variants) {
const variantPath = path.join(uploadDir, path.basename(variant.path));
assert.ok((await fs.promises.stat(variantPath)).size < sourceBuffer.length);
}
await removeImageVariants(filePath);
assert.equal((await fs.promises.readdir(uploadDir)).filter(function (name) { return name.includes('.variant-'); }).length, 0);
} finally {
await fs.promises.rm(uploadDir, { recursive: true, force: true });
}
});
test('media library registers uploaded files with their metadata', async () => {
const queries = [];
const pool = {
+81 -2
View File
@@ -5,17 +5,18 @@ const test = require('node:test');
const assert = require('node:assert/strict');
function loadAnnouncementTemplateHelpers() {
const sharedScriptPath = path.join(__dirname, '..', 'src', 'player', 'rendering-shared.js');
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-announcement-templates.js');
const sharedScript = fs.readFileSync(sharedScriptPath, 'utf8');
const script = fs.readFileSync(scriptPath, 'utf8');
const sandbox = {
window: {},
module: { exports: {} },
exports: {},
console
};
sandbox.window = sandbox;
vm.runInNewContext(sharedScript, sandbox, { filename: sharedScriptPath });
vm.runInNewContext(script, sandbox, { filename: scriptPath });
return sandbox;
}
@@ -105,4 +106,82 @@ test('announcement markup strips line breaks from the message', () => {
assert.match(markup, /First line Second line Third line/);
assert.doesNotMatch(markup, /<br\s*\/?\s*>/i);
});
test('announcement fallback polling stops when the websocket opens and resumes when it closes', () => {
const intervals = new Map();
const listeners = new Map();
let nextTimerId = 1;
let announcementSocket = null;
let announcementRequests = 0;
class MockWebSocket {
constructor() {
this.readyState = MockWebSocket.CONNECTING;
announcementSocket = this;
}
open() {
this.readyState = MockWebSocket.OPEN;
if (this.onopen) this.onopen();
}
close() {
this.readyState = MockWebSocket.CLOSED;
if (this.onclose) this.onclose();
}
}
MockWebSocket.CONNECTING = 0;
MockWebSocket.OPEN = 1;
MockWebSocket.CLOSED = 3;
class MockXhr {
open() {}
setRequestHeader() {}
send() { announcementRequests += 1; }
}
const window = {
slug: 'test',
location: { origin: 'http://localhost:8081' },
WebSocket: MockWebSocket,
setInterval(callback, delay) {
const id = nextTimerId++;
intervals.set(id, { callback: callback, delay: delay });
return id;
},
clearInterval(id) { intervals.delete(id); },
setTimeout() { return nextTimerId++; },
clearTimeout() {},
addEventListener() {}
};
const sandbox = {
window: window,
document: {
readyState: 'loading',
addEventListener(name, callback) { listeners.set(name, callback); }
},
WebSocket: MockWebSocket,
XMLHttpRequest: MockXhr,
URL,
Date,
JSON,
String,
Array,
Math
};
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'player-page-announcements.js');
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
listeners.get('DOMContentLoaded')();
assert.equal(announcementRequests, 0);
assert.equal(intervals.size, 1);
assert.equal(Array.from(intervals.values())[0].delay, 15000);
announcementSocket.open();
assert.equal(announcementRequests, 1);
assert.equal(intervals.size, 0);
announcementSocket.close();
assert.equal(intervals.size, 1);
});
+28
View File
@@ -0,0 +1,28 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { createPlayerBridgeState } = require('../src/player-bridge/connection-state');
test('bridge state merges snapshot sources and tracks device ids', () => {
const state = createPlayerBridgeState();
state.setScreenSnapshotSource('demo-screen', 'player-a', [{ clientId: 'client-1' }]);
state.setScreenSnapshotSource('demo-screen', 'player-b', [{ clientId: 'client-2' }]);
const snapshot = state.buildMergedScreenSnapshot('demo-screen');
assert.equal(snapshot.count, 2);
assert.deepEqual(snapshot.deviceIds.sort(), ['player-a', 'player-b']);
assert.equal(state.getScreenSnapshotCount('demo-screen'), 2);
});
test('bridge state clears snapshot sources by player device id', () => {
const state = createPlayerBridgeState();
state.setScreenSnapshotSource('demo-screen', 'player-a', [{ clientId: 'client-1' }]);
state.setScreenSnapshotSource('demo-screen', 'player-b', [{ clientId: 'client-2' }]);
state.clearPlayerSnapshotSources('player-a');
const snapshot = state.buildMergedScreenSnapshot('demo-screen');
assert.equal(snapshot.count, 1);
assert.deepEqual(snapshot.deviceIds, ['player-b']);
});
+2
View File
@@ -71,7 +71,9 @@ test('primeSlideMarkup restores the current canvas dimensions', () => {
};
sandbox.window = sandbox;
const sharedRenderingPath = path.join(__dirname, '..', 'src', 'player', 'rendering-shared.js');
const renderingPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-rendering.js');
loadScript(sharedRenderingPath, sandbox);
loadScript(renderingPath, sandbox);
const slide = {
+243 -27
View File
@@ -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);
});
+405 -12
View File
@@ -4,7 +4,306 @@ const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
test('playlist refresh queues updates until the next slide transition', async () => {
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: [],
@@ -137,7 +436,7 @@ test('playlist refresh queues updates until the next slide transition', async ()
`;
vm.runInNewContext(prelude + '\n' + script, sandbox, { filename: scriptPath });
await sandbox.refresh();
await sandbox.refresh(true);
assert.equal(calls.showCurrent, 0);
assert.equal(sandbox.currentPlaylistSignature, 'old-signature');
@@ -149,6 +448,74 @@ test('playlist refresh queues updates until the next slide transition', async ()
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 = {
@@ -244,7 +611,7 @@ test('playlist refresh reloads source data after a cached snapshot receives 304'
assert.deepEqual(sandbox.initialData.weatherLocations, [{ id: 1 }]);
});
test('centralized slide advance timing preserves the configured slide duration', () => {
test('runtime scripts have one slide timing owner and preserve configured fade timing', () => {
const sandbox = {
window: null,
currentPlaylistFadeBetweenSlides: false,
@@ -256,11 +623,19 @@ test('centralized slide advance timing preserves the configured slide duration',
};
sandbox.window = sandbox;
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'js', 'player-page-playback.js');
const script = fs.readFileSync(scriptPath, 'utf8');
vm.runInNewContext(script, sandbox, { filename: scriptPath });
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);
@@ -268,7 +643,24 @@ test('centralized slide advance timing preserves the configured slide duration',
assert.equal(sandbox.getSlideAdvanceDelay({ duration_seconds: 12, use_video_duration: true }), 11440);
});
test('single-slide playlists re-render the active slide instead of refreshing after a queued update', async () => {
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
@@ -611,7 +1003,7 @@ test('player page arrow keys move between slides', async () => {
assert.deepEqual(calls, [0, 2]);
});
test('removing the currently visible slide from a two-slide playlist applies the one-slide update immediately', async () => {
test('playlist removal of the current slide queues without rerendering it immediately', async () => {
const calls = {
showCurrent: 0,
logDebug: []
@@ -733,8 +1125,9 @@ test('removing the currently visible slide from a two-slide playlist applies the
await sandbox.refresh();
assert.equal(sandbox.currentPlaylistSignature, 'next-signature');
assert.equal(sandbox.pendingPlaylistUpdate, null);
assert.deepEqual(sandbox.slides, [{ id: 1, duration_seconds: 12, disable_audio: false }]);
assert.equal(calls.logDebug.some((entry) => entry.includes('applying on next slide transition')), false);
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);
});
+203
View File
@@ -18,6 +18,209 @@ function loadHtmlScript(scriptPath, sandbox) {
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('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,
+203 -4
View File
@@ -2,6 +2,9 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const packageVersion = require('../package.json').version;
const renderingShared = require('../src/player/rendering-shared');
const { renderPlayerPage, renderPlayerOnboardingFormPage } = require('../src/player/render');
require('../src/common');
@@ -13,7 +16,8 @@ const {
renderEditorJsContent,
renderHtmlRegionContent,
sanitizeRichText,
getAnnouncementIconsDataScript
getAnnouncementIconsDataScript,
getPlayerRuntimeScripts
} = require('../src/player/render-helpers');
test('mediaKind classifies player media by extension', () => {
@@ -52,9 +56,9 @@ test('normalizeSlide normalizes nested content without mutating the source', ()
assert.notEqual(normalized.content, slide.content);
assert.deepEqual(normalized.content.hero, {
value: { headline: 'Hello' },
font_family: ' Open Sans! ',
font_size: '42',
font_color: 'not-a-color',
font_family: 'Open Sans',
font_size: 42,
font_color: '#000000',
type: 'text'
});
assert.deepEqual(normalized.content.footer, {
@@ -70,6 +74,27 @@ test('normalizeSlide normalizes nested content without mutating the source', ()
});
});
test('server and browser slide normalization stay in sync', () => {
const browserContext = { window: {} };
const sharedRenderingSource = fs.readFileSync(require.resolve('../src/player/rendering-shared.js'), 'utf8');
const browserRenderingSource = fs.readFileSync(require.resolve('../src/player/public/js/player-page-rendering.js'), 'utf8');
const slide = {
content: {
hero: {
value: '{"headline":"Hello"}',
font_family: ' Open Sans! ',
font_size: '42',
font_color: 'not-a-color',
type: 'text'
}
}
};
vm.runInNewContext(sharedRenderingSource, browserContext);
vm.runInNewContext(browserRenderingSource, browserContext);
assert.equal(JSON.stringify(browserContext.normalizeSlide(slide)), JSON.stringify(normalizeSlide(slide)));
});
test('renderEditorJsContent sanitizes editor blocks and wraps legacy text', () => {
const editorJson = {
blocks: [
@@ -86,6 +111,90 @@ test('renderEditorJsContent sanitizes editor blocks and wraps legacy text', () =
assert.equal(renderEditorJsContent('plain text'), '<p>plain text</p>');
});
test('region animation decoration preserves plain markup and annotates animated roots', () => {
const plainMarkup = '<div class="template-region">Text</div>';
const animationConfig = { intro: { preset: 'fadeIn', duration_ms: 700 } };
assert.equal(renderingShared.decorateRegionMarkup(plainMarkup, { animationConfig: {} }), plainMarkup);
assert.equal(
renderingShared.decorateRegionMarkup(plainMarkup, { animationConfig: animationConfig }),
'<div class="template-region" data-animation-json="{&quot;intro&quot;:{&quot;preset&quot;:&quot;fadeIn&quot;,&quot;duration_ms&quot;:700}}">Text</div>'
);
const browserContext = { window: {} };
vm.runInNewContext(fs.readFileSync(require.resolve('../src/player/rendering-shared.js'), 'utf8'), browserContext);
vm.runInNewContext(fs.readFileSync(require.resolve('../src/player/public/js/player-page-rendering.js'), 'utf8'), browserContext);
browserContext.getTemplateRenderPlan = function () {
return {
layout: {
regions: [{ regionKey: 'hero', regionType: 'text', animationJson: JSON.stringify(animationConfig) }],
canvasWidth: 1920,
canvasHeight: 1080,
backgroundColor: '#000000',
backgroundGradient: '',
background: ''
},
renderRegion: function () { return plainMarkup; }
};
};
browserContext.normalizePlayerAnimationConfig = function (value) { return JSON.parse(value); };
browserContext.selectPlayerMediaSource = function () { return ''; };
browserContext.setPlayerCanvasDimensions = function () {};
browserContext.buildBackdropStyle = function () { return ''; };
browserContext.renderSlideShell = function (_slide, _canvasClass, _width, _height, content) { return content; };
const renderedSlide = browserContext.renderTemplateSlideMarkup({ template: {}, content: {} });
assert.match(renderedSlide, /data-animation-json=/);
});
test('browser rendering helpers select media and expose playlist revision and canvas helpers', () => {
const styles = {};
const browserContext = {
window: {},
document: {
documentElement: {
style: {
setProperty: function (key, value) { styles[key] = value; }
}
}
},
currentPlaylistEtag: '"cached-revision"'
};
vm.runInNewContext(fs.readFileSync(require.resolve('../src/player/rendering-shared.js'), 'utf8'), browserContext);
vm.runInNewContext(fs.readFileSync(require.resolve('../src/player/public/js/player-page-rendering.js'), 'utf8'), browserContext);
assert.equal(browserContext.selectPlayerMediaSource('/media/original.jpg', [
{ url: '/media/small.jpg', width: 400 },
{ url: '/media/large.jpg', width: 1200 }
], 800), '/media/large.jpg');
assert.equal(browserContext.getPlaylistRevision({ revision: 'new-revision' }), 'new-revision');
assert.equal(browserContext.getPlaylistRevision({}), 'cached-revision');
browserContext.setPlayerCanvasDimensions(1920, 1080);
assert.deepEqual(styles, {
'--player-canvas-width': '1920px',
'--player-canvas-height': '1080px'
});
});
test('server and browser rich-text rendering stay in sync', () => {
const browserContext = {
window: {}
};
const sharedRenderingSource = fs.readFileSync(require.resolve('../src/player/rendering-shared.js'), 'utf8');
const browserRenderingSource = fs.readFileSync(require.resolve('../src/player/public/js/player-page-rendering.js'), 'utf8');
vm.runInNewContext(sharedRenderingSource, browserContext);
vm.runInNewContext(browserRenderingSource, browserContext);
const value = {
blocks: [{
type: 'paragraph',
data: { text: '<table><tbody class="body"><tr><td>Cell</td></tr></tbody></table>' }
}]
};
assert.equal(browserContext.renderEditorJsContent(value), renderEditorJsContent(value));
});
test('shared API progress placeholder renders a clamped bar from numeric fields', () => {
const sandbox = { window: {} };
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), sandbox);
@@ -170,6 +279,34 @@ test('player announcement bootstrap includes catalog-only icons', () => {
assert.match(getAnnouncementIconsDataScript(), /bank/);
});
test('onboarding form page injects its configured device id', () => {
const page = renderPlayerOnboardingFormPage('device-123');
assert.match(page, /var deviceId = "device-123";/);
});
test('player runtime script URLs include content fingerprints', () => {
const page = renderPlayerPage('test', { slides: [] });
assert.match(page, new RegExp('player-script/playback\\.js\\?v=' + packageVersion.replace(/\./g, '\\.') + '-[a-f0-9]{12}'));
});
test('player runtime script assembly includes shared and region scripts once', () => {
const runtimeScripts = getPlayerRuntimeScripts();
const scriptNames = runtimeScripts.map(function (entry) { return entry[0]; });
const sharedRenderingIndex = scriptNames.indexOf('rendering-shared');
const pageRenderingIndex = scriptNames.indexOf('rendering');
const htmlRegionScript = runtimeScripts.find(function (entry) {
return entry[0].startsWith('region-') && entry[1].includes("registry.register('html'");
});
assert.ok(sharedRenderingIndex >= 0 && sharedRenderingIndex < pageRenderingIndex);
assert.ok(scriptNames.includes('placeholder-utils'));
assert.ok(scriptNames.includes('qr-code-svg'));
assert.ok(htmlRegionScript);
assert.equal(new Set(scriptNames).size, scriptNames.length);
});
test('shared iframe renderers size preview content explicitly', () => {
const playerHtmlRegionSource = fs.readFileSync(require.resolve('../src/player/regions/html.js'), 'utf8');
const playerWebpageRegionSource = fs.readFileSync(require.resolve('../src/player/regions/webpage.js'), 'utf8');
@@ -191,4 +328,66 @@ test('html region helpers unwrap object-shaped values before rendering', () => {
assert.match(rendered, /srcdoc="&lt;!doctype html&gt;&lt;html&gt;&lt;head&gt;&lt;style&gt;html,body\{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;\}&lt;\/style&gt;&lt;\/head&gt;&lt;body&gt;&lt;div&gt;Hi&lt;\/div&gt;&lt;\/body&gt;&lt;\/html&gt;"/);
assert.doesNotMatch(rendered, /\[object Object\]/);
});
test('shared render value normalization follows caller key order recursively', () => {
assert.equal(
renderingShared.normalizeRenderableString({ value: { content: '<strong>Hi</strong>' } }, ['value', 'text', 'html', 'content']),
'<strong>Hi</strong>'
);
assert.equal(
renderingShared.normalizeRenderableString({ value: { url: 'https://example.test' } }, ['value', 'text', 'url', 'href', 'src', 'content']),
'https://example.test'
);
assert.equal(renderingShared.normalizeRenderableString(null, ['value', 'text']), '');
});
test('shared region value normalization preserves selected values and stringifies other objects', () => {
const nestedValue = { amount: 12 };
assert.equal(renderingShared.normalizeRegionValue({ value: nestedValue }, ['value', 'text', 'html']), nestedValue);
assert.equal(renderingShared.normalizeRegionValue({ text: 'hello' }, ['value', 'text', 'html']), 'hello');
assert.equal(renderingShared.normalizeRegionValue({ amount: 12 }, ['value', 'text', 'html']), '{"amount":12}');
assert.equal(renderingShared.normalizeRegionValue(12, ['value', 'text', 'html']), 12);
const circularValue = {};
circularValue.self = circularValue;
assert.equal(renderingShared.normalizeRegionValue(circularValue, ['value', 'text', 'html']), '');
});
test('shared HTML document builder wraps fragments and preserves complete documents', () => {
assert.equal(
renderingShared.buildHtmlDocument('<main>Hi</main>'),
'<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body><main>Hi</main></body></html>'
);
assert.equal(renderingShared.buildHtmlDocument('<!doctype html><html><body>Hi</body></html>'), '<!doctype html><html><body>Hi</body></html>');
assert.equal(renderingShared.buildHtmlDocument(' '), '');
});
test('browser HTML and webpage regions preserve their distinct output modes', () => {
const modules = {};
const browserContext = {
window: {
pulsePlayerRegionTypes: {
register: function (type, regionModule) {
modules[type] = regionModule;
}
}
}
};
[
'../src/player/rendering-shared.js',
'../src/player/public/js/player-page-rendering.js',
'../src/player/regions/html.js',
'../src/player/regions/webpage.js'
].forEach(function (scriptPath) {
vm.runInNewContext(fs.readFileSync(require.resolve(scriptPath), 'utf8'), browserContext);
});
const htmlFragment = modules.html.renderRegion({ baseStyle: '' }, { value: { content: '<strong>Hi</strong>' } });
const htmlDocument = modules.html.renderRegion({ baseStyle: '' }, { value: '<!doctype html><html><body>Hi</body></html>' });
const webpage = modules.webpage.renderRegion({ baseStyle: '', label: 'Weather' }, { value: { url: 'https://example.test' } });
assert.match(htmlFragment, /template-region-html-content/);
assert.match(htmlFragment, /<strong>Hi<\/strong>/);
assert.match(htmlDocument, /sandbox=""/);
assert.match(webpage, /src="https:\/\/example\.test"/);
});
+161
View File
@@ -0,0 +1,161 @@
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');
async function activateWorker(version, cacheKeys) {
const listeners = new Map();
const deleted = [];
let claimed = false;
const sandbox = {
URL,
self: {
location: { href: 'http://localhost/sw.js?v=' + encodeURIComponent(version) },
addEventListener(name, callback) { listeners.set(name, callback); },
clients: { async claim() { claimed = true; } }
},
caches: {
async keys() { return cacheKeys; },
async delete(key) { deleted.push(key); return true; }
}
};
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'sw.js');
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
let activation;
listeners.get('activate')({ waitUntil(promise) { activation = promise; } });
await activation;
assert.equal(claimed, true);
return deleted;
}
test('player service worker activation preserves caches owned by other applications', async () => {
const deleted = await activateWorker('2.14.0', ['other-app-assets', 'other-app-pages']);
assert.deepEqual(deleted, []);
});
test('player service worker retains its active version and the most recently created other generation', async () => {
const cacheKeys = [
'other-app-cache',
'pulse-signage-player-pages-2.10.0',
'pulse-signage-player-assets-2.10.0',
'pulse-signage-player-media-2.10.0',
'pulse-signage-player-playlists-2.10.0',
'pulse-signage-player-pages-2.9.0',
'pulse-signage-player-assets-2.11.0',
'pulse-signage-player-pages-2.11.0'
];
const deleted = await activateWorker('2.10.0', cacheKeys);
assert.deepEqual(deleted, ['pulse-signage-player-pages-2.9.0']);
});
test('player service worker retention supports opaque versions before active caches exist', async () => {
const deleted = await activateWorker('current-build', [
'pulse-signage-player-pages-z-old-build',
'pulse-signage-player-assets-z-old-build',
'pulse-signage-player-pages-a-newer-build',
'pulse-signage-player-playlists-a-newer-build'
]);
assert.deepEqual(deleted, [
'pulse-signage-player-pages-z-old-build',
'pulse-signage-player-assets-z-old-build'
]);
});
function createFetchWorker({ failure, cachedResponse = null, fetchError = false } = {}) {
const networkResponse = new Response('fresh network body', {
status: 200, headers: { ETag: '"fresh"', 'Content-Type': 'text/plain' }
});
const calls = { fetch: 0, writes: [] };
const listeners = new Map();
const sandbox = {
URL,
Response,
Request,
Headers,
self: {
location: { href: 'http://localhost/sw.js', origin: 'http://localhost' },
addEventListener(name, callback) { listeners.set(name, callback); }
},
async fetch() {
calls.fetch += 1;
if (fetchError) throw new Error('Network unavailable');
return networkResponse;
},
caches: {
async match() { return cachedResponse; },
async open() {
if (failure === 'open') throw new Error('Cache storage unavailable');
return {
async put(key, response) {
if (failure === 'put') throw new Error('QuotaExceededError');
calls.writes.push({ key, response });
}
};
}
}
};
const scriptPath = path.join(__dirname, '..', 'src', 'player', 'public', 'sw.js');
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath });
return { sandbox, calls, networkResponse, listeners };
}
test('successful network responses survive cache open and write failures', async () => {
for (const failure of ['open', 'put']) {
for (const strategy of ['networkFirst', 'cacheFirst', 'staleWhileRevalidate']) {
for (const cached of strategy === 'networkFirst' ? [false, true] : [false]) {
const cachedResponse = cached ? new Response('stale body') : null;
const { sandbox, networkResponse } = createFetchWorker({ failure, cachedResponse });
const response = await sandbox[strategy]({ url: 'http://localhost/screen/test' }, 'cache');
assert.equal(response, networkResponse, strategy + ', failure=' + failure + ', cached=' + cached);
assert.equal(response.status, 200);
assert.equal(response.headers.get('ETag'), '"fresh"');
assert.equal(await response.text(), 'fresh network body');
}
}
}
});
test('successful cache writes retain the normalized key and do not consume the network body', async () => {
const { sandbox, calls, networkResponse } = createFetchWorker();
const request = { url: 'http://localhost/api/screens/test/playlist?ts=1' };
const normalizedKey = { url: 'http://localhost/api/screens/test/playlist' };
const response = await sandbox.networkFirst(request, 'cache', normalizedKey);
assert.equal(response, networkResponse);
assert.equal(calls.writes.length, 1);
assert.equal(calls.writes[0].key, normalizedKey);
assert.notEqual(calls.writes[0].response, networkResponse);
assert.equal(await calls.writes[0].response.text(), 'fresh network body');
assert.equal(await response.text(), 'fresh network body');
});
test('asset cache hits do not fetch and offline pages still fall back to cached responses', async () => {
const cachedResponse = new Response('cached body');
const cacheHit = createFetchWorker({ cachedResponse });
assert.equal(await cacheHit.sandbox.cacheFirst({}, 'cache'), cachedResponse);
assert.equal(cacheHit.calls.fetch, 0);
const offlineHit = createFetchWorker({ cachedResponse, fetchError: true });
assert.equal(await offlineHit.sandbox.networkFirst({}, 'cache'), cachedResponse);
const offlineMiss = createFetchWorker({ fetchError: true });
assert.equal((await offlineMiss.sandbox.networkFirst({}, 'cache')).status, 504);
});
test('player script assets refresh from network and retain offline cache fallback', async () => {
const cachedResponse = new Response('old script body');
const online = createFetchWorker({ cachedResponse });
let onlineResponsePromise;
online.listeners.get('fetch')({
request: new Request('http://localhost/assets/player-script/rendering.js?v=2.14.0'),
respondWith(promise) { onlineResponsePromise = promise; }
});
assert.equal(await onlineResponsePromise, online.networkResponse);
assert.equal(online.calls.fetch, 1);
const offline = createFetchWorker({ cachedResponse: cachedResponse, fetchError: true });
let offlineResponsePromise;
offline.listeners.get('fetch')({
request: new Request('http://localhost/assets/player-script/rendering.js?v=2.14.0'),
respondWith(promise) { offlineResponsePromise = promise; }
});
assert.equal(await offlineResponsePromise, cachedResponse);
});
+2
View File
@@ -5,6 +5,7 @@ const vm = require('node:vm');
function loadTimeDateModule() {
const webUiHelpersScript = fs.readFileSync(require.resolve('../src/web/public/js/web-ui-helpers.js'), 'utf8');
const sharedRenderingScript = fs.readFileSync(require.resolve('../src/player/rendering-shared.js'), 'utf8');
const renderingScript = fs.readFileSync(require.resolve('../src/player/public/js/player-page-rendering.js'), 'utf8');
const placeholderScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8');
const timeDateScript = fs.readFileSync(require.resolve('../src/player/regions/time-date.js'), 'utf8');
@@ -39,6 +40,7 @@ function loadTimeDateModule() {
vm.runInNewContext(webUiHelpersScript, sandbox, { filename: 'web-ui-helpers.js' });
sandbox.escapeHtml = sandbox.window.escapeHtml;
vm.runInNewContext(sharedRenderingScript, sandbox, { filename: 'rendering-shared.js' });
vm.runInNewContext(renderingScript, sandbox, { filename: 'player-page-rendering.js' });
vm.runInNewContext(placeholderScript, sandbox, { filename: 'placeholder-utils.js' });
vm.runInNewContext(timeDateScript, sandbox, { filename: 'time-date.js' });
+8
View File
@@ -30,6 +30,11 @@ test('slide editor keeps normal TinyMCE image insertion unchanged', () => {
assert.ok(slideFormEditorSource.includes('images_upload_handler: uploadEditorImage'));
});
test('slide editor preserves player-local media paths and image placeholders without web-editor URL prefixes', () => {
assert.ok(slideFormEditorSource.includes('convert_urls: false'));
assert.doesNotMatch(slideFormEditorSource, /convert_urls:\s*true/);
});
test('slide editor inserts tables with zero padding and spacing by default', () => {
assert.ok(slideFormEditorSource.includes("table_default_attributes: {"));
assert.ok(slideFormEditorSource.includes("cellpadding: '0'"));
@@ -78,6 +83,9 @@ test('slide thumbnail previews treat image-only text as visible content', () =>
test('popup preview falls back to iframe sizing rules', () => {
const popupPreviewSource = fs.readFileSync(require.resolve('../src/web/views/signage/slides/popup-preview.hbs'), 'utf8');
assert.ok(popupPreviewSource.includes('/assets/js/regions/chart-data.js'));
assert.ok(popupPreviewSource.includes('.slide-preview-chart-region canvas'));
assert.ok(popupPreviewSource.includes('background: transparent !important'));
assert.ok(popupPreviewSource.includes('#popup-preview-canvas iframe'));
assert.ok(popupPreviewSource.includes('#popup-preview-canvas .slide-preview-webpage-region iframe'));
assert.ok(popupPreviewSource.includes('#popup-preview-canvas .slide-preview-html-region iframe'));