Release 2.21.0: non-destructive media and scoped player sync
This commit is contained in:
1 parent
24df245ea1
commit
7cff39dc76
35 files changed
+2009
-949
No files matched your search
+627
-1
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user