473 lines
21 KiB
JavaScript
473 lines
21 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const vm = require('node:vm');
|
|
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 {
|
|
normalizeMediaPath,
|
|
createImageVariants,
|
|
removeImageVariants,
|
|
optimizeUploadedImage,
|
|
registerMediaAssets,
|
|
syncMediaAssetsFromDirectory,
|
|
countMediaAssetReferences
|
|
} = require('../src/web/lib/media/library');
|
|
|
|
test('media library normalizes only media URLs', () => {
|
|
assert.equal(normalizeMediaPath('/media/uploads/photo.png'), '/media/uploads/photo.png');
|
|
assert.equal(normalizeMediaPath(' /media/video.mp4 '), '/media/video.mp4');
|
|
assert.equal(normalizeMediaPath('/assets/photo.png'), null);
|
|
});
|
|
|
|
test('media library sorts Date-valued creation timestamps chronologically', async () => {
|
|
const originalSyncMediaAssets = mediaLibrary.syncMediaAssetsFromDirectory;
|
|
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
|
|
const assets = [
|
|
{ id: 1, original_name: 'October 14', media_type: 'image', file_size: 1, created_at: new Date('2026-10-14T12:00:00Z') },
|
|
{ id: 2, original_name: 'October 15', media_type: 'image', file_size: 1, created_at: new Date('2026-10-15T12:00:00Z') },
|
|
{ id: 3, original_name: 'October 17', media_type: 'image', file_size: 1, created_at: new Date('2026-10-17T12:00:00Z') }
|
|
];
|
|
const handlers = {};
|
|
mediaLibrary.syncMediaAssetsFromDirectory = async function () { return 0; };
|
|
mediaLibrary.countMediaAssetReferences = async function () { return 0; };
|
|
|
|
try {
|
|
registerMediaLibraryRoutes({
|
|
get(route, ...middleware) { handlers[route] = middleware.at(-1); },
|
|
post() {}
|
|
}, {
|
|
pool: { async query() { return [assets]; } },
|
|
pages: {},
|
|
upload: { single() { return function () {}; } },
|
|
uploadDir: os.tmpdir(),
|
|
requirePermission() { return function () {}; }
|
|
});
|
|
|
|
const response = { json(data) { this.data = data; } };
|
|
const next = function (error) { if (error) throw error; };
|
|
const request = { currentUser: { permissions: [] }, query: { offset: '0', limit: '24', sort: 'date-desc' } };
|
|
await handlers['/media-library/assets'](request, response, next);
|
|
assert.deepEqual(response.data.assets.map(asset => asset.original_name), ['October 17', 'October 15', 'October 14']);
|
|
|
|
request.query.sort = 'date-asc';
|
|
await handlers['/media-library/assets'](request, response, next);
|
|
assert.deepEqual(response.data.assets.map(asset => asset.original_name), ['October 14', 'October 15', 'October 17']);
|
|
} finally {
|
|
mediaLibrary.syncMediaAssetsFromDirectory = originalSyncMediaAssets;
|
|
mediaLibrary.countMediaAssetReferences = originalCountReferences;
|
|
}
|
|
});
|
|
|
|
test('media library optimizes large raster uploads in place', async () => {
|
|
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-optimize-'));
|
|
const filePath = path.join(uploadDir, 'large.jpg');
|
|
const sourceBuffer = await sharp(crypto.randomBytes(5000 * 100 * 3), {
|
|
raw: { width: 5000, height: 100, channels: 3 }
|
|
}).jpeg({ quality: 100 }).toBuffer();
|
|
await fs.promises.writeFile(filePath, sourceBuffer);
|
|
|
|
try {
|
|
const file = { path: filePath, size: sourceBuffer.length };
|
|
await optimizeUploadedImage(file, { maxDimension: 8192 });
|
|
const optimizedStats = await fs.promises.stat(filePath);
|
|
|
|
assert.equal(file.path, filePath);
|
|
assert.equal(file.size, optimizedStats.size);
|
|
assert.ok(optimizedStats.size < sourceBuffer.length);
|
|
const metadata = await sharp(filePath).metadata();
|
|
assert.equal(metadata.format, 'jpeg');
|
|
assert.equal(metadata.width, 5000);
|
|
} finally {
|
|
await fs.promises.rm(uploadDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('media library creates only smaller image variants and preserves the original', async () => {
|
|
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-variants-'));
|
|
const filePath = path.join(uploadDir, 'large.jpg');
|
|
const sourceBuffer = await sharp(crypto.randomBytes(5000 * 100 * 3), {
|
|
raw: { width: 5000, height: 100, channels: 3 }
|
|
}).jpeg({ quality: 100 }).toBuffer();
|
|
await fs.promises.writeFile(filePath, sourceBuffer);
|
|
|
|
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);
|
|
for (const variant of variants) {
|
|
const variantPath = path.join(uploadDir, path.basename(variant.path));
|
|
assert.ok((await fs.promises.stat(variantPath)).size < sourceBuffer.length);
|
|
}
|
|
await removeImageVariants(filePath);
|
|
assert.equal((await fs.promises.readdir(uploadDir)).filter(function (name) { return name.includes('.variant-'); }).length, 0);
|
|
} finally {
|
|
await fs.promises.rm(uploadDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('media library registers uploaded files with their metadata', async () => {
|
|
const queries = [];
|
|
const pool = {
|
|
async query(sql, params) {
|
|
queries.push({ sql, params });
|
|
return [{ affectedRows: 1 }];
|
|
}
|
|
};
|
|
const registered = await registerMediaAssets(pool, [{
|
|
filename: 'upload.png',
|
|
originalname: 'Photo.png',
|
|
mimetype: 'image/png',
|
|
size: 42
|
|
}], file => '/media/uploads/' + file.filename, () => 'image', 7);
|
|
|
|
assert.deepEqual(registered, ['/media/uploads/upload.png']);
|
|
assert.match(queries[0].sql, /INSERT INTO c_media_assets/);
|
|
assert.deepEqual(queries[0].params, [
|
|
'/media/uploads/upload.png',
|
|
'Photo.png',
|
|
'image',
|
|
'image/png',
|
|
42,
|
|
1,
|
|
7,
|
|
7
|
|
]);
|
|
});
|
|
|
|
test('media library backfills existing image and video uploads', async () => {
|
|
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-library-'));
|
|
await fs.promises.writeFile(path.join(uploadDir, 'legacy.png'), Buffer.from('image'));
|
|
await fs.promises.writeFile(path.join(uploadDir, 'legacy.mp4'), Buffer.from('video'));
|
|
await fs.promises.writeFile(path.join(uploadDir, 'ignore.txt'), Buffer.from('other'));
|
|
const queries = [];
|
|
const pool = {
|
|
async query(sql, params) {
|
|
queries.push({ sql, params });
|
|
return [{ affectedRows: 1 }];
|
|
}
|
|
};
|
|
|
|
try {
|
|
assert.equal(await syncMediaAssetsFromDirectory(pool, uploadDir), 2);
|
|
assert.deepEqual(queries.map(query => query.params[0]).sort(), [
|
|
'/media/uploads/legacy.mp4',
|
|
'/media/uploads/legacy.png'
|
|
]);
|
|
assert.match(queries[0].sql, /original_name = original_name/);
|
|
assert.match(queries[0].sql, /is_published = is_published/);
|
|
} finally {
|
|
await fs.promises.rm(uploadDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('media library counts slide and template references', async () => {
|
|
const queries = [];
|
|
const pool = {
|
|
async query(sql, params) {
|
|
queries.push({ sql, params });
|
|
if (sql.includes('FROM c_slides')) {
|
|
return [[{ ref_count: 2 }]];
|
|
}
|
|
return [[{ ref_count: 1 }]];
|
|
}
|
|
};
|
|
|
|
assert.equal(await countMediaAssetReferences(pool, '/media/uploads/shared.png'), 3);
|
|
assert.equal(queries.length, 2);
|
|
assert.equal(queries[0].params[0], '/media/uploads/shared.png');
|
|
assert.equal(queries[1].params[0], '/media/uploads/shared.png');
|
|
assert.match(queries[0].sql, /LOCATE/);
|
|
assert.match(queries[0].sql, /CAST\(COALESCE\(content_json, ''\) AS CHAR\)/);
|
|
});
|
|
|
|
test('media library upload and delete routes audit successful asset changes', async () => {
|
|
const originalCreateVariants = mediaLibrary.createImageVariants;
|
|
const originalRegisterAsset = mediaLibrary.registerMediaAsset;
|
|
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
|
|
const originalRemoveVariants = mediaLibrary.removeImageVariants;
|
|
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-audit-'));
|
|
const uploadDir = path.join(root, 'media', 'uploads');
|
|
const filePath = path.join(uploadDir, 'photo.png');
|
|
await fs.promises.mkdir(uploadDir, { recursive: true });
|
|
await fs.promises.writeFile(filePath, 'image');
|
|
const routeHandlers = {};
|
|
const events = [];
|
|
const app = {
|
|
get(route, ...handlers) { routeHandlers['GET ' + route] = handlers; },
|
|
post(route, ...handlers) { routeHandlers['POST ' + route] = handlers; }
|
|
};
|
|
const pool = {
|
|
async query(sql) {
|
|
if (sql.includes('FROM o_app_settings')) return [[]];
|
|
if (sql.includes('FROM c_media_assets')) {
|
|
return [[{ id: 9, media_path: '/media/uploads/photo.png', original_name: 'Photo.png', media_type: 'image', file_size: 5 }]];
|
|
}
|
|
return [[]];
|
|
}
|
|
};
|
|
|
|
mediaLibrary.createImageVariants = async function () {};
|
|
mediaLibrary.registerMediaAsset = async function () {};
|
|
mediaLibrary.countMediaAssetReferences = async function () { return 0; };
|
|
mediaLibrary.removeImageVariants = async function () {};
|
|
|
|
try {
|
|
registerMediaLibraryRoutes(app, {
|
|
pool: pool,
|
|
pages: {},
|
|
upload: { single() { return function (_req, _res, next) { next(); }; } },
|
|
uploadDir: uploadDir,
|
|
getAuditUserId(req) { return req.currentUser.id; },
|
|
recordRequestAuditEvent(_pool, _req, event) { events.push(event); },
|
|
requirePermission() { return function (_req, _res, next) { next(); }; }
|
|
});
|
|
const response = { redirect(value) { this.location = value; return this; } };
|
|
const next = function (error) { if (error) throw error; };
|
|
const request = {
|
|
currentUser: { id: 4 },
|
|
body: {},
|
|
file: { path: filePath, filename: 'photo.png', originalname: 'Photo.png', mimetype: 'image/png', size: 5 }
|
|
};
|
|
|
|
await routeHandlers['POST /media-library'].at(-1)(request, response, next);
|
|
await routeHandlers['POST /media-library/:id/delete'].at(-1)(
|
|
{ currentUser: { id: 4 }, params: { id: '9' } }, response, next
|
|
);
|
|
|
|
assert.deepEqual(events.map(function (event) { return event.eventType; }), ['media.uploaded', 'media.deleted']);
|
|
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; }));
|
|
} finally {
|
|
mediaLibrary.createImageVariants = originalCreateVariants;
|
|
mediaLibrary.registerMediaAsset = originalRegisterAsset;
|
|
mediaLibrary.countMediaAssetReferences = originalCountReferences;
|
|
mediaLibrary.removeImageVariants = originalRemoveVariants;
|
|
await fs.promises.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('media library delete redirects preserve filters for individual and bulk outcomes', async () => {
|
|
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
|
|
const originalRemoveVariants = mediaLibrary.removeImageVariants;
|
|
const handlers = {};
|
|
let outcome = 'deleted';
|
|
const query = { search: 'Photo & Video', type: 'image', usage: 'unused', sort: 'name-desc' };
|
|
mediaLibrary.countMediaAssetReferences = async function () { return outcome === 'in-use' ? 1 : 0; };
|
|
mediaLibrary.removeImageVariants = async function () {};
|
|
try {
|
|
registerMediaLibraryRoutes({
|
|
get() {},
|
|
post(route, ...middleware) { handlers[route] = middleware.at(-1); }
|
|
}, {
|
|
pool: {
|
|
async query(sql) {
|
|
if (sql.startsWith('SELECT')) {
|
|
return [outcome === 'missing' ? [] : [{ id: 9, media_path: '/media/uploads/missing-test.png' }]];
|
|
}
|
|
return [[]];
|
|
}
|
|
},
|
|
pages: {},
|
|
upload: { single() { return function () {}; } },
|
|
uploadDir: path.join(os.tmpdir(), 'pulse-media-filter-test', 'uploads'),
|
|
requirePermission() { return function () {}; }
|
|
});
|
|
for (const route of ['/media-library/:id/delete', '/media-library/delete']) {
|
|
for (outcome of ['deleted', 'in-use', 'missing', 'empty']) {
|
|
let location;
|
|
await handlers[route]({ query, params: { id: '9' }, body: { 'assetIds[]': outcome === 'empty' ? [] : ['9'] } }, {
|
|
redirect(value) { location = value; }
|
|
}, function (error) { throw error; });
|
|
const redirect = new URL(location, 'http://localhost');
|
|
assert.equal(redirect.pathname, '/media-library');
|
|
for (const [key, value] of Object.entries(query)) {
|
|
assert.equal(redirect.searchParams.get(key), value, route + ': ' + outcome + ': ' + key);
|
|
}
|
|
assert.ok(redirect.searchParams.get('message'));
|
|
}
|
|
}
|
|
} finally {
|
|
mediaLibrary.countMediaAssetReferences = originalCountReferences;
|
|
mediaLibrary.removeImageVariants = originalRemoveVariants;
|
|
}
|
|
});
|
|
|
|
test('media gallery restores controls and submits current filters with both delete actions', () => {
|
|
const listeners = {};
|
|
const controls = {};
|
|
for (const [name, values] of Object.entries({
|
|
search: [''], 'type-filter': ['all', 'image', 'video'],
|
|
'usage-filter': ['all', 'used', 'unused'], sort: ['date-desc', 'name-desc', 'size-asc']
|
|
})) {
|
|
controls['[data-media-gallery-' + name + ']'] = {
|
|
value: values[0], options: values.map(value => ({ value })), addEventListener() {}
|
|
};
|
|
}
|
|
const gallery = {
|
|
dataset: {}, querySelector(selector) { return controls[selector] || null; },
|
|
querySelectorAll() { return []; },
|
|
addEventListener(name, callback) { listeners[name] = callback; }
|
|
};
|
|
let submittedForm;
|
|
const context = vm.createContext({
|
|
URLSearchParams,
|
|
window: { location: { search: '?search=Photo+%26+Video&type=image&usage=unused&sort=name-desc' }, innerWidth: 1200, confirm() { return true; } },
|
|
document: {
|
|
getElementById() { return null; },
|
|
querySelector(selector) { return selector === '.media-gallery' ? gallery : null; },
|
|
createElement() { return { appendChild() {}, submit() { submittedForm = this; } }; },
|
|
body: { appendChild() {} }
|
|
}
|
|
});
|
|
vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/media-library.js'), 'utf8'), context);
|
|
assert.equal(controls['[data-media-gallery-search]'].value, 'Photo & Video');
|
|
assert.equal(controls['[data-media-gallery-type-filter]'].value, 'image');
|
|
assert.equal(controls['[data-media-gallery-usage-filter]'].value, 'unused');
|
|
assert.equal(controls['[data-media-gallery-sort]'].value, 'name-desc');
|
|
controls['[data-media-gallery-search]'].value = 'Updated & current';
|
|
controls['[data-media-gallery-sort]'].value = 'size-asc';
|
|
const form = { getAttribute() { return '/media-library/9/delete'; } };
|
|
listeners.submit({ target: form });
|
|
vm.runInContext("selectedAssetIds.add('9'); submitBulkDelete();", context);
|
|
for (const target of [form, submittedForm]) {
|
|
const url = new URL(target.action, 'http://localhost');
|
|
assert.equal(url.searchParams.get('search'), 'Updated & current');
|
|
assert.equal(url.searchParams.get('type'), 'image');
|
|
assert.equal(url.searchParams.get('usage'), 'unused');
|
|
assert.equal(url.searchParams.get('sort'), 'size-asc');
|
|
assert.equal(url.searchParams.has('offset'), false);
|
|
}
|
|
});
|
|
|
|
test('empty media gallery retains filters and its refreshable results container', () => {
|
|
const renderMediaLibraryPage = require('../src/web/routes/settings/media-library/list');
|
|
const html = renderMediaLibraryPage({ assets: [], assetTotal: 0 }, '', { permissions: ['media-library.read', 'media-library.delete'] });
|
|
assert.match(html, /data-media-gallery-search/);
|
|
assert.match(html, /data-media-gallery-type-filter/);
|
|
assert.match(html, /data-media-gallery-usage-filter/);
|
|
assert.match(html, /data-media-gallery-sort/);
|
|
assert.match(html, /class="media-gallery-scroll"/);
|
|
assert.match(html, /data-media-gallery-no-results>/);
|
|
});
|
|
|
|
test('media rename validates filenames and changes only metadata with update permission and auditing', async () => {
|
|
const handlers = {};
|
|
const queries = [];
|
|
const events = [];
|
|
const permissions = [];
|
|
let exists = true;
|
|
registerMediaLibraryRoutes({
|
|
get() {}, post(route, ...middleware) { handlers[route] = middleware.at(-1); }
|
|
}, {
|
|
pool: {
|
|
async query(sql, params) {
|
|
queries.push({ sql, params });
|
|
return [sql.startsWith('SELECT') && exists ? [{ id: 9, original_name: 'old.png', media_path: '/media/uploads/uuid.png' }] : []];
|
|
}
|
|
}, pages: {}, upload: { single() { return function () {}; } }, uploadDir: os.tmpdir(),
|
|
getAuditUserId() { return 4; },
|
|
requirePermission(permission) { permissions.push(permission); return function () {}; },
|
|
recordRequestAuditEvent(_pool, _req, event) { events.push(event); }
|
|
});
|
|
assert.ok(permissions.includes('media-library.update'));
|
|
const rename = handlers['/media-library/:id/rename'];
|
|
const response = { statusCode: 200, status(code) { this.statusCode = code; return this; }, json(data) { this.data = data; } };
|
|
const next = function (error) { throw error; };
|
|
for (const name of ['', ' ', '../photo.png', 'a\\b.png', 'a\n.png', 'a?b.png', 'a'.repeat(256), ['photo.png'], '..']) {
|
|
await rename({ params: { id: '9' }, body: { name } }, response, next);
|
|
assert.equal(response.statusCode, 400);
|
|
}
|
|
assert.equal(queries.length, 0);
|
|
response.statusCode = 200;
|
|
await rename({ params: { id: '9' }, body: { name: ' New photo.png ' } }, response, next);
|
|
assert.deepEqual(response.data, { id: 9, name: 'New photo.png' });
|
|
assert.deepEqual(queries[1], {
|
|
sql: 'UPDATE c_media_assets SET original_name = ?, modified_by = ? WHERE id = ?', params: ['New photo.png', 4, 9]
|
|
});
|
|
assert.equal(events[0].eventType, 'media.renamed');
|
|
assert.deepEqual(events[0].details, { mediaPath: '/media/uploads/uuid.png', previousName: 'old.png', name: 'New photo.png' });
|
|
await rename({ params: { id: '9' }, body: { name: 'old.png' } }, response, next);
|
|
assert.equal(queries.filter(query => query.sql.startsWith('UPDATE')).length, 1);
|
|
exists = false;
|
|
await rename({ params: { id: '9' }, body: { name: 'missing.png' } }, response, next);
|
|
assert.equal(response.statusCode, 404);
|
|
});
|
|
|
|
test('media preview offers filename editing only with media update permission', () => {
|
|
const renderMediaLibraryPage = require('../src/web/routes/settings/media-library/list');
|
|
const data = { assets: [], assetTotal: 0 };
|
|
const readOnly = renderMediaLibraryPage(data, '', { permissions: ['media-library.read'] });
|
|
assert.doesNotMatch(readOnly, /data-media-gallery-rename-form/);
|
|
const editable = renderMediaLibraryPage(data, '', { permissions: ['media-library.read', 'media-library.update'] });
|
|
assert.match(editable, /data-media-gallery-rename-form/);
|
|
assert.match(editable, /maxlength="255" required data-media-gallery-filename/);
|
|
assert.match(editable, /data-media-gallery-extension/);
|
|
assert.match(editable, /data-media-gallery-rename-save/);
|
|
});
|
|
|
|
test('media preview rename keeps the original filename extension fixed', async () => {
|
|
const handlers = {};
|
|
const submittedNames = [];
|
|
const input = { value: '', disabled: false, maxLength: 255 };
|
|
const extension = { textContent: '' };
|
|
const title = { textContent: '' };
|
|
const body = { replaceChildren() {}, appendChild() {}, querySelector() { return null; } };
|
|
const renameForm = { action: '', addEventListener(name, callback) { handlers['form:' + name] = callback; } };
|
|
const renameSave = { disabled: false };
|
|
const renameStatus = { hidden: true, classList: { add() {}, remove() {} } };
|
|
const modal = {
|
|
querySelector(selector) {
|
|
return {
|
|
'[data-media-gallery-preview-body]': body,
|
|
'#media-gallery-preview-modal-label': title,
|
|
'[data-media-gallery-rename-form]': renameForm,
|
|
'[data-media-gallery-filename]': input,
|
|
'[data-media-gallery-extension]': extension,
|
|
'[data-media-gallery-rename-save]': renameSave,
|
|
'[data-media-gallery-rename-status]': renameStatus
|
|
}[selector] || null;
|
|
},
|
|
addEventListener(name, callback) { handlers['modal:' + name] = callback; }
|
|
};
|
|
const column = { dataset: { mediaId: '9' }, querySelector() { return null; } };
|
|
const preview = {
|
|
dataset: { mediaName: 'original.png' },
|
|
getAttribute(name) { return { 'data-media-path': '/media/uploads/id.png', 'data-media-type': 'image', 'data-media-name': 'original.png' }[name]; },
|
|
closest() { return column; },
|
|
setAttribute() {}
|
|
};
|
|
const context = vm.createContext({
|
|
URLSearchParams,
|
|
window: { location: { search: '' }, innerWidth: 1200, pulseModal: { show() {} } },
|
|
document: {
|
|
getElementById() { return modal; },
|
|
querySelector() { return null; },
|
|
addEventListener(name, callback) { handlers['document:' + name] = callback; },
|
|
createElement() { return {}; }
|
|
},
|
|
fetch: async (_url, options) => {
|
|
if (options && options.body) {
|
|
submittedNames.push(JSON.parse(options.body).name);
|
|
return { ok: true, async json() { return { name: 'updated.png' }; } };
|
|
}
|
|
return { ok: true, async json() { return { assets: [], nextOffset: 0, hasMore: false, total: 0 }; } };
|
|
}
|
|
});
|
|
vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/media-library.js'), 'utf8'), context);
|
|
|
|
handlers['document:click']({ target: { closest() { return preview; } } });
|
|
assert.equal(input.value, 'original');
|
|
assert.equal(extension.textContent, '.png');
|
|
assert.equal(input.maxLength, 251);
|
|
input.value = 'updated';
|
|
await handlers['form:submit']({ preventDefault() {} });
|
|
assert.deepEqual(submittedNames, ['updated.png']);
|
|
});
|