Files
pulse-signage/test/slide-preview-renderer.test.js
lzstealth 24df245ea1
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m29s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 34s
Release 2.20.6: saved-image slide previews
2026-10-26 17:48:05 +00:00

306 lines
13 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const { render } = require('../src/web/public/js/shared/slide-preview-renderer');
const { buildThumbnailPreviewPayload } = require('../src/web/lib/media/slide-thumbnails');
const placeholderUtils = require('../src/web/public/js/shared/placeholder-utils');
const { resolveRegionContent } = require('../src/web/public/js/shared/region-content');
const { getRegionEditorScripts } = require('../src/web/lib/region-scripts');
function loadEditorTimeDateModule() {
const source = fs.readFileSync(require.resolve('../src/web/public/js/regions/type/time-date.js'), 'utf8');
const modules = new Map();
const sandbox = {
window: {
pulseRegionTypes: {
register(type, definition) {
modules.set(type, definition);
}
},
pulseRegionUtils: {
escapeHtml(value) {
return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
},
sanitizePreviewHtml(value) {
return String(value);
}
},
placeholderUtils: placeholderUtils
}
};
vm.runInNewContext(source, sandbox, { filename: 'time-date.js' });
return modules.get('time-date');
}
test('list preview loads a saved image with legacy fallback and clears it on close', () => {
const source = fs.readFileSync(require.resolve('../src/web/views/signage/slides/list.hbs'), 'utf8');
assert.doesNotMatch(source, /<iframe|\/api\/internal\/slide-thumbnails/);
assert.match(source, /{{> slide-preview-modal}}/);
const modalSource = fs.readFileSync(require.resolve('../src/web/views/shared/slide-preview-modal.hbs'), 'utf8');
const css = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
assert.match(css, /\.slide-list-preview-frame\[hidden\]\s*\{\s*display: none;/);
const handlers = {};
const image = {
addEventListener(name, handler) { handlers['image:' + name] = handler; },
getAttribute(name) { return this[name]; },
removeAttribute(name) { delete this[name]; }
};
const empty = {};
const title = {};
const modal = {
querySelector(selector) {
return selector === '[data-slide-preview-image]' ? image : selector === '[data-slide-preview-empty]' ? empty : title;
},
addEventListener(name, handler) { handlers[name] = handler; },
style: { setProperty() {} }
};
vm.runInNewContext(modalSource.match(/<script>([\s\S]*?)<\/script>/)[1], {
document: { getElementById() { return modal; } },
window: { innerWidth: 1440, innerHeight: 900, addEventListener() {} }
});
function open(url) {
handlers['show.bs.modal']({ relatedTarget: { getAttribute(name) {
return { 'data-slide-preview-url': url, 'data-slide-preview-title': 'Saved slide', 'data-canvas-width': '1080', 'data-canvas-height': '1920' }[name];
} } });
}
open('/media/thumbnails/slides/slide-7.png');
assert.equal(image.src, '/media/thumbnails/slides/slide-7.full.png');
assert.equal(image.hidden, false);
assert.equal(title.textContent, 'Slide preview: Saved slide');
handlers['image:error']();
assert.equal(image.src, '/media/thumbnails/slides/slide-7.png');
handlers['image:error']();
assert.equal(image.hidden, true);
assert.equal(empty.hidden, false);
open('/media/thumbnails/slides/slide-8.png');
handlers['hidden.bs.modal']();
assert.equal(image.src, undefined);
assert.equal(image.hidden, true);
open('');
assert.equal(image.src, undefined);
assert.equal(empty.hidden, false);
});
test('thumbnail capture retains a full-size preview alongside the small thumbnail', async () => {
const path = require('node:path');
const os = require('node:os');
const sharp = require('sharp');
const modulePath = require.resolve('../src/web/lib/media/slide-thumbnails');
const localRequire = require('node:module').createRequire(modulePath);
const mediaDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-preview-'));
const page = {
async setViewport() {},
async setExtraHTTPHeaders() {},
async goto() { return { status() { return 200; } }; },
async waitForFunction() {},
async evaluate() {},
async $() { return { async boundingBox() { return { x: 0, y: 0, width: 1080, height: 1920 }; } }; },
async screenshot(options) {
await sharp({ create: { width: options.clip.width, height: options.clip.height, channels: 3, background: '#123456' } }).png().toFile(options.path);
},
async close() {}
};
const sandbox = {
module: { exports: {} },
process: { env: {} },
setTimeout(callback) { callback(); },
require(name) {
if (name === 'fs') return { ...fs, existsSync(candidate) { return candidate === '/usr/bin/chromium' || fs.existsSync(candidate); } };
if (name === 'puppeteer-core') return { async launch() { return { async newPage() { return page; }, async close() {} }; } };
return localRequire(name);
}
};
sandbox.require.resolve = localRequire.resolve;
vm.runInNewContext(fs.readFileSync(modulePath, 'utf8'), sandbox);
const queries = [];
try {
const options = {
mediaDir,
slideId: 7,
baseUrl: 'http://localhost:8080',
pool: { async query(sql, params) { queries.push({ sql, params }); } },
common: { async fetchSlideById() { return { id: 7, template: { canvas_size_width: 1080, canvas_size_height: 1920 } }; } }
};
for (let attempt = 0; attempt < 2; attempt += 1) {
const result = await sandbox.module.exports.captureSlideThumbnail(options);
const large = await sharp(result.fullSizePath).metadata();
const small = await sharp(result.filePath).metadata();
assert.equal(large.width, 1080);
assert.equal(large.height, 1920);
assert.ok(small.width <= 480 && small.height <= 270);
assert.equal(result.thumbnailPath, '/media/thumbnails/slides/slide-7.png');
assert.equal(fs.existsSync(result.fullSizePath.replace('.full.png', '.full.tmp.png')), false);
}
assert.equal(queries.length, 2);
} finally {
await fs.promises.rm(mediaDir, { recursive: true, force: true });
}
});
test('preview sanitizers preserve entity-quoted font families without double escaping', () => {
const entities = { apos: "'", quot: '"', amp: '&', lt: '<', gt: '>' };
const sandbox = {
window: {},
document: {
createElement(tag) {
assert.equal(tag, 'textarea');
return {
set innerHTML(value) {
this.value = value.replace(/&(#x[\da-f]+|#\d+|apos|quot|amp|lt|gt);/gi, (_match, entity) => {
if (entity[0] !== '#') return entities[entity];
return String.fromCodePoint(entity[1].toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : Number(entity.slice(1)));
});
}
};
}
}
};
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/regions/region-utils'), 'utf8'), sandbox);
for (const sanitize of [sandbox.window.pulseRegionUtils.sanitizePreviewHtml, sandbox.window.pulseRegionUtils.sanitizeRichText]) {
for (const quotes of ["'Ubuntu Regular'", '&#39;Ubuntu Regular&#39;', '&quot;Ubuntu Regular&quot;']) {
const markup = sanitize('<span style="font-family: ' + quotes + ';">Player</span>');
assert.doesNotMatch(markup, /&amp;#39;|&amp;quot;/);
assert.equal(sanitize(markup), markup);
assert.match(markup, /font-family: (?:&#39;Ubuntu Regular&#39;|&quot;Ubuntu Regular&quot;);/);
}
const unsafe = sanitize('<a href="java&#115;cript:alert(1)" style="background:u&#114;l(test)">Player</a>');
assert.doesNotMatch(unsafe, /href=|style=/);
}
});
test('page route wiring preserves the configured media directory for popup font stylesheets', () => {
let pageDependencies;
const sandbox = {
module: { exports: {} },
require(name) {
if (name === '#src/rbac') return { PERMISSIONS: [], normalizePermissionKeys: value => value };
return function (_app, dependencies) {
if (name === '#src/web/routes/admin/pages') pageDependencies = dependencies;
};
}
};
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/routes/register'), 'utf8'), sandbox);
sandbox.module.exports.registerRoutes({}, {
mediaDir: '/configured/signage-media',
playerActionService: {}
});
assert.equal(pageDependencies.mediaDir, '/configured/signage-media');
});
test('thumbnail payload passes saved region geometry and content to the popup renderer', () => {
const payload = buildThumbnailPreviewPayload({
template: {
canvas_size_width: 1280,
canvas_size_height: 720,
regions: [{ id: 14, region_key: 'clock', region_type: 'time-date', x: 20, y: 30, width: 400, height: 90, z_index: 3 }]
},
content: { clock: { value: '{{hh}}:{{mm}}', timezone: 'Europe/London' } }
});
assert.equal(payload.regions[0].x, 20);
assert.equal(payload.regions[0].width, 400);
assert.equal(payload.regions[0].content.value, '{{hh}}:{{mm}}');
assert.equal(payload.existingContent.clock.timezone, 'Europe/London');
});
test('thumbnail popup respects template defaults, read-only regions and explicit slide content', () => {
const payload = buildThumbnailPreviewPayload({
template: {
canvas_size_width: 1280,
canvas_size_height: 720,
regions: [
{ region_key: 'logo', region_type: 'image', read_only: 1, default_content: { value: '/media/uploads/template-logo.png' }, x: 0, y: 0, width: 200, height: 100 },
{ region_key: 'heading', region_type: 'text', default_content: JSON.stringify({ value: 'Template heading' }), x: 0, y: 100, width: 400, height: 100 },
{ region_key: 'custom', region_type: 'text', default_content: { value: 'Default custom' }, x: 0, y: 200, width: 400, height: 100 },
{ region_key: 'blank', region_type: 'text', default_content: { value: 'Do not restore' }, x: 0, y: 300, width: 400, height: 100 }
]
},
content: {
logo: { value: '/media/uploads/stale-logo.png' },
custom: { value: 'Slide heading' },
blank: { value: '' }
}
});
assert.equal(payload.regions[0].content.value, '/media/uploads/template-logo.png');
assert.equal(payload.regions[0].content.type, 'image');
assert.equal(payload.regions[1].content.value, 'Template heading');
assert.equal(payload.regions[2].content.value, 'Slide heading');
assert.equal(payload.regions[3].content.value, '');
assert.equal(payload.existingContent.logo.value, '/media/uploads/template-logo.png');
assert.equal(payload.existingContent.heading.value, 'Template heading');
assert.match(payload.html, /template-logo\.png/);
assert.match(payload.html, /Template heading/);
assert.match(payload.html, /Slide heading/);
assert.doesNotMatch(payload.html, /stale-logo|Default custom|Do not restore/);
});
test('editor and popup load the same browser-compatible content resolver', () => {
const sandbox = { window: {} };
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/shared/region-content'), 'utf8'), sandbox);
const region = { region_key: 'brand', region_type: 'text', read_only: true, default_content: { value: 'Shared brand' } };
const saved = { brand: { value: 'Stale brand' } };
assert.equal(JSON.stringify(sandbox.window.pulseRegionContent.resolveRegionContent(region, saved)), JSON.stringify(resolveRegionContent(region, saved)));
assert.ok(getRegionEditorScripts().includes('js/shared/region-content.js'));
const editorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form.js'), 'utf8');
assert.match(editorSource, /window\.pulseRegionContent\.resolveRegionContent\(region, existingContent\)/);
});
test('thumbnail regions use shared region renderers and their source context', () => {
const calls = [];
const registry = {
get(type) {
return {
renderPreview(...args) {
calls.push({ type, args });
return '<span>' + type + '</span>';
}
};
}
};
const apiSources = [{ id: 8, responseJson: [{ name: 'Ada' }] }];
const payload = {
regions: [
{ id: 1, region_key: 'clock', region_type: 'time-date', x: 20, y: 12, width: 200, height: 80, z_index: 4, content: { value: '{{hh}}:{{mm}}', timezone: 'UTC' } },
{ id: 2, region_key: 'donations', region_type: 'api', x: 220, y: 12, width: 300, height: 80, z_index: 2, content: { source_id: 8, value: '{{name}}' } }
],
apiSources: apiSources,
rssFeeds: [],
weatherLocations: [],
timetableGroups: []
};
const html = render(payload, registry);
assert.match(html, /data-region-key="clock"[^>]*><span>time-date<\/span>/);
assert.match(html, /data-region-key="donations"[^>]*><span>api<\/span>/);
assert.equal(calls[0].args[3], 1);
assert.equal(calls[1].args[2].sources, apiSources);
});
test('thumbnail time/date uses the editor renderer to expand current-time tokens', () => {
const registry = {
get(type) {
return type === 'time-date' ? loadEditorTimeDateModule() : null;
}
};
const html = render({
regions: [{
id: 4,
region_key: 'clock',
region_type: 'time-date',
x: 0,
y: 0,
width: 320,
height: 80,
content: { value: '{{hh}}:{{mm}}', timezone: 'UTC' }
}]
}, registry);
assert.match(html, /<div class="template-region time-date"/);
assert.match(html, /class="template-region-text-scale"[^>]*>\d{2}:\d{2}<\/div>/);
});