Release 2.21.0: non-destructive media and scoped player sync
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m36s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s

This commit is contained in:
lzstealth committed 2026-10-26 21:46:33 +00:00
1 parent 24df245ea1
commit 7cff39dc76
35 files changed
+2009 -949

No files matched your search

+627 -1
View File
@@ -9,6 +9,10 @@ const sharp = require('sharp');
require('../src/common');
const mediaLibrary = require('../src/web/lib/media/library');
const registerMediaLibraryRoutes = require('../src/web/routes/settings/media-library');
const { buildSlidePayload } = require('../src/data/slides');
const { outputSize, isFullImage } = require('../src/web/public/js/shared/image-crop');
const { prepareImageCopies } = require('../src/web/lib/media/image-crops');
const { removeUnusedImageCopies, removePendingMediaAssets } = require('../src/web/lib/media/library');
const {
normalizeMediaPath,
createImageVariants,
@@ -25,6 +29,530 @@ test('media library normalizes only media URLs', () => {
assert.equal(normalizeMediaPath('/assets/photo.png'), null);
});
test('unused generated copies and their variants are deleted without deleting originals or referenced copies', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-copy-cleanup-'));
const generated = 'image-crop-' + 'a'.repeat(64) + '.png';
const referenced = 'image-playback-' + 'b'.repeat(64) + '.png';
const files = [generated, generated.replace('.png', '.variant-2048.png'), referenced, 'original.png', 'original-crop-name.png', 'draft.png'];
for (const filename of files) await fs.promises.writeFile(path.join(uploadDir, filename), Buffer.from('fixture'));
const deleted = [];
const pool = { async query(sql, params) {
if (sql.startsWith('SELECT id, original_name')) return [[{ id: 1, is_published: params[0].endsWith('/draft.png') ? 0 : 1, original_name: /\/(?:draft|original-crop-name)\.png$/.test(params[0]) ? 'slide-image-crop.png' : path.basename(params[0]) }]];
if (sql.includes('COUNT(DISTINCT owner_type, owner_id)')) return [[{ owner_count: params.includes('/media/uploads/' + referenced) ? 1 : 0 }]];
if (sql.startsWith('DELETE')) deleted.push(params[0]);
return [[{ ref_count: 0 }]];
} };
try {
await removeUnusedImageCopies(pool, uploadDir, files.map(filename => '/media/uploads/' + filename));
assert.deepEqual((await fs.promises.readdir(uploadDir)).sort(), ['original.png', 'original-crop-name.png', referenced].sort());
assert.ok(deleted.includes('/media/uploads/' + generated));
assert.ok(deleted.includes('/media/uploads/draft.png'));
assert.ok(!deleted.includes('/media/uploads/original.png'));
} finally {
await fs.promises.rm(uploadDir, { recursive: true, force: true });
}
});
test('pending crop cleanup removes physical copies and variants but protects published and referenced files', async () => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-pending-crop-'));
const uploadDir = path.join(directory, 'uploads');
await fs.promises.mkdir(uploadDir);
const files = ['draft.png', 'draft.variant-2048.png', 'referenced.png', 'published.png', 'original.png'];
for (const filename of files) await fs.promises.writeFile(path.join(uploadDir, filename), Buffer.from('fixture'));
const pool = { async query(sql, params) {
if (sql.startsWith('SELECT id FROM c_media_assets')) return [params[0].endsWith('/published.png') ? [] : [{ id: 1 }]];
if (sql.includes('COUNT(DISTINCT owner_type, owner_id)')) return [[{ owner_count: params.includes('/media/uploads/referenced.png') ? 1 : 0 }]];
return [[{ ref_count: 0 }]];
} };
try {
await removePendingMediaAssets(pool, uploadDir, ['/media/uploads/draft.png', '/media/uploads/referenced.png', '/media/uploads/published.png']);
assert.deepEqual((await fs.promises.readdir(uploadDir)).sort(), ['original.png', 'published.png', 'referenced.png']);
} finally {
await fs.promises.rm(directory, { recursive: true, force: true });
}
});
test('crop playback sizes fit the region and canvas without upscaling or changing aspect ratio', () => {
assert.deepEqual(outputSize({ width: 6000, height: 4000 }, { width: 600, height: 300 }, { width: 1920, height: 1080 }), { width: 450, height: 300 });
assert.deepEqual(outputSize({ width: 6000, height: 4000 }, { width: 5000, height: 3000 }, { width: 1920, height: 1080 }), { width: 1620, height: 1080 });
assert.deepEqual(outputSize({ width: 120, height: 80 }, { width: 600, height: 300 }, { width: 1920, height: 1080 }), { width: 120, height: 80 });
assert.deepEqual(outputSize({ width: 4000, height: 6000 }, { width: 1080, height: 1920 }, { width: 1080, height: 1920 }), { width: 1080, height: 1620 });
});
test('untouched full-image selections are not treated as crops', () => {
const image = { naturalWidth: 600, naturalHeight: 400 };
const full = { x: 0, y: 0, width: 600, height: 400, rotate: 0, scaleX: 1, scaleY: 1 };
assert.equal(isFullImage(full, image), true);
assert.equal(isFullImage({ ...full, x: 0.000001 }, image), true);
for (const change of [{ width: 599 }, { x: 1 }, { rotate: 90 }, { scaleX: -1 }, { scaleY: -1 }]) assert.equal(isFullImage({ ...full, ...change }, image), false);
});
test('cancelling an initial crop clears the selection and cleans its upload, but cancelling a re-crop keeps it', () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
const start = source.indexOf('function discardInitialSelection()');
const end = source.indexOf('\n function loadCropper()', start);
const cleaned = [];
const current = { value: '/media/uploads/new.png', dataset: { cropSelectionPending: '1' }, dispatchEvent() {} };
const preview = { innerHTML: '' };
const active = { initialSelection: true, applied: false, previousValue: current.value, current, original: current.value, originalInput: { value: current.value }, cropInput: { value: '' }, fileInput: { value: '', dataset: { uploadedPath: current.value, uploadedNeedsCleanup: '1' } }, card: { querySelector() { return preview; } } };
const discard = vm.runInNewContext('(' + source.slice(start, end).trim() + ')', { active, Event, cleanupCopies(paths) { cleaned.push(...paths); } });
active.initialSelection = false;
discard();
assert.equal(current.value, '/media/uploads/new.png');
assert.equal(cleaned.length, 0);
active.initialSelection = true;
active.applied = true;
discard();
assert.equal(cleaned.length, 0);
active.applied = false;
discard();
assert.deepEqual(cleaned, ['/media/uploads/new.png']);
assert.equal(current.value, '');
assert.equal(active.originalInput.value, '');
assert.equal(active.fileInput.dataset.uploadedNeedsCleanup, undefined);
assert.match(preview.innerHTML, /No image/);
});
test('playback crops regenerate from the original for larger layouts and preserve source bytes', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-crop-size-'));
const original = await sharp({ create: { width: 1200, height: 800, channels: 3, background: '#fa4030' } }).png().toBuffer();
await fs.promises.writeFile(path.join(uploadDir, 'original.png'), original);
const template = { canvas_size_width: 1920, canvas_size_height: 1080, regions: [{ region_key: 'photo', region_type: 'image', x: 0, y: 0, width: 300, height: 200 }] };
const content = { photo: { type: 'image', value: '/media/uploads/old.png', original_value: '/media/uploads/original.png', crop: { x: 0, y: 0, width: 1200, height: 800, rotate: 0, scaleX: 1, scaleY: 1 } } };
try {
let files = await prepareImageCopies(content, template, uploadDir);
let metadata = await sharp(files[0].path).metadata();
assert.equal(metadata.width, 300);
assert.equal(metadata.height, 200);
const firstPath = content.photo.value;
await prepareImageCopies(content, template, uploadDir);
assert.equal(content.photo.value, firstPath);
await fs.promises.unlink(files[0].path);
content.photo.layout = { x: 0, y: 0, width: 900, height: 600 };
files = await prepareImageCopies(content, template, uploadDir);
metadata = await sharp(files[0].path).metadata();
assert.equal(metadata.width, 900);
assert.equal(metadata.height, 600);
assert.notEqual(content.photo.value, firstPath);
content.photo.layout = { x: 0, y: 0, width: 1920, height: 1080 };
files = await prepareImageCopies(content, template, uploadDir);
metadata = await sharp(files[0].path).metadata();
assert.equal(metadata.width, 1200);
assert.equal(metadata.height, 800);
assert.deepEqual(await fs.promises.readFile(path.join(uploadDir, 'original.png')), original);
content.photo.original_value = '/media/uploads/../original.png';
await assert.rejects(prepareImageCopies(content, template, uploadDir), /local upload/);
} finally {
await fs.promises.rm(uploadDir, { recursive: true, force: true });
}
});
test('uncropped 8K images get bounded full-frame playback copies and regenerate from the original', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-full-image-size-'));
try {
const original = await sharp({ create: { width: 7680, height: 4320, channels: 3, background: '#236944' } }).jpeg().toBuffer();
await fs.promises.writeFile(path.join(uploadDir, 'original.jpg'), original);
const image = { type: 'image', value: '/media/uploads/original.jpg', crop: null };
const template = { canvas_size_width: 1920, canvas_size_height: 1080, regions: [{ region_key: 'photo', region_type: 'image', width: 960, height: 540 }] };
let files = await prepareImageCopies({ photo: image }, template, uploadDir);
let metadata = await sharp(files[0].path).metadata();
assert.equal(metadata.width, 960);
assert.equal(metadata.height, 540);
assert.equal(image.original_value, '/media/uploads/original.jpg');
assert.equal(image.crop, null);
const firstPath = image.value;
await prepareImageCopies({ photo: image }, template, uploadDir);
assert.equal(image.value, firstPath);
await fs.promises.unlink(files[0].path);
image.layout = { x: 0, y: 0, width: 1920, height: 1080 };
files = await prepareImageCopies({ photo: image }, template, uploadDir);
metadata = await sharp(files[0].path).metadata();
assert.equal(metadata.width, 1920);
assert.equal(metadata.height, 1080);
assert.notEqual(image.value, firstPath);
assert.deepEqual(await fs.promises.readFile(path.join(uploadDir, 'original.jpg')), original);
} finally {
await fs.promises.rm(uploadDir, { recursive: true, force: true });
}
});
test('pre-crop-change images remain usable across edit, save, preview and player rendering', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-legacy-image-'));
const region = { id: 22, region_key: 'photo', region_type: 'image', width: 800, height: 600, label: 'Legacy image', baseStyle: '' };
const template = { id: 7, canvas_size_width: 1920, canvas_size_height: 1080, regions: [region] };
const pool = { async query(sql) {
if (sql.includes('FROM c_templates st')) return [[template]];
if (sql.includes('FROM c_template_regions')) return [[region]];
return [[]];
} };
const modules = {};
const browserWindow = {
pulseRegionTypes: { register(type, definition) { modules.editor = definition; }, renderEditorCardShell(context) { return context.headerActions + context.bodyHtml; } },
pulsePlayerRegionTypes: { register(type, definition) { modules.player = definition; } }
};
const context = vm.createContext({ window: browserWindow, escapeHtml: String, selectPlayerMediaSource: value => value });
vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/regions/type/image.js'), 'utf8'), context);
vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/player/regions/image.js'), 'utf8'), context);
try {
for (const [filename, width, height] of [['old-cropped.png', 600, 400], ['old-large.png', 2400, 800]]) {
const original = await sharp({ create: { width, height, channels: 3, background: '#237744' } }).png().toBuffer();
await fs.promises.writeFile(path.join(uploadDir, filename), original);
const legacy = { type: 'image', value: '/media/uploads/' + filename };
const editorContext = modules.editor.buildEditorCardContext({ region, current: legacy.value }, { photo: legacy });
const card = modules.editor.renderEditorCard(editorContext);
assert.ok(card.includes('name="region_image_original_22" value="' + legacy.value + '"'));
const preview = modules.editor.renderPreview(region, modules.editor.buildPreviewRenderContext(region, null, { photo: legacy }));
assert.ok(preview.includes(legacy.value));
assert.ok(modules.player.renderRegion(region, legacy).includes(legacy.value));
const payload = await buildSlidePayload(pool, { body: { title: 'Legacy saved again', template_id: '7' }, files: [] }, { template_id: 7, content_json: JSON.stringify({ photo: legacy }) });
const saved = JSON.parse(payload.contentJson);
assert.equal(saved.photo.value, legacy.value);
assert.equal(saved.photo.original_value, legacy.value);
assert.equal(saved.photo.crop, null);
const copies = await prepareImageCopies(saved, template, uploadDir);
if (width === 600) {
assert.equal(copies.length, 0);
assert.equal(saved.photo.value, legacy.value);
} else {
assert.equal(copies.length, 1);
const metadata = await sharp(copies[0].path).metadata();
assert.ok(metadata.width <= region.width);
assert.ok(metadata.height <= region.height);
assert.equal(metadata.width / metadata.height, width / height);
assert.equal(saved.photo.original_value, legacy.value);
}
assert.ok(modules.player.renderRegion(region, saved.photo).includes(saved.photo.value));
assert.deepEqual(await fs.promises.readFile(path.join(uploadDir, filename)), original);
assert.equal(legacy.original_value, undefined);
assert.equal(legacy.crop, undefined);
}
} finally {
await fs.promises.rm(uploadDir, { recursive: true, force: true });
}
});
test('uncropped playback preserves animation and never enlarges small images', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-full-image-animation-'));
try {
const red = await sharp({ create: { width: 400, height: 200, channels: 4, background: '#ff0000' } }).raw().toBuffer();
const green = await sharp({ create: { width: 400, height: 200, channels: 4, background: '#00ff00' } }).raw().toBuffer();
const template = { canvas_size_width: 1920, canvas_size_height: 1080, regions: [{ region_key: 'photo', region_type: 'image', width: 100, height: 100 }] };
for (const format of ['gif', 'webp']) {
const original = await sharp(Buffer.concat([red, green]), { raw: { width: 400, height: 400, channels: 4, pageHeight: 200 } })[format]({ delay: [120, 240], loop: 0 }).toBuffer();
const filename = 'original.' + format;
await fs.promises.writeFile(path.join(uploadDir, filename), original);
const image = { value: '/media/uploads/' + filename, crop: null };
const files = await prepareImageCopies({ photo: image }, template, uploadDir);
const metadata = await sharp(await fs.promises.readFile(files[0].path), { animated: true }).metadata();
assert.equal(metadata.width, 100);
assert.equal(metadata.pageHeight, 50);
assert.equal(metadata.pages, 2);
assert.deepEqual(metadata.delay, [120, 240]);
assert.equal(metadata.loop, 0);
assert.equal(image.crop, null);
assert.deepEqual(await fs.promises.readFile(path.join(uploadDir, filename)), original);
}
await sharp({ create: { width: 40, height: 20, channels: 4, background: '#00ff0080' } }).png().toFile(path.join(uploadDir, 'small.png'));
const before = (await fs.promises.readdir(uploadDir)).sort();
const small = { value: '/media/uploads/previous-copy.png', original_value: '/media/uploads/small.png', crop: null };
const files = await prepareImageCopies({ photo: small }, template, uploadDir);
assert.equal(files.length, 0);
assert.equal(small.value, '/media/uploads/small.png');
assert.equal(small.crop, null);
assert.deepEqual((await fs.promises.readdir(uploadDir)).sort(), before);
const metadata = await sharp(path.join(uploadDir, 'small.png')).metadata();
assert.equal(metadata.width, 40);
assert.equal(metadata.height, 20);
assert.equal(metadata.hasAlpha, true);
} finally {
await fs.promises.rm(uploadDir, { recursive: true, force: true });
}
});
test('regenerated crop pixels preserve cropper rotation and flips', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-crop-transform-'));
const red = [255, 0, 0, 255];
const green = [0, 255, 0, 255];
const pixels = Buffer.from([...red, ...red, ...green, ...green, ...red, ...red, ...green, ...green]);
try {
await sharp(pixels, { raw: { width: 4, height: 2, channels: 4 } }).png().toFile(path.join(uploadDir, 'original.png'));
const content = { photo: { value: '/media/uploads/old.png', original_value: '/media/uploads/original.png', crop: { x: 0, y: 0, width: 2, height: 4, rotate: 90, scaleX: -1, scaleY: 1 } } };
const template = { canvas_size_width: 1920, canvas_size_height: 1080, regions: [{ region_key: 'photo', region_type: 'image', width: 2, height: 4 }] };
const files = await prepareImageCopies(content, template, uploadDir);
const output = await sharp(files[0].path).raw().toBuffer();
assert.deepEqual([...output.subarray(0, 4)], green);
assert.deepEqual([...output.subarray(output.length - 4)], red);
content.photo.crop = { x: -2, y: 0, width: 6, height: 2 };
template.regions[0].width = 6;
template.regions[0].height = 2;
const padded = await prepareImageCopies(content, template, uploadDir);
const padding = await sharp(padded[0].path).raw().toBuffer();
assert.equal(padding[3], 0);
assert.deepEqual([...padding.subarray(8, 12)], red);
} finally {
await fs.promises.rm(uploadDir, { recursive: true, force: true });
}
});
test('editor offers the cropper and player uses the crop copy while retaining original metadata', () => {
const modules = {};
const browserWindow = {
pulseRegionTypes: { register(type, definition) { modules.editor = definition; }, renderEditorCardShell(context) { return '<div class="card-header">' + context.headerActions + '</div><div class="card-body">' + context.bodyHtml + '</div>'; } },
pulsePlayerRegionTypes: { register(type, definition) { modules.player = definition; } }
};
const context = vm.createContext({ window: browserWindow, escapeHtml: String, selectPlayerMediaSource: value => value });
vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/regions/type/image.js'), 'utf8'), context);
vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/player/regions/image.js'), 'utf8'), context);
const photo = { value: '/media/uploads/crop.png', original_value: '/media/uploads/original.svg', crop: { x: 20, y: 30, width: 400, height: 200, rotate: 90, scaleX: -1, scaleY: 1 } };
const region = { id: 22, region_key: 'photo', baseStyle: '', label: 'Photo' };
const editorContext = modules.editor.buildEditorCardContext({ region, current: photo.value }, { photo });
assert.deepEqual(editorContext.imageContent, photo);
const card = modules.editor.renderEditorCard(editorContext);
assert.match(card, /data-edit-image-crop="22"/);
const header = card.slice(0, card.indexOf('class="card-body"'));
assert.doesNotMatch(header, /data-edit-image-crop/);
assert.match(card, /class="slide-image-region-preview-tools"><button type="button" class="slide-image-region-crop"/);
assert.ok(card.indexOf('data-edit-image-crop') < card.indexOf('slide-image-region-preview-box'));
assert.match(card, /title="Edit crop" aria-label="Edit crop"><i class="bi bi-crop" aria-hidden="true"><\/i><\/button>/);
assert.match(card, /name="region_image_original_22"[^>]*original\.svg/);
assert.doesNotMatch(card, /type="range"|region_image_fit/);
const previewContext = modules.editor.buildPreviewRenderContext(region, null, { photo });
const preview = modules.editor.renderPreview(region, previewContext);
const player = modules.player.renderRegion(region, photo);
for (const html of [preview, player]) {
assert.match(html, /crop\.png/);
assert.doesNotMatch(html, /original\.svg/);
}
assert.equal(previewContext.original_value, photo.original_value);
assert.deepEqual(previewContext.crop, photo.crop);
});
test('image uploads open the cropper and library selections wait for the picker to close', () => {
const handlers = {};
const timers = [];
let opens = 0;
let pickerVisible = false;
let afterPickerHidden;
const original = { value: '' };
const crop = { value: 'old crop' };
const button = { disabled: false, click() { opens += 1; } };
const card = { isConnected: true, querySelector(selector) { return selector.includes('original') ? original : selector.includes('crop_') ? crop : button; } };
const input = { name: 'existing_region_image_22', value: '/media/uploads/photo.jpg', dataset: {}, closest() { return card; } };
const picker = { classList: { contains() { return pickerVisible; } }, addEventListener(name, handler, options) { assert.equal(name, 'hidden.bs.modal'); assert.equal(options.once, true); afterPickerHidden = handler; } };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/regions/type/image.js'), 'utf8'), {
window: { pulseRegionTypes: { register() {} }, setTimeout(handler) { timers.push(handler); } },
document: { addEventListener(name, handler) { handlers[name] = handler; }, getElementById() { return picker; } }
});
handlers.change({ target: input });
assert.equal(original.value, input.value);
assert.equal(crop.value, '');
assert.equal(opens, 0);
timers.shift()();
assert.equal(opens, 1);
pickerVisible = true;
input.value = '/media/uploads/library.jpg';
handlers.change({ target: input });
assert.equal(opens, 1);
assert.equal(timers.length, 0);
pickerVisible = false;
afterPickerHidden();
assert.equal(opens, 2);
input.dataset.applyingCrop = '1';
input.value = '/media/uploads/copy.png';
handlers.change({ target: input });
assert.equal(original.value, '/media/uploads/library.jpg');
assert.equal(timers.length, 0);
delete input.dataset.applyingCrop;
input.value = '';
handlers.change({ target: input });
assert.equal(timers.length, 0);
input.value = '/media/uploads/pending.jpg';
handlers.change({ target: input });
input.value = '';
timers.shift()();
assert.equal(opens, 2);
});
test('opening another image waits until the previous cropper has finished closing', async () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
const marker = "document.addEventListener('click', ";
const start = source.lastIndexOf(marker) + marker.length;
const end = source.lastIndexOf('\n });');
let afterHidden;
let clicks = 0;
const button = { isConnected: true, click() { clicks += 1; } };
const modal = { dataset: { cropClosing: '1' }, addEventListener(name, callback, options) { assert.equal(name, 'hidden.bs.modal'); assert.equal(options.once, true); afterHidden = callback; } };
const open = vm.runInNewContext('(' + source.slice(start, end + 4) + ')', { modal, busy: false });
await open({ target: { closest() { return button; } } });
assert.equal(clicks, 0);
delete modal.dataset.cropClosing;
afterHidden();
assert.equal(clicks, 1);
});
test('cropped draft copies are cleaned on replacement, removal and editor cancellation without deleting originals', () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
const handlers = {};
const requests = [];
const context = vm.createContext({
Map, Set,
document: { addEventListener(name, handler) { handlers[name] = handler; } },
window: { addEventListener(name, handler) { handlers[name] = handler; } },
fetch(url, options) { requests.push({ url, paths: JSON.parse(options.body).uploadPaths, keepalive: options.keepalive }); return Promise.resolve({ ok: true }); }
});
vm.runInContext(source.slice(source.indexOf('var draftCopies'), source.indexOf('function loadCropper()')), context);
const input = { name: 'existing_region_image_22', value: '/media/uploads/crop-1.png', dataset: {} };
context.input = input;
vm.runInContext("trackCopy(input, '/media/uploads/crop-1.png')", context);
assert.equal(requests.length, 0);
input.value = '/media/uploads/crop-2.png';
vm.runInContext("trackCopy(input, '/media/uploads/crop-2.png')", context);
assert.deepEqual(requests.pop().paths, ['/media/uploads/crop-1.png']);
input.dataset.applyingCrop = '1';
handlers.change({ target: input });
assert.equal(requests.length, 0);
delete input.dataset.applyingCrop;
input.value = '';
handlers.change({ target: input });
assert.deepEqual(requests.pop().paths, ['/media/uploads/crop-2.png']);
input.value = '/media/uploads/original.png';
handlers.change({ target: input });
assert.equal(requests.length, 0);
vm.runInContext("trackCopy(input, '/media/uploads/abandoned.png')", context);
assert.equal(handlers.beforeunload, undefined);
handlers.pagehide();
const cancelled = requests.pop();
assert.equal(cancelled.url, '/slides/uploads/cleanup');
assert.equal(cancelled.keepalive, true);
assert.deepEqual(cancelled.paths, ['/media/uploads/abandoned.png']);
});
test('cropper footer contains apply and cancel without a separate original-image action', () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
assert.doesNotMatch(source, /data-crop-original|Use original/);
assert.match(source, /data-crop-apply>Apply crop/);
assert.match(source, /data-bs-dismiss="modal">Cancel/);
assert.match(source, /data-crop-action="reset"/);
});
test('cropper toolbar retains labeled rotate and flip groups, ratio presets and a separate danger reset', () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
const start = source.indexOf('function createModal()');
const end = source.indexOf('document.body.appendChild(modal);', start);
const context = vm.createContext({ modal: null, document: { createElement() { return { setAttribute() {} }; } } });
const html = vm.runInContext(source.slice(start, end) + '\nreturn modal.innerHTML; }\ncreateModal();', context);
assert.match(html, /aria-label="Rotate"/);
assert.match(html, /aria-label="Flip"/);
for (const label of ['Rotate left', 'Rotate right', 'Flip horizontal', 'Flip vertical']) assert.ok(html.includes('</i> ' + label + '</button>'));
for (const ratio of ['Free', '1:1', '4:3', '16:9', 'Region']) assert.ok(html.includes('data-crop-ratio="' + ratio + '"'));
assert.match(html, /class="btn btn-outline-danger btn-sm" data-crop-action="reset"/);
assert.match(html, /slide-image-cropper-frame is-loading" aria-busy="true"/);
assert.match(html, /slide-image-cropper-loading-overlay" aria-hidden="false"/);
assert.match(html, /spinner-border text-primary slide-image-cropper-loading-spinner" role="status"/);
assert.doesNotMatch(html, /zoom-in|zoom-out|data-crop-original/);
});
test('cropper fits the transformed original before restoring a saved crop box', () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
const start = source.indexOf('function () {', source.indexOf('ready:'));
const end = source.indexOf('\n });', start);
const crop = { x: 20, y: 30, width: 400, height: 200, rotate: 90, scaleX: -1, scaleY: 1 };
const calls = [];
const ready = vm.runInNewContext('(' + source.slice(start, end) + ')', {
active: { crop, original: '/media/uploads/original.jpg' },
cropper: {
rotateTo(value) { calls.push(['rotate', value]); },
scale(horizontal, vertical) { calls.push(['scale', horizontal, vertical]); },
setCropBoxData(value) { calls.push(['box', value.width, value.height]); },
getContainerData() { return { width: 900, height: 600 }; },
getCanvasData() { return { naturalWidth: 600, naturalHeight: 1200 }; },
zoomTo(value) { calls.push(['zoom', value]); },
setData(value) { calls.push(['data', value]); }
},
setLoading(value) { calls.push(['loading', value]); }, setBusy() {}, status() {}
});
ready();
assert.deepEqual(calls, [['rotate', 90], ['scale', -1, 1], ['box', 1, 1], ['zoom', 0.5], ['data', crop], ['loading', false]]);
});
test('crop loading toggles the frame overlay and accessible busy state', () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-image-cropper.js'), 'utf8');
const start = source.indexOf('function setLoading(');
const end = source.indexOf('\n function createModal()', start);
const attributes = {};
const overlay = { setAttribute(name, value) { attributes['overlay.' + name] = value; } };
const frame = {
classList: { toggle(name, value) { attributes[name] = value; } },
setAttribute(name, value) { attributes[name] = value; },
querySelector() { return overlay; }
};
const loading = vm.runInNewContext('(' + source.slice(start, end).trim() + ')', { modal: { querySelector() { return frame; } } });
loading(true);
assert.deepEqual(attributes, { 'is-loading': true, 'aria-busy': 'true', 'overlay.aria-hidden': 'false' });
loading(false);
assert.deepEqual(attributes, { 'is-loading': false, 'aria-busy': 'false', 'overlay.aria-hidden': 'true' });
assert.ok(source.indexOf('setLoading(true);', source.indexOf('active = {')) < source.indexOf(".src = active.original"));
assert.match(source, /addEventListener\('error', function \(\) \{\s+setLoading\(false\);/);
});
test('library upload selection and drop submit the original File and reject invalid files', () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/media-library.js'), 'utf8');
const handlers = {};
let submissions = 0;
const input = { accept: 'image/png, image/gif, image/svg+xml', dataset: { imageMaxBytes: '1000' }, files: [], addEventListener(name, handler) { handlers[name] = handler; } };
const zone = { classList: { add() {}, remove() {} }, addEventListener(name, handler) { handlers[name] = handler; } };
const context = vm.createContext({
uploadInput: input, uploadZone: zone, uploadName: {},
uploadForm: { requestSubmit() { submissions += 1; } },
window: { PulseUpload: { showMessage() {}, formatUploadLimit: String } },
DataTransfer: class { constructor() { this.files = []; this.items = { add: file => this.files.push(file) }; } }
});
vm.runInContext(source.slice(source.indexOf('function setUploadFile('), source.indexOf('if (uploadForm) {')), context);
for (const type of ['image/png', 'image/gif', 'image/svg+xml']) {
const file = { name: 'original', type, size: 42 };
input.files = [file];
handlers.change();
assert.equal(input.files[0], file);
handlers.drop({ preventDefault() {}, dataTransfer: { files: [file] } });
assert.equal(input.files[0], file);
}
assert.equal(submissions, 6);
input.files = [{ type: 'image/png', size: 1001 }];
handlers.change();
handlers.drop({ preventDefault() {}, dataTransfer: { files: [{ type: 'text/plain', size: 1 }] } });
assert.equal(submissions, 6);
const slideForm = fs.readFileSync(path.join(__dirname, '../src/web/views/signage/slides/form.hbs'), 'utf8');
assert.doesNotMatch(slideForm, /slide-image-cropper|cropper\.min/);
});
test('slide images retain original references and re-editable crops across saves', async () => {
const pool = { async query(sql) {
if (sql.includes('FROM c_templates st')) return [[{ id: 7, canvas_size_width: 1920, canvas_size_height: 1080 }]];
if (sql.includes('FROM c_template_regions')) return [[{ id: 22, region_key: 'photo', region_type: 'image' }]];
return [[]];
} };
const crop = { x: 20, y: 30, width: 400, height: 200, rotate: 90, scaleX: -1, scaleY: 1 };
const body = { title: 'Photo', template_id: '7', existing_region_image_22: '/media/uploads/crop.png', region_image_original_22: '/media/uploads/original.jpg', region_image_crop_22: JSON.stringify(crop) };
const saved = await buildSlidePayload(pool, { body, files: [] }, null);
const photo = JSON.parse(saved.contentJson).photo;
assert.equal(photo.value, '/media/uploads/crop.png');
assert.equal(photo.original_value, '/media/uploads/original.jpg');
assert.deepEqual(photo.crop, crop);
const reopened = await buildSlidePayload(pool, { body: { title: 'Renamed', template_id: '7' }, files: [] }, { template_id: 7, content_json: saved.contentJson });
assert.deepEqual(JSON.parse(reopened.contentJson).photo, photo);
const references = require('../src/data/content-references').collectContentReferences('slide', 1, { photo });
assert.deepEqual(references.filter(reference => reference.reference_type === 'media').map(reference => reference.reference_value).sort(), ['/media/uploads/crop.png', '/media/uploads/original.jpg']);
const reset = await buildSlidePayload(pool, { body: { ...body, existing_region_image_22: photo.original_value, region_image_crop_22: '' }, files: [] }, { content_json: saved.contentJson });
assert.equal(JSON.parse(reset.contentJson).photo.value, photo.original_value);
assert.equal(JSON.parse(reset.contentJson).photo.crop, null);
const replaced = await buildSlidePayload(pool, { body: { title: 'Photo', template_id: '7', existing_region_image_22: '/media/uploads/new.jpg' }, files: [] }, { content_json: saved.contentJson });
assert.equal(JSON.parse(replaced.contentJson).photo.original_value, '/media/uploads/new.jpg');
assert.equal(JSON.parse(replaced.contentJson).photo.crop, null);
const cleared = await buildSlidePayload(pool, { body: { ...body, existing_region_image_22: '' }, files: [] }, { content_json: saved.contentJson });
assert.equal(JSON.parse(cleared.contentJson).photo.original_value, '');
assert.equal(JSON.parse(cleared.contentJson).photo.crop, null);
});
test('media library sorts Date-valued creation timestamps chronologically', async () => {
const originalSyncMediaAssets = mediaLibrary.syncMediaAssetsFromDirectory;
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
@@ -99,7 +627,7 @@ test('media library creates only smaller image variants and preserves the origin
try {
const variants = await createImageVariants({ path: filePath });
assert.deepEqual(variants.map(function (variant) { return variant.width; }), [2048, 4096]);
assert.equal((await fs.promises.stat(filePath)).size, sourceBuffer.length);
assert.deepEqual(await fs.promises.readFile(filePath), sourceBuffer);
for (const variant of variants) {
const variantPath = path.join(uploadDir, path.basename(variant.path));
assert.ok((await fs.promises.stat(variantPath)).size < sourceBuffer.length);
@@ -264,6 +792,22 @@ test('media library upload and delete routes audit successful asset changes', as
assert.equal(events[0].targetLabel, 'Photo.png');
assert.equal(events[1].targetId, 9);
assert.ok(events.every(function (event) { return event.category === 'media-assets' && event.actorUserId === 4; }));
await fs.promises.writeFile(filePath, 'image');
const jsonResponse = {
statusCode: 200,
status(value) { this.statusCode = value; return this; },
json(value) { this.data = value; return this; },
redirect() { throw new Error('JSON upload must not redirect.'); }
};
await routeHandlers['POST /media-library'].at(-1)({ ...request, headers: { accept: 'application/json' } }, jsonResponse, next);
assert.deepEqual(jsonResponse.data, { success: true, path: '/media/uploads/photo.png' });
assert.equal(jsonResponse.statusCode, 200);
await routeHandlers['POST /media-library'].at(-1)({ headers: { accept: 'application/json' } }, jsonResponse, next);
assert.equal(jsonResponse.statusCode, 400);
assert.match(jsonResponse.data.error, /Choose an image or video/);
await routeHandlers['POST /media-library'].at(-1)({ ...request, headers: { accept: 'application/json' }, file: { ...request.file, mimetype: 'text/plain' } }, jsonResponse, next);
assert.equal(jsonResponse.statusCode, 400);
assert.match(jsonResponse.data.error, /disabled/);
} finally {
mediaLibrary.createImageVariants = originalCreateVariants;
mediaLibrary.registerMediaAsset = originalRegisterAsset;
@@ -273,6 +817,88 @@ test('media library upload and delete routes audit successful asset changes', as
}
});
test('media library drop-zone upload reports transfer progress, processing, errors and success', async () => {
const source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/media-library.js'), 'utf8');
const handlers = {};
const requests = [];
const messages = [];
const attributes = {};
const progress = { hidden: true };
const label = { textContent: '' };
const bar = { setAttribute(name, value) { attributes[name] = value; }, removeAttribute(name) { delete attributes[name]; } };
const fill = { style: {}, classList: { toggle() {} }, textContent: '' };
const input = { disabled: false, files: [{ name: 'original.png' }], value: 'original.png' };
const closeButton = { disabled: false };
let refreshed = 0;
let hidden = 0;
const zone = {
classList: { toggle(name, value) { attributes[name] = value; } },
setAttribute(name, value) { attributes[name] = value; },
querySelector(selector) { return selector.endsWith('progress]') ? progress : selector.endsWith('text]') ? label : selector.endsWith('bar]') ? bar : fill; }
};
const context = vm.createContext({
uploadForm: { action: '/media-library', addEventListener(name, handler) { handlers[name] = handler; } },
uploadInput: input, uploadZone: zone,
uploadModal: { querySelectorAll() { return [closeButton]; }, addEventListener(name, handler) { handlers[name] = handler; } },
window: { PulseUpload: { showMessage(message) { messages.push(message); } }, showToast() {} },
hideModal() { hidden += 1; }, async refreshGallery() { refreshed += 1; },
FormData: class { constructor() { assert.equal(input.disabled, false); } },
XMLHttpRequest: class {
constructor() { this.upload = {}; requests.push(this); }
open(method, url) { this.method = method; this.url = url; }
setRequestHeader() {}
send(data) { this.data = data; }
}
});
const start = source.indexOf('function setUploadProgress(');
const end = source.indexOf('function getGalleryFilters()', start);
vm.runInContext('let uploadInProgress = false;\n' + source.slice(start, end), context);
const submit = () => handlers.submit({ preventDefault() {} });
submit();
assert.equal(progress.hidden, false);
assert.equal(input.disabled, true);
assert.equal(closeButton.disabled, true);
assert.equal(attributes['aria-busy'], 'true');
requests[0].upload.onprogress({ lengthComputable: true, loaded: 250, total: 1000 });
assert.equal(fill.style.width, '25%');
assert.equal(label.textContent, 'Uploading 25%');
assert.equal(attributes['aria-valuenow'], '25');
requests[0].upload.onload();
assert.equal(label.textContent, 'Processing...');
assert.equal(fill.style.width, '100%');
assert.equal(attributes['aria-valuenow'], undefined);
submit();
assert.equal(requests.length, 1);
let prevented = false;
handlers['hide.bs.modal']({ preventDefault() { prevented = true; } });
assert.equal(prevented, true);
requests[0].status = 400;
requests[0].responseText = JSON.stringify({ error: 'Upload rejected.' });
await requests[0].onload();
assert.deepEqual(messages, ['Upload rejected.']);
assert.equal(input.disabled, false);
assert.equal(closeButton.disabled, false);
assert.equal(progress.hidden, true);
submit();
requests[1].ontimeout();
assert.equal(input.disabled, false);
assert.match(messages[1], /timed out/);
submit();
requests[2].status = 200;
requests[2].responseText = JSON.stringify({ success: true });
await requests[2].onload();
assert.equal(hidden, 1);
assert.equal(refreshed, 1);
assert.equal(input.value, '');
assert.equal(progress.hidden, true);
assert.equal(attributes['aria-busy'], 'false');
const view = fs.readFileSync(path.join(__dirname, '../src/web/views/settings/media-library/list.hbs'), 'utf8');
assert.match(view, /data-media-library-upload-progress/);
assert.match(view, /role="progressbar" aria-label="Media upload progress"/);
assert.match(view, /class="progress rounded-pill slide-image-region-upload-zone-progress-bar"/);
assert.match(view, /class="progress-bar bg-primary rounded-pill progress-bar-striped progress-bar-animated"/);
});
test('media library delete redirects preserve filters for individual and bulk outcomes', async () => {
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
const originalRemoveVariants = mediaLibrary.removeImageVariants;
+79 -6
View File
@@ -462,7 +462,7 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par
]);
});
function createSharedLayoutRouteHarness(failUpdate, allowSlideUpdate) {
function createSharedLayoutRouteHarness(failUpdate, allowSlideUpdate, imageOptions) {
const registerContentRoutes = require('../src/web/routes/admin/content');
const handlers = {};
const registrations = [];
@@ -475,6 +475,13 @@ function createSharedLayoutRouteHarness(failUpdate, allowSlideUpdate) {
id: 11, region_key: 'hero', region_type: 'text', x: 10, y: 20, width: 300, height: 100,
z_index: 1, lock_ratio: null, animation_json: '{"intro":{"preset":"fadeIn"}}'
}] };
if (imageOptions) {
template.regions[0].region_type = 'image';
template.regions[0].width = 300;
template.regions[0].height = 200;
if (imageOptions.defaultContent) template.regions[0].default_content = imageOptions.defaultContent;
savedContent.hero = imageOptions.content;
}
const connection = {
async beginTransaction() { calls.push(['begin']); },
async commit() { calls.push(['commit']); },
@@ -483,6 +490,10 @@ function createSharedLayoutRouteHarness(failUpdate, allowSlideUpdate) {
async query(sql, params) {
calls.push([sql, params]);
if (sql.startsWith('SELECT id, template_id, content_json')) return [[{ id: 42, template_id: 7, content_json: JSON.stringify(savedContent) }]];
if (sql.startsWith('SELECT id, content_json FROM c_slides')) return [[{ id: 42, content_json: JSON.stringify(savedContent) }]];
if (imageOptions && sql.startsWith('UPDATE c_template_regions SET x')) {
Object.assign(template.regions[0], { x: params[0], y: params[1], width: params[2], height: params[3] });
}
if (failUpdate && sql.startsWith('UPDATE c_template_regions')) throw new Error('Write failed');
if (sql.startsWith('UPDATE c_template_regions SET animation_json')) {
template.regions.find(region => region.id === params[2]).animation_json = params[0];
@@ -496,13 +507,14 @@ function createSharedLayoutRouteHarness(failUpdate, allowSlideUpdate) {
get(route, ...actions) { handlers['GET ' + route] = actions; registrations.push('GET ' + route); },
post(route, ...actions) { handlers['POST ' + route] = actions; registrations.push('POST ' + route); }
}, {
pool: { async getConnection() { return connection; }, async query() { return [[{ slide_count: 3 }]]; } },
common: { fetchTemplateById: async () => template, parseJsonSafe: JSON.parse }, pages: {},
uploadDir: imageOptions && imageOptions.uploadDir,
pool: { async getConnection() { return connection; }, async query(sql, params) { calls.push([sql, params]); return [[{ slide_count: 3 }]]; } },
common: { fetchTemplateById: async () => imageOptions ? JSON.parse(JSON.stringify(template)) : template, parseJsonSafe: JSON.parse }, pages: {},
upload: { any: () => noop, single: () => noop }, setAuthMessageCookie: noop,
fetchScreensBySlideId: async () => [], fetchScreensByTemplateId: async () => [{ slug: 'lobby' }],
collectUploadReferencesFromSlide: () => [], collectUploadReferencesFromTemplate: () => [],
collectUploadReferencesFromPayload: () => [], removeUnusedUploadFiles: noop,
syncPlaylistUploadsOnChange: noop, getAuditUserId: () => 9, redirectAfterSave: noop,
collectUploadReferencesFromPayload: payload => imageOptions ? require('../src/data/content-references').collectContentReferences('slide', 42, JSON.parse(payload.contentJson)).filter(reference => reference.reference_type === 'media').map(reference => reference.reference_value) : [], removeUnusedUploadFiles: noop,
syncPlaylistUploadsOnChange: async value => calls.push(['sync', value]), getAuditUserId: () => 9, redirectAfterSave: noop,
notifyPlayerScreens: async (...args) => calls.push(['notify', ...args]), broadcastDashboardState: noop,
getSlideDeleteBlockMessage: noop, getTemplateDeleteBlockMessage: noop, getCanvasSizeDeleteBlockMessage: noop,
hasAnyPermission: () => Boolean(allowSlideUpdate),
@@ -691,4 +703,65 @@ test('thumbnail markup uses template defaults for read-only regions', function (
}, 'http://localhost');
assert.match(html, /\/media\/brand\.png/);
assert.doesNotMatch(html, /old-brand/);
});
});
test('shared layout save regenerates cropped playback images and syncs the new file', async () => {
const fs = require('node:fs/promises');
const path = require('node:path');
const uploadDir = await fs.mkdtemp(path.join(require('node:os').tmpdir(), 'pulse-layout-crop-'));
try {
await require('sharp')({ create: { width: 1200, height: 800, channels: 3, background: '#ff0000' } }).png().toFile(path.join(uploadDir, 'original.png'));
const content = { type: 'image', value: '/media/uploads/small.png', original_value: '/media/uploads/original.png', crop: { x: 0, y: 0, width: 1200, height: 800 } };
const { handlers, calls, template } = createSharedLayoutRouteHarness(false, true, { uploadDir, content });
await handlers['POST /templates/:id/layout'].at(-1)({ params: { id: '7' }, body: {
baseline: getTemplateLayoutSnapshot(template),
regions: [{ region_key: 'hero', x: 0, y: 0, width: 900, height: 600, z_index: 1, lock_ratio: '' }]
} }, { json() {} }, error => { throw error; });
const write = calls.find(call => call[0].startsWith('UPDATE c_slides SET content_json'));
const saved = JSON.parse(write[1][0]).hero;
assert.equal(saved.original_value, content.original_value);
assert.deepEqual(saved.crop, content.crop);
const metadata = await require('sharp')(path.join(uploadDir, path.basename(saved.value))).metadata();
assert.equal(metadata.width, 900);
assert.equal(metadata.height, 600);
assert.ok(calls.some(call => call[0].startsWith('UPDATE c_media_assets SET is_published')));
assert.ok(calls.some(call => call[0] === 'sync' && call[1].nextUploadRefs.has(saved.value)));
assert.ok(calls.some(call => call[0].startsWith('INSERT IGNORE INTO c_content_references')));
} finally {
await fs.rm(uploadDir, { recursive: true, force: true });
}
});
for (const uncropped of [false, true]) {
test('shared image layouts resize ' + (uncropped ? 'uncropped' : 'cropped') + ' defaults but retain explicit slide geometry', async () => {
const fs = require('node:fs/promises');
const path = require('node:path');
const uploadDir = await fs.mkdtemp(path.join(require('node:os').tmpdir(), 'pulse-default-crop-'));
try {
await require('sharp')({ create: { width: 1200, height: 800, channels: 3, background: '#00ff00' } }).png().toFile(path.join(uploadDir, 'original.png'));
const original = { type: 'image', value: '/media/uploads/old.png', original_value: '/media/uploads/original.png', crop: uncropped ? null : { x: 0, y: 0, width: 1200, height: 800 } };
const { handlers, calls, template } = createSharedLayoutRouteHarness(false, true, {
uploadDir, defaultContent: { ...original }, content: { ...original, layout: { x: 0, y: 0, width: 150, height: 100 } }
});
await handlers['POST /templates/:id/layout'].at(-1)({ params: { id: '7' }, body: {
baseline: getTemplateLayoutSnapshot(template),
regions: [{ region_key: 'hero', x: 0, y: 0, width: 900, height: 600, z_index: 1, lock_ratio: '' }]
} }, { json() {} }, error => { throw error; });
const defaultWrite = calls.find(call => call[0].startsWith('UPDATE c_template_regions SET default_content_json'));
const defaultImage = JSON.parse(defaultWrite[1][0]);
const defaultMetadata = await require('sharp')(path.join(uploadDir, path.basename(defaultImage.value))).metadata();
assert.equal(defaultMetadata.width, 900);
assert.equal(defaultMetadata.height, 600);
const slideWrite = calls.find(call => call[0].startsWith('UPDATE c_slides SET content_json'));
const slideImage = JSON.parse(slideWrite[1][0]).hero;
const slideMetadata = await require('sharp')(path.join(uploadDir, path.basename(slideImage.value))).metadata();
assert.equal(slideMetadata.width, 150);
assert.equal(slideMetadata.height, 100);
assert.equal(slideImage.layout.width, 150);
assert.equal(defaultImage.original_value, original.original_value);
assert.deepEqual(defaultImage.crop, original.crop);
} finally {
await fs.rm(uploadDir, { recursive: true, force: true });
}
});
}
+10 -7
View File
@@ -19,19 +19,22 @@ test('slide table thumbnails size to the canvas ratio and show the whole image',
assert.match(placeholderRule, /min-height:\s*0;/);
});
test('slide list thumbnails open and release an authorized preview modal', () => {
test('slide list thumbnails open and release the shared saved-image preview modal', () => {
const listTemplate = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'signage', 'slides', 'list.hbs'), 'utf8');
const slidesRoutes = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'routes', 'signage', 'slides', 'routes.js'), 'utf8');
const previewTemplate = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'shared', 'slide-preview-modal.hbs'), 'utf8');
const stylesheet = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'css', 'theme-custom.css'), 'utf8');
assert.match(listTemplate, /class="playlist-slide-thumb slide-list-thumbnail-button"[^>]*data-bs-toggle="modal"/);
assert.match(listTemplate, /data-slide-preview-url="\/api\/internal\/slide-thumbnails\/\{\{id\}\}\/popup-preview"/);
assert.match(listTemplate, /data-slide-preview-url="\{\{thumbnail_path\}\}"/);
assert.match(listTemplate, /data-canvas-width="\{\{canvas_width\}\}" data-canvas-height="\{\{canvas_height\}\}"/);
assert.match(listTemplate, /currentAspectRatio = canvasWidth > 0 && canvasHeight > 0 \? canvasWidth \/ canvasHeight : 16 \/ 9/);
assert.match(listTemplate, /\{\{> slide-preview-modal\}\}/);
assert.match(previewTemplate, /currentAspectRatio = canvasWidth > 0 && canvasHeight > 0 \? canvasWidth \/ canvasHeight : 16 \/ 9/);
assert.match(listTemplate, /aria-label="Preview slide: \{\{title\}\}"/);
assert.match(listTemplate, /data-slide-preview-frame/);
assert.match(listTemplate, /frame\.src = 'about:blank'/);
assert.match(slidesRoutes, /\/api\/internal\/slide-thumbnails\/:id\/popup-preview', requirePopupPreviewAccess, async function/);
assert.match(previewTemplate, /<img[^>]+data-slide-preview-image hidden/);
assert.match(previewTemplate, /image\.src = thumbnailUrl\.replace/);
assert.match(previewTemplate, /image\.src = thumbnailUrl;/);
assert.match(previewTemplate, /modal\.addEventListener\('hidden\.bs\.modal', function \(\) \{\s*thumbnailUrl = '';\s*image\.hidden = true;\s*image\.removeAttribute\('src'\)/);
assert.doesNotMatch(previewTemplate, /<iframe\b|popup-preview|about:blank/);
assert.match(stylesheet, /^\.slide-list-thumbnail-button:focus-visible\s*\{/m);
const previewFrameRule = stylesheet.match(/^\.slide-list-preview-frame\s*\{([^}]+)\}/m)[1];
assert.match(previewFrameRule, /background:\s*var\(--bs-body-bg\);/);
+238 -2
View File
@@ -8,6 +8,49 @@ const express = require('express');
require('../src/common');
const { createUploadSyncService } = require('../src/web/lib/media');
const { buildPlayerAssetPlan } = require('../src/web/lib/media/player-assets');
const { getMediaStorageId } = require('../src/media-storage');
test('media storage IDs distinguish separate player volumes from the shared web volume', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-storage-identity-'));
try {
const web = path.join(root, 'web');
const local = path.join(root, 'player-local');
const remote = path.join(root, 'player-remote');
const webId = await getMediaStorageId(web);
assert.equal(await getMediaStorageId(web), webId);
assert.notEqual(await getMediaStorageId(local), webId);
assert.notEqual(await getMediaStorageId(remote), webId);
const moved = path.join(root, 'web-moved');
fs.renameSync(web, moved);
assert.equal(await getMediaStorageId(moved), webId);
const copied = path.join(root, 'web-independent-copy');
fs.cpSync(moved, copied, { recursive: true });
assert.equal(await getMediaStorageId(copied), webId);
fs.unlinkSync(path.join(copied, '.storage-id'));
assert.notEqual(await getMediaStorageId(copied), webId);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test('unused generated copies are removed before media sync even with no connected players', async () => {
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-no-player-copy-cleanup-'));
const filename = 'image-crop-' + 'c'.repeat(64) + '.png';
fs.writeFileSync(path.join(uploadDir, filename), Buffer.from('copy'));
fs.writeFileSync(path.join(uploadDir, 'original.png'), Buffer.from('original'));
const pool = { async query(sql) {
if (sql.startsWith('SELECT id, original_name')) return [[{ id: 1, original_name: filename, is_published: 1 }]];
if (sql.includes('COUNT(DISTINCT owner_type, owner_id)')) return [[{ owner_count: 0 }]];
return [[{ ref_count: 0 }]];
} };
const service = createUploadSyncService({ pool, common: {}, playerSnapshotCache: new Map(), notifyPlayerScreens: async () => {}, fetchPlayerRegistrations: async () => [], backgroundTaskQueue: { async enqueueTaskAndWait() {} } });
try {
await service.syncPlaylistUploadsOnChange({ key: 'slide:update:1', pool, localUploadDir: uploadDir, previousUploadRefs: ['/media/uploads/' + filename, '/media/uploads/original.png'], nextUploadRefs: [] });
assert.equal(fs.existsSync(path.join(uploadDir, filename)), false);
assert.equal(fs.existsSync(path.join(uploadDir, 'original.png')), true);
} finally {
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('upload reference collection includes WYSIWYG images across slide regions', () => {
const uploadSyncService = createUploadSyncService({
@@ -307,6 +350,9 @@ test('slide update sync removes uploads only after the web-managed file is gone'
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'uploads', 'keep.bin'), Buffer.from('keep'));
fs.writeFileSync(path.join(uploadDir, 'uploads', 'remove.bin'), Buffer.from('remove'));
const manifestDir = path.join(uploadDir, 'player-cache', 'media-sync');
fs.mkdirSync(manifestDir, { recursive: true });
fs.writeFileSync(path.join(manifestDir, require('node:crypto').createHash('sha256').update('player-one').digest('hex') + '.json'), JSON.stringify({ playerIdentifier: 'player-one', files: ['/media/uploads/remove.bin'], clients: [{ id: 'client-one', deviceId: 'client-one', playerIdentifier: 'player-one', screenSlug: 'screen-one', seenAt: Date.now() }] }));
const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString();
const fetchCalls = [];
@@ -332,8 +378,11 @@ test('slide update sync removes uploads only after the web-managed file is gone'
common: {
async fetchAdminData() {
return {
screens: [{ id: 1, slug: 'screen-one', playlist_id: 1 }],
playlistSlides: [{ playlist_id: 1, slide_id: 1 }],
slides: [
{
id: 1,
content_json: JSON.stringify({
imageRegion: {
type: 'image',
@@ -441,7 +490,11 @@ test('slide update sync queues one media task per live player and targets each p
let uploadSyncService;
uploadSyncService = createUploadSyncService({
common: {},
common: { async fetchAdminData() { return {
screens: [{ id: 1, slug: 'screen-one', playlist_id: 1 }, { id: 2, slug: 'screen-two', playlist_id: 1 }],
playlistSlides: [{ playlist_id: 1, slide_id: 1 }],
slides: [{ id: 1, content_json: { photo: { value: '/media/uploads/sample.bin' } } }], templates: []
}; } },
pool: {
async query() {
return [[
@@ -463,7 +516,10 @@ test('slide update sync queues one media task per live player and targets each p
]];
}
},
playerSnapshotCache: new Map(),
playerSnapshotCache: new Map([
['screen-one', { connections: [{ deviceId: 'client-one', playerDeviceId: 'player-one' }] }],
['screen-two', { connections: [{ deviceId: 'client-two', playerDeviceId: 'player-two' }] }]
]),
notifyPlayerScreens: async () => {},
backgroundTaskQueue: {
async enqueueTaskAndWait(definition) {
@@ -569,4 +625,184 @@ test('remote media deletion uses the bridge device route', async () => {
global.fetch = originalFetch;
fs.rmSync(uploadDir, { recursive: true, force: true });
}
});
test('playback reference collection excludes editing originals without weakening retention references', () => {
const service = createUploadSyncService({ common: {}, playerSnapshotCache: new Map(), notifyPlayerScreens: async () => {} });
const content = {
photo: { type: 'image', value: '/media/uploads/playback.png', original_value: '/media/uploads/original.png' },
text: { value: '<img src="/media/uploads/inline.png">' },
small: { type: 'image', value: '/media/uploads/small.png', original_value: '/media/uploads/small.png' }
};
assert.deepEqual([...service.collectPlaybackUploadReferences(content)].sort(), ['/media/uploads/inline.png', '/media/uploads/playback.png', '/media/uploads/small.png']);
const retained = new Set();
service.collectUploadReferencesFromValue(content, retained);
assert.ok(retained.has('/media/uploads/original.png'));
});
test('player asset plans separate direct and bridged players and retain offline client assignments', () => {
const service = createUploadSyncService({ common: {}, playerSnapshotCache: new Map(), notifyPlayerScreens: async () => {} });
const players = [{ identifier: 'local', public_base_url: 'http://player:8081' }, { identifier: 'remote', public_base_url: '' }];
const data = {
screens: [{ id: 1, slug: 'one', playlist_id: 10 }, { id: 2, slug: 'two', playlist_id: 20 }],
playlistSlides: [{ playlist_id: 10, slide_id: 1 }, { playlist_id: 20, slide_id: 2 }],
slides: [{ id: 1, template_id: 1, content_json: { photo: { value: '/media/uploads/local-copy.png', original_value: '/media/uploads/local-original.png' }, brand: { value: '/media/uploads/ignored.png' } } }, { id: 2, template_id: 2, content_json: { photo: { value: '/media/uploads/remote-copy.png', original_value: '/media/uploads/remote-original.png' } } }],
templates: [{ id: 1, background_image_path: '/media/uploads/background.png' }, { id: 2 }],
templateRegions: [{ template_id: 1, region_key: 'photo', region_type: 'image' }, { template_id: 1, region_key: 'brand', region_type: 'image', read_only: 1, default_content: { value: '/media/uploads/brand.png' } }, { template_id: 2, region_key: 'photo', region_type: 'image' }]
};
const snapshots = new Map([['one', { connections: [{ deviceId: 'client-one', playerPublicBaseUrl: 'http://player:8081' }] }], ['two', { connections: [{ deviceId: 'client-two', playerDeviceId: 'remote' }] }]]);
const plan = buildPlayerAssetPlan(data, snapshots, players, [], service.collectPlaybackUploadReferences);
assert.deepEqual([...plan.assets.get('local')].sort(), ['/media/uploads/background.png', '/media/uploads/brand.png', '/media/uploads/local-copy.png']);
assert.deepEqual([...plan.assets.get('remote')], ['/media/uploads/remote-copy.png']);
const offline = buildPlayerAssetPlan(data, new Map(), players, plan.clients, service.collectPlaybackUploadReferences);
assert.deepEqual([...offline.assets.get('remote')], ['/media/uploads/remote-copy.png']);
const moved = buildPlayerAssetPlan({ ...data, onboardingDevices: [{ device_id: 'client-two', screen_id: 1 }] }, new Map(), players, plan.clients, service.collectPlaybackUploadReferences);
assert.ok(moved.assets.get('remote').has('/media/uploads/local-copy.png'));
assert.ok(!moved.assets.get('remote').has('/media/uploads/remote-copy.png'));
});
test('scoped sync sends only assigned playback files through direct and bridge transports and persists offline assignments', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-scoped-players-'));
const uploads = path.join(root, 'uploads');
fs.mkdirSync(uploads);
for (const name of ['local.png', 'remote.png', 'original.png', 'unassigned.png', 'remote.variant-2048.png']) fs.writeFileSync(path.join(uploads, name), name);
const players = [
{ identifier: 'local', internal_base_url: 'http://player:8081', public_base_url: 'http://local.test', last_seen_at: new Date() },
{ identifier: 'remote', internal_base_url: '', public_base_url: '', last_seen_at: new Date() }
];
const data = {
screens: [{ id: 1, slug: 'one', playlist_id: 1 }, { id: 2, slug: 'two', playlist_id: 2 }],
playlistSlides: [{ playlist_id: 1, slide_id: 1 }, { playlist_id: 2, slide_id: 2 }],
slides: [{ id: 1, content_json: { photo: { value: '/media/uploads/local.png', original_value: '/media/uploads/original.png' } } }, { id: 2, content_json: { photo: { value: '/media/uploads/remote.png', original_value: '/media/uploads/original.png' } } }], templates: []
};
const pool = { async query(sql) {
if (sql.includes('FROM d_players')) return [players];
if (sql.includes('FROM d_onboarding_devices')) return [[{ device_id: 'a', screen_id: 1 }, { device_id: 'b', screen_id: 2 }]];
return [[]];
} };
const calls = [];
const originalFetch = global.fetch;
let failTransfer = false;
global.fetch = async function (url, init) { calls.push({ url, method: init.method, player: init.headers['x-pulse-player-device-id'] }); return { ok: !(failTransfer && init.method === 'PUT'), status: failTransfer ? 503 : 200 }; };
const snapshots = new Map([['one', { connections: [{ deviceId: 'a', playerDeviceId: 'local' }] }], ['two', { connections: [{ deviceId: 'b', playerDeviceId: 'remote' }] }]]);
let service;
const options = { pool, common: { async fetchAdminData() { return data; } }, bridgeInternalBaseUrl: 'http://player-bridge:8090', playerSnapshotCache: snapshots, notifyPlayerScreens: async () => {}, backgroundTaskQueue: { async enqueueTaskAndWait(task) { await service.runMediaSyncTask(task.payload); } } };
try {
service = createUploadSyncService(options);
const savedSnapshots = [...snapshots];
snapshots.clear();
await service.runMediaSyncTask({ mode: 'playlist', playerIdentifier: 'remote', operation: { localUploadDir: uploads } });
assert.equal(calls.length, 0);
for (const [slug, snapshot] of savedSnapshots) snapshots.set(slug, snapshot);
failTransfer = true;
await assert.rejects(service.syncPlaylistUploadsOnChange({ key: 'slide:update:2', localUploadDir: uploads, nextUploadRefs: ['/media/uploads/remote.png'] }), /required playback asset/);
snapshots.delete('two');
failTransfer = false;
calls.length = 0;
service = createUploadSyncService(options);
await service.syncPlaylistUploadsOnChange({ key: 'slide:update:2', localUploadDir: uploads, nextUploadRefs: ['/media/uploads/remote.png'] });
assert.deepEqual(calls.filter(call => call.method === 'PUT'), [{ url: 'http://player-bridge:8090/api/media/uploads%2Fremote.png', method: 'PUT', player: 'remote' }]);
assert.ok(calls.some(call => call.method === 'DELETE' && call.url.endsWith('original.png')));
calls.length = 0;
await service.runMediaSyncTask({ mode: 'initial', uploadDir: root, playerIdentifier: 'local' });
assert.deepEqual(calls.filter(call => call.method === 'PUT'), [{ url: 'http://player:8081/api/media/uploads%2Flocal.png', method: 'PUT', player: 'local' }]);
snapshots.clear();
service = createUploadSyncService(options);
calls.length = 0;
await service.runMediaSyncTask({ mode: 'playlist', playerIdentifier: 'remote', operation: { localUploadDir: uploads } });
assert.deepEqual(calls.filter(call => call.method === 'PUT').map(call => call.url), ['http://player-bridge:8090/api/media/uploads%2Fremote.png']);
assert.equal(fs.existsSync(path.join(uploads, 'original.png')), true);
assert.equal(fs.existsSync(path.join(uploads, 'local.png')), true);
} finally {
global.fetch = originalFetch;
fs.rmSync(root, { recursive: true, force: true });
}
});
test('client-assignment sync refreshes its display after required media transfer', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-assignment-refresh-'));
const originalTimer = global.setTimeout;
let scheduled;
let queuedResolve;
const queued = new Promise(resolve => { queuedResolve = resolve; });
const service = createUploadSyncService({
pool: { async query(sql) { return sql.includes('FROM d_players') ? [[{ identifier: 'one', internal_base_url: 'http://player:8081', last_seen_at: new Date() }]] : [[]]; } },
common: { async fetchAdminData() { return { screens: [{ id: 1, slug: 'screen-one', playlist_id: 1 }], playlistSlides: [], slides: [], templates: [] }; } },
playerSnapshotCache: new Map([['screen-one', { connections: [{ deviceId: 'client-one', playerDeviceId: 'one' }] }]]),
notifyPlayerScreens: async () => {},
backgroundTaskQueue: { async enqueueTaskAndWait(task) { queuedResolve(task); } }
});
try {
global.setTimeout = callback => { scheduled = callback; return { unref() {} }; };
service.scheduleAssignmentSync(root);
global.setTimeout = originalTimer;
scheduled();
const task = await queued;
assert.deepEqual(task.payload.operation.refreshScreenSlugs, ['screen-one']);
} finally {
global.setTimeout = originalTimer;
fs.rmSync(root, { recursive: true, force: true });
}
});
test('explicit player refresh retries failed local media even when playlist refresh is already in flight', () => {
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../src/player/public/js/player-page-playback.js'), 'utf8');
const start = source.indexOf('function refresh(applyImmediately, bypassDebounce)');
const end = source.indexOf(' playlistRefreshInFlight = true;', start);
const failed = { complete: true, naturalWidth: 0, src: 'http://player.test/media/uploads/crop.png' };
const loaded = { complete: true, naturalWidth: 1280, src: 'http://player.test/media/uploads/loaded.png' };
const remote = { complete: true, naturalWidth: 0, src: 'https://other.test/image.png' };
const pending = { complete: false, naturalWidth: 0, src: 'http://player.test/media/uploads/pending.png' };
const refresh = vm.runInNewContext('(' + source.slice(start, end) + '\n})', { document: { querySelectorAll() { return [failed, loaded, remote, pending]; } }, window: { location: { href: 'http://player.test/screen/test', origin: 'http://player.test' } }, URL, Date, playlistRefreshInFlight: true });
refresh(true);
assert.match(failed.src, /crop\.png\?_mediaRefresh=\d+/);
assert.equal(loaded.src, 'http://player.test/media/uploads/loaded.png');
assert.equal(remote.src, 'https://other.test/image.png');
assert.equal(pending.src, 'http://player.test/media/uploads/pending.png');
});
test('player keeps an initial loading screen until slide images decode and cancels stale readiness waits', async () => {
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../src/player/public/js/player-page-media.js'), 'utf8');
const start = source.indexOf('async function waitForSlideImages(');
const images = [];
const loaders = [];
const context = vm.createContext({
Image: class { constructor() { images.push(this); } decode() { return Promise.resolve(); } },
document: { createElement(type) { return type === 'template' ? { content: { querySelectorAll() { return [{ getAttribute() { return '/media/uploads/crop.png'; } }]; } } } : { setAttribute() {} }; } },
app: { querySelector() { return loaders[0] || null; }, appendChild(element) { loaders.push(element); } },
window: { setTimeout() { return 1; }, clearTimeout() {}, location: { href: 'http://player.test/screen/test', origin: 'http://player.test' } },
lastRenderedViewKey: '', slideRenderRequestId: 1, URL, Date
});
vm.runInContext(source.slice(start), context);
const waiting = context.waitForSlideImages('<img src="/media/uploads/crop.png">', 1);
assert.equal(loaders.length, 1);
assert.match(loaders[0].innerHTML, /Loading media/);
assert.equal(images.length, 1);
images[0].naturalWidth = 1280;
await images[0].onload();
assert.equal(await waiting, '<img src="/media/uploads/crop.png">');
context.slideRenderRequestId = 2;
const stale = context.waitForSlideImages('<img src="/media/uploads/crop.png">', 2);
context.slideRenderRequestId = 3;
images[1].naturalWidth = 1280;
await images[1].onload();
assert.equal(await stale, false);
});
test('player watchdog does not reload while initial media is still loading', () => {
const vm = require('node:vm');
const source = fs.readFileSync(path.join(__dirname, '../src/player/public/js/player-page-transition.js'), 'utf8');
const start = source.indexOf('function startPlayerWatchdog()');
const end = source.indexOf('// Cancel the current slide-advance timer.', start);
let tick;
let reloaded = false;
const context = vm.createContext({ playerWatchdogTimer: null, playerLastProgressAt: 0, playerWatchdogRecoveryKey: 'check', isPaused: false, slides: [{}], lastRenderedSlide: {}, document: { visibilityState: 'visible', querySelector() { return {}; } }, window: { setInterval(callback) { tick = callback; return 1; }, sessionStorage: { getItem() { return '0'; }, setItem() {} }, location: { reload() { reloaded = true; } } }, Date, getSlideAdvanceDelay() { return 1000; } });
vm.runInContext(source.slice(start, end) + '\nstartPlayerWatchdog();', context);
tick();
assert.equal(reloaded, false);
context.document.querySelector = () => null;
tick();
assert.equal(reloaded, true);
});