306 lines
13 KiB
JavaScript
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
},
|
|
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'", ''Ubuntu Regular'', '"Ubuntu Regular"']) {
|
|
const markup = sanitize('<span style="font-family: ' + quotes + ';">Player</span>');
|
|
assert.doesNotMatch(markup, /&#39;|&quot;/);
|
|
assert.equal(sanitize(markup), markup);
|
|
assert.match(markup, /font-family: (?:'Ubuntu Regular'|"Ubuntu Regular");/);
|
|
}
|
|
const unsafe = sanitize('<a href="javascript:alert(1)" style="background:url(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>/);
|
|
}); |