@xiboplayer/cache 0.1.0

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.
@@ -0,0 +1,391 @@
1
+ /**
2
+ * CacheProxy Tests
3
+ *
4
+ * Contract-based testing for CacheProxy and ServiceWorkerBackend
5
+ * Service Worker only architecture - no fallback
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
9
+ import { CacheProxy } from './cache-proxy.js';
10
+ import { createTestBlob } from './test-utils.js';
11
+
12
+ // The source computes BASE from window.location.pathname.
13
+ // In jsdom the default pathname is '/' which resolves to '/player/pwa'.
14
+ const BASE = '/player/pwa';
15
+
16
+ /**
17
+ * Helper: set up navigator.serviceWorker mock.
18
+ *
19
+ * Options:
20
+ * supported – whether 'serviceWorker' exists on navigator (default true)
21
+ * controller – the SW controller object (or null)
22
+ * active – the active ServiceWorker object (derived from controller if omitted)
23
+ * installing – registration.installing value (default null)
24
+ * waiting – registration.waiting value (default null)
25
+ * registration – override the full registration object
26
+ * swReadyResolves – whether navigator.serviceWorker.ready resolves (default true)
27
+ */
28
+ function setupServiceWorker(opts = {}) {
29
+ const {
30
+ supported = true,
31
+ controller = null,
32
+ active = undefined,
33
+ installing = null,
34
+ waiting = null,
35
+ swReadyResolves = true,
36
+ } = opts;
37
+
38
+ if (!supported) {
39
+ // Must delete entirely so that ('serviceWorker' in navigator) is false.
40
+ // First make the property configurable if it isn't already, then delete.
41
+ Object.defineProperty(navigator, 'serviceWorker', {
42
+ value: undefined,
43
+ configurable: true,
44
+ writable: true,
45
+ });
46
+ delete navigator.serviceWorker;
47
+ return;
48
+ }
49
+
50
+ const activeSW = active !== undefined
51
+ ? active
52
+ : controller
53
+ ? { state: 'activated', postMessage: controller.postMessage }
54
+ : null;
55
+
56
+ const registration = {
57
+ active: activeSW,
58
+ installing,
59
+ waiting,
60
+ };
61
+
62
+ const messageListeners = [];
63
+
64
+ const swContainer = {
65
+ controller,
66
+ ready: swReadyResolves
67
+ ? Promise.resolve(registration)
68
+ : new Promise(() => {}), // never resolves
69
+ getRegistration: vi.fn().mockResolvedValue(registration),
70
+ addEventListener: vi.fn((event, handler) => {
71
+ if (event === 'message') {
72
+ messageListeners.push(handler);
73
+ }
74
+ }),
75
+ removeEventListener: vi.fn(),
76
+ };
77
+
78
+ // Expose the message listeners so tests can dispatch SW_READY
79
+ swContainer._messageListeners = messageListeners;
80
+ // Also expose registration for manipulation
81
+ swContainer._registration = registration;
82
+
83
+ Object.defineProperty(navigator, 'serviceWorker', {
84
+ value: swContainer,
85
+ configurable: true,
86
+ writable: true,
87
+ });
88
+
89
+ return swContainer;
90
+ }
91
+
92
+ /**
93
+ * Dispatch a simulated message event to all registered SW message listeners
94
+ */
95
+ function dispatchSWMessage(swContainer, data) {
96
+ for (const listener of swContainer._messageListeners || []) {
97
+ listener({ data });
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Set up a mock MessageChannel (needed by requestDownload / prioritizeDownload).
103
+ * Returns a factory that captures ports so tests can simulate SW responses.
104
+ */
105
+ function setupMessageChannel() {
106
+ // Each call to new MessageChannel() produces a linked pair.
107
+ // The implementation sends on port2 (transferred to SW), listens on port1.
108
+ // We capture the pair so the test can push a response into port1.onmessage.
109
+ const channels = [];
110
+
111
+ global.MessageChannel = class {
112
+ constructor() {
113
+ const self = { port1: { onmessage: null }, port2: {} };
114
+ channels.push(self);
115
+ this.port1 = self.port1;
116
+ this.port2 = self.port2;
117
+ }
118
+ };
119
+
120
+ return {
121
+ /** The most recently created channel */
122
+ get lastChannel() {
123
+ return channels[channels.length - 1];
124
+ },
125
+ /** Simulate SW replying through the channel */
126
+ respondOnLastChannel(data) {
127
+ const ch = channels[channels.length - 1];
128
+ if (ch && ch.port1.onmessage) {
129
+ ch.port1.onmessage({ data });
130
+ }
131
+ },
132
+ channels,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Reset navigator.serviceWorker and global.fetch between tests
138
+ */
139
+ function resetMocks() {
140
+ // Restore a bare serviceWorker so the next setupServiceWorker can override it
141
+ Object.defineProperty(navigator, 'serviceWorker', {
142
+ value: undefined,
143
+ configurable: true,
144
+ writable: true,
145
+ });
146
+ global.fetch = vi.fn();
147
+ delete global.MessageChannel;
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Helper to create a fully initialised CacheProxy for the common "happy path"
152
+ // where SW is active and ready. Sends SW_READY so the fetchReadyPromise inside
153
+ // ServiceWorkerBackend resolves, allowing getFile / hasFile / isCached to work.
154
+ // ---------------------------------------------------------------------------
155
+ async function createInitialisedProxy() {
156
+ const controller = { postMessage: vi.fn() };
157
+ const sw = setupServiceWorker({ controller });
158
+
159
+ const proxy = new CacheProxy();
160
+ const initPromise = proxy.init();
161
+
162
+ // The backend's init() sets up a message listener then posts PING.
163
+ // We need to simulate the SW_READY response.
164
+ // Give init a microtask to register the listener, then fire SW_READY.
165
+ await Promise.resolve(); // let init() progress
166
+ dispatchSWMessage(sw, { type: 'SW_READY' });
167
+
168
+ await initPromise;
169
+ return { proxy, sw, controller };
170
+ }
171
+
172
+ // ===========================================================================
173
+ // Tests
174
+ // ===========================================================================
175
+
176
+ describe('CacheProxy', () => {
177
+ beforeEach(() => {
178
+ resetMocks();
179
+ });
180
+
181
+ describe('Service Worker Requirement', () => {
182
+ it('should require Service Worker to be available', async () => {
183
+ setupServiceWorker({ supported: false });
184
+
185
+ const proxy = new CacheProxy();
186
+
187
+ await expect(proxy.init()).rejects.toThrow('Service Worker not supported');
188
+ });
189
+
190
+ it('should wait for Service Worker to be ready and controlling', async () => {
191
+ const { proxy } = await createInitialisedProxy();
192
+
193
+ expect(proxy.backendType).toBe('service-worker');
194
+ expect(proxy.backend).toBeTruthy();
195
+ });
196
+
197
+ it('should throw if Service Worker not controlling after ready', async () => {
198
+ // Provide a registration with no active SW and no controller.
199
+ // The slow path waits for ready, then ServiceWorkerBackend.init() will
200
+ // call getRegistration() which returns { active: null }, so it falls
201
+ // through to the ready path where controller is null -> throws.
202
+ const sw = setupServiceWorker({
203
+ controller: null,
204
+ active: null,
205
+ });
206
+
207
+ const proxy = new CacheProxy();
208
+
209
+ await expect(proxy.init()).rejects.toThrow('Service Worker not controlling page');
210
+ });
211
+ });
212
+
213
+ describe('Pre-condition: initialization required', () => {
214
+ it('should throw if getFile() called before init()', async () => {
215
+ const proxy = new CacheProxy();
216
+
217
+ await expect(proxy.getFile('media', '123')).rejects.toThrow('CacheProxy not initialized');
218
+ });
219
+
220
+ it('should throw if requestDownload() called before init()', async () => {
221
+ const proxy = new CacheProxy();
222
+
223
+ await expect(proxy.requestDownload([])).rejects.toThrow('CacheProxy not initialized');
224
+ });
225
+
226
+ it('should throw if isCached() called before init()', async () => {
227
+ const proxy = new CacheProxy();
228
+
229
+ await expect(proxy.isCached('media', '123')).rejects.toThrow('CacheProxy not initialized');
230
+ });
231
+ });
232
+ });
233
+
234
+ describe('ServiceWorkerBackend', () => {
235
+ beforeEach(() => {
236
+ resetMocks();
237
+ setupMessageChannel();
238
+ });
239
+
240
+ describe('init()', () => {
241
+ it('should initialize with SW controller', async () => {
242
+ const { proxy } = await createInitialisedProxy();
243
+
244
+ expect(proxy.backendType).toBe('service-worker');
245
+ });
246
+
247
+ it('should throw if SW not supported', async () => {
248
+ setupServiceWorker({ supported: false });
249
+
250
+ const proxy = new CacheProxy();
251
+
252
+ await expect(proxy.init()).rejects.toThrow('Service Worker not supported');
253
+ });
254
+
255
+ it('should throw if SW not controlling page', async () => {
256
+ setupServiceWorker({ controller: null, active: null });
257
+
258
+ const proxy = new CacheProxy();
259
+
260
+ await expect(proxy.init()).rejects.toThrow('Service Worker not controlling page');
261
+ });
262
+ });
263
+
264
+ describe('getFile()', () => {
265
+ it('should fetch from cache URL with correct BASE path', async () => {
266
+ const { proxy } = await createInitialisedProxy();
267
+
268
+ const testBlob = createTestBlob(1024);
269
+ global.fetch = vi.fn((url) => {
270
+ if (url === `${BASE}/cache/media/123`) {
271
+ return Promise.resolve({
272
+ ok: true,
273
+ status: 200,
274
+ blob: () => Promise.resolve(testBlob),
275
+ });
276
+ }
277
+ return Promise.resolve({ ok: false, status: 404 });
278
+ });
279
+
280
+ const blob = await proxy.getFile('media', '123');
281
+
282
+ expect(blob).toBe(testBlob);
283
+ expect(global.fetch).toHaveBeenCalledWith(`${BASE}/cache/media/123`);
284
+ });
285
+
286
+ it('should return null for 404', async () => {
287
+ const { proxy } = await createInitialisedProxy();
288
+
289
+ global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 }));
290
+
291
+ const blob = await proxy.getFile('media', '123');
292
+
293
+ expect(blob).toBeNull();
294
+ });
295
+
296
+ it('should return null on fetch error', async () => {
297
+ const { proxy } = await createInitialisedProxy();
298
+
299
+ global.fetch = vi.fn(() => Promise.reject(new Error('Network error')));
300
+
301
+ const blob = await proxy.getFile('media', '123');
302
+
303
+ expect(blob).toBeNull();
304
+ });
305
+ });
306
+
307
+ describe('requestDownload()', () => {
308
+ it('should post DOWNLOAD_FILES message to SW', async () => {
309
+ const { proxy } = await createInitialisedProxy();
310
+
311
+ const files = [
312
+ { id: '1', type: 'media', path: 'http://test.com/file1.mp4', md5: 'abc123' },
313
+ { id: '2', type: 'media', path: 'http://test.com/file2.mp4', md5: 'def456' },
314
+ ];
315
+
316
+ // Mock the backend method to verify call signature
317
+ proxy.backend.requestDownload = vi.fn().mockResolvedValue();
318
+
319
+ await proxy.requestDownload(files);
320
+
321
+ expect(proxy.backend.requestDownload).toHaveBeenCalledWith(files);
322
+ });
323
+
324
+ it('should resolve when SW acknowledges', async () => {
325
+ const { proxy } = await createInitialisedProxy();
326
+
327
+ proxy.backend.requestDownload = vi.fn().mockResolvedValue();
328
+
329
+ const files = [{ id: '1', type: 'media', path: 'http://test.com/file.mp4' }];
330
+
331
+ await expect(proxy.requestDownload(files)).resolves.toBeUndefined();
332
+ });
333
+
334
+ it('should reject when SW returns error', async () => {
335
+ const { proxy } = await createInitialisedProxy();
336
+
337
+ proxy.backend.requestDownload = vi.fn().mockRejectedValue(new Error('Download failed'));
338
+
339
+ const files = [{ id: '1', type: 'media', path: 'http://test.com/file.mp4' }];
340
+
341
+ await expect(proxy.requestDownload(files)).rejects.toThrow('Download failed');
342
+ });
343
+
344
+ it('should throw if SW controller not available', async () => {
345
+ const { proxy } = await createInitialisedProxy();
346
+
347
+ // Simulate controller becoming null after init
348
+ proxy.backend.controller = null;
349
+
350
+ await expect(proxy.requestDownload([])).rejects.toThrow('Service Worker not available');
351
+ });
352
+ });
353
+
354
+ describe('isCached()', () => {
355
+ it('should perform HEAD request to check if cached', async () => {
356
+ const { proxy } = await createInitialisedProxy();
357
+
358
+ global.fetch = vi.fn((url, options) => {
359
+ if (url === `${BASE}/cache/media/123` && options?.method === 'HEAD') {
360
+ return Promise.resolve({ ok: true, status: 200 });
361
+ }
362
+ return Promise.resolve({ ok: false, status: 404 });
363
+ });
364
+
365
+ const cached = await proxy.isCached('media', '123');
366
+
367
+ expect(cached).toBe(true);
368
+ expect(global.fetch).toHaveBeenCalledWith(`${BASE}/cache/media/123`, { method: 'HEAD' });
369
+ });
370
+
371
+ it('should return false for 404', async () => {
372
+ const { proxy } = await createInitialisedProxy();
373
+
374
+ global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 }));
375
+
376
+ const cached = await proxy.isCached('media', '123');
377
+
378
+ expect(cached).toBe(false);
379
+ });
380
+
381
+ it('should return false on fetch error', async () => {
382
+ const { proxy } = await createInitialisedProxy();
383
+
384
+ global.fetch = vi.fn(() => Promise.reject(new Error('Network error')));
385
+
386
+ const cached = await proxy.isCached('media', '123');
387
+
388
+ expect(cached).toBe(false);
389
+ });
390
+ });
391
+ });