kempo-media 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +1 -0
- package/.gitattributes +5 -0
- package/.github/workflows/publish.yml +85 -0
- package/.github/workflows/test.yml +123 -0
- package/CHANGELOG.md +25 -0
- package/README.md +114 -0
- package/admin/asset-picker.global.html +63 -0
- package/admin/components/MediaLibrary.js +304 -0
- package/admin/icons/archive.svg +1 -0
- package/admin/icons/audio.svg +1 -0
- package/admin/icons/image.svg +1 -0
- package/admin/icons/model3d.svg +1 -0
- package/admin/icons/video.svg +1 -0
- package/admin/index.page.html +35 -0
- package/admin/nav.global.html +3 -0
- package/docker-compose.yml +22 -0
- package/drizzle.config.js +26 -0
- package/install.js +18 -0
- package/kempo-config.json +18 -0
- package/package.json +39 -0
- package/public/api/media/DELETE.js +21 -0
- package/public/api/media/GET.js +38 -0
- package/public/api/media/PATCH.js +18 -0
- package/public/api/media/POST.js +57 -0
- package/public/sdk.js +62 -0
- package/scripts/build.js +0 -0
- package/sdk.js +15 -0
- package/server/db/schema.js +18 -0
- package/server/utils/media/createMediaAsset.js +19 -0
- package/server/utils/media/deleteMediaAsset.js +37 -0
- package/server/utils/media/getMediaAsset.js +14 -0
- package/server/utils/media/getMediaAssets.js +39 -0
- package/server/utils/media/updateMediaAsset.js +32 -0
- package/server/utils/uploads/fileTypes.js +50 -0
- package/server/utils/uploads/generateThumbnail.js +63 -0
- package/server/utils/uploads/parseMultipart.js +99 -0
- package/server/utils/uploads/storeUpload.js +89 -0
- package/tests/browser-fixtures.node-test.js +61 -0
- package/tests/browser-modules-parse.node-test.js +62 -0
- package/tests/components/MediaLibrary.browser-test.js +412 -0
- package/tests/fixtures/kempo-media-sdk-mock/sdk.js +16 -0
- package/tests/media-api-contract.node-test.js +363 -0
- package/tests/media-data-layer.node-test.js +268 -0
- package/tests/module-graph.node-test.js +86 -0
- package/tests/package-invariants.node-test.js +247 -0
- package/tests/permission-name-consistency.node-test.js +102 -0
- package/tests/uploads.node-test.js +255 -0
- package/update.js +3 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import MediaLibrary from '../../admin/components/MediaLibrary.js';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
MediaLibrary.js talks to /kempo-media/sdk.js by absolute URL, which
|
|
5
|
+
tests/browser-fixtures.node-test.js points at tests/fixtures/kempo-media-sdk-mock/sdk.js instead
|
|
6
|
+
of the real one — that fake delegates every call to window.__mediaSdkMock, which each test below
|
|
7
|
+
installs before rendering the component. This is the same "swap the boundary hook" shape as
|
|
8
|
+
kempo-ui's own window.kempo.openAssetPicker tests, just applied to a module import instead of a
|
|
9
|
+
global.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const PAGE_SIZE = 24;
|
|
13
|
+
|
|
14
|
+
const flush = (ms = 0) => new Promise(r => setTimeout(r, ms));
|
|
15
|
+
|
|
16
|
+
const fixtureAsset = (overrides = {}) => ({
|
|
17
|
+
id: 'asset1',
|
|
18
|
+
filename: 'a1b2c3d4.png',
|
|
19
|
+
originalName: 'photo.png',
|
|
20
|
+
mimeType: 'image/png',
|
|
21
|
+
kind: 'image',
|
|
22
|
+
sizeBytes: 2048,
|
|
23
|
+
path: 'media/uploads/a1b2c3d4.png',
|
|
24
|
+
thumbnailPath: 'media/thumbnails/a1b2c3d4.png',
|
|
25
|
+
altText: '',
|
|
26
|
+
width: 800,
|
|
27
|
+
height: 600,
|
|
28
|
+
uploadedBy: 'user1',
|
|
29
|
+
createdAt: new Date().toISOString(),
|
|
30
|
+
updatedAt: new Date().toISOString(),
|
|
31
|
+
...overrides,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const defaultMock = () => ({
|
|
35
|
+
getMediaAssets: async () => [null, { assets: [], total: 0 }],
|
|
36
|
+
uploadMedia: async () => [null, {}],
|
|
37
|
+
deleteMediaAsset: async () => [null, {}],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const createLibrary = async (attrs = {}, mockOverrides = {}) => {
|
|
41
|
+
window.__mediaSdkMock = { ...defaultMock(), ...mockOverrides };
|
|
42
|
+
|
|
43
|
+
const container = document.createElement('div');
|
|
44
|
+
const el = document.createElement('kempo-media-library');
|
|
45
|
+
if(attrs.selectable) el.selectable = true;
|
|
46
|
+
if(attrs.canUpload) el.setAttribute('can-upload', '');
|
|
47
|
+
if(attrs.canDelete) el.setAttribute('can-delete', '');
|
|
48
|
+
if(attrs.kind) el.kind = attrs.kind;
|
|
49
|
+
container.appendChild(el);
|
|
50
|
+
document.body.appendChild(container);
|
|
51
|
+
|
|
52
|
+
await el.updateComplete;
|
|
53
|
+
await flush(20); // let the connectedCallback-triggered load() resolve and re-render
|
|
54
|
+
await el.updateComplete;
|
|
55
|
+
|
|
56
|
+
return { container, el };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const cleanup = container => {
|
|
60
|
+
if(container?.parentNode) container.parentNode.removeChild(container);
|
|
61
|
+
delete window.__mediaSdkMock;
|
|
62
|
+
// Toasts/dialogs default to a 5s auto-close and outlive a single test otherwise
|
|
63
|
+
document.querySelectorAll('k-toast-container').forEach(c => c.remove());
|
|
64
|
+
document.querySelectorAll('k-dialog').forEach(d => d.remove());
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export default {
|
|
68
|
+
'creates a kempo-media-library element': async ({ pass, fail }) => {
|
|
69
|
+
const { container, el } = await createLibrary();
|
|
70
|
+
const ok = el instanceof MediaLibrary;
|
|
71
|
+
cleanup(container);
|
|
72
|
+
return ok ? pass('MediaLibrary created') : fail('element is not a MediaLibrary instance');
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
'loads assets on connect with default paging': async ({ pass, fail }) => {
|
|
76
|
+
let received = null;
|
|
77
|
+
const { container, el } = await createLibrary({}, {
|
|
78
|
+
getMediaAssets: async params => {
|
|
79
|
+
received = params;
|
|
80
|
+
return [null, { assets: [fixtureAsset(), fixtureAsset({ id: 'asset2' })], total: 2 }];
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const tiles = el.shadowRoot.querySelectorAll('.grid .tile').length;
|
|
84
|
+
cleanup(container);
|
|
85
|
+
if(!received) return fail('getMediaAssets was never called');
|
|
86
|
+
if(received.limit !== PAGE_SIZE) return fail(`limit was ${received.limit}, expected ${PAGE_SIZE}`);
|
|
87
|
+
if(received.offset !== 0) return fail(`offset was ${received.offset}, expected 0`);
|
|
88
|
+
if(received.kind !== undefined) return fail(`kind was ${received.kind}, expected undefined with no filter set`);
|
|
89
|
+
if(received.search !== undefined) return fail(`search was ${received.search}, expected undefined with no filter set`);
|
|
90
|
+
if(tiles !== 2) return fail(`rendered ${tiles} tiles, expected 2`);
|
|
91
|
+
pass('loaded with default paging');
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
'shows an empty message when the library has no assets': async ({ pass, fail }) => {
|
|
95
|
+
const { container, el } = await createLibrary();
|
|
96
|
+
const empty = el.shadowRoot.querySelector('.empty')?.textContent || '';
|
|
97
|
+
cleanup(container);
|
|
98
|
+
return empty.includes('No media yet.') ? pass('empty state shown') : fail(`unexpected empty text: ${empty}`);
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
'shows a different empty message when a filter matches nothing': async ({ pass, fail }) => {
|
|
102
|
+
const { container, el } = await createLibrary({}, {
|
|
103
|
+
getMediaAssets: async () => [null, { assets: [], total: 0 }],
|
|
104
|
+
});
|
|
105
|
+
el.search = 'nothing-matches-this';
|
|
106
|
+
el.offset = 0;
|
|
107
|
+
await el.load();
|
|
108
|
+
await el.updateComplete;
|
|
109
|
+
const empty = el.shadowRoot.querySelector('.empty')?.textContent || '';
|
|
110
|
+
cleanup(container);
|
|
111
|
+
return empty.includes('Nothing matches that filter.') ? pass('filtered empty state shown') : fail(`unexpected empty text: ${empty}`);
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
'a load failure shows a toast and leaves the grid empty': async ({ pass, fail }) => {
|
|
115
|
+
const { container, el } = await createLibrary({}, {
|
|
116
|
+
getMediaAssets: async () => [{ code: 500, msg: 'boom' }, null],
|
|
117
|
+
});
|
|
118
|
+
const toast = document.querySelector('k-toast.bg-danger');
|
|
119
|
+
const grid = el.shadowRoot.querySelector('.grid');
|
|
120
|
+
cleanup(container);
|
|
121
|
+
if(!toast) return fail('no error toast was shown');
|
|
122
|
+
if(grid) return fail('grid was rendered despite a load failure');
|
|
123
|
+
pass('error surfaced via toast');
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
'search is debounced to one request carrying the last-typed value': async ({ pass, fail }) => {
|
|
127
|
+
const calls = [];
|
|
128
|
+
const { container, el } = await createLibrary({}, {
|
|
129
|
+
getMediaAssets: async params => { calls.push(params.search); return [null, { assets: [], total: 0 }]; },
|
|
130
|
+
});
|
|
131
|
+
const before = calls.length;
|
|
132
|
+
|
|
133
|
+
el.handleSearch({ target: { value: 'k' } });
|
|
134
|
+
el.handleSearch({ target: { value: 'ke' } });
|
|
135
|
+
el.handleSearch({ target: { value: 'kem' } });
|
|
136
|
+
await flush(400);
|
|
137
|
+
|
|
138
|
+
const after = calls.slice(before);
|
|
139
|
+
cleanup(container);
|
|
140
|
+
if(after.length !== 1) return fail(`expected exactly 1 debounced request, got ${after.length}: ${JSON.stringify(after)}`);
|
|
141
|
+
if(after[0] !== 'kem') return fail(`debounced request carried "${after[0]}", expected "kem"`);
|
|
142
|
+
pass('debounced to the last value');
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
'pagination requests the next and previous offsets': async ({ pass, fail }) => {
|
|
146
|
+
const calls = [];
|
|
147
|
+
const { container, el } = await createLibrary({}, {
|
|
148
|
+
getMediaAssets: async params => { calls.push(params.offset); return [null, { assets: [fixtureAsset()], total: 50 }]; },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
el.goTo(PAGE_SIZE);
|
|
152
|
+
await flush(20);
|
|
153
|
+
el.goTo(0);
|
|
154
|
+
await flush(20);
|
|
155
|
+
|
|
156
|
+
cleanup(container);
|
|
157
|
+
if(calls.length !== 3) return fail(`expected 3 loads (initial + 2 goTo), got ${calls.length}`);
|
|
158
|
+
if(calls[1] !== PAGE_SIZE) return fail(`goTo(${PAGE_SIZE}) requested offset ${calls[1]}`);
|
|
159
|
+
if(calls[2] !== 0) return fail(`goTo(0) requested offset ${calls[2]}`);
|
|
160
|
+
pass('paginated correctly');
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
'goTo never requests a negative offset': async ({ pass, fail }) => {
|
|
164
|
+
const { container, el } = await createLibrary();
|
|
165
|
+
el.goTo(-PAGE_SIZE);
|
|
166
|
+
await flush(20);
|
|
167
|
+
cleanup(container);
|
|
168
|
+
return el.offset === 0 ? pass('clamped to 0') : fail(`offset was ${el.offset}`);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
'pager buttons are disabled at the first and last page': async ({ pass, fail }) => {
|
|
172
|
+
const { container, el } = await createLibrary({}, {
|
|
173
|
+
getMediaAssets: async () => [null, { assets: [fixtureAsset()], total: 50 }], // 3 pages
|
|
174
|
+
});
|
|
175
|
+
const prevAtStart = el.shadowRoot.querySelector('.pager button:first-child').disabled;
|
|
176
|
+
|
|
177
|
+
el.goTo(PAGE_SIZE * 2); // last page
|
|
178
|
+
await flush(20);
|
|
179
|
+
await el.updateComplete;
|
|
180
|
+
const nextAtEnd = el.shadowRoot.querySelector('.pager button:last-child').disabled;
|
|
181
|
+
|
|
182
|
+
cleanup(container);
|
|
183
|
+
if(!prevAtStart) return fail('Previous was enabled on the first page');
|
|
184
|
+
if(!nextAtEnd) return fail('Next was enabled on the last page');
|
|
185
|
+
pass('pager boundaries respected');
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
'uploads run one at a time, not in parallel': async ({ pass, fail }) => {
|
|
189
|
+
const started = [];
|
|
190
|
+
const pendingResolvers = [];
|
|
191
|
+
const uploadMedia = file => new Promise(resolve => {
|
|
192
|
+
started.push(file.name);
|
|
193
|
+
pendingResolvers.push(() => resolve([null, {}]));
|
|
194
|
+
});
|
|
195
|
+
const { container, el } = await createLibrary({ canUpload: true }, { uploadMedia });
|
|
196
|
+
|
|
197
|
+
const files = [{ name: 'a.png' }, { name: 'b.png' }, { name: 'c.png' }];
|
|
198
|
+
const done = el.uploadFiles(files);
|
|
199
|
+
await flush(20);
|
|
200
|
+
const startedBeforeAnyResolve = started.length;
|
|
201
|
+
|
|
202
|
+
pendingResolvers.shift()(); // resolve a.png
|
|
203
|
+
await flush(20);
|
|
204
|
+
const startedAfterFirstResolve = started.length;
|
|
205
|
+
|
|
206
|
+
pendingResolvers.shift()(); // resolve b.png
|
|
207
|
+
await flush(20);
|
|
208
|
+
pendingResolvers.shift()(); // resolve c.png
|
|
209
|
+
await done;
|
|
210
|
+
|
|
211
|
+
cleanup(container);
|
|
212
|
+
if(startedBeforeAnyResolve !== 1) return fail(`expected only 1 upload started before any resolved, got ${startedBeforeAnyResolve}`);
|
|
213
|
+
if(startedAfterFirstResolve !== 2) return fail(`expected b.png to start only after a.png resolved, got ${startedAfterFirstResolve} started`);
|
|
214
|
+
if(started.join(',') !== 'a.png,b.png,c.png') return fail(`wrong order: ${started.join(',')}`);
|
|
215
|
+
pass('uploaded sequentially');
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
'upload progress is tracked per file': async ({ pass, fail }) => {
|
|
219
|
+
let resolveUpload;
|
|
220
|
+
const uploadMedia = (file, opts, onProgress) => {
|
|
221
|
+
onProgress(0.5);
|
|
222
|
+
return new Promise(resolve => { resolveUpload = () => { onProgress(1); resolve([null, {}]); }; });
|
|
223
|
+
};
|
|
224
|
+
const { container, el } = await createLibrary({ canUpload: true }, { uploadMedia });
|
|
225
|
+
|
|
226
|
+
const done = el.uploadFiles([{ name: 'a.png' }]);
|
|
227
|
+
await flush(20);
|
|
228
|
+
const midProgress = el.uploads[0]?.progress;
|
|
229
|
+
|
|
230
|
+
resolveUpload();
|
|
231
|
+
await done;
|
|
232
|
+
|
|
233
|
+
cleanup(container);
|
|
234
|
+
if(midProgress !== 0.5) return fail(`expected mid-upload progress 0.5, got ${midProgress}`);
|
|
235
|
+
pass('progress tracked');
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
'a failed upload is flagged but does not stop the rest, and the library still reloads': async ({ pass, fail }) => {
|
|
239
|
+
let loadCount = 0;
|
|
240
|
+
const uploadMedia = file => file.name === 'bad.png'
|
|
241
|
+
? Promise.resolve([{ code: 400, msg: 'rejected' }, null])
|
|
242
|
+
: Promise.resolve([null, {}]);
|
|
243
|
+
const { container, el } = await createLibrary({ canUpload: true }, {
|
|
244
|
+
uploadMedia,
|
|
245
|
+
getMediaAssets: async () => { loadCount++; return [null, { assets: [], total: 0 }]; },
|
|
246
|
+
});
|
|
247
|
+
const loadsBeforeUpload = loadCount;
|
|
248
|
+
|
|
249
|
+
await el.uploadFiles([{ name: 'bad.png' }, { name: 'good.png' }]);
|
|
250
|
+
|
|
251
|
+
const toast = document.querySelector('k-toast.bg-danger');
|
|
252
|
+
cleanup(container);
|
|
253
|
+
if(!toast) return fail('no error toast shown for the failed file');
|
|
254
|
+
if(loadCount !== loadsBeforeUpload + 1) return fail(`expected exactly 1 reload after uploadFiles, got ${loadCount - loadsBeforeUpload}`);
|
|
255
|
+
pass('failure isolated to one file, library still reloaded');
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
'delete asks for confirmation before removing anything': async ({ pass, fail }) => {
|
|
259
|
+
let deleteCalled = false;
|
|
260
|
+
const { container, el } = await createLibrary({ canDelete: true }, {
|
|
261
|
+
getMediaAssets: async () => [null, { assets: [fixtureAsset()], total: 1 }],
|
|
262
|
+
deleteMediaAsset: async () => { deleteCalled = true; return [null, {}]; },
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const asset = fixtureAsset();
|
|
266
|
+
el.handleDelete(asset, { stopPropagation() {} });
|
|
267
|
+
await flush(20);
|
|
268
|
+
const calledBeforeConfirm = deleteCalled;
|
|
269
|
+
|
|
270
|
+
const dialog = document.querySelector('k-dialog');
|
|
271
|
+
cleanup(container);
|
|
272
|
+
if(!dialog) return fail('handleDelete did not open a confirmation dialog');
|
|
273
|
+
if(calledBeforeConfirm) return fail('deleteMediaAsset was called before the dialog was confirmed');
|
|
274
|
+
pass('confirmation required');
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
'confirming delete removes the asset and reloads': async ({ pass, fail }) => {
|
|
278
|
+
let deletedId = null;
|
|
279
|
+
let loadCount = 0;
|
|
280
|
+
const { container, el } = await createLibrary({ canDelete: true }, {
|
|
281
|
+
getMediaAssets: async () => { loadCount++; return [null, { assets: [fixtureAsset()], total: 1 }]; },
|
|
282
|
+
deleteMediaAsset: async id => { deletedId = id; return [null, {}]; },
|
|
283
|
+
});
|
|
284
|
+
const loadsBeforeDelete = loadCount;
|
|
285
|
+
|
|
286
|
+
el.handleDelete(fixtureAsset(), { stopPropagation() {} });
|
|
287
|
+
await flush(20);
|
|
288
|
+
document.querySelector('k-dialog')?.confirmAction();
|
|
289
|
+
await flush(20);
|
|
290
|
+
|
|
291
|
+
cleanup(container);
|
|
292
|
+
if(deletedId !== 'asset1') return fail(`deleteMediaAsset called with ${deletedId}, expected asset1`);
|
|
293
|
+
if(loadCount !== loadsBeforeDelete + 1) return fail('library did not reload after a successful delete');
|
|
294
|
+
pass('deleted and reloaded');
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
'declining the confirmation deletes nothing': async ({ pass, fail }) => {
|
|
298
|
+
let deleteCalled = false;
|
|
299
|
+
const { container, el } = await createLibrary({ canDelete: true }, {
|
|
300
|
+
getMediaAssets: async () => [null, { assets: [fixtureAsset()], total: 1 }],
|
|
301
|
+
deleteMediaAsset: async () => { deleteCalled = true; return [null, {}]; },
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
el.handleDelete(fixtureAsset(), { stopPropagation() {} });
|
|
305
|
+
await flush(20);
|
|
306
|
+
document.querySelector('k-dialog')?.cancelAction();
|
|
307
|
+
await flush(20);
|
|
308
|
+
|
|
309
|
+
cleanup(container);
|
|
310
|
+
return deleteCalled ? fail('deleteMediaAsset was called despite declining the confirmation') : pass('nothing deleted');
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
'deleting the last item on a page steps back to the previous page': async ({ pass, fail }) => {
|
|
314
|
+
const offsetsRequested = [];
|
|
315
|
+
const { container, el } = await createLibrary({ canDelete: true }, {
|
|
316
|
+
getMediaAssets: async params => {
|
|
317
|
+
offsetsRequested.push(params.offset);
|
|
318
|
+
// Page 1 (offset 0) is full; page 2 (offset 24) holds the single asset being deleted
|
|
319
|
+
return params.offset === PAGE_SIZE
|
|
320
|
+
? [null, { assets: [fixtureAsset()], total: PAGE_SIZE + 1 }]
|
|
321
|
+
: [null, { assets: Array.from({ length: PAGE_SIZE }, (_, i) => fixtureAsset({ id: `page1-${i}` })), total: PAGE_SIZE + 1 }];
|
|
322
|
+
},
|
|
323
|
+
deleteMediaAsset: async () => [null, {}],
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
el.goTo(PAGE_SIZE);
|
|
327
|
+
await flush(20);
|
|
328
|
+
|
|
329
|
+
el.handleDelete(fixtureAsset(), { stopPropagation() {} });
|
|
330
|
+
await flush(20);
|
|
331
|
+
document.querySelector('k-dialog')?.confirmAction();
|
|
332
|
+
await flush(20);
|
|
333
|
+
|
|
334
|
+
cleanup(container);
|
|
335
|
+
const lastOffset = offsetsRequested[offsetsRequested.length - 1];
|
|
336
|
+
return lastOffset === 0 ? pass('stepped back a page') : fail(`final reload used offset ${lastOffset}, expected 0`);
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
'clicking a tile does nothing when the library is not selectable': async ({ pass, fail }) => {
|
|
340
|
+
const { container, el } = await createLibrary({}, {
|
|
341
|
+
getMediaAssets: async () => [null, { assets: [fixtureAsset()], total: 1 }],
|
|
342
|
+
});
|
|
343
|
+
let fired = false;
|
|
344
|
+
el.addEventListener('asset-selected', () => { fired = true; });
|
|
345
|
+
el.shadowRoot.querySelector('.tile').click();
|
|
346
|
+
await el.updateComplete;
|
|
347
|
+
|
|
348
|
+
cleanup(container);
|
|
349
|
+
return fired ? fail('asset-selected fired despite selectable being false') : pass('click ignored');
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
'clicking a tile selects it and dispatches asset-selected when selectable': async ({ pass, fail }) => {
|
|
353
|
+
const { container, el } = await createLibrary({ selectable: true }, {
|
|
354
|
+
getMediaAssets: async () => [null, { assets: [fixtureAsset()], total: 1 }],
|
|
355
|
+
});
|
|
356
|
+
let detail = null;
|
|
357
|
+
el.addEventListener('asset-selected', e => { detail = e.detail; });
|
|
358
|
+
el.shadowRoot.querySelector('.tile').click();
|
|
359
|
+
await el.updateComplete;
|
|
360
|
+
|
|
361
|
+
const selectedClass = el.shadowRoot.querySelector('.tile').classList.contains('selected');
|
|
362
|
+
cleanup(container);
|
|
363
|
+
if(!detail) return fail('asset-selected never fired');
|
|
364
|
+
if(detail.asset.id !== 'asset1') return fail(`selected asset id was ${detail.asset.id}`);
|
|
365
|
+
if(el.selectedAsset?.id !== 'asset1') return fail('selectedAsset getter did not reflect the selection');
|
|
366
|
+
if(!selectedClass) return fail('.tile did not get the selected class');
|
|
367
|
+
pass('selection worked');
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
'double-clicking a selectable tile dispatches asset-confirmed': async ({ pass, fail }) => {
|
|
371
|
+
const { container, el } = await createLibrary({ selectable: true }, {
|
|
372
|
+
getMediaAssets: async () => [null, { assets: [fixtureAsset()], total: 1 }],
|
|
373
|
+
});
|
|
374
|
+
let detail = null;
|
|
375
|
+
el.addEventListener('asset-confirmed', e => { detail = e.detail; });
|
|
376
|
+
el.shadowRoot.querySelector('.tile').dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
|
377
|
+
await el.updateComplete;
|
|
378
|
+
|
|
379
|
+
cleanup(container);
|
|
380
|
+
return detail?.asset?.id === 'asset1' ? pass('confirmed') : fail(`asset-confirmed did not fire with the right asset: ${JSON.stringify(detail)}`);
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
'upload control is only rendered when canUpload is true': async ({ pass, fail }) => {
|
|
384
|
+
const { container: c1, el: withoutUpload } = await createLibrary({ canUpload: false });
|
|
385
|
+
const withoutButton = !!withoutUpload.shadowRoot.querySelector('.upload-button');
|
|
386
|
+
cleanup(c1);
|
|
387
|
+
|
|
388
|
+
const { container: c2, el: withUpload } = await createLibrary({ canUpload: true });
|
|
389
|
+
const withButton = !!withUpload.shadowRoot.querySelector('.upload-button');
|
|
390
|
+
cleanup(c2);
|
|
391
|
+
|
|
392
|
+
if(withoutButton) return fail('upload button rendered without can-upload');
|
|
393
|
+
if(!withButton) return fail('upload button missing with can-upload set');
|
|
394
|
+
pass('upload control gated correctly');
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
'delete button on a tile is only rendered when canDelete is true': async ({ pass, fail }) => {
|
|
398
|
+
const mock = { getMediaAssets: async () => [null, { assets: [fixtureAsset()], total: 1 }] };
|
|
399
|
+
|
|
400
|
+
const { container: c1, el: withoutDelete } = await createLibrary({ canDelete: false }, mock);
|
|
401
|
+
const withoutButton = !!withoutDelete.shadowRoot.querySelector('.tile .delete');
|
|
402
|
+
cleanup(c1);
|
|
403
|
+
|
|
404
|
+
const { container: c2, el: withDelete } = await createLibrary({ canDelete: true }, mock);
|
|
405
|
+
const withButton = !!withDelete.shadowRoot.querySelector('.tile .delete');
|
|
406
|
+
cleanup(c2);
|
|
407
|
+
|
|
408
|
+
if(withoutButton) return fail('delete button rendered without can-delete');
|
|
409
|
+
if(!withButton) return fail('delete button missing with can-delete set');
|
|
410
|
+
pass('delete control gated correctly');
|
|
411
|
+
},
|
|
412
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Served at /kempo-media/sdk.js during browser tests (see ../../browser-fixtures.node-test.js and
|
|
3
|
+
MediaLibrary.browser-test.js), standing in for the real public/sdk.js. MediaLibrary.js imports
|
|
4
|
+
getMediaAssets/uploadMedia/deleteMediaAsset by name from that absolute URL, so the only way to
|
|
5
|
+
control what it sees without booting a real server is to have this URL resolve to something
|
|
6
|
+
test-controlled instead. Each test installs window.__mediaSdkMock before rendering the component.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const mock = () => {
|
|
10
|
+
if(!window.__mediaSdkMock) throw new Error('window.__mediaSdkMock not installed before importing the mocked sdk.js');
|
|
11
|
+
return window.__mediaSdkMock;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const getMediaAssets = (...args) => mock().getMediaAssets(...args);
|
|
15
|
+
export const uploadMedia = (...args) => mock().uploadMedia(...args);
|
|
16
|
+
export const deleteMediaAsset = (...args) => mock().deleteMediaAsset(...args);
|