Release v2.14.1
This commit is contained in:
1 parent
635e1a9942
commit
c3299abaac
174 files changed
+4483
-1687
No files matched your search
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') }
|
||||
}
|
||||
};
|
||||
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</);
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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="{"intro":{"preset":"fadeIn","duration_ms":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="<!doctype html><html><head><style>html,body\{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;\}<\/style><\/head><body><div>Hi<\/div><\/body><\/html>"/);
|
||||
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"/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in new issue
Block a user