Release 2.20.6: saved-image slide previews
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

This commit is contained in:
lzstealth committed 2026-10-26 17:48:05 +00:00
1 parent 205ce42337
commit 24df245ea1
20 files changed
+318 -75

No files matched your search

+13
View File
@@ -193,6 +193,19 @@ test('media library counts slide, template background, and region default refere
assert.match(queries[2].sql, /thumbnail_path/);
});
test('full-size slide previews share the small thumbnail reference', async () => {
const pool = {
async query(sql, params) {
if (sql.includes('thumbnail_path')) {
assert.deepEqual(params, ['/media/thumbnails/slides/slide-7.png']);
return [[{ ref_count: 1 }]];
}
return [[{ owner_count: 0, ref_count: 0 }]];
}
};
assert.equal(await countMediaAssetReferences(pool, '/media/thumbnails/slides/slide-7.full.png'), 1);
});
test('media library upload and delete routes audit successful asset changes', async () => {
const originalCreateVariants = mediaLibrary.createImageVariants;
const originalRegisterAsset = mediaLibrary.registerMediaAsset;
+36
View File
@@ -5,6 +5,42 @@ require('../src/common');
const { buildPlaylistFormViewModel } = require('../src/web/routes/signage/playlists/form-view-model');
test('playlist add and edit forms share the saved-image preview and accessible thumbnail buttons', () => {
const { renderView } = require('../src/web/view');
for (const isEdit of [false, true]) {
const html = renderView('playlists/form', {
isEdit,
playlist: { id: 3, name: 'Preview test' },
selectableSlides: [],
playlistSlideTemplate: { scheduleRules: [] },
playlistSlides: [{ slide_id: 124, title: 'Landscape', thumbnail_path: '/media/thumbnails/slides/slide-124.png', canvas_width: 1920, canvas_height: 1080, scheduleRules: [] }]
});
assert.equal((html.match(/id="slide-preview-modal"/g) || []).length, 1);
assert.match(html, /<button[^>]*data-slide-preview-url="\/media\/thumbnails\/slides\/slide-124.png"[^>]*data-canvas-width="1920"[^>]*aria-label="Preview slide: Landscape"/);
assert.match(html, /data-slide-preview-image/);
assert.doesNotMatch(html, /data-slide-preview-frame/);
}
});
test('new playlist rows receive preview URLs, titles and canvas dimensions', () => {
const fs = require('node:fs');
const vm = require('node:vm');
const source = fs.readFileSync(require.resolve('../src/web/public/js/playlists/playlist-schedule'), 'utf8');
const start = source.indexOf(' function populatePlaylistSlideRow(');
const end = source.indexOf(' function createRow(', start);
const sandbox = { escapeHtmlAttribute: value => value };
vm.runInNewContext(source.slice(start, end), sandbox);
const attrs = {};
const thumb = { setAttribute(name, value) { attrs[name] = value; }, style: { setProperty() {} } };
const row = { setAttribute() {}, querySelector(selector) { return selector === '[data-playlist-slide-thumb]' ? thumb : null; } };
sandbox.populatePlaylistSlideRow(row, { slide_id: 125, title: 'Portrait', thumbnail_path: '/media/thumbnails/slides/slide-125.png', canvas_width: 1080, canvas_height: 1920, summary: 'Always visible' }, 'new-1');
assert.equal(attrs['data-slide-preview-url'], '/media/thumbnails/slides/slide-125.png');
assert.equal(attrs['data-slide-preview-title'], 'Portrait');
assert.equal(attrs['data-canvas-width'], '1080');
assert.equal(attrs['data-canvas-height'], '1920');
assert.equal(attrs['aria-label'], 'Preview slide: Portrait');
});
test('playlist schedule summaries show up to two short rules on separate lines and count longer summaries', () => {
const fs = require('node:fs');
const path = require('node:path');
+16
View File
@@ -111,6 +111,14 @@ test('popup preview falls back to iframe sizing rules', () => {
assert.ok(popupPreviewSource.includes('#popup-preview-canvas .slide-preview-html-region iframe'));
});
test('slide preview shrinks proportionally without adding a nested scrollbar', () => {
const stylesheet = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
assert.match(stylesheet, /\.slide-editor-shell \.slide-preview-stage\s*\{[^}]*max-height:\s*max\(12rem,\s*calc\(100vh\s*-\s*22rem\)\);/s);
assert.doesNotMatch(stylesheet, /\.slide-editor-shell \.slide-preview-scroll\s*\{[^}]*overflow:\s*auto;/s);
assert.match(slideFormSource, /function sizePreviewStage\(canvasWidth, canvasHeight\)[\s\S]*?stageContainer\.clientWidth - parseFloat\(containerStyle\.paddingLeft\) - parseFloat\(containerStyle\.paddingRight\)[\s\S]*?Math\.min\(availableWidth, maxHeight \* canvasWidth \/ canvasHeight\)[\s\S]*?marginInline = 'auto'/);
});
test('browser preview helpers normalize object-shaped HTML and webpage values', () => {
assert.ok(slideHtmlRegionSource.includes('normalizePreviewValue'));
assert.ok(slideWebpageRegionSource.includes('normalizePreviewValue'));
@@ -182,4 +190,12 @@ test('slide template chooser searches and previews templates while preserving th
test('popup preview is disabled and guarded until a template is selected', () => {
assert.match(slideFormSource, /if \(openPreviewPopupButton\) openPreviewPopupButton\.disabled = !template;/);
assert.match(slideFormSource, /function openPreviewPopup\(\) \{\s*if \(!getSelectedTemplate\(\)\)\s*\{\s*return;/);
});
test('reopening the slide popup updates the existing window without navigating through about:blank', () => {
const openPreviewPopup = slideFormSource.match(/function openPreviewPopup\(\) \{([\s\S]*?)\n function syncSlideEditorSidebar\(\)/);
assert.ok(openPreviewPopup);
assert.match(openPreviewPopup[1], /if \(previewPopupWindow && !previewPopupWindow\.closed\)[\s\S]*?location\.hash = buildPreviewPopupHash\(\);[\s\S]*?previewPopupWindow\.focus\(\);\s*return;/);
assert.ok(openPreviewPopup[1].indexOf('if (previewPopupWindow && !previewPopupWindow.closed)') < openPreviewPopup[1].indexOf("window.open('about:blank'"));
});
+105
View File
@@ -35,6 +35,111 @@ function loadEditorTimeDateModule() {
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 = {