@xiboplayer/cache 0.3.4 → 0.3.6

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/src/cache.test.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Cache Manager Tests
3
3
  *
4
- * Tests for file caching with Cache API + IndexedDB
5
- * Including large file downloads with parallel chunking
4
+ * Tests for the slimmed-down CacheManager: dependant tracking, getCacheKey,
5
+ * and clearAll. Download/IndexedDB methods have been removed (handled by SW).
6
6
  */
7
7
 
8
8
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@@ -11,31 +11,22 @@ import { CacheManager } from './cache.js';
11
11
  describe('CacheManager', () => {
12
12
  let manager;
13
13
  let mockCache;
14
- let mockDB;
15
- let mockIndexedDB;
16
14
 
17
15
  beforeEach(async () => {
18
- // Mock Cache API — stores raw text/blob and headers as plain objects
16
+ // Mock Cache API
19
17
  mockCache = {
20
18
  _storage: new Map(),
21
19
  async match(key) {
22
20
  const keyStr = typeof key === 'string' ? key : key.toString();
23
21
  const entry = this._storage.get(keyStr);
24
22
  if (!entry) return undefined;
25
-
26
- // Reconstruct a real Response from stored data
27
- return new Response(entry.body, {
28
- headers: entry.headers
29
- });
23
+ return new Response(entry.body, { headers: entry.headers });
30
24
  },
31
25
  async put(key, response) {
32
26
  const keyStr = typeof key === 'string' ? key : key.toString();
33
- // Read the body as text (preserves strings) and store headers as plain object
34
27
  const bodyText = await response.text();
35
28
  const headers = {};
36
- response.headers.forEach((value, name) => {
37
- headers[name] = value;
38
- });
29
+ response.headers.forEach((value, name) => { headers[name] = value; });
39
30
  this._storage.set(keyStr, { body: bodyText, headers });
40
31
  },
41
32
  async delete(key) {
@@ -44,49 +35,9 @@ describe('CacheManager', () => {
44
35
  }
45
36
  };
46
37
 
47
- // Setup global mocks
48
38
  global.caches = {
49
- async open() {
50
- return mockCache;
51
- },
52
- async delete() {
53
- mockCache._storage.clear();
54
- }
55
- };
56
-
57
- // Use real fake-indexeddb (provided by vitest.setup.js)
58
- // Clean the database between tests to avoid data leaking
59
- const deleteRequest = indexedDB.deleteDatabase('xibo-player');
60
- await new Promise((resolve) => {
61
- deleteRequest.onsuccess = resolve;
62
- deleteRequest.onerror = resolve;
63
- deleteRequest.onblocked = resolve;
64
- });
65
-
66
- // Mock @xiboplayer/utils config
67
- vi.mock('@xiboplayer/utils', () => ({
68
- config: {
69
- cmsAddress: 'https://test.cms.com'
70
- }
71
- }));
72
-
73
- // Mock fetch
74
- global.fetch = vi.fn();
75
-
76
- // Mock window events
77
- global.window = {
78
- dispatchEvent: vi.fn(),
79
- location: {
80
- origin: 'https://test.cms.com',
81
- pathname: '/player/pwa/index.html'
82
- }
83
- };
84
-
85
- // Mock navigator.serviceWorker
86
- global.navigator = {
87
- serviceWorker: {
88
- controller: null
89
- }
39
+ async open() { return mockCache; },
40
+ async delete() { mockCache._storage.clear(); }
90
41
  };
91
42
 
92
43
  manager = new CacheManager();
@@ -94,66 +45,6 @@ describe('CacheManager', () => {
94
45
 
95
46
  afterEach(() => {
96
47
  vi.clearAllMocks();
97
- // Close any open DB connections to allow deleteDatabase in next beforeEach
98
- if (manager && manager.db) {
99
- manager.db.close();
100
- }
101
- });
102
-
103
- describe('Initialization', () => {
104
- it('should initialize cache and database', async () => {
105
- await manager.init();
106
-
107
- expect(manager.cache).toBeDefined();
108
- expect(manager.db).toBeDefined();
109
- });
110
- });
111
-
112
- describe('extractFilename()', () => {
113
- it('should extract filename from URL query parameter', () => {
114
- const filename = manager.extractFilename('https://test.com/xmds.php?file=image.jpg&key=123');
115
-
116
- expect(filename).toBe('image.jpg');
117
- });
118
-
119
- it('should return "unknown" for invalid URL', () => {
120
- const filename = manager.extractFilename('not-a-url');
121
-
122
- expect(filename).toBe('unknown');
123
- });
124
-
125
- it('should return "unknown" when file parameter missing', () => {
126
- const filename = manager.extractFilename('https://test.com/xmds.php?other=param');
127
-
128
- expect(filename).toBe('unknown');
129
- });
130
- });
131
-
132
- describe('rewriteUrl()', () => {
133
- it('should rewrite URL to use configured CMS address', () => {
134
- const rewritten = manager.rewriteUrl('https://different.com/path?file=test.jpg');
135
-
136
- expect(rewritten).toContain('test.cms.com');
137
- });
138
-
139
- it('should not rewrite URL if same origin', () => {
140
- const original = 'https://test.cms.com/path?file=test.jpg';
141
- const rewritten = manager.rewriteUrl(original);
142
-
143
- expect(rewritten).toBe(original);
144
- });
145
-
146
- it('should return URL as-is if invalid', () => {
147
- const invalid = 'not-a-url';
148
- const rewritten = manager.rewriteUrl(invalid);
149
-
150
- expect(rewritten).toBe(invalid);
151
- });
152
-
153
- it('should handle null/undefined URLs', () => {
154
- expect(manager.rewriteUrl(null)).toBeNull();
155
- expect(manager.rewriteUrl(undefined)).toBeUndefined();
156
- });
157
48
  });
158
49
 
159
50
  describe('getCacheKey()', () => {
@@ -176,465 +67,6 @@ describe('CacheManager', () => {
176
67
  });
177
68
  });
178
69
 
179
- describe('File Record Management', () => {
180
- beforeEach(async () => {
181
- await manager.init();
182
- });
183
-
184
- it('should save file record', async () => {
185
- const record = {
186
- id: '1',
187
- type: 'media',
188
- path: 'http://test.com/file.mp4',
189
- md5: 'abc123',
190
- size: 1024,
191
- cachedAt: Date.now()
192
- };
193
-
194
- await manager.saveFile(record);
195
- const retrieved = await manager.getFile('1');
196
-
197
- expect(retrieved).toEqual(record);
198
- });
199
-
200
- it('should get all files', async () => {
201
- await manager.saveFile({ id: '1', type: 'media' });
202
- await manager.saveFile({ id: '2', type: 'layout' });
203
-
204
- const files = await manager.getAllFiles();
205
-
206
- expect(files).toHaveLength(2);
207
- });
208
-
209
- it('should return undefined for non-existent file', async () => {
210
- const file = await manager.getFile('non-existent');
211
-
212
- expect(file).toBeUndefined();
213
- });
214
- });
215
-
216
- describe('downloadFile() - Small Files', () => {
217
- beforeEach(async () => {
218
- await manager.init();
219
-
220
- // Helper: create a mock blob with arrayBuffer()/stream() support
221
- function createMockBlob(content, type) {
222
- const blob = new Blob([content], { type });
223
- // Polyfill arrayBuffer() for jsdom environments that lack it
224
- if (!blob.arrayBuffer) {
225
- blob.arrayBuffer = async () => {
226
- const reader = new FileReader();
227
- return new Promise((resolve) => {
228
- reader.onload = () => resolve(reader.result);
229
- reader.readAsArrayBuffer(blob);
230
- });
231
- };
232
- }
233
- // Polyfill stream() for jsdom environments that lack it
234
- if (!blob.stream) {
235
- blob.stream = () => new ReadableStream({
236
- start(controller) {
237
- controller.enqueue(new TextEncoder().encode(content));
238
- controller.close();
239
- }
240
- });
241
- }
242
- return blob;
243
- }
244
-
245
- // Mock successful download
246
- global.fetch.mockImplementation(async (url, options) => {
247
- if (options?.method === 'HEAD') {
248
- return {
249
- ok: true,
250
- status: 200,
251
- headers: {
252
- get: (name) => name === 'Content-Length' ? '1024' : null
253
- }
254
- };
255
- }
256
-
257
- const blob = createMockBlob('test data', 'image/jpeg');
258
- return {
259
- ok: true,
260
- status: 200,
261
- headers: {
262
- get: (name) => name === 'Content-Type' ? 'image/jpeg' : null
263
- },
264
- blob: async () => blob
265
- };
266
- });
267
-
268
- // Mock SparkMD5
269
- vi.mock('spark-md5', () => ({
270
- default: {
271
- ArrayBuffer: {
272
- hash: () => 'abc123'
273
- }
274
- }
275
- }));
276
- });
277
-
278
- it('should download and cache file', async () => {
279
- const fileInfo = {
280
- id: '1',
281
- type: 'media',
282
- path: 'http://test.com/file.jpg',
283
- md5: 'abc123',
284
- download: 'http'
285
- };
286
-
287
- const result = await manager.downloadFile(fileInfo);
288
-
289
- expect(result).toMatchObject({
290
- id: '1',
291
- type: 'media',
292
- md5: 'abc123'
293
- });
294
- });
295
-
296
- it('should skip download if already cached with matching MD5', async () => {
297
- // Pre-cache file
298
- await manager.saveFile({
299
- id: '1',
300
- type: 'media',
301
- md5: 'abc123',
302
- path: 'http://test.com/file.jpg',
303
- size: 1024,
304
- cachedAt: Date.now()
305
- });
306
-
307
- // Use content >= 100 bytes to avoid "tiny file" corruption check
308
- const largeContent = 'x'.repeat(200);
309
- const cacheKey = manager.getCacheKey('media', '1');
310
- await mockCache.put(cacheKey, new Response(largeContent, {
311
- headers: { 'Content-Type': 'image/jpeg' }
312
- }));
313
-
314
- const fileInfo = {
315
- id: '1',
316
- type: 'media',
317
- path: 'http://test.com/file.jpg',
318
- md5: 'abc123'
319
- };
320
-
321
- const result = await manager.downloadFile(fileInfo);
322
-
323
- expect(result.md5).toBe('abc123');
324
- expect(global.fetch).not.toHaveBeenCalled();
325
- });
326
-
327
- it('should re-download if MD5 mismatch', async () => {
328
- // Pre-cache file with different MD5
329
- await manager.saveFile({
330
- id: '1',
331
- type: 'media',
332
- md5: 'old-md5',
333
- path: 'http://test.com/file.jpg',
334
- size: 1024,
335
- cachedAt: Date.now()
336
- });
337
-
338
- const fileInfo = {
339
- id: '1',
340
- type: 'media',
341
- path: 'http://test.com/file.jpg',
342
- md5: 'new-md5'
343
- };
344
-
345
- await manager.downloadFile(fileInfo);
346
-
347
- expect(global.fetch).toHaveBeenCalled();
348
- });
349
-
350
- it('should skip files with no download URL', async () => {
351
- const fileInfo = {
352
- id: '1',
353
- type: 'resource',
354
- path: null
355
- };
356
-
357
- const result = await manager.downloadFile(fileInfo);
358
-
359
- expect(result).toBeNull();
360
- expect(global.fetch).not.toHaveBeenCalled();
361
- });
362
-
363
- it('should handle HTTP errors', async () => {
364
- global.fetch.mockImplementation(async (url, options) => {
365
- if (options?.method === 'HEAD') {
366
- return {
367
- ok: true,
368
- status: 200,
369
- headers: { get: (name) => name === 'Content-Length' ? '1024' : null }
370
- };
371
- }
372
- // GET download returns error
373
- return {
374
- ok: false,
375
- status: 404,
376
- headers: { get: () => null }
377
- };
378
- });
379
-
380
- const fileInfo = {
381
- id: '1',
382
- type: 'media',
383
- path: 'http://test.com/file.jpg'
384
- };
385
-
386
- await expect(manager.downloadFile(fileInfo)).rejects.toThrow('Failed to download');
387
- });
388
-
389
- it('should delete corrupted cache (text/plain responses)', async () => {
390
- // Pre-cache corrupted file
391
- await manager.saveFile({
392
- id: '1',
393
- type: 'media',
394
- md5: 'abc123',
395
- path: 'http://test.com/file.jpg',
396
- size: 50,
397
- cachedAt: Date.now()
398
- });
399
-
400
- const cacheKey = manager.getCacheKey('media', '1');
401
- await mockCache.put(cacheKey, new Response(new Blob(['error']), {
402
- headers: { 'Content-Type': 'text/plain' }
403
- }));
404
-
405
- const fileInfo = {
406
- id: '1',
407
- type: 'media',
408
- path: 'http://test.com/file.jpg',
409
- md5: 'abc123'
410
- };
411
-
412
- await manager.downloadFile(fileInfo);
413
-
414
- // Should re-download
415
- expect(global.fetch).toHaveBeenCalled();
416
- });
417
-
418
- it('should handle Service Worker active (skip download)', async () => {
419
- global.navigator.serviceWorker.controller = {}; // SW active
420
-
421
- const fileInfo = {
422
- id: '1',
423
- type: 'media',
424
- path: 'http://test.com/file.jpg',
425
- md5: 'abc123'
426
- };
427
-
428
- const result = await manager.downloadFile(fileInfo);
429
-
430
- expect(result.isServiceWorkerDownload).toBe(true);
431
- expect(global.fetch).not.toHaveBeenCalled();
432
-
433
- global.navigator.serviceWorker.controller = null; // Reset
434
- });
435
-
436
- it('should handle HTTP 202 (background download pending)', async () => {
437
- // HEAD request returns 202 (background download in progress)
438
- global.fetch.mockImplementation(async (url, options) => {
439
- return {
440
- ok: true,
441
- status: 202,
442
- headers: { get: () => null }
443
- };
444
- });
445
-
446
- const fileInfo = {
447
- id: '1',
448
- type: 'media',
449
- path: 'http://test.com/file.jpg',
450
- md5: 'abc123'
451
- };
452
-
453
- const result = await manager.downloadFile(fileInfo);
454
-
455
- expect(result.isPending).toBe(true);
456
- });
457
- });
458
-
459
- describe('downloadFile() - Large Files', () => {
460
- beforeEach(async () => {
461
- await manager.init();
462
-
463
- // Mock large file (>100MB)
464
- global.fetch.mockImplementation(async (url, options) => {
465
- if (options?.method === 'HEAD') {
466
- return {
467
- ok: true,
468
- headers: {
469
- get: (name) => name === 'Content-Length' ? '200000000' : null // 200 MB
470
- }
471
- };
472
- }
473
-
474
- return {
475
- ok: true,
476
- status: 200,
477
- headers: {
478
- get: (name) => name === 'Content-Type' ? 'video/mp4' : null
479
- },
480
- blob: async () => new Blob(['chunk data'])
481
- };
482
- });
483
- });
484
-
485
- it('should start background download for large files', async () => {
486
- const fileInfo = {
487
- id: '1',
488
- type: 'media',
489
- path: 'http://test.com/large-video.mp4',
490
- md5: 'abc123'
491
- };
492
-
493
- const result = await manager.downloadFile(fileInfo);
494
-
495
- expect(result.isBackgroundDownload).toBe(true);
496
- expect(result.size).toBe(200000000);
497
- });
498
-
499
- it('should return immediately for large files (non-blocking)', async () => {
500
- const fileInfo = {
501
- id: '1',
502
- type: 'media',
503
- path: 'http://test.com/large-video.mp4',
504
- md5: 'abc123'
505
- };
506
-
507
- const startTime = Date.now();
508
- await manager.downloadFile(fileInfo);
509
- const duration = Date.now() - startTime;
510
-
511
- // Should return in <100ms (not wait for download)
512
- expect(duration).toBeLessThan(100);
513
- });
514
- });
515
-
516
- describe('getCachedFile()', () => {
517
- beforeEach(async () => {
518
- await manager.init();
519
- });
520
-
521
- it('should retrieve cached file as blob', async () => {
522
- const cacheKey = manager.getCacheKey('media', '1');
523
- const blob = new Blob(['test data']);
524
- await mockCache.put(cacheKey, new Response(blob));
525
-
526
- const retrieved = await manager.getCachedFile('media', '1');
527
-
528
- expect(retrieved).toBeTruthy();
529
- expect(retrieved.size).toBeGreaterThan(0);
530
- });
531
-
532
- it('should return null for non-cached file', async () => {
533
- const retrieved = await manager.getCachedFile('media', 'non-existent');
534
-
535
- expect(retrieved).toBeNull();
536
- });
537
- });
538
-
539
- describe('getCachedResponse()', () => {
540
- beforeEach(async () => {
541
- await manager.init();
542
- });
543
-
544
- it('should retrieve cached response with headers', async () => {
545
- const cacheKey = manager.getCacheKey('media', '1');
546
- await mockCache.put(cacheKey, new Response(new Blob(['data']), {
547
- headers: { 'Content-Type': 'image/jpeg' }
548
- }));
549
-
550
- const response = await manager.getCachedResponse('media', '1');
551
-
552
- expect(response).toBeInstanceOf(Response);
553
- expect(response.headers.get('Content-Type')).toBe('image/jpeg');
554
- });
555
- });
556
-
557
- describe('getCachedFileText()', () => {
558
- beforeEach(async () => {
559
- await manager.init();
560
- });
561
-
562
- it('should retrieve cached file as text', async () => {
563
- const cacheKey = manager.getCacheKey('layout', '100');
564
- await mockCache.put(cacheKey, new Response('layout XML'));
565
-
566
- const text = await manager.getCachedFileText('layout', '100');
567
-
568
- expect(text).toBe('layout XML');
569
- });
570
- });
571
-
572
- describe('cacheWidgetHtml()', () => {
573
- beforeEach(async () => {
574
- await manager.init();
575
- });
576
-
577
- it('should cache widget HTML with base tag injection', async () => {
578
- const html = '<html><head><title>Widget</title></head><body>Content</body></html>';
579
-
580
- const cacheKey = await manager.cacheWidgetHtml('100', '1', '2', html);
581
-
582
- expect(cacheKey).toBe('/player/pwa/cache/widget/100/1/2');
583
-
584
- // Verify base tag was injected
585
- const cached = await mockCache.match(new URL(cacheKey, 'https://test.cms.com'));
586
- const cachedHtml = await cached.text();
587
-
588
- expect(cachedHtml).toContain('<base href="/player/cache/media/">');
589
- });
590
-
591
- it('should inject base tag after <head> tag', async () => {
592
- const html = '<html><head><title>Test</title></head></html>';
593
-
594
- await manager.cacheWidgetHtml('100', '1', '2', html);
595
-
596
- const cacheKey = '/player/pwa/cache/widget/100/1/2';
597
- const cached = await mockCache.match(new URL(cacheKey, 'https://test.cms.com'));
598
- const cachedHtml = await cached.text();
599
-
600
- expect(cachedHtml).toMatch(/<head><base href="\/player\/cache\/media\/">/);
601
- });
602
-
603
- it('should prepend base tag if no <head> tag', async () => {
604
- const html = '<div>Widget content</div>';
605
-
606
- await manager.cacheWidgetHtml('100', '1', '2', html);
607
-
608
- const cacheKey = '/player/pwa/cache/widget/100/1/2';
609
- const cached = await mockCache.match(new URL(cacheKey, 'https://test.cms.com'));
610
- const cachedHtml = await cached.text();
611
-
612
- expect(cachedHtml).toMatch(/^<base href="\/player\/cache\/media\/">/);
613
- });
614
- });
615
-
616
- describe('clearAll()', () => {
617
- beforeEach(async () => {
618
- await manager.init();
619
-
620
- // Add some cached data
621
- await manager.saveFile({ id: '1', type: 'media' });
622
- await manager.saveFile({ id: '2', type: 'layout' });
623
- const cacheKey = manager.getCacheKey('media', '1');
624
- await mockCache.put(cacheKey, new Response('data'));
625
- });
626
-
627
- it('should clear all caches and IndexedDB', async () => {
628
- await manager.clearAll();
629
-
630
- const files = await manager.getAllFiles();
631
- expect(files).toHaveLength(0);
632
-
633
- const cached = await manager.getCachedFile('media', '1');
634
- expect(cached).toBeNull();
635
- });
636
- });
637
-
638
70
  describe('Dependant Tracking', () => {
639
71
  it('should add a dependant mapping from media to layout', () => {
640
72
  manager.addDependant('media1', 'layout1');
@@ -722,49 +154,16 @@ describe('CacheManager', () => {
722
154
  });
723
155
  });
724
156
 
725
- describe('Download Progress Events', () => {
726
- beforeEach(async () => {
727
- await manager.init();
728
- });
729
-
730
- it('should dispatch download-progress events', () => {
731
- manager.notifyDownloadProgress('test.mp4', 512, 1024);
732
-
733
- expect(window.dispatchEvent).toHaveBeenCalledWith(
734
- expect.objectContaining({
735
- type: 'download-progress',
736
- detail: expect.objectContaining({
737
- filename: 'test.mp4',
738
- loaded: 512,
739
- total: 1024,
740
- percent: 50
741
- })
742
- })
743
- );
744
- });
745
-
746
- it('should mark download as complete', () => {
747
- manager.notifyDownloadProgress('test.mp4', 1024, 1024, true);
748
-
749
- expect(window.dispatchEvent).toHaveBeenCalledWith(
750
- expect.objectContaining({
751
- detail: expect.objectContaining({
752
- complete: true
753
- })
754
- })
755
- );
756
- });
157
+ describe('clearAll()', () => {
158
+ it('should clear caches and dependants', async () => {
159
+ manager.addDependant('media1', 'layout1');
160
+ manager.addDependant('media2', 'layout2');
757
161
 
758
- it('should mark download as error', () => {
759
- manager.notifyDownloadProgress('test.mp4', 512, 1024, false, true);
162
+ await manager.clearAll();
760
163
 
761
- expect(window.dispatchEvent).toHaveBeenCalledWith(
762
- expect.objectContaining({
763
- detail: expect.objectContaining({
764
- error: true
765
- })
766
- })
767
- );
164
+ expect(manager.isMediaReferenced('media1')).toBe(false);
165
+ expect(manager.isMediaReferenced('media2')).toBe(false);
166
+ expect(manager.dependants.size).toBe(0);
768
167
  });
769
168
  });
770
169
  });
package/src/index.js CHANGED
@@ -4,3 +4,5 @@ export const VERSION = pkg.version;
4
4
  export { CacheManager, cacheManager } from './cache.js';
5
5
  export { CacheProxy } from './cache-proxy.js';
6
6
  export { DownloadManager, FileDownload, LayoutTaskBuilder, isUrlExpired } from './download-manager.js';
7
+ export { CacheAnalyzer } from './cache-analyzer.js';
8
+ export { cacheWidgetHtml } from './widget-html.js';