@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.
- package/README.md +22 -13
- package/docs/CACHE_PROXY_ARCHITECTURE.md +165 -368
- package/package.json +7 -4
- package/src/cache-analyzer.js +9 -5
- package/src/cache-analyzer.test.js +6 -6
- package/src/cache-proxy.test.js +239 -237
- package/src/cache.js +3 -6
- package/src/cache.test.js +2 -30
- package/src/download-client.js +222 -0
- package/src/download-manager.js +48 -5
- package/src/index.js +3 -2
- package/src/store-client.js +114 -0
- package/src/widget-html.js +70 -54
- package/src/widget-html.test.js +71 -62
- package/src/cache-proxy.js +0 -532
package/src/cache-analyzer.js
CHANGED
|
@@ -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
|
|
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('./
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
16
|
+
// Mock StoreClient with in-memory file store
|
|
17
17
|
const files = new Map();
|
|
18
18
|
|
|
19
19
|
mockCache = {
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
//
|
|
218
|
-
expect(mockCache.
|
|
217
|
+
// remove should be called on the store client
|
|
218
|
+
expect(mockCache.remove).toHaveBeenCalled();
|
|
219
219
|
|
|
220
220
|
vi.unstubAllGlobals();
|
|
221
221
|
});
|
package/src/cache-proxy.test.js
CHANGED
|
@@ -1,29 +1,200 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* StoreClient & DownloadClient Tests
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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(() => {}),
|
|
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
|
|
160
|
-
const initPromise =
|
|
295
|
+
const client = new DownloadClient();
|
|
296
|
+
const initPromise = client.init();
|
|
161
297
|
|
|
162
|
-
|
|
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 {
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
317
|
+
setupMessageChannel();
|
|
318
|
+
const { client } = await createInitialisedDownloadClient();
|
|
243
319
|
|
|
244
|
-
expect(
|
|
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
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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'
|
|
313
|
-
{ id: '2', type: 'media', path: 'http://test.com/file2.mp4'
|
|
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
|
-
|
|
317
|
-
|
|
344
|
+
const mc = setupMessageChannel();
|
|
345
|
+
const downloadPromise = client.download(files);
|
|
318
346
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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(
|
|
355
|
+
await expect(downloadPromise).resolves.toBeUndefined();
|
|
332
356
|
});
|
|
333
357
|
|
|
334
358
|
it('should reject when SW returns error', async () => {
|
|
335
|
-
|
|
359
|
+
setupMessageChannel();
|
|
360
|
+
const { client } = await createInitialisedDownloadClient();
|
|
361
|
+
|
|
362
|
+
client.controller.postMessage = vi.fn();
|
|
336
363
|
|
|
337
|
-
|
|
364
|
+
const mc = setupMessageChannel();
|
|
365
|
+
const downloadPromise = client.download([]);
|
|
338
366
|
|
|
339
|
-
|
|
367
|
+
mc.respondOnLastChannel({ success: false, error: 'Download failed' });
|
|
340
368
|
|
|
341
|
-
await expect(
|
|
369
|
+
await expect(downloadPromise).rejects.toThrow('Download failed');
|
|
342
370
|
});
|
|
343
371
|
|
|
344
372
|
it('should throw if SW controller not available', async () => {
|
|
345
|
-
|
|
373
|
+
setupMessageChannel();
|
|
374
|
+
const { client } = await createInitialisedDownloadClient();
|
|
346
375
|
|
|
347
|
-
|
|
348
|
-
proxy.backend.controller = null;
|
|
376
|
+
client.controller = null;
|
|
349
377
|
|
|
350
|
-
await expect(
|
|
378
|
+
await expect(client.download([])).rejects.toThrow('Service Worker not available');
|
|
351
379
|
});
|
|
352
380
|
});
|
|
353
381
|
|
|
354
|
-
describe('
|
|
355
|
-
it('should
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
388
|
+
const progress = await client.getProgress();
|
|
387
389
|
|
|
388
|
-
expect(
|
|
390
|
+
expect(progress).toEqual({});
|
|
389
391
|
});
|
|
390
392
|
});
|
|
391
393
|
});
|