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
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:
@@ -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')"));
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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>/);
|
||||
});
|
||||
@@ -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)'));
|
||||
});
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>');
|
||||
});
|
||||
Reference in New Issue
Block a user