@xiboplayer/cache 0.5.8 → 0.5.10

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.
@@ -5,7 +5,7 @@
5
5
  * orphaned media that is no longer needed. Logs a summary every collection
6
6
  * cycle. Only evicts when storage pressure exceeds a configurable threshold.
7
7
  *
8
- * Works entirely through CacheProxy (postMessage to SW) — no IndexedDB,
8
+ * Works entirely through StoreClient (REST to proxy) — no IndexedDB,
9
9
  * no direct Cache API access.
10
10
  */
11
11
 
@@ -27,7 +27,7 @@ function formatBytes(bytes) {
27
27
 
28
28
  export class CacheAnalyzer {
29
29
  /**
30
- * @param {import('./cache-proxy.js').CacheProxy} cache - CacheProxy instance
30
+ * @param {import('./store-client.js').StoreClient} cache - StoreClient instance
31
31
  * @param {object} [options]
32
32
  * @param {number} [options.threshold=80] - Storage usage % above which eviction triggers
33
33
  */
@@ -43,7 +43,7 @@ export class CacheAnalyzer {
43
43
  * @returns {Promise<object>} Analysis report
44
44
  */
45
45
  async analyze(requiredFiles) {
46
- const cachedFiles = await this.cache.getAllFiles();
46
+ const cachedFiles = await this.cache.list();
47
47
  const storage = await this._getStorageEstimate();
48
48
 
49
49
  // Build set of required file IDs (as strings for consistent comparison)
@@ -64,6 +64,10 @@ export class CacheAnalyzer {
64
64
  } else {
65
65
  orphaned.push(file);
66
66
  }
67
+ } else if (file.type === 'static') {
68
+ // Static files (bundle.min.js, fonts.css, fonts, images) are shared widget
69
+ // dependencies — never orphan them, they're referenced from widget HTML
70
+ required.push(file);
67
71
  } else {
68
72
  orphaned.push(file);
69
73
  }
@@ -141,7 +145,7 @@ export class CacheAnalyzer {
141
145
 
142
146
  /**
143
147
  * Evict orphaned files (oldest first) until targetBytes are freed.
144
- * Delegates deletion to CacheProxy.deleteFiles() which routes to SW.
148
+ * Delegates deletion to StoreClient.remove() which routes to proxy.
145
149
  *
146
150
  * @param {Array} orphanedFiles - Files to evict, sorted oldest-first
147
151
  * @param {number} targetBytes - Bytes to free
@@ -161,7 +165,7 @@ export class CacheAnalyzer {
161
165
 
162
166
  try {
163
167
  const filesToDelete = toEvict.map(f => ({ type: f.type, id: f.id }));
164
- await this.cache.deleteFiles(filesToDelete);
168
+ await this.cache.remove(filesToDelete);
165
169
 
166
170
  for (const f of toEvict) {
167
171
  log.info(` Evicted: ${f.type}/${f.id} (${formatBytes(f.size || 0)})`);
@@ -2,7 +2,7 @@
2
2
  * CacheAnalyzer Tests
3
3
  *
4
4
  * Tests for stale media detection, storage health reporting, and eviction logic.
5
- * Mock follows the CacheProxy interface (getAllFiles, deleteFiles).
5
+ * Mock follows the StoreClient interface (list, remove).
6
6
  */
7
7
 
8
8
  import { describe, it, expect, beforeEach, vi } from 'vitest';
@@ -13,12 +13,12 @@ describe('CacheAnalyzer', () => {
13
13
  let mockCache;
14
14
 
15
15
  beforeEach(() => {
16
- // Mock CacheProxy with in-memory file store
16
+ // Mock StoreClient with in-memory file store
17
17
  const files = new Map();
18
18
 
19
19
  mockCache = {
20
- getAllFiles: vi.fn(async () => [...files.values()]),
21
- deleteFiles: vi.fn(async (filesToDelete) => ({
20
+ list: vi.fn(async () => [...files.values()]),
21
+ remove: vi.fn(async (filesToDelete) => ({
22
22
  deleted: filesToDelete.length,
23
23
  total: filesToDelete.length,
24
24
  })),
@@ -214,8 +214,8 @@ describe('CacheAnalyzer', () => {
214
214
  expect(report.evicted.length).toBeGreaterThan(0);
215
215
  // Should evict oldest first
216
216
  expect(report.evicted[0].id).toBe('old');
217
- // deleteFiles should be called on the cache proxy
218
- expect(mockCache.deleteFiles).toHaveBeenCalled();
217
+ // remove should be called on the store client
218
+ expect(mockCache.remove).toHaveBeenCalled();
219
219
 
220
220
  vi.unstubAllGlobals();
221
221
  });
@@ -1,29 +1,200 @@
1
1
  /**
2
- * CacheProxy Tests
2
+ * StoreClient & DownloadClient Tests
3
3
  *
4
- * Contract-based testing for CacheProxy and ServiceWorkerBackend
5
- * Service Worker only architecture - no fallback
4
+ * StoreClient: pure REST client for ContentStore no SW dependency
5
+ * DownloadClient: SW postMessage client for download orchestration
6
6
  */
7
7
 
8
8
  import { describe, it, expect, beforeEach, vi } from 'vitest';
9
- import { CacheProxy } from './cache-proxy.js';
9
+ import { StoreClient } from './store-client.js';
10
+ import { DownloadClient } from './download-client.js';
10
11
  import { createTestBlob } from './test-utils.js';
11
12
 
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';
13
+ /**
14
+ * Reset global.fetch between tests
15
+ */
16
+ function resetMocks() {
17
+ global.fetch = vi.fn();
18
+ delete global.MessageChannel;
19
+ }
20
+
21
+ // ===========================================================================
22
+ // StoreClient Tests
23
+ // ===========================================================================
24
+
25
+ describe('StoreClient', () => {
26
+ let store;
27
+
28
+ beforeEach(() => {
29
+ resetMocks();
30
+ store = new StoreClient();
31
+ });
32
+
33
+ describe('has()', () => {
34
+ it('should perform HEAD request to /store/:type/:id', async () => {
35
+ global.fetch = vi.fn((url, options) => {
36
+ if (url === '/store/media/123' && options?.method === 'HEAD') {
37
+ return Promise.resolve({ ok: true, status: 200 });
38
+ }
39
+ return Promise.resolve({ ok: false, status: 404 });
40
+ });
41
+
42
+ const exists = await store.has('media', '123');
43
+
44
+ expect(exists).toBe(true);
45
+ expect(global.fetch).toHaveBeenCalledWith('/store/media/123', { method: 'HEAD' });
46
+ });
47
+
48
+ it('should return false for 404', async () => {
49
+ global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 }));
50
+
51
+ const exists = await store.has('media', '123');
52
+
53
+ expect(exists).toBe(false);
54
+ });
55
+
56
+ it('should return false on fetch error', async () => {
57
+ global.fetch = vi.fn(() => Promise.reject(new Error('Network error')));
58
+
59
+ const exists = await store.has('media', '123');
60
+
61
+ expect(exists).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe('get()', () => {
66
+ it('should fetch from /store/:type/:id and return blob', async () => {
67
+ const testBlob = createTestBlob(1024);
68
+ global.fetch = vi.fn((url) => {
69
+ if (url === '/store/media/123') {
70
+ return Promise.resolve({
71
+ ok: true,
72
+ status: 200,
73
+ blob: () => Promise.resolve(testBlob),
74
+ });
75
+ }
76
+ return Promise.resolve({ ok: false, status: 404 });
77
+ });
78
+
79
+ const blob = await store.get('media', '123');
80
+
81
+ expect(blob).toBe(testBlob);
82
+ expect(global.fetch).toHaveBeenCalledWith('/store/media/123');
83
+ });
84
+
85
+ it('should return null for 404', async () => {
86
+ global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 }));
87
+
88
+ const blob = await store.get('media', '123');
89
+
90
+ expect(blob).toBeNull();
91
+ });
92
+
93
+ it('should return null on fetch error', async () => {
94
+ global.fetch = vi.fn(() => Promise.reject(new Error('Network error')));
95
+
96
+ const blob = await store.get('media', '123');
97
+
98
+ expect(blob).toBeNull();
99
+ });
100
+ });
101
+
102
+ describe('put()', () => {
103
+ it('should PUT content to /store/:type/:id', async () => {
104
+ global.fetch = vi.fn(() => Promise.resolve({ ok: true }));
105
+
106
+ const result = await store.put('widget', '1/2/3', '<html>test</html>', 'text/html');
107
+
108
+ expect(result).toBe(true);
109
+ expect(global.fetch).toHaveBeenCalledWith('/store/widget/1/2/3', {
110
+ method: 'PUT',
111
+ headers: { 'Content-Type': 'text/html' },
112
+ body: '<html>test</html>',
113
+ });
114
+ });
115
+
116
+ it('should use default content type when not specified', async () => {
117
+ global.fetch = vi.fn(() => Promise.resolve({ ok: true }));
118
+
119
+ await store.put('media', '42', new Blob([new Uint8Array(10)]));
120
+
121
+ expect(global.fetch).toHaveBeenCalledWith('/store/media/42', expect.objectContaining({
122
+ headers: { 'Content-Type': 'application/octet-stream' },
123
+ }));
124
+ });
125
+
126
+ it('should return false on error', async () => {
127
+ global.fetch = vi.fn(() => Promise.reject(new Error('Network error')));
128
+
129
+ const result = await store.put('widget', '1/2/3', 'data');
130
+
131
+ expect(result).toBe(false);
132
+ });
133
+ });
134
+
135
+ describe('remove()', () => {
136
+ it('should POST to /store/delete with file list', async () => {
137
+ global.fetch = vi.fn(() => Promise.resolve({
138
+ ok: true,
139
+ json: () => Promise.resolve({ deleted: 2, total: 2 }),
140
+ }));
141
+
142
+ const files = [
143
+ { type: 'media', id: '1' },
144
+ { type: 'media', id: '2' },
145
+ ];
146
+ const result = await store.remove(files);
147
+
148
+ expect(result).toEqual({ deleted: 2, total: 2 });
149
+ expect(global.fetch).toHaveBeenCalledWith('/store/delete', {
150
+ method: 'POST',
151
+ headers: { 'Content-Type': 'application/json' },
152
+ body: JSON.stringify({ files }),
153
+ });
154
+ });
155
+
156
+ it('should return zeros on error', async () => {
157
+ global.fetch = vi.fn(() => Promise.reject(new Error('fail')));
158
+
159
+ const result = await store.remove([{ type: 'media', id: '1' }]);
160
+
161
+ expect(result).toEqual({ deleted: 0, total: 1 });
162
+ });
163
+ });
164
+
165
+ describe('list()', () => {
166
+ it('should GET /store/list and return files', async () => {
167
+ const mockFiles = [
168
+ { id: '1', type: 'media', size: 1024 },
169
+ { id: '2', type: 'layout', size: 512 },
170
+ ];
171
+ global.fetch = vi.fn(() => Promise.resolve({
172
+ ok: true,
173
+ json: () => Promise.resolve({ files: mockFiles }),
174
+ }));
175
+
176
+ const files = await store.list();
177
+
178
+ expect(files).toEqual(mockFiles);
179
+ expect(global.fetch).toHaveBeenCalledWith('/store/list');
180
+ });
181
+
182
+ it('should return empty array on error', async () => {
183
+ global.fetch = vi.fn(() => Promise.reject(new Error('fail')));
184
+
185
+ const files = await store.list();
186
+
187
+ expect(files).toEqual([]);
188
+ });
189
+ });
190
+ });
191
+
192
+ // ===========================================================================
193
+ // DownloadClient Tests
194
+ // ===========================================================================
15
195
 
16
196
  /**
17
197
  * 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
198
  */
28
199
  function setupServiceWorker(opts = {}) {
29
200
  const {
@@ -36,8 +207,6 @@ function setupServiceWorker(opts = {}) {
36
207
  } = opts;
37
208
 
38
209
  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
210
  Object.defineProperty(navigator, 'serviceWorker', {
42
211
  value: undefined,
43
212
  configurable: true,
@@ -65,7 +234,7 @@ function setupServiceWorker(opts = {}) {
65
234
  controller,
66
235
  ready: swReadyResolves
67
236
  ? Promise.resolve(registration)
68
- : new Promise(() => {}), // never resolves
237
+ : new Promise(() => {}),
69
238
  getRegistration: vi.fn().mockResolvedValue(registration),
70
239
  addEventListener: vi.fn((event, handler) => {
71
240
  if (event === 'message') {
@@ -75,9 +244,7 @@ function setupServiceWorker(opts = {}) {
75
244
  removeEventListener: vi.fn(),
76
245
  };
77
246
 
78
- // Expose the message listeners so tests can dispatch SW_READY
79
247
  swContainer._messageListeners = messageListeners;
80
- // Also expose registration for manipulation
81
248
  swContainer._registration = registration;
82
249
 
83
250
  Object.defineProperty(navigator, 'serviceWorker', {
@@ -89,23 +256,13 @@ function setupServiceWorker(opts = {}) {
89
256
  return swContainer;
90
257
  }
91
258
 
92
- /**
93
- * Dispatch a simulated message event to all registered SW message listeners
94
- */
95
259
  function dispatchSWMessage(swContainer, data) {
96
260
  for (const listener of swContainer._messageListeners || []) {
97
261
  listener({ data });
98
262
  }
99
263
  }
100
264
 
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
265
  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
266
  const channels = [];
110
267
 
111
268
  global.MessageChannel = class {
@@ -118,11 +275,9 @@ function setupMessageChannel() {
118
275
  };
119
276
 
120
277
  return {
121
- /** The most recently created channel */
122
278
  get lastChannel() {
123
279
  return channels[channels.length - 1];
124
280
  },
125
- /** Simulate SW replying through the channel */
126
281
  respondOnLastChannel(data) {
127
282
  const ch = channels[channels.length - 1];
128
283
  if (ch && ch.port1.onmessage) {
@@ -133,259 +288,106 @@ function setupMessageChannel() {
133
288
  };
134
289
  }
135
290
 
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() {
291
+ async function createInitialisedDownloadClient() {
156
292
  const controller = { postMessage: vi.fn() };
157
293
  const sw = setupServiceWorker({ controller });
158
294
 
159
- const proxy = new CacheProxy();
160
- const initPromise = proxy.init();
295
+ const client = new DownloadClient();
296
+ const initPromise = client.init();
161
297
 
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
298
+ await Promise.resolve();
166
299
  dispatchSWMessage(sw, { type: 'SW_READY' });
167
300
 
168
301
  await initPromise;
169
- return { proxy, sw, controller };
302
+ return { client, sw, controller };
170
303
  }
171
304
 
172
- // ===========================================================================
173
- // Tests
174
- // ===========================================================================
175
-
176
- describe('CacheProxy', () => {
305
+ describe('DownloadClient', () => {
177
306
  beforeEach(() => {
178
307
  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');
308
+ Object.defineProperty(navigator, 'serviceWorker', {
309
+ value: undefined,
310
+ configurable: true,
311
+ writable: true,
230
312
  });
231
313
  });
232
- });
233
-
234
- describe('ServiceWorkerBackend', () => {
235
- beforeEach(() => {
236
- resetMocks();
237
- setupMessageChannel();
238
- });
239
314
 
240
315
  describe('init()', () => {
241
316
  it('should initialize with SW controller', async () => {
242
- const { proxy } = await createInitialisedProxy();
317
+ setupMessageChannel();
318
+ const { client } = await createInitialisedDownloadClient();
243
319
 
244
- expect(proxy.backendType).toBe('service-worker');
320
+ expect(client.controller).toBeTruthy();
245
321
  });
246
322
 
247
323
  it('should throw if SW not supported', async () => {
248
324
  setupServiceWorker({ supported: false });
249
325
 
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 });
326
+ const client = new DownloadClient();
257
327
 
258
- const proxy = new CacheProxy();
259
-
260
- await expect(proxy.init()).rejects.toThrow('Service Worker not controlling page');
328
+ await expect(client.init()).rejects.toThrow('Service Worker not supported');
261
329
  });
262
330
  });
263
331
 
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()', () => {
332
+ describe('download()', () => {
308
333
  it('should post DOWNLOAD_FILES message to SW', async () => {
309
- const { proxy } = await createInitialisedProxy();
334
+ setupMessageChannel();
335
+ const { client } = await createInitialisedDownloadClient();
336
+
337
+ client.controller.postMessage = vi.fn();
310
338
 
311
339
  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' },
340
+ { id: '1', type: 'media', path: 'http://test.com/file1.mp4' },
341
+ { id: '2', type: 'media', path: 'http://test.com/file2.mp4' },
314
342
  ];
315
343
 
316
- // Mock the backend method to verify call signature
317
- proxy.backend.requestDownload = vi.fn().mockResolvedValue();
344
+ const mc = setupMessageChannel();
345
+ const downloadPromise = client.download(files);
318
346
 
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' }];
347
+ // Simulate SW acknowledging the download
348
+ mc.respondOnLastChannel({
349
+ success: true,
350
+ enqueuedCount: 2,
351
+ activeCount: 2,
352
+ queuedCount: 0,
353
+ });
330
354
 
331
- await expect(proxy.requestDownload(files)).resolves.toBeUndefined();
355
+ await expect(downloadPromise).resolves.toBeUndefined();
332
356
  });
333
357
 
334
358
  it('should reject when SW returns error', async () => {
335
- const { proxy } = await createInitialisedProxy();
359
+ setupMessageChannel();
360
+ const { client } = await createInitialisedDownloadClient();
361
+
362
+ client.controller.postMessage = vi.fn();
336
363
 
337
- proxy.backend.requestDownload = vi.fn().mockRejectedValue(new Error('Download failed'));
364
+ const mc = setupMessageChannel();
365
+ const downloadPromise = client.download([]);
338
366
 
339
- const files = [{ id: '1', type: 'media', path: 'http://test.com/file.mp4' }];
367
+ mc.respondOnLastChannel({ success: false, error: 'Download failed' });
340
368
 
341
- await expect(proxy.requestDownload(files)).rejects.toThrow('Download failed');
369
+ await expect(downloadPromise).rejects.toThrow('Download failed');
342
370
  });
343
371
 
344
372
  it('should throw if SW controller not available', async () => {
345
- const { proxy } = await createInitialisedProxy();
373
+ setupMessageChannel();
374
+ const { client } = await createInitialisedDownloadClient();
346
375
 
347
- // Simulate controller becoming null after init
348
- proxy.backend.controller = null;
376
+ client.controller = null;
349
377
 
350
- await expect(proxy.requestDownload([])).rejects.toThrow('Service Worker not available');
378
+ await expect(client.download([])).rejects.toThrow('Service Worker not available');
351
379
  });
352
380
  });
353
381
 
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')));
382
+ describe('getProgress()', () => {
383
+ it('should return empty object when controller is null', async () => {
384
+ setupMessageChannel();
385
+ const { client } = await createInitialisedDownloadClient();
386
+ client.controller = null;
385
387
 
386
- const cached = await proxy.isCached('media', '123');
388
+ const progress = await client.getProgress();
387
389
 
388
- expect(cached).toBe(false);
390
+ expect(progress).toEqual({});
389
391
  });
390
392
  });
391
393
  });