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

This commit is contained in:
lzstealth committed 2026-10-17 16:55:37 +01:00
1 parent 0e77998e92
commit 2fd059d35a
18 files changed
+385 -30

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const registerErrorPageMiddleware = require('../src/web/middleware/error-page');
function setupMiddleware(request) {
let middleware = null;
const pages = {
renderErrorPage(options) {
return '<html>formatted ' + options.statusCode + ': ' + options.message + '</html>';
}
};
registerErrorPageMiddleware({
use(value) {
middleware = value;
}
}, pages);
const response = {
statusCode: 200,
body: null,
status(code) {
this.statusCode = code;
return this;
},
get() {
return '';
},
send(body) {
this.body = body;
return this;
},
sendStatus(code) {
this.statusCode = code;
this.body = 'status ' + code;
return this;
}
};
middleware(request, response, function () {});
return response;
}
test('plain browser error responses use the shared formatted page', () => {
const response = setupMiddleware({
originalUrl: '/slides/180/edit',
currentUser: { id: 1 },
accepts() { return 'html'; },
get() { return ''; }
});
response.status(404).send('Slide not found');
assert.match(response.body, /^<html>formatted 404: Slide not found<\/html>$/);
});
test('API errors retain their response body', () => {
const response = setupMiddleware({
originalUrl: '/api/slides/180',
accepts() { return 'html'; },
get() { return ''; }
});
response.status(404).send('Slide not found');
assert.equal(response.body, 'Slide not found');
});
test('object error responses retain their JSON payload', () => {
const response = setupMiddleware({
originalUrl: '/admin/action',
accepts() { return 'html'; },
get() { return ''; }
});
const payload = { error: 'Request failed' };
response.status(400).send(payload);
assert.equal(response.body, payload);
});
test('already-rendered HTML errors are left intact', () => {
const response = setupMiddleware({
originalUrl: '/reset-password',
accepts() { return 'html'; },
get() { return ''; }
});
response.status(400).send('<html>custom form error</html>');
assert.equal(response.body, '<html>custom form error</html>');
});
test('sendStatus errors use the shared formatted page for browser requests', () => {
const response = setupMiddleware({
originalUrl: '/missing',
accepts() { return 'html'; },
get() { return ''; }
});
response.sendStatus(404);
assert.match(response.body, /^<html>formatted 404:/);
});
+7
View File
@@ -4,6 +4,7 @@ const fs = require('node:fs');
const slideFormEditorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form-editor.js'), 'utf8');
const slideFormSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form.js'), 'utf8');
const mediaPickerSource = fs.readFileSync(require.resolve('../src/web/public/js/media-picker.js'), 'utf8');
const slideThumbnailPreviewSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnail-preview.js'), 'utf8');
const slideThumbnailsSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnails.js'), 'utf8');
const slideHtmlRegionSource = fs.readFileSync(require.resolve('../src/web/public/js/regions/type/html.js'), 'utf8');
@@ -73,6 +74,12 @@ test('slide form queues editor image cleanup on save and close', () => {
assert.ok(slideFormSource.includes("regionMediaController.queueUploadCleanup(slideFormEditorController.getImageUploadCleanupPaths())"));
});
test('media library selections keep image and video remove controls', () => {
assert.ok(mediaPickerSource.includes("var removeAction = isVideo ? 'region-video' : isQrImage ? 'region-qr-image' : 'region-image';"));
assert.ok(mediaPickerSource.includes('data-remove-\' + removeAction'));
assert.ok(mediaPickerSource.includes('slide-image-region-preview-remove'));
});
test('slide thumbnail previews treat image-only text as visible content', () => {
assert.ok(slideThumbnailPreviewSource.includes('/<img\\b/i.test(raw)'));
assert.ok(slideThumbnailsSource.includes('/<img\\b/i.test(raw)'));
+111
View File
@@ -1,6 +1,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
require('../src/common');
@@ -332,6 +333,116 @@ test('template designer warns when invalid region names block save', () => {
assert.ok(script.includes('regionNameValidationToastShown = false;'));
});
function createRegionCardHarness(lockRatio = '') {
const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
const fields = {};
const cardListeners = {};
const initialValues = { name: 'Region', type: 'text', key: 'Region', label: 'Region', x: '80', y: '80', z: '1', width: '300', height: '120', lock_ratio: lockRatio, animation_json: '{}' };
for (const [field, value] of Object.entries(initialValues)) {
fields[`[name="region_${field}[]"]`] = {
value,
listeners: {},
addEventListener(type, listener) { this.listeners[type] = listener; }
};
}
fields['[data-region-remove-button]'] = { addEventListener() {} };
const card = {
dataset: {},
querySelector(selector) { return fields[selector] || null; },
querySelectorAll() { return []; },
addEventListener(type, listener) { cardListeners[type] = listener; }
};
const previews = [];
let dirtyCount = 0;
const context = vm.createContext({
window: {},
regionCardTemplate: { content: { firstElementChild: { cloneNode: () => card } } },
populateRegionCard() {},
setRegionRemovalState() {},
getRegionName: () => 'Region',
updateRegionLockBadge() {},
markTemplateFormDirty() { dirtyCount += 1; },
requestOverlayRender() { previews.push(context.readCard(card)); },
getCanvasSize: () => ({ width: 1920, height: 1080 }),
normalizeLockRatio: (value) => value
});
vm.runInContext(fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer-utils.js'), 'utf8'), context);
Object.assign(context, {
readCard: context.window.templateDesignerUtils.readCard,
writeCard: context.window.templateDesignerUtils.writeCard,
parseLockRatio: context.window.templateDesignerUtils.parseLockRatio,
clamp: context.window.templateDesignerUtils.clamp
});
for (const name of ['fitRegionToCanvas', 'normalizeRegionCard', 'syncLockedDimensions', 'makeRegionCard']) {
const match = script.match(new RegExp(' function ' + name + '\\([^]*?\\n }'));
assert.ok(match, `Missing designer function: ${name}`);
vm.runInContext(match[0], context);
}
context.makeRegionCard({ region_key: 'Region' });
return {
previews,
get dirtyCount() { return dirtyCount; },
edit(field, value, type = 'input') {
const input = fields[`[name="region_${field}[]"]`];
input.value = value;
input.listeners[type]();
},
value(field) { return Number(fields[`[name="region_${field}[]"]`].value); },
rawValue(field) { return fields[`[name="region_${field}[]"]`].value; },
keydown: (event) => cardListeners.keydown(event)
};
}
test('region coordinate and stacking fields immediately refresh the preview and mark it dirty', () => {
const harness = createRegionCardHarness();
harness.edit('x', '140');
harness.edit('y', '220');
harness.edit('z', '3');
assert.equal(harness.previews.length, 3);
assert.equal(harness.previews.at(-1).x, 140);
assert.equal(harness.previews.at(-1).y, 220);
assert.equal(harness.previews.at(-1).z_index, 3);
assert.equal(harness.dirtyCount, 3);
});
test('region dimensions allow clearing and typing below the minimum until committed', () => {
for (const field of ['width', 'height']) {
const harness = createRegionCardHarness();
for (const value of ['', '1', '10', '100']) {
harness.edit(field, value);
assert.equal(harness.rawValue(field), value);
}
harness.edit(field, '1', 'change');
assert.equal(harness.value(field), 12);
harness.edit(field, '9999', 'change');
assert.equal(harness.value(field), field === 'width' ? 1920 : 1080);
}
});
test('locked region dimensions update their counterpart without replacing partial input', () => {
const harness = createRegionCardHarness('2:1');
harness.edit('width', '');
assert.equal(harness.rawValue('width'), '');
harness.edit('width', '1');
assert.equal(harness.rawValue('width'), '1');
harness.edit('width', '400');
assert.equal(harness.value('height'), 200);
harness.edit('height', '150');
assert.equal(harness.value('width'), 300);
harness.edit('height', '150', 'change');
assert.equal(harness.value('height'), 150);
assert.equal(harness.value('width'), 300);
});
test('Enter in region inputs prevents implicit saves without blocking buttons or textarea newlines', () => {
const harness = createRegionCardHarness();
for (const [key, tagName, expected] of [['Enter', 'INPUT', true], ['Tab', 'INPUT', false], ['Enter', 'BUTTON', false], ['Enter', 'TEXTAREA', false]]) {
let prevented = false;
harness.keydown({ key, target: { tagName }, preventDefault() { prevented = true; } });
assert.equal(prevented, expected);
}
});
test('normalizeBackgroundGradient accepts linear colors and clamps the angle', () => {
assert.equal(normalizeBackgroundGradient(JSON.stringify({
type: 'radial',
+31
View File
@@ -130,6 +130,34 @@ test('timetable date ranges agree in preview and player, before limiting items',
const config = { value: '{{title}}', timetable_group_id: 1, display_mode: 'upcoming', date_filter: 'between', date_from: '2099-08-20T00:00:00Z' };
assert.doesNotMatch(previewModule.renderPreview({}, config, { timetableGroups: groups }), /Live/);
assert.doesNotMatch(playerModule.renderRegion({}, config), /Live/);
groups[0].entries = [
{ id: 5, title: 'Future', start_datetime: '2099-08-20T10:00:00Z' },
groups[0].entries[0],
{ id: 6, title: 'Past', start_datetime: '2000-08-20T10:00:00Z', end_datetime: '2000-08-20T11:00:00Z' }
];
const displayCases = [
[{ display_mode: 'upcoming' }, ['Future']],
[{ display_mode: 'current' }, ['Live']],
[{ display_mode: 'both' }, ['Live', 'Future']],
[{ display_mode: 'all' }, ['Past', 'Live', 'Future']],
[{ display_mode: 'all', max_items: 1 }, ['Past']],
[{ display_mode: 'all', date_filter: 'before', date_to: '2001-01-01T00:00:00Z' }, ['Past']],
[{ display_mode: 'all', date_filter: 'after', date_from: '2099-01-01T00:00:00Z' }, ['Future']],
[{ display_mode: 'all', date_filter: 'between', date_from: '2000-01-01T00:00:00Z', date_to: '2001-01-01T00:00:00Z' }, ['Past']]
];
for (const [settings, titles] of displayCases) {
const content = { value: '{{title}}', timetable_group_id: 1, max_items: 5, ...settings };
const outputs = [previewModule.renderPreview({}, content, { timetableGroups: groups }), playerModule.renderRegion({}, content)];
for (const output of outputs) {
for (const title of ['Past', 'Live', 'Future']) {
assert.equal(output.includes(title), titles.includes(title), JSON.stringify(settings));
}
for (let index = 1; index < titles.length; index++) {
assert.ok(output.indexOf(titles[index - 1]) < output.indexOf(titles[index]));
}
}
}
});
test('timetable date controls default to all and pass unsaved date ranges to preview', () => {
@@ -158,6 +186,9 @@ test('timetable date controls default to all and pass unsaved date ranges to pre
assert.equal(context.date_from, '');
assert.equal(context.date_to, fields['[name="region_date_to_47"]'].value);
assert.doesNotMatch(html, /Use the selected group and display mode/);
assert.match(html, /<option value="both">Current &amp; Upcoming<\/option>/);
const allMode = module.renderEditorCard({ region, current: { display_mode: 'all' }, timetableGroups: [] });
assert.match(allMode, /<option value="all" selected>All<\/option>/);
for (const mode of ['before', 'after']) {
const current = { ...saved, date_filter: mode };
const markup = module.renderEditorCard({ region, current, timetableGroups: [] });
+30
View File
@@ -2,6 +2,35 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const { buildSlidePayload } = require('../src/data/slides');
const { normalizeDisplayMode } = require('../src/data/timetables');
test('thumbnail timetable regions resolve entries instead of displaying placeholders', () => {
const { buildThumbnailPreviewPayload } = require('../src/web/lib/media/slide-thumbnails');
const slide = {
template: { regions: [{ region_key: 'agenda', region_type: 'timetable', width: 600, height: 300 }] },
content: { agenda: { value: '<p>{{index}}: {{title}} at {{start.time(HH:mm,"Europe/Amsterdam")}}</p>', timetable_group_id: 7, display_mode: 'all', max_items: 1 } }
};
const options = { timetableGroups: [{ id: 7, timezone: 'Europe/Amsterdam', entries: [
{ id: 2, title: 'Later', start_datetime: '2099-01-01T12:00:00Z' },
{ id: 1, title: 'Opening & welcome', start_datetime: '2099-01-01T10:00:00Z' }
] }] };
const payload = buildThumbnailPreviewPayload(slide, options);
assert.match(payload.html, /1: Opening &amp; welcome at 11:00/);
assert.doesNotMatch(payload.html, /\{\{|Later/);
slide.content.agenda.date_filter = 'after';
slide.content.agenda.date_from = '2099-01-01T11:00:00Z';
const filtered = buildThumbnailPreviewPayload(slide, options);
assert.match(filtered.html, /1: Later at 13:00/);
assert.doesNotMatch(filtered.html, /Opening|\{\{/);
assert.doesNotMatch(buildThumbnailPreviewPayload(slide, {}).html, /\{\{|Opening|Later/);
});
test('timetable display mode normalization accepts all and preserves existing modes', () => {
for (const mode of ['upcoming', 'current', 'both', 'all']) {
assert.equal(normalizeDisplayMode(mode.toUpperCase()), mode);
}
assert.equal(normalizeDisplayMode('invalid'), 'upcoming');
});
test('buildSlidePayload normalizes timetable region fields', async () => {
const pool = {
@@ -65,6 +94,7 @@ test('timetable date settings persist, clear, default to all, and reject invalid
return JSON.parse(payload.contentJson).timetable;
}
assert.equal((await save({})).date_filter, 'all');
assert.equal((await save({ region_timetable_display_mode_47: 'all' })).display_mode, 'all');
assert.equal((await save({}, existing)).date_from, '2099-08-20T10:00:00.000Z');
assert.equal((await save({ region_date_from_47: '' }, existing)).date_from, '');
const all = await save({ region_date_filter_47: 'all' }, existing);