Release v2.7.0
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m18s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 33s

This commit is contained in:
2026-08-14 13:36:47 +01:00
parent 30f5ed11b8
commit e7ec276317
28 changed files with 872 additions and 198 deletions
+7
View File
@@ -8,4 +8,11 @@ test('async save errors keep validation failures as warning toasts', () => {
assert.ok(adminPageScript.includes('function isWarningSaveError(error)'));
assert.ok(adminPageScript.includes('error.status = response.status;'));
assert.ok(adminPageScript.includes('var variant = isWarningSaveError(error) ? \'warning\' : \'danger\';'));
});
test('async save runs success hooks before redirecting close or new saves', () => {
assert.ok(adminPageScript.includes('var responseText = await response.text();'));
assert.ok(adminPageScript.includes('if (typeof settings.afterSuccess === \'function\')'));
assert.ok(adminPageScript.includes('clearFormDirty(form);'));
assert.ok(adminPageScript.includes("if (submitterValue === 'close' || submitterValue === 'new')"));
});
+124
View File
@@ -292,3 +292,127 @@ test('slide upload cleanup route removes unused uploads', async () => {
assert.equal(cleanupCall.uploadDir, 'e:\\Projects Git\\pulse-signage\\media\\uploads');
assert.deepEqual(cleanupCall.uploadPaths, ['/media/uploads/test-file.png']);
});
test('wysiwyg image uploads are capped below the dedicated image region limit', async () => {
const handlers = {};
const app = {
get(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
}
};
const deps = {
pool: {
async query() {
return [[]];
}
},
common: {
fetchTemplatesData: async () => ({}),
fetchRssFeedsData: async () => ({ rssFeeds: [] }),
fetchApiSourcesData: async () => ({ apiSources: [] }),
fetchTimetablesData: async () => ({ timetableGroups: [] }),
parseJsonSafe: () => null,
fetchRssFeedItemsByFeedId: async () => [],
normalizeRssFeedItem: (item) => item,
fetchSlidesPage: async () => ({}),
fetchSlideById: async () => null,
fetchTemplatesPage: async () => ({}),
fetchTemplateById: async () => null,
fetchCanvasSizesPage: async () => ({}),
fetchCanvasSizeById: async () => null,
getSearchQuery: () => '',
getSortQuery: () => '',
getSortDirectionQuery: () => 'asc',
fetchDuplicateName: async () => null,
buildCanvasSizePayload
},
pages: {
renderCanvasSizesPage() { return ''; },
renderCanvasSizeEditPage() { return ''; },
renderCanvasSizeAddPage() { return ''; },
renderSlideAddPage() { return ''; },
renderSlideEditPage() { return ''; },
renderTemplatesPage() { return ''; },
renderTemplateAddPage() { return ''; },
renderTemplateEditPage() { return ''; }
},
upload: {
any() {
return function (_req, _res, next) {
next();
};
},
single() {
return function (_req, _res, next) {
next();
};
}
},
setAuthMessageCookie() {},
fetchScreensBySlideId: async () => [],
fetchScreensByTemplateId: async () => [],
collectUploadReferencesFromSlide: () => [],
collectUploadReferencesFromTemplate: () => [],
collectUploadReferencesFromPayload: () => [],
removeUnusedUploadFiles: async () => {},
syncPlaylistUploadsOnChange: async () => {},
getAuditUserId: () => 1,
redirectAfterSave: () => {},
notifyPlayerScreens: async () => 0,
broadcastDashboardState: async () => {},
backgroundTaskQueue: { enqueueTask: async () => null },
getSlideDeleteBlockMessage: async () => '',
getTemplateDeleteBlockMessage: async () => '',
getCanvasSizeDeleteBlockMessage: async () => '',
requirePermission() {
return function (_req, _res, next) {
next();
};
},
hasAnyPermission: () => true,
uploadDir: 'e:\\Projects Git\\pulse-signage\\media\\uploads'
};
registerContentRoutes(app, deps);
const routeHandlers = handlers['/slides/uploads'];
assert.ok(Array.isArray(routeHandlers));
const req = {
file: {
filename: 'wysiwyg-large.png',
originalname: 'wysiwyg-large.png',
mimetype: 'image/png',
size: 11 * 1024 * 1024
},
get(headerName) {
return headerName === 'X-Upload-Context' ? 'wysiwyg' : '';
},
currentUser: { id: 1, permissions: ['slides.create'] }
};
const res = {
statusCode: 0,
body: '',
json(body) {
this.body = body;
return this;
},
status(code) {
this.statusCode = code;
return this;
}
};
let nextError = null;
await routeHandlers[2](req, res, (error) => {
nextError = error || null;
});
assert.ok(nextError);
assert.equal(nextError.statusCode, 400);
assert.equal(nextError.message, 'Image must be 2 MB or smaller. Larger images should use the dedicated Image region.');
});
+10 -2
View File
@@ -1,8 +1,11 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
require('../src/common');
const timetableRegionSource = fs.readFileSync(require.resolve('../src/player/regions/timetable.js'), 'utf8');
const {
mediaKind,
normalizeSlide,
@@ -18,11 +21,11 @@ test('mediaKind classifies player media by extension', () => {
});
test('sanitizeRichText strips unsafe content but preserves allowed markup', () => {
const html = '<div class="wrap"><a href="https://example.com" target="_blank">Link</a><script>alert(1)</script><span style="color:red">Text</span><img src="x" onerror="alert(1)"></div>';
const html = '<div class="wrap"><a href="https://example.com" target="_blank">Link</a><script>alert(1)</script><span style="color:red">Text</span><table class="grid"><colgroup><col span="1" style="width:50%"><col span="1" style="width:50%"></colgroup><thead><tr><th scope="col">Name</th><th scope="col">Value</th></tr></thead><tbody><tr><td>Alpha</td><td>Beta</td></tr></tbody></table><img src="/media/uploads/photo.png" alt="Photo" loading="lazy" onerror="alert(1)"></div>';
assert.equal(
sanitizeRichText(html),
'<div class="wrap"><a href="https://example.com" target="_blank" rel="noreferrer noopener">Link</a><span style="color:red">Text</span></div>'
'<div class="wrap"><a href="https://example.com" target="_blank" rel="noreferrer noopener">Link</a><span style="color:red">Text</span><table class="grid"><colgroup><col span="1" style="width:50%"><col span="1" style="width:50%"></colgroup><thead><tr><th scope="col">Name</th><th scope="col">Value</th></tr></thead><tbody><tr><td>Alpha</td><td>Beta</td></tr></tbody></table><img src="/media/uploads/photo.png" alt="Photo" loading="lazy"></div>'
);
});
@@ -78,4 +81,9 @@ test('renderEditorJsContent sanitizes editor blocks and wraps legacy text', () =
'<h2><strong>Title</strong></h2><p><a>bad</a><em>ok</em></p><ol style="list-style-type:decimal;padding-left:1.4em;"><li>One</li><li><span>Two</span></li></ol>'
);
assert.equal(renderEditorJsContent('plain text'), '<p>plain text</p>');
});
test('timetable region registers the timetable type', () => {
assert.ok(timetableRegionSource.includes("registry.register('timetable'"));
assert.ok(timetableRegionSource.includes("sanitizeRichText(substituteTimetableVariables(value"));
});
+65
View File
@@ -0,0 +1,65 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
function loadTimeDateModule() {
const webUiHelpersScript = fs.readFileSync(require.resolve('../src/web/public/js/web-ui-helpers.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');
const registry = new Map();
const sandbox = {
document: {
addEventListener() {}
},
window: {
pulsePlayerRegionTypes: {
register(type, module) {
registry.set(type, module);
}
},
innerWidth: 1280,
innerHeight: 720,
Intl: Intl,
Date: Date,
Object: Object,
Array: Array,
Number: Number,
String: String,
Boolean: Boolean,
Math: Math,
JSON: JSON,
RegExp: RegExp,
console: console
}
};
sandbox.window = Object.assign({}, sandbox.window);
vm.runInNewContext(webUiHelpersScript, sandbox, { filename: 'web-ui-helpers.js' });
sandbox.escapeHtml = sandbox.window.escapeHtml;
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' });
return registry.get('time-date');
}
test('time/date region renders placeholder tokens on the player side', () => {
const module = loadTimeDateModule();
const markup = module.renderRegion(
{
pixelWidth: 320,
pixelHeight: 180,
canvasScale: 1,
baseStyle: 'position:absolute;'
},
{
value: '{{hh}}:{{mm}}',
timezone: 'UTC'
}
);
assert.match(markup, /<p>\d{2}:\d{2}<\/p>/);
});
+50
View File
@@ -3,7 +3,57 @@ const assert = require('node:assert/strict');
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 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');
test('slide editor disables pasted data images in TinyMCE', () => {
assert.ok(slideFormEditorSource.includes('paste_data_images: false'));
});
test('slide editor enables server-backed image uploads', () => {
assert.ok(slideFormEditorSource.includes("plugins: 'lists code advlist fullscreen table image'"));
assert.ok(slideFormEditorSource.includes('automatic_uploads: true'));
assert.ok(slideFormEditorSource.includes('images_file_types: imageUploadFileTypes'));
assert.ok(slideFormEditorSource.includes('images_upload_handler: uploadEditorImage'));
assert.ok(slideFormEditorSource.includes('table image chip | fullscreen'));
assert.ok(slideFormEditorSource.includes('relative_urls: false'));
assert.ok(slideFormEditorSource.includes('remove_script_host: false'));
});
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'"));
assert.ok(slideFormEditorSource.includes("cellspacing: '0'"));
assert.ok(slideFormEditorSource.includes('td, th { border: 1px solid currentColor; padding: 0; vertical-align: top; }'));
});
test('slide editor uses a smaller wysiwyg image limit', () => {
assert.ok(slideFormEditorSource.includes('var imageUploadMaxBytes = Math.max(1, Number(settings.imageUploadMaxBytes || 2 * 1024 * 1024));'));
assert.ok(slideFormEditorSource.includes('Image must be '));
assert.ok(slideFormEditorSource.includes('Larger images should use the dedicated Image region.'));
assert.ok(slideFormEditorSource.includes("xhr.setRequestHeader('X-Upload-Context', imageUploadContext);"));
});
test('slide editor tracks uploaded image paths for cleanup', () => {
assert.ok(slideFormEditorSource.includes('var editorImageUploadPaths = new Set();'));
assert.ok(slideFormEditorSource.includes('getImageUploadCleanupPaths'));
assert.ok(slideFormEditorSource.includes('getCommittedImageUploadCleanupPaths'));
assert.ok(slideFormEditorSource.includes('getPendingImageUploadPaths'));
assert.ok(slideFormEditorSource.includes('clearImageUploadPaths'));
});
test('slide editor keeps image-only rich text from being treated as empty', () => {
assert.ok(slideFormEditorSource.includes('/<img\\b/i.test(raw)'));
});
test('slide form queues editor image cleanup on save and close', () => {
assert.ok(slideFormSource.includes('getImageUploadCleanupPaths'));
assert.ok(slideFormSource.includes('getCommittedImageUploadCleanupPaths'));
assert.ok(slideFormSource.includes("regionMediaController.queueUploadCleanup(slideFormEditorController.getImageUploadCleanupPaths())"));
});
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)'));
});
+5 -5
View File
@@ -6,7 +6,7 @@ const vm = require('node:vm');
function loadScheduleModule(overrides) {
const timeDatePlaceholdersScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/time-date-placeholders.js'), 'utf8');
const placeholderScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8');
const scriptPath = require.resolve('../src/web/public/js/regions/type/schedule.js');
const scriptPath = require.resolve('../src/web/public/js/regions/type/timetable.js');
const script = fs.readFileSync(scriptPath, 'utf8');
const registry = new Map();
const customWindow = overrides && overrides.window ? overrides.window : {};
@@ -161,8 +161,8 @@ test('timetable region preview resolves timezone placeholders from explicit time
id: 101,
title: 'Launch',
short_description: 'Doors open',
start_datetime: '2026-08-10T10:00:00.000Z',
end_datetime: '2026-08-10T11:00:00.000Z'
start_datetime: '2026-08-20T10:00:00.000Z',
end_datetime: '2026-08-20T11:00:00.000Z'
}
]
}
@@ -281,8 +281,8 @@ test('timetable preview does not force the group timezone into placeholder trans
id: 101,
title: 'Launch',
short_description: 'Doors open',
start_datetime: '2026-08-10T10:00:00.000Z',
end_datetime: '2026-08-10T11:00:00.000Z'
start_datetime: '2026-08-20T10:00:00.000Z',
end_datetime: '2026-08-20T11:00:00.000Z'
}
]
}
+58
View File
@@ -39,4 +39,62 @@ test('buildSlidePayload normalizes timetable region fields', async () => {
assert.equal(content.timetable.max_items, '7');
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_display_mode'), false);
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_max_items'), false);
});
test('buildSlidePayload preserves safe image markup in text regions', async () => {
const pool = {
async query(sql) {
if (sql.includes('FROM c_templates st')) {
return [[{ id: 9, name: 'Template 9', canvas_size_id: 1, canvas_size_width: 1920, canvas_size_height: 1080 }]];
}
if (sql.includes('FROM c_template_regions')) {
return [[{ id: 47, template_id: 9, region_key: 'body', region_type: 'text', label: 'Body' }]];
}
return [[]];
}
};
const payload = await buildSlidePayload(pool, {
body: {
title: 'Text slide',
template_id: '9',
region_text_47: '<p><img src="/media/uploads/photo.png" alt="Photo" onerror="alert(1)"></p>'
},
files: []
}, null);
const content = JSON.parse(payload.contentJson);
assert.equal(content.body.type, 'text');
assert.equal(content.body.value, '<p><img src="/media/uploads/photo.png" alt="Photo"></p>');
});
test('buildSlidePayload preserves safe color spans in text regions', async () => {
const pool = {
async query(sql) {
if (sql.includes('FROM c_templates st')) {
return [[{ id: 9, name: 'Template 9', canvas_size_id: 1, canvas_size_width: 1920, canvas_size_height: 1080 }]];
}
if (sql.includes('FROM c_template_regions')) {
return [[{ id: 47, template_id: 9, region_key: 'body', region_type: 'text', label: 'Body' }]];
}
return [[]];
}
};
const payload = await buildSlidePayload(pool, {
body: {
title: 'Text slide',
template_id: '9',
region_text_47: '<p><span style="color:#ff0000" class="text-emphasis">Hello</span></p>'
},
files: []
}, null);
const content = JSON.parse(payload.contentJson);
assert.equal(content.body.type, 'text');
assert.equal(content.body.value, '<p><span style="color:#ff0000" class="text-emphasis">Hello</span></p>');
});