@xiboplayer/cache 0.1.3 → 0.3.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.
- package/README.md +50 -0
- package/package.json +2 -2
- package/src/cache-proxy.js +12 -17
- package/src/cache.test.js +12 -2
- package/src/download-manager.js +767 -303
- package/src/download-manager.test.js +1130 -325
- package/src/index.js +3 -1
- package/docs/README.md +0 -118
|
@@ -1,79 +1,132 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DownloadManager Tests
|
|
2
|
+
* DownloadManager Tests — Flat Queue Architecture
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Tests for:
|
|
5
|
+
* - DownloadTask: Single-fetch unit (one HTTP request)
|
|
6
|
+
* - FileDownload: Orchestrator (HEAD + creates tasks)
|
|
7
|
+
* - DownloadQueue: Flat queue with single concurrency limit
|
|
8
|
+
* - DownloadManager: Public facade
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
9
|
-
import { DownloadTask, DownloadQueue, DownloadManager } from './download-manager.js';
|
|
12
|
+
import { DownloadTask, FileDownload, DownloadQueue, DownloadManager, LayoutTaskBuilder, BARRIER, PRIORITY } from './download-manager.js';
|
|
10
13
|
import { mockFetch, mockChunkedFetch, createTestBlob, waitFor, createSpy } from './test-utils.js';
|
|
11
14
|
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// DownloadTask — Single HTTP fetch unit
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
12
19
|
describe('DownloadTask', () => {
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
13
24
|
describe('State Machine', () => {
|
|
14
25
|
it('should start in pending state', () => {
|
|
15
26
|
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
16
27
|
|
|
17
|
-
// Post-condition: Initial state
|
|
18
28
|
expect(task.state).toBe('pending');
|
|
19
|
-
expect(task.
|
|
20
|
-
expect(task.
|
|
21
|
-
expect(task.waiters.length).toBe(0);
|
|
29
|
+
expect(task.blob).toBeNull();
|
|
30
|
+
expect(task.chunkIndex).toBeNull();
|
|
22
31
|
});
|
|
23
32
|
|
|
24
33
|
it('should transition pending -> downloading -> complete', async () => {
|
|
25
|
-
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
26
34
|
const testBlob = createTestBlob(1024);
|
|
35
|
+
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
27
36
|
|
|
28
37
|
mockFetch({
|
|
29
|
-
'
|
|
30
|
-
headers: { 'Content-Length': '1024' }
|
|
31
|
-
},
|
|
32
|
-
'GET http://test.com/file.mp4': {
|
|
33
|
-
blob: testBlob
|
|
34
|
-
}
|
|
38
|
+
'GET http://test.com/file.mp4': { blob: testBlob }
|
|
35
39
|
});
|
|
36
40
|
|
|
37
|
-
|
|
38
|
-
expect(task.state).toBe('pending');
|
|
39
|
-
|
|
40
|
-
// Start download
|
|
41
|
-
const promise = task.start();
|
|
42
|
-
|
|
43
|
-
// Should be downloading (but completes quickly in tests)
|
|
44
|
-
await promise;
|
|
41
|
+
await task.start();
|
|
45
42
|
|
|
46
|
-
// Post-condition
|
|
47
43
|
expect(task.state).toBe('complete');
|
|
48
|
-
expect(task.
|
|
49
|
-
expect(task.
|
|
44
|
+
expect(task.blob).toBeInstanceOf(Blob);
|
|
45
|
+
expect(task.blob.size).toBe(1024);
|
|
50
46
|
});
|
|
51
47
|
|
|
52
|
-
it('should transition
|
|
48
|
+
it('should transition to failed on HTTP error after retries', async () => {
|
|
53
49
|
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
54
50
|
|
|
55
51
|
mockFetch({
|
|
56
|
-
'
|
|
57
|
-
ok: false,
|
|
58
|
-
status: 500,
|
|
59
|
-
statusText: 'Server Error'
|
|
60
|
-
}
|
|
52
|
+
'GET http://test.com/file.mp4': { ok: false, status: 500 }
|
|
61
53
|
});
|
|
62
54
|
|
|
63
|
-
|
|
64
|
-
expect(task.state).toBe('
|
|
55
|
+
await expect(task.start()).rejects.toThrow('Fetch failed: 500');
|
|
56
|
+
expect(task.state).toBe('failed');
|
|
57
|
+
}, 5000); // Retry backoff: 500ms + 1s + 1.5s = 3s
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('Range Requests', () => {
|
|
61
|
+
it('should send Range header for chunk tasks', async () => {
|
|
62
|
+
const sourceBlob = createTestBlob(200);
|
|
63
|
+
const task = new DownloadTask(
|
|
64
|
+
{ id: '1', type: 'media', path: 'http://test.com/file.mp4' },
|
|
65
|
+
{ chunkIndex: 0, rangeStart: 0, rangeEnd: 99 }
|
|
66
|
+
);
|
|
65
67
|
|
|
66
|
-
|
|
67
|
-
await expect(task.start()).rejects.toThrow();
|
|
68
|
+
const fetchMock = mockChunkedFetch(sourceBlob);
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
await task.start();
|
|
71
|
+
|
|
72
|
+
expect(task.chunkIndex).toBe(0);
|
|
73
|
+
expect(task.blob.size).toBe(100);
|
|
74
|
+
|
|
75
|
+
// Verify Range header was sent
|
|
76
|
+
const call = fetchMock.mock.calls.find(c => c[1]?.headers?.Range);
|
|
77
|
+
expect(call[1].headers.Range).toBe('bytes=0-99');
|
|
71
78
|
});
|
|
72
79
|
});
|
|
73
80
|
|
|
74
|
-
describe('
|
|
75
|
-
it('should
|
|
81
|
+
describe('Retry Logic', () => {
|
|
82
|
+
it('should retry on failure with exponential backoff', async () => {
|
|
83
|
+
const testBlob = createTestBlob(1024);
|
|
84
|
+
let attempts = 0;
|
|
85
|
+
|
|
86
|
+
global.fetch = vi.fn(async () => {
|
|
87
|
+
attempts++;
|
|
88
|
+
if (attempts < 3) {
|
|
89
|
+
return { ok: false, status: 503, headers: { get: () => null } };
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
ok: true, status: 200,
|
|
93
|
+
headers: { get: () => null },
|
|
94
|
+
blob: () => Promise.resolve(testBlob)
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
|
|
76
98
|
const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
99
|
+
await task.start();
|
|
100
|
+
|
|
101
|
+
expect(task.state).toBe('complete');
|
|
102
|
+
expect(attempts).toBe(3);
|
|
103
|
+
}, 5000); // Retry backoff: 500ms + 1s = 1.5s before 3rd attempt
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ============================================================================
|
|
108
|
+
// FileDownload — Orchestrator
|
|
109
|
+
// ============================================================================
|
|
110
|
+
|
|
111
|
+
describe('FileDownload', () => {
|
|
112
|
+
afterEach(() => {
|
|
113
|
+
vi.restoreAllMocks();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('State Machine', () => {
|
|
117
|
+
it('should start in pending state', () => {
|
|
118
|
+
const file = new FileDownload({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
119
|
+
|
|
120
|
+
expect(file.state).toBe('pending');
|
|
121
|
+
expect(file.downloadedBytes).toBe(0);
|
|
122
|
+
expect(file.totalBytes).toBe(0);
|
|
123
|
+
expect(file.totalChunks).toBe(0);
|
|
124
|
+
expect(file.onChunkDownloaded).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('Small File Downloads (≤ 100MB)', () => {
|
|
129
|
+
it('should create single task and resolve wait() with blob', async () => {
|
|
77
130
|
const testBlob = createTestBlob(1024);
|
|
78
131
|
|
|
79
132
|
mockFetch({
|
|
@@ -81,21 +134,66 @@ describe('DownloadTask', () => {
|
|
|
81
134
|
'GET http://test.com/file.mp4': { blob: testBlob }
|
|
82
135
|
});
|
|
83
136
|
|
|
84
|
-
|
|
85
|
-
|
|
137
|
+
const file = new FileDownload(
|
|
138
|
+
{ id: '1', type: 'media', path: 'http://test.com/file.mp4' }
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Simulate what DownloadQueue does
|
|
142
|
+
const mockQueue = { enqueueChunkTasks: vi.fn(), processQueue: vi.fn() };
|
|
143
|
+
mockQueue.enqueueChunkTasks.mockImplementation((tasks) => {
|
|
144
|
+
// Immediately start tasks (simulate queue processing)
|
|
145
|
+
for (const task of tasks) {
|
|
146
|
+
task.start()
|
|
147
|
+
.then(() => task._parentFile.onTaskComplete(task))
|
|
148
|
+
.catch(err => task._parentFile.onTaskFailed(task, err));
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await file.prepare(mockQueue);
|
|
153
|
+
|
|
154
|
+
expect(file.tasks.length).toBe(1);
|
|
155
|
+
expect(file.tasks[0].chunkIndex).toBeNull(); // Full file, not a chunk
|
|
86
156
|
|
|
87
|
-
|
|
88
|
-
const blob = await task.wait();
|
|
157
|
+
const blob = await file.wait();
|
|
89
158
|
|
|
90
|
-
|
|
159
|
+
expect(file.state).toBe('complete');
|
|
91
160
|
expect(blob).toBeInstanceOf(Blob);
|
|
92
161
|
expect(blob.size).toBe(1024);
|
|
162
|
+
expect(file.downloadedBytes).toBe(1024);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should update downloadedBytes correctly', async () => {
|
|
166
|
+
const testBlob = createTestBlob(5000);
|
|
93
167
|
|
|
94
|
-
|
|
168
|
+
mockFetch({
|
|
169
|
+
'HEAD http://test.com/file.mp4': { headers: { 'Content-Length': '5000' } },
|
|
170
|
+
'GET http://test.com/file.mp4': { blob: testBlob }
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const file = new FileDownload(
|
|
174
|
+
{ id: '1', type: 'media', path: 'http://test.com/file.mp4' }
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const mockQueue = {
|
|
178
|
+
enqueueChunkTasks: vi.fn((tasks) => {
|
|
179
|
+
for (const task of tasks) {
|
|
180
|
+
task.start()
|
|
181
|
+
.then(() => task._parentFile.onTaskComplete(task))
|
|
182
|
+
.catch(err => task._parentFile.onTaskFailed(task, err));
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
await file.prepare(mockQueue);
|
|
188
|
+
await file.wait();
|
|
189
|
+
|
|
190
|
+
expect(file.downloadedBytes).toBe(5000);
|
|
191
|
+
expect(file.totalBytes).toBe(5000);
|
|
95
192
|
});
|
|
193
|
+
});
|
|
96
194
|
|
|
97
|
-
|
|
98
|
-
|
|
195
|
+
describe('wait()', () => {
|
|
196
|
+
it('should support multiple concurrent waiters', async () => {
|
|
99
197
|
const testBlob = createTestBlob(1024);
|
|
100
198
|
|
|
101
199
|
mockFetch({
|
|
@@ -103,127 +201,449 @@ describe('DownloadTask', () => {
|
|
|
103
201
|
'GET http://test.com/file.mp4': { blob: testBlob }
|
|
104
202
|
});
|
|
105
203
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
204
|
+
const file = new FileDownload(
|
|
205
|
+
{ id: '1', type: 'media', path: 'http://test.com/file.mp4' }
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
// Set up waiters before download starts
|
|
209
|
+
const waiter1 = file.wait();
|
|
210
|
+
const waiter2 = file.wait();
|
|
211
|
+
const waiter3 = file.wait();
|
|
110
212
|
|
|
111
|
-
|
|
213
|
+
const mockQueue = {
|
|
214
|
+
enqueueChunkTasks: vi.fn((tasks) => {
|
|
215
|
+
for (const task of tasks) {
|
|
216
|
+
task.start()
|
|
217
|
+
.then(() => task._parentFile.onTaskComplete(task))
|
|
218
|
+
.catch(err => task._parentFile.onTaskFailed(task, err));
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
};
|
|
112
222
|
|
|
113
|
-
|
|
114
|
-
await task.start();
|
|
223
|
+
await file.prepare(mockQueue);
|
|
115
224
|
|
|
116
225
|
// All waiters resolve with same blob
|
|
117
226
|
const [blob1, blob2, blob3] = await Promise.all([waiter1, waiter2, waiter3]);
|
|
118
|
-
|
|
119
227
|
expect(blob1).toBe(blob2);
|
|
120
228
|
expect(blob2).toBe(blob3);
|
|
121
|
-
expect(
|
|
229
|
+
expect(blob1.size).toBe(1024);
|
|
122
230
|
});
|
|
123
231
|
|
|
124
|
-
it('should
|
|
125
|
-
|
|
126
|
-
const testBlob = createTestBlob(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
232
|
+
it('should still create download task when HEAD fails (graceful fallback)', async () => {
|
|
233
|
+
// HEAD failure is non-fatal — the GET will be attempted
|
|
234
|
+
const testBlob = createTestBlob(512);
|
|
235
|
+
global.fetch = vi.fn(async (url, opts) => {
|
|
236
|
+
if (opts?.method === 'HEAD') {
|
|
237
|
+
return { ok: false, status: 404, headers: { get: () => null } };
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
ok: true, status: 200,
|
|
241
|
+
headers: { get: () => null },
|
|
242
|
+
blob: () => Promise.resolve(testBlob)
|
|
243
|
+
};
|
|
131
244
|
});
|
|
132
245
|
|
|
133
|
-
|
|
246
|
+
const file = new FileDownload(
|
|
247
|
+
{ id: '1', type: 'media', path: 'http://test.com/file.mp4' }
|
|
248
|
+
);
|
|
134
249
|
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
250
|
+
const waiter1 = file.wait();
|
|
251
|
+
const waiter2 = file.wait();
|
|
252
|
+
|
|
253
|
+
const mockQueue = {
|
|
254
|
+
enqueueChunkTasks: vi.fn((tasks) => {
|
|
255
|
+
for (const task of tasks) {
|
|
256
|
+
task.start()
|
|
257
|
+
.then(() => task._parentFile.onTaskComplete(task))
|
|
258
|
+
.catch(err => task._parentFile.onTaskFailed(task, err));
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
};
|
|
262
|
+
await file.prepare(mockQueue);
|
|
263
|
+
|
|
264
|
+
// Both waiters resolve (GET succeeds despite HEAD failing)
|
|
265
|
+
const [blob1, blob2] = await Promise.all([waiter1, waiter2]);
|
|
266
|
+
expect(blob1).toBe(blob2);
|
|
267
|
+
expect(file.state).toBe('complete');
|
|
138
268
|
});
|
|
269
|
+
});
|
|
139
270
|
|
|
140
|
-
|
|
141
|
-
|
|
271
|
+
describe('Error Handling', () => {
|
|
272
|
+
it('should handle network errors gracefully', async () => {
|
|
273
|
+
global.fetch = vi.fn(() => Promise.reject(new Error('Network error')));
|
|
142
274
|
|
|
143
|
-
|
|
144
|
-
'
|
|
275
|
+
const file = new FileDownload(
|
|
276
|
+
{ id: '1', type: 'media', path: 'http://test.com/file.mp4' }
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const mockQueue = { enqueueChunkTasks: vi.fn() };
|
|
280
|
+
await file.prepare(mockQueue);
|
|
281
|
+
|
|
282
|
+
await expect(file.wait()).rejects.toThrow('Network error');
|
|
283
|
+
expect(file.state).toBe('failed');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should fail when both HEAD and GET fail', async () => {
|
|
287
|
+
// When HEAD fails (size unknown) and GET also fails → download fails
|
|
288
|
+
global.fetch = vi.fn(async () => {
|
|
289
|
+
return { ok: false, status: 500, headers: { get: () => null } };
|
|
145
290
|
});
|
|
146
291
|
|
|
147
|
-
const
|
|
148
|
-
|
|
292
|
+
const file = new FileDownload(
|
|
293
|
+
{ id: '1', type: 'media', path: 'http://test.com/file.mp4' }
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
const mockQueue = {
|
|
297
|
+
enqueueChunkTasks: vi.fn((tasks) => {
|
|
298
|
+
for (const task of tasks) {
|
|
299
|
+
task.start()
|
|
300
|
+
.then(() => task._parentFile.onTaskComplete(task))
|
|
301
|
+
.catch(err => task._parentFile.onTaskFailed(task, err));
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
};
|
|
305
|
+
await file.prepare(mockQueue);
|
|
306
|
+
|
|
307
|
+
await expect(file.wait()).rejects.toThrow('Fetch failed: 500');
|
|
308
|
+
expect(file.state).toBe('failed');
|
|
309
|
+
}, 10000);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ============================================================================
|
|
314
|
+
// FileDownload - Skip HEAD when size is known
|
|
315
|
+
// ============================================================================
|
|
149
316
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
317
|
+
describe('FileDownload - Skip HEAD', () => {
|
|
318
|
+
afterEach(() => {
|
|
319
|
+
vi.restoreAllMocks();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should skip HEAD when fileInfo.size is provided', async () => {
|
|
323
|
+
const testBlob = createTestBlob(1024);
|
|
324
|
+
const fetchSpy = vi.fn(async (url, opts) => {
|
|
325
|
+
return {
|
|
326
|
+
ok: true, status: 200,
|
|
327
|
+
headers: { get: () => null },
|
|
328
|
+
blob: () => Promise.resolve(testBlob)
|
|
329
|
+
};
|
|
330
|
+
});
|
|
331
|
+
global.fetch = fetchSpy;
|
|
332
|
+
|
|
333
|
+
const file = new FileDownload(
|
|
334
|
+
{ id: '1', type: 'media', path: 'http://test.com/file.mp4', size: 1024 }
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
const mockQueue = {
|
|
338
|
+
enqueueChunkTasks: vi.fn((tasks) => {
|
|
339
|
+
for (const task of tasks) {
|
|
340
|
+
task.start()
|
|
341
|
+
.then(() => task._parentFile.onTaskComplete(task))
|
|
342
|
+
.catch(err => task._parentFile.onTaskFailed(task, err));
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
await file.prepare(mockQueue);
|
|
348
|
+
await file.wait();
|
|
349
|
+
|
|
350
|
+
// No HEAD request should have been made — only GET
|
|
351
|
+
const headCalls = fetchSpy.mock.calls.filter(c => c[1]?.method === 'HEAD');
|
|
352
|
+
expect(headCalls.length).toBe(0);
|
|
353
|
+
expect(file.totalBytes).toBe(1024);
|
|
354
|
+
expect(file.state).toBe('complete');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('should fall back to HEAD when size is 0', async () => {
|
|
358
|
+
const testBlob = createTestBlob(2048);
|
|
359
|
+
global.fetch = vi.fn(async (url, opts) => {
|
|
360
|
+
if (opts?.method === 'HEAD') {
|
|
361
|
+
return {
|
|
362
|
+
ok: true, status: 200,
|
|
363
|
+
headers: { get: (name) => name === 'Content-Length' ? '2048' : 'video/mp4' }
|
|
364
|
+
};
|
|
155
365
|
}
|
|
366
|
+
return {
|
|
367
|
+
ok: true, status: 200,
|
|
368
|
+
headers: { get: () => null },
|
|
369
|
+
blob: () => Promise.resolve(testBlob)
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const file = new FileDownload(
|
|
374
|
+
{ id: '1', type: 'media', path: 'http://test.com/file.mp4', size: 0 }
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
const mockQueue = {
|
|
378
|
+
enqueueChunkTasks: vi.fn((tasks) => {
|
|
379
|
+
for (const task of tasks) {
|
|
380
|
+
task.start()
|
|
381
|
+
.then(() => task._parentFile.onTaskComplete(task))
|
|
382
|
+
.catch(err => task._parentFile.onTaskFailed(task, err));
|
|
383
|
+
}
|
|
384
|
+
})
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
await file.prepare(mockQueue);
|
|
388
|
+
await file.wait();
|
|
156
389
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
390
|
+
// HEAD was called as fallback
|
|
391
|
+
const headCalls = global.fetch.mock.calls.filter(c => c[1]?.method === 'HEAD');
|
|
392
|
+
expect(headCalls.length).toBe(1);
|
|
393
|
+
expect(file.totalBytes).toBe(2048);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should fall back to HEAD when size is missing', async () => {
|
|
397
|
+
const testBlob = createTestBlob(512);
|
|
398
|
+
global.fetch = vi.fn(async (url, opts) => {
|
|
399
|
+
if (opts?.method === 'HEAD') {
|
|
400
|
+
return {
|
|
401
|
+
ok: true, status: 200,
|
|
402
|
+
headers: { get: (name) => name === 'Content-Length' ? '512' : null }
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
ok: true, status: 200,
|
|
407
|
+
headers: { get: () => null },
|
|
408
|
+
blob: () => Promise.resolve(testBlob)
|
|
409
|
+
};
|
|
161
410
|
});
|
|
411
|
+
|
|
412
|
+
const file = new FileDownload(
|
|
413
|
+
{ id: '1', type: 'media', path: 'http://test.com/file.mp4' }
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
const mockQueue = {
|
|
417
|
+
enqueueChunkTasks: vi.fn((tasks) => {
|
|
418
|
+
for (const task of tasks) {
|
|
419
|
+
task.start()
|
|
420
|
+
.then(() => task._parentFile.onTaskComplete(task))
|
|
421
|
+
.catch(err => task._parentFile.onTaskFailed(task, err));
|
|
422
|
+
}
|
|
423
|
+
})
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
await file.prepare(mockQueue);
|
|
427
|
+
|
|
428
|
+
const headCalls = global.fetch.mock.calls.filter(c => c[1]?.method === 'HEAD');
|
|
429
|
+
expect(headCalls.length).toBe(1);
|
|
162
430
|
});
|
|
163
431
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
432
|
+
it('should infer content type from file extension', async () => {
|
|
433
|
+
global.fetch = vi.fn(async () => ({
|
|
434
|
+
ok: true, status: 200,
|
|
435
|
+
headers: { get: () => null },
|
|
436
|
+
blob: () => Promise.resolve(createTestBlob(100))
|
|
437
|
+
}));
|
|
438
|
+
|
|
439
|
+
const file = new FileDownload(
|
|
440
|
+
{ id: '1', type: 'media', path: 'http://test.com/image.png', size: 100 }
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const mockQueue = {
|
|
444
|
+
enqueueChunkTasks: vi.fn((tasks) => {
|
|
445
|
+
for (const task of tasks) {
|
|
446
|
+
task.start()
|
|
447
|
+
.then(() => task._parentFile.onTaskComplete(task))
|
|
448
|
+
.catch(err => task._parentFile.onTaskFailed(task, err));
|
|
449
|
+
}
|
|
450
|
+
})
|
|
451
|
+
};
|
|
168
452
|
|
|
169
|
-
|
|
170
|
-
'HEAD http://test.com/small.mp4': {
|
|
171
|
-
headers: { 'Content-Length': String(10 * 1024 * 1024) }
|
|
172
|
-
},
|
|
173
|
-
'GET http://test.com/small.mp4': { blob: testBlob }
|
|
174
|
-
});
|
|
453
|
+
await file.prepare(mockQueue);
|
|
175
454
|
|
|
176
|
-
|
|
455
|
+
expect(file._contentType).toBe('image/png');
|
|
456
|
+
});
|
|
457
|
+
});
|
|
177
458
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
459
|
+
// ============================================================================
|
|
460
|
+
// FileDownload - Progressive Streaming
|
|
461
|
+
// ============================================================================
|
|
462
|
+
|
|
463
|
+
describe('FileDownload - Progressive Streaming', () => {
|
|
464
|
+
afterEach(() => {
|
|
465
|
+
vi.restoreAllMocks();
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
describe('onChunkDownloaded callback', () => {
|
|
469
|
+
it('should initialize onChunkDownloaded as null', () => {
|
|
470
|
+
const file = new FileDownload({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
471
|
+
expect(file.onChunkDownloaded).toBeNull();
|
|
185
472
|
});
|
|
186
473
|
|
|
187
|
-
it('should
|
|
188
|
-
|
|
189
|
-
const
|
|
474
|
+
it('should call onChunkDownloaded for each chunk during chunked download', async () => {
|
|
475
|
+
// 200MB file → will use chunks (threshold is 100MB)
|
|
476
|
+
const fileSize = 200 * 1024 * 1024;
|
|
477
|
+
const sourceBlob = createTestBlob(fileSize, 'video/mp4');
|
|
190
478
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
479
|
+
mockChunkedFetch(sourceBlob);
|
|
480
|
+
|
|
481
|
+
const file = new FileDownload(
|
|
482
|
+
{ id: '1', type: 'media', path: 'http://test.com/big.mp4' },
|
|
483
|
+
{ chunkSize: 50 * 1024 * 1024 }
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const chunkCalls = [];
|
|
487
|
+
file.onChunkDownloaded = vi.fn(async (index, blob, total) => {
|
|
488
|
+
chunkCalls.push({ index, size: blob.size, total });
|
|
194
489
|
});
|
|
195
490
|
|
|
196
|
-
|
|
491
|
+
const mockQueue = {
|
|
492
|
+
enqueueChunkTasks: vi.fn((tasks) => {
|
|
493
|
+
for (const task of tasks) {
|
|
494
|
+
task.start()
|
|
495
|
+
.then(() => task._parentFile.onTaskComplete(task))
|
|
496
|
+
.catch(err => task._parentFile.onTaskFailed(task, err));
|
|
497
|
+
}
|
|
498
|
+
})
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
await file.prepare(mockQueue);
|
|
502
|
+
await file.wait();
|
|
503
|
+
|
|
504
|
+
// 200MB / 50MB = 4 chunks
|
|
505
|
+
expect(file.onChunkDownloaded).toHaveBeenCalledTimes(4);
|
|
506
|
+
expect(chunkCalls.length).toBe(4);
|
|
507
|
+
|
|
508
|
+
for (const call of chunkCalls) {
|
|
509
|
+
expect(call.total).toBe(4);
|
|
510
|
+
expect(call.size).toBeGreaterThan(0);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// All chunk indices should be present
|
|
514
|
+
const indices = chunkCalls.map(c => c.index).sort();
|
|
515
|
+
expect(indices).toEqual([0, 1, 2, 3]);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('should return empty blob when onChunkDownloaded is set', async () => {
|
|
519
|
+
const fileSize = 200 * 1024 * 1024;
|
|
520
|
+
const sourceBlob = createTestBlob(fileSize, 'video/mp4');
|
|
521
|
+
|
|
522
|
+
mockChunkedFetch(sourceBlob);
|
|
523
|
+
|
|
524
|
+
const file = new FileDownload(
|
|
525
|
+
{ id: '1', type: 'media', path: 'http://test.com/big.mp4' },
|
|
526
|
+
{ chunkSize: 50 * 1024 * 1024 }
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
file.onChunkDownloaded = vi.fn(async () => {});
|
|
530
|
+
|
|
531
|
+
const mockQueue = {
|
|
532
|
+
enqueueChunkTasks: vi.fn((tasks) => {
|
|
533
|
+
for (const task of tasks) {
|
|
534
|
+
task.start()
|
|
535
|
+
.then(() => task._parentFile.onTaskComplete(task))
|
|
536
|
+
.catch(err => task._parentFile.onTaskFailed(task, err));
|
|
537
|
+
}
|
|
538
|
+
})
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
await file.prepare(mockQueue);
|
|
542
|
+
const blob = await file.wait();
|
|
197
543
|
|
|
198
|
-
|
|
199
|
-
expect(
|
|
200
|
-
expect(task.downloadedBytes).toBe(5000);
|
|
544
|
+
expect(blob.size).toBe(0);
|
|
545
|
+
expect(file.state).toBe('complete');
|
|
201
546
|
});
|
|
202
|
-
});
|
|
203
547
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
548
|
+
it('should return full blob when onChunkDownloaded is NOT set', async () => {
|
|
549
|
+
const fileSize = 200 * 1024 * 1024;
|
|
550
|
+
const sourceBlob = createTestBlob(fileSize, 'video/mp4');
|
|
207
551
|
|
|
208
|
-
|
|
552
|
+
mockChunkedFetch(sourceBlob);
|
|
209
553
|
|
|
210
|
-
|
|
211
|
-
|
|
554
|
+
const file = new FileDownload(
|
|
555
|
+
{ id: '1', type: 'media', path: 'http://test.com/big.mp4' },
|
|
556
|
+
{ chunkSize: 50 * 1024 * 1024 }
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
// No callback → traditional reassembly
|
|
560
|
+
const mockQueue = {
|
|
561
|
+
enqueueChunkTasks: vi.fn((tasks) => {
|
|
562
|
+
for (const task of tasks) {
|
|
563
|
+
task.start()
|
|
564
|
+
.then(() => task._parentFile.onTaskComplete(task))
|
|
565
|
+
.catch(err => task._parentFile.onTaskFailed(task, err));
|
|
566
|
+
}
|
|
567
|
+
})
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
await file.prepare(mockQueue);
|
|
571
|
+
const blob = await file.wait();
|
|
572
|
+
|
|
573
|
+
expect(blob.size).toBe(fileSize);
|
|
574
|
+
expect(file.state).toBe('complete');
|
|
212
575
|
});
|
|
213
576
|
|
|
214
|
-
it('should
|
|
215
|
-
const
|
|
577
|
+
it('should not call onChunkDownloaded for small files (single request)', async () => {
|
|
578
|
+
const fileSize = 10 * 1024 * 1024; // 10MB - below 100MB threshold
|
|
579
|
+
const sourceBlob = createTestBlob(fileSize);
|
|
216
580
|
|
|
217
|
-
|
|
218
|
-
|
|
581
|
+
mockChunkedFetch(sourceBlob);
|
|
582
|
+
|
|
583
|
+
const file = new FileDownload(
|
|
584
|
+
{ id: '1', type: 'media', path: 'http://test.com/small.mp4' },
|
|
585
|
+
{ chunkSize: 50 * 1024 * 1024 }
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
file.onChunkDownloaded = vi.fn(async () => {});
|
|
589
|
+
|
|
590
|
+
const mockQueue = {
|
|
591
|
+
enqueueChunkTasks: vi.fn((tasks) => {
|
|
592
|
+
for (const task of tasks) {
|
|
593
|
+
task.start()
|
|
594
|
+
.then(() => task._parentFile.onTaskComplete(task))
|
|
595
|
+
.catch(err => task._parentFile.onTaskFailed(task, err));
|
|
596
|
+
}
|
|
597
|
+
})
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
await file.prepare(mockQueue);
|
|
601
|
+
const blob = await file.wait();
|
|
602
|
+
|
|
603
|
+
// Small file uses single task → callback not called
|
|
604
|
+
expect(file.onChunkDownloaded).not.toHaveBeenCalled();
|
|
605
|
+
expect(blob.size).toBe(fileSize);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('should handle async callback errors gracefully', async () => {
|
|
609
|
+
const fileSize = 200 * 1024 * 1024;
|
|
610
|
+
const sourceBlob = createTestBlob(fileSize, 'video/mp4');
|
|
611
|
+
|
|
612
|
+
mockChunkedFetch(sourceBlob);
|
|
613
|
+
|
|
614
|
+
const file = new FileDownload(
|
|
615
|
+
{ id: '1', type: 'media', path: 'http://test.com/big.mp4' },
|
|
616
|
+
{ chunkSize: 50 * 1024 * 1024 }
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
// Callback throws — should not crash download
|
|
620
|
+
file.onChunkDownloaded = vi.fn(async () => {
|
|
621
|
+
throw new Error('Cache storage failed');
|
|
219
622
|
});
|
|
220
623
|
|
|
221
|
-
|
|
222
|
-
|
|
624
|
+
const mockQueue = {
|
|
625
|
+
enqueueChunkTasks: vi.fn((tasks) => {
|
|
626
|
+
for (const task of tasks) {
|
|
627
|
+
task.start()
|
|
628
|
+
.then(() => task._parentFile.onTaskComplete(task))
|
|
629
|
+
.catch(err => task._parentFile.onTaskFailed(task, err));
|
|
630
|
+
}
|
|
631
|
+
})
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
await file.prepare(mockQueue);
|
|
635
|
+
await file.wait();
|
|
636
|
+
|
|
637
|
+
expect(file.state).toBe('complete');
|
|
638
|
+
expect(file.onChunkDownloaded).toHaveBeenCalledTimes(4);
|
|
223
639
|
});
|
|
224
640
|
});
|
|
225
641
|
});
|
|
226
642
|
|
|
643
|
+
// ============================================================================
|
|
644
|
+
// DownloadQueue
|
|
645
|
+
// ============================================================================
|
|
646
|
+
|
|
227
647
|
describe('DownloadQueue', () => {
|
|
228
648
|
afterEach(() => {
|
|
229
649
|
vi.restoreAllMocks();
|
|
@@ -264,15 +684,11 @@ describe('DownloadQueue', () => {
|
|
|
264
684
|
queue.enqueue({ id: '4', type: 'media', path: 'http://test.com/file4.mp4' });
|
|
265
685
|
queue.enqueue({ id: '5', type: 'media', path: 'http://test.com/file5.mp4' });
|
|
266
686
|
|
|
267
|
-
// Wait
|
|
268
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
687
|
+
// Wait for HEAD requests + queue processing to start
|
|
688
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
269
689
|
|
|
270
690
|
// Invariant: running <= concurrency
|
|
271
691
|
expect(queue.running).toBeLessThanOrEqual(2);
|
|
272
|
-
expect(queue.running).toBeGreaterThan(0); // Some should be running
|
|
273
|
-
|
|
274
|
-
// Queue should have pending items
|
|
275
|
-
expect(queue.queue.length + queue.running).toBeGreaterThan(2);
|
|
276
692
|
});
|
|
277
693
|
|
|
278
694
|
it('should process queue as tasks complete', async () => {
|
|
@@ -288,43 +704,59 @@ describe('DownloadQueue', () => {
|
|
|
288
704
|
'GET http://test.com/file3.mp4': { blob: testBlob }
|
|
289
705
|
});
|
|
290
706
|
|
|
291
|
-
const
|
|
292
|
-
const
|
|
293
|
-
const
|
|
707
|
+
const file1 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file1.mp4' });
|
|
708
|
+
const file2 = queue.enqueue({ id: '2', type: 'media', path: 'http://test.com/file2.mp4' });
|
|
709
|
+
const file3 = queue.enqueue({ id: '3', type: 'media', path: 'http://test.com/file3.mp4' });
|
|
294
710
|
|
|
295
711
|
// Wait for all to complete
|
|
296
|
-
await Promise.all([
|
|
712
|
+
await Promise.all([file1.wait(), file2.wait(), file3.wait()]);
|
|
297
713
|
|
|
298
|
-
// Post-condition: all complete
|
|
714
|
+
// Post-condition: all complete, files stay in active until removeCompleted()
|
|
299
715
|
expect(queue.running).toBe(0);
|
|
300
716
|
expect(queue.queue.length).toBe(0);
|
|
717
|
+
expect(queue.active.size).toBe(3);
|
|
718
|
+
|
|
719
|
+
// Simulate caller removing after caching
|
|
720
|
+
queue.removeCompleted('media/1');
|
|
721
|
+
queue.removeCompleted('media/2');
|
|
722
|
+
queue.removeCompleted('media/3');
|
|
301
723
|
expect(queue.active.size).toBe(0);
|
|
302
724
|
});
|
|
303
725
|
});
|
|
304
726
|
|
|
305
727
|
describe('Idempotent Enqueue', () => {
|
|
306
|
-
it('should return same
|
|
728
|
+
it('should return same FileDownload for duplicate file IDs', async () => {
|
|
307
729
|
const queue = new DownloadQueue();
|
|
308
730
|
|
|
309
|
-
// Mock fetch to prevent actual downloads
|
|
310
731
|
const testBlob = createTestBlob(1024);
|
|
311
732
|
mockFetch({
|
|
312
733
|
'HEAD http://test.com/file.mp4': { headers: { 'Content-Length': '1024' } },
|
|
313
734
|
'GET http://test.com/file.mp4': { blob: testBlob }
|
|
314
735
|
});
|
|
315
736
|
|
|
316
|
-
const
|
|
317
|
-
const
|
|
737
|
+
const file1 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
738
|
+
const file2 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
318
739
|
|
|
319
|
-
|
|
320
|
-
expect(task1).toBe(task2);
|
|
740
|
+
expect(file1).toBe(file2);
|
|
321
741
|
expect(queue.active.size).toBe(1);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it('should deduplicate same file with different signed URLs', () => {
|
|
745
|
+
const queue = new DownloadQueue();
|
|
746
|
+
const testBlob = createTestBlob(1024);
|
|
747
|
+
mockFetch({
|
|
748
|
+
'HEAD http://test.com/file.mp4?token=abc': { headers: { 'Content-Length': '1024' } },
|
|
749
|
+
'GET http://test.com/file.mp4?token=abc': { blob: testBlob }
|
|
750
|
+
});
|
|
322
751
|
|
|
323
|
-
|
|
324
|
-
|
|
752
|
+
const file1 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4?token=abc' });
|
|
753
|
+
const file2 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4?token=xyz' });
|
|
754
|
+
|
|
755
|
+
expect(file1).toBe(file2);
|
|
756
|
+
expect(queue.active.size).toBe(1);
|
|
325
757
|
});
|
|
326
758
|
|
|
327
|
-
it('should create different
|
|
759
|
+
it('should create different FileDownloads for different file IDs', () => {
|
|
328
760
|
const queue = new DownloadQueue();
|
|
329
761
|
const testBlob = createTestBlob(1024);
|
|
330
762
|
mockFetch({
|
|
@@ -334,16 +766,16 @@ describe('DownloadQueue', () => {
|
|
|
334
766
|
'GET http://test.com/file2.mp4': { blob: testBlob }
|
|
335
767
|
});
|
|
336
768
|
|
|
337
|
-
const
|
|
338
|
-
const
|
|
769
|
+
const file1 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file1.mp4' });
|
|
770
|
+
const file2 = queue.enqueue({ id: '2', type: 'media', path: 'http://test.com/file2.mp4' });
|
|
339
771
|
|
|
340
|
-
expect(
|
|
772
|
+
expect(file1).not.toBe(file2);
|
|
341
773
|
expect(queue.active.size).toBe(2);
|
|
342
774
|
});
|
|
343
775
|
});
|
|
344
776
|
|
|
345
777
|
describe('getTask()', () => {
|
|
346
|
-
it('should return active
|
|
778
|
+
it('should return active FileDownload', () => {
|
|
347
779
|
const queue = new DownloadQueue();
|
|
348
780
|
const testBlob = createTestBlob(1024);
|
|
349
781
|
mockFetch({
|
|
@@ -351,23 +783,20 @@ describe('DownloadQueue', () => {
|
|
|
351
783
|
'GET http://test.com/file.mp4': { blob: testBlob }
|
|
352
784
|
});
|
|
353
785
|
|
|
354
|
-
const
|
|
355
|
-
const retrieved = queue.getTask('
|
|
786
|
+
const file = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
787
|
+
const retrieved = queue.getTask('media/1');
|
|
356
788
|
|
|
357
|
-
expect(retrieved).toBe(
|
|
789
|
+
expect(retrieved).toBe(file);
|
|
358
790
|
});
|
|
359
791
|
|
|
360
792
|
it('should return null for non-existent task', () => {
|
|
361
793
|
const queue = new DownloadQueue();
|
|
362
|
-
|
|
363
|
-
const retrieved = queue.getTask('http://test.com/nonexistent.mp4');
|
|
364
|
-
|
|
365
|
-
expect(retrieved).toBeNull();
|
|
794
|
+
expect(queue.getTask('media/999')).toBeNull();
|
|
366
795
|
});
|
|
367
796
|
});
|
|
368
797
|
|
|
369
798
|
describe('clear()', () => {
|
|
370
|
-
it('should clear queue and active
|
|
799
|
+
it('should clear queue and active files', () => {
|
|
371
800
|
const queue = new DownloadQueue();
|
|
372
801
|
const testBlob = createTestBlob(1024);
|
|
373
802
|
mockFetch({
|
|
@@ -383,10 +812,8 @@ describe('DownloadQueue', () => {
|
|
|
383
812
|
queue.enqueue({ id: '2', type: 'media', path: 'http://test.com/file2.mp4' });
|
|
384
813
|
queue.enqueue({ id: '3', type: 'media', path: 'http://test.com/file3.mp4' });
|
|
385
814
|
|
|
386
|
-
// Clear
|
|
387
815
|
queue.clear();
|
|
388
816
|
|
|
389
|
-
// Post-condition: everything cleared
|
|
390
817
|
expect(queue.queue.length).toBe(0);
|
|
391
818
|
expect(queue.active.size).toBe(0);
|
|
392
819
|
expect(queue.running).toBe(0);
|
|
@@ -394,6 +821,93 @@ describe('DownloadQueue', () => {
|
|
|
394
821
|
});
|
|
395
822
|
});
|
|
396
823
|
|
|
824
|
+
// ============================================================================
|
|
825
|
+
// DownloadQueue - Priority
|
|
826
|
+
// ============================================================================
|
|
827
|
+
|
|
828
|
+
describe('DownloadQueue - Priority', () => {
|
|
829
|
+
afterEach(() => {
|
|
830
|
+
vi.restoreAllMocks();
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
describe('prioritize()', () => {
|
|
834
|
+
it('should move queued file tasks to front', () => {
|
|
835
|
+
const queue = new DownloadQueue({ concurrency: 0 }); // Prevent auto-start
|
|
836
|
+
|
|
837
|
+
// Create FileDownloads and their tasks manually
|
|
838
|
+
const file1 = new FileDownload({ id: '1', type: 'media', path: 'http://a' });
|
|
839
|
+
const file2 = new FileDownload({ id: '2', type: 'media', path: 'http://b' });
|
|
840
|
+
const file3 = new FileDownload({ id: '3', type: 'media', path: 'http://c' });
|
|
841
|
+
file3.totalChunks = 1;
|
|
842
|
+
|
|
843
|
+
const task1 = new DownloadTask({ id: '1', type: 'media', path: 'http://a' });
|
|
844
|
+
task1._parentFile = file1;
|
|
845
|
+
const task2 = new DownloadTask({ id: '2', type: 'media', path: 'http://b' });
|
|
846
|
+
task2._parentFile = file2;
|
|
847
|
+
const task3 = new DownloadTask({ id: '3', type: 'media', path: 'http://c' });
|
|
848
|
+
task3._parentFile = file3;
|
|
849
|
+
|
|
850
|
+
queue.queue = [task1, task2, task3];
|
|
851
|
+
queue.active.set('media/1', file1);
|
|
852
|
+
queue.active.set('media/2', file2);
|
|
853
|
+
queue.active.set('media/3', file3);
|
|
854
|
+
|
|
855
|
+
// task3 is at position 2 → prioritize it
|
|
856
|
+
const found = queue.prioritize('media', '3');
|
|
857
|
+
|
|
858
|
+
expect(found).toBe(true);
|
|
859
|
+
expect(queue.queue[0]).toBe(task3);
|
|
860
|
+
expect(queue.queue[1]).toBe(task1);
|
|
861
|
+
expect(queue.queue[2]).toBe(task2);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it('should return true if file is already downloading', () => {
|
|
865
|
+
const queue = new DownloadQueue();
|
|
866
|
+
const file = new FileDownload({ id: '5', type: 'media', path: 'http://x' });
|
|
867
|
+
file.state = 'downloading';
|
|
868
|
+
queue.active.set('media/5', file);
|
|
869
|
+
|
|
870
|
+
const found = queue.prioritize('media', '5');
|
|
871
|
+
expect(found).toBe(true);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it('should return false if file not found', () => {
|
|
875
|
+
const queue = new DownloadQueue();
|
|
876
|
+
expect(queue.prioritize('media', '999')).toBe(false);
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it('should sort chunk 0 and last chunk to absolute front', () => {
|
|
880
|
+
const queue = new DownloadQueue({ concurrency: 0 });
|
|
881
|
+
|
|
882
|
+
const file = new FileDownload({ id: '1', type: 'media', path: 'http://a' });
|
|
883
|
+
file.totalChunks = 5;
|
|
884
|
+
queue.active.set('media/1', file);
|
|
885
|
+
|
|
886
|
+
// Create chunk tasks in shuffled order
|
|
887
|
+
const chunks = [2, 4, 0, 1, 3].map(idx => {
|
|
888
|
+
const task = new DownloadTask(
|
|
889
|
+
{ id: '1', type: 'media', path: 'http://a' },
|
|
890
|
+
{ chunkIndex: idx, rangeStart: idx * 100, rangeEnd: (idx + 1) * 100 - 1 }
|
|
891
|
+
);
|
|
892
|
+
task._parentFile = file;
|
|
893
|
+
return task;
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
queue.queue = [...chunks];
|
|
897
|
+
|
|
898
|
+
queue.prioritize('media', '1');
|
|
899
|
+
|
|
900
|
+
// All tasks boosted to high priority, stable sort preserves insertion order
|
|
901
|
+
expect(queue.queue.every(t => t._priority >= 2)).toBe(true); // PRIORITY.high
|
|
902
|
+
expect(queue.queue.length).toBe(5);
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
// ============================================================================
|
|
908
|
+
// DownloadManager
|
|
909
|
+
// ============================================================================
|
|
910
|
+
|
|
397
911
|
describe('DownloadManager', () => {
|
|
398
912
|
afterEach(() => {
|
|
399
913
|
vi.restoreAllMocks();
|
|
@@ -408,10 +922,10 @@ describe('DownloadManager', () => {
|
|
|
408
922
|
});
|
|
409
923
|
const manager = new DownloadManager({ concurrency: 4 });
|
|
410
924
|
|
|
411
|
-
const
|
|
925
|
+
const file = manager.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
412
926
|
|
|
413
|
-
expect(
|
|
414
|
-
expect(manager.queue.active.has('
|
|
927
|
+
expect(file).toBeInstanceOf(FileDownload);
|
|
928
|
+
expect(manager.queue.active.has('media/1')).toBe(true);
|
|
415
929
|
});
|
|
416
930
|
|
|
417
931
|
it('should delegate getTask to queue', () => {
|
|
@@ -422,10 +936,10 @@ describe('DownloadManager', () => {
|
|
|
422
936
|
});
|
|
423
937
|
const manager = new DownloadManager();
|
|
424
938
|
|
|
425
|
-
const
|
|
426
|
-
const retrieved = manager.getTask('
|
|
939
|
+
const file = manager.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
|
|
940
|
+
const retrieved = manager.getTask('media/1');
|
|
427
941
|
|
|
428
|
-
expect(retrieved).toBe(
|
|
942
|
+
expect(retrieved).toBe(file);
|
|
429
943
|
});
|
|
430
944
|
|
|
431
945
|
it('should delegate getProgress to queue', () => {
|
|
@@ -473,254 +987,545 @@ describe('DownloadManager', () => {
|
|
|
473
987
|
expect(manager.queue.chunkSize).toBe(25 * 1024 * 1024);
|
|
474
988
|
});
|
|
475
989
|
|
|
476
|
-
it('should pass chunksPerFile to queue', () => {
|
|
477
|
-
const manager = new DownloadManager({ chunksPerFile: 8 });
|
|
478
|
-
|
|
479
|
-
expect(manager.queue.chunksPerFile).toBe(8);
|
|
480
|
-
});
|
|
481
|
-
|
|
482
990
|
it('should use defaults if not specified', () => {
|
|
483
991
|
const manager = new DownloadManager();
|
|
484
992
|
|
|
485
|
-
expect(manager.queue.concurrency).toBe(
|
|
993
|
+
expect(manager.queue.concurrency).toBe(6); // DEFAULT_CONCURRENCY
|
|
486
994
|
expect(manager.queue.chunkSize).toBe(50 * 1024 * 1024); // DEFAULT_CHUNK_SIZE
|
|
487
995
|
});
|
|
488
996
|
});
|
|
489
997
|
});
|
|
490
998
|
|
|
491
999
|
// ============================================================================
|
|
492
|
-
//
|
|
1000
|
+
// Resume Support
|
|
493
1001
|
// ============================================================================
|
|
494
1002
|
|
|
495
|
-
describe('
|
|
1003
|
+
describe('FileDownload - Resume', () => {
|
|
496
1004
|
afterEach(() => {
|
|
497
1005
|
vi.restoreAllMocks();
|
|
498
1006
|
});
|
|
499
1007
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
1008
|
+
it('should skip cached chunks when resuming', async () => {
|
|
1009
|
+
const fileSize = 200 * 1024 * 1024; // 200MB = 4 chunks
|
|
1010
|
+
const sourceBlob = createTestBlob(fileSize, 'video/mp4');
|
|
503
1011
|
|
|
504
|
-
|
|
505
|
-
});
|
|
1012
|
+
mockChunkedFetch(sourceBlob);
|
|
506
1013
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
1014
|
+
// Chunks 0 and 1 already cached
|
|
1015
|
+
const file = new FileDownload(
|
|
1016
|
+
{ id: '1', type: 'media', path: 'http://test.com/big.mp4', skipChunks: new Set([0, 1]) },
|
|
1017
|
+
{ chunkSize: 50 * 1024 * 1024 }
|
|
1018
|
+
);
|
|
510
1019
|
|
|
511
|
-
|
|
1020
|
+
const mockQueue = {
|
|
1021
|
+
enqueueChunkTasks: vi.fn((tasks) => {
|
|
1022
|
+
for (const task of tasks) {
|
|
1023
|
+
task.start()
|
|
1024
|
+
.then(() => task._parentFile.onTaskComplete(task))
|
|
1025
|
+
.catch(err => task._parentFile.onTaskFailed(task, err));
|
|
1026
|
+
}
|
|
1027
|
+
})
|
|
1028
|
+
};
|
|
512
1029
|
|
|
513
|
-
|
|
514
|
-
});
|
|
1030
|
+
await file.prepare(mockQueue);
|
|
515
1031
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
1032
|
+
// Should only create tasks for chunks 2 and 3
|
|
1033
|
+
expect(file.tasks.length).toBe(2);
|
|
1034
|
+
expect(file.tasks[0].chunkIndex).toBe(2);
|
|
1035
|
+
expect(file.tasks[1].chunkIndex).toBe(3);
|
|
520
1036
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
{ chunkSize: 50 * 1024 * 1024, chunksPerFile: 4 }
|
|
524
|
-
);
|
|
1037
|
+
// All tasks should be normal priority (resume mode)
|
|
1038
|
+
expect(file.tasks.every(t => t._priority === 0)).toBe(true); // PRIORITY.normal
|
|
525
1039
|
|
|
526
|
-
|
|
1040
|
+
await file.wait();
|
|
527
1041
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
1042
|
+
expect(file.state).toBe('complete');
|
|
1043
|
+
// downloadedBytes includes skipped chunks
|
|
1044
|
+
expect(file.downloadedBytes).toBeGreaterThan(0);
|
|
1045
|
+
});
|
|
532
1046
|
|
|
533
|
-
|
|
1047
|
+
it('should resolve immediately when all chunks cached', async () => {
|
|
1048
|
+
const fileSize = 200 * 1024 * 1024;
|
|
1049
|
+
const sourceBlob = createTestBlob(fileSize, 'video/mp4');
|
|
534
1050
|
|
|
535
|
-
|
|
536
|
-
expect(task.onChunkDownloaded).toHaveBeenCalledTimes(4);
|
|
537
|
-
expect(chunkCalls.length).toBe(4);
|
|
1051
|
+
mockChunkedFetch(sourceBlob);
|
|
538
1052
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
1053
|
+
// All 4 chunks already cached
|
|
1054
|
+
const file = new FileDownload(
|
|
1055
|
+
{ id: '1', type: 'media', path: 'http://test.com/big.mp4', skipChunks: new Set([0, 1, 2, 3]) },
|
|
1056
|
+
{ chunkSize: 50 * 1024 * 1024 }
|
|
1057
|
+
);
|
|
544
1058
|
|
|
545
|
-
|
|
546
|
-
const indices = chunkCalls.map(c => c.index).sort();
|
|
547
|
-
expect(indices).toEqual([0, 1, 2, 3]);
|
|
548
|
-
});
|
|
1059
|
+
const mockQueue = { enqueueChunkTasks: vi.fn() };
|
|
549
1060
|
|
|
550
|
-
|
|
551
|
-
const fileSize = 200 * 1024 * 1024;
|
|
552
|
-
const sourceBlob = createTestBlob(fileSize, 'video/mp4');
|
|
1061
|
+
await file.prepare(mockQueue);
|
|
553
1062
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
);
|
|
1063
|
+
// No tasks should be created
|
|
1064
|
+
expect(file.tasks.length).toBe(0);
|
|
1065
|
+
expect(mockQueue.enqueueChunkTasks).not.toHaveBeenCalled();
|
|
558
1066
|
|
|
559
|
-
|
|
1067
|
+
const blob = await file.wait();
|
|
1068
|
+
expect(blob.size).toBe(0); // Empty blob — data already in cache
|
|
1069
|
+
expect(file.state).toBe('complete');
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
560
1072
|
|
|
561
|
-
|
|
562
|
-
|
|
1073
|
+
// ============================================================================
|
|
1074
|
+
// BARRIER — Hard gate in download queue
|
|
1075
|
+
// ============================================================================
|
|
563
1076
|
|
|
564
|
-
|
|
1077
|
+
describe('BARRIER', () => {
|
|
1078
|
+
it('should be a unique Symbol', () => {
|
|
1079
|
+
expect(typeof BARRIER).toBe('symbol');
|
|
1080
|
+
expect(BARRIER.toString()).toContain('BARRIER');
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
565
1083
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
1084
|
+
describe('DownloadQueue - Barrier Support', () => {
|
|
1085
|
+
afterEach(() => {
|
|
1086
|
+
vi.restoreAllMocks();
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Helper: create a DownloadTask with a mock parent FileDownload.
|
|
1091
|
+
* The task is in 'pending' state and can be started via _startTask().
|
|
1092
|
+
*/
|
|
1093
|
+
function createMockTask(fileId, opts = {}) {
|
|
1094
|
+
const fileInfo = { id: fileId, type: 'media', path: `http://test.com/${fileId}.mp4` };
|
|
1095
|
+
const file = new FileDownload(fileInfo);
|
|
1096
|
+
file._runningCount = 0;
|
|
1097
|
+
file.totalChunks = opts.totalChunks ?? 1;
|
|
1098
|
+
const task = new DownloadTask(fileInfo, {
|
|
1099
|
+
chunkIndex: opts.chunkIndex ?? null,
|
|
1100
|
+
rangeStart: opts.rangeStart ?? null,
|
|
1101
|
+
rangeEnd: opts.rangeEnd ?? null
|
|
1102
|
+
});
|
|
1103
|
+
task._parentFile = file;
|
|
1104
|
+
task._priority = opts.priority ?? PRIORITY.normal;
|
|
1105
|
+
return task;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
describe('enqueueOrderedTasks()', () => {
|
|
1109
|
+
it('should push tasks and barriers preserving order', () => {
|
|
1110
|
+
const queue = new DownloadQueue({ concurrency: 1 });
|
|
1111
|
+
// Override processQueue to prevent auto-start for this test
|
|
1112
|
+
queue.processQueue = vi.fn();
|
|
1113
|
+
|
|
1114
|
+
const t1 = createMockTask('1');
|
|
1115
|
+
const t2 = createMockTask('2');
|
|
1116
|
+
const t3 = createMockTask('3');
|
|
1117
|
+
|
|
1118
|
+
queue.enqueueOrderedTasks([t1, t2, BARRIER, t3]);
|
|
1119
|
+
|
|
1120
|
+
expect(queue.queue.length).toBe(4);
|
|
1121
|
+
expect(queue.queue[0]).toBe(t1);
|
|
1122
|
+
expect(queue.queue[1]).toBe(t2);
|
|
1123
|
+
expect(queue.queue[2]).toBe(BARRIER);
|
|
1124
|
+
expect(queue.queue[3]).toBe(t3);
|
|
569
1125
|
});
|
|
1126
|
+
});
|
|
570
1127
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
const
|
|
1128
|
+
describe('processQueue() with barriers', () => {
|
|
1129
|
+
it('should stop at barrier when tasks are in-flight', () => {
|
|
1130
|
+
const queue = new DownloadQueue({ concurrency: 6 });
|
|
1131
|
+
const startedTasks = [];
|
|
1132
|
+
|
|
1133
|
+
// Override _startTask to track starts without actual HTTP
|
|
1134
|
+
queue._startTask = (task) => {
|
|
1135
|
+
queue.running++;
|
|
1136
|
+
task._parentFile._runningCount++;
|
|
1137
|
+
startedTasks.push(task);
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
const t1 = createMockTask('1');
|
|
1141
|
+
const t2 = createMockTask('2');
|
|
1142
|
+
const t3 = createMockTask('3'); // Should NOT start (behind barrier)
|
|
1143
|
+
|
|
1144
|
+
queue.enqueueOrderedTasks([t1, t2, BARRIER, t3]);
|
|
1145
|
+
queue.processQueue();
|
|
1146
|
+
|
|
1147
|
+
// t1 and t2 should start, t3 should NOT (barrier blocks)
|
|
1148
|
+
expect(startedTasks.length).toBe(2);
|
|
1149
|
+
expect(startedTasks).toContain(t1);
|
|
1150
|
+
expect(startedTasks).toContain(t2);
|
|
1151
|
+
expect(startedTasks).not.toContain(t3);
|
|
1152
|
+
// Barrier and t3 remain in queue
|
|
1153
|
+
expect(queue.queue.length).toBe(2);
|
|
1154
|
+
expect(queue.queue[0]).toBe(BARRIER);
|
|
1155
|
+
expect(queue.queue[1]).toBe(t3);
|
|
1156
|
+
});
|
|
574
1157
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
);
|
|
1158
|
+
it('should pass through barrier when no tasks are in-flight', () => {
|
|
1159
|
+
const queue = new DownloadQueue({ concurrency: 6 });
|
|
1160
|
+
const startedTasks = [];
|
|
579
1161
|
|
|
580
|
-
|
|
1162
|
+
queue._startTask = (task) => {
|
|
1163
|
+
queue.running++;
|
|
1164
|
+
task._parentFile._runningCount++;
|
|
1165
|
+
startedTasks.push(task);
|
|
1166
|
+
};
|
|
581
1167
|
|
|
582
|
-
|
|
583
|
-
await task.start();
|
|
1168
|
+
const t1 = createMockTask('1');
|
|
584
1169
|
|
|
585
|
-
//
|
|
586
|
-
|
|
587
|
-
|
|
1170
|
+
// Barrier at front with nothing running → should pass through
|
|
1171
|
+
queue.enqueueOrderedTasks([BARRIER, t1]);
|
|
1172
|
+
queue.processQueue();
|
|
1173
|
+
|
|
1174
|
+
expect(startedTasks.length).toBe(1);
|
|
1175
|
+
expect(startedTasks[0]).toBe(t1);
|
|
1176
|
+
expect(queue.queue.length).toBe(0);
|
|
588
1177
|
});
|
|
589
1178
|
|
|
590
|
-
it('should
|
|
591
|
-
const
|
|
592
|
-
const
|
|
1179
|
+
it('should process tasks after barrier completes', () => {
|
|
1180
|
+
const queue = new DownloadQueue({ concurrency: 6 });
|
|
1181
|
+
const startedTasks = [];
|
|
593
1182
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
1183
|
+
queue._startTask = (task) => {
|
|
1184
|
+
queue.running++;
|
|
1185
|
+
task._parentFile._runningCount++;
|
|
1186
|
+
queue._activeTasks.push(task);
|
|
1187
|
+
startedTasks.push(task);
|
|
1188
|
+
};
|
|
598
1189
|
|
|
599
|
-
|
|
1190
|
+
const t1 = createMockTask('1');
|
|
1191
|
+
const t2 = createMockTask('2');
|
|
600
1192
|
|
|
601
|
-
|
|
1193
|
+
queue.enqueueOrderedTasks([t1, BARRIER, t2]);
|
|
1194
|
+
queue.processQueue();
|
|
602
1195
|
|
|
603
|
-
|
|
1196
|
+
// Only t1 should start (barrier blocks t2)
|
|
1197
|
+
expect(startedTasks.length).toBe(1);
|
|
1198
|
+
expect(startedTasks[0]).toBe(t1);
|
|
604
1199
|
|
|
605
|
-
//
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
});
|
|
1200
|
+
// Simulate t1 completing
|
|
1201
|
+
queue.running--;
|
|
1202
|
+
t1._parentFile._runningCount--;
|
|
1203
|
+
queue._activeTasks = [];
|
|
610
1204
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
const sourceBlob = createTestBlob(fileSize, 'video/mp4');
|
|
1205
|
+
// Re-process: barrier should lift, t2 should start
|
|
1206
|
+
queue.processQueue();
|
|
614
1207
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
1208
|
+
expect(startedTasks.length).toBe(2);
|
|
1209
|
+
expect(startedTasks[1]).toBe(t2);
|
|
1210
|
+
expect(queue.queue.length).toBe(0);
|
|
1211
|
+
});
|
|
619
1212
|
|
|
620
|
-
|
|
1213
|
+
it('should keep slots empty when barrier is hit (no fill-ahead)', () => {
|
|
1214
|
+
const queue = new DownloadQueue({ concurrency: 4 });
|
|
1215
|
+
const startedTasks = [];
|
|
621
1216
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
1217
|
+
queue._startTask = (task) => {
|
|
1218
|
+
queue.running++;
|
|
1219
|
+
task._parentFile._runningCount++;
|
|
1220
|
+
startedTasks.push(task);
|
|
1221
|
+
};
|
|
626
1222
|
|
|
627
|
-
|
|
628
|
-
|
|
1223
|
+
const t1 = createMockTask('1');
|
|
1224
|
+
const t2 = createMockTask('2');
|
|
1225
|
+
const t3 = createMockTask('3');
|
|
629
1226
|
|
|
630
|
-
|
|
631
|
-
|
|
1227
|
+
// 2 tasks above barrier, 1 below. With concurrency=4, 2 slots should stay empty.
|
|
1228
|
+
queue.enqueueOrderedTasks([t1, t2, BARRIER, t3]);
|
|
1229
|
+
queue.processQueue();
|
|
1230
|
+
|
|
1231
|
+
expect(startedTasks.length).toBe(2);
|
|
1232
|
+
expect(queue.running).toBe(2);
|
|
1233
|
+
// 2 of 4 slots are empty — barrier enforced
|
|
632
1234
|
});
|
|
633
1235
|
|
|
634
|
-
it('should
|
|
635
|
-
const
|
|
636
|
-
const
|
|
1236
|
+
it('should handle consecutive barriers', () => {
|
|
1237
|
+
const queue = new DownloadQueue({ concurrency: 6 });
|
|
1238
|
+
const startedTasks = [];
|
|
637
1239
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
1240
|
+
queue._startTask = (task) => {
|
|
1241
|
+
queue.running++;
|
|
1242
|
+
task._parentFile._runningCount++;
|
|
1243
|
+
startedTasks.push(task);
|
|
1244
|
+
};
|
|
642
1245
|
|
|
643
|
-
|
|
1246
|
+
const t1 = createMockTask('1');
|
|
1247
|
+
const t2 = createMockTask('2');
|
|
644
1248
|
|
|
645
|
-
|
|
1249
|
+
queue.enqueueOrderedTasks([t1, BARRIER, BARRIER, t2]);
|
|
1250
|
+
queue.processQueue();
|
|
646
1251
|
|
|
647
|
-
//
|
|
648
|
-
|
|
1252
|
+
// t1 starts, both barriers block t2
|
|
1253
|
+
expect(startedTasks.length).toBe(1);
|
|
1254
|
+
expect(startedTasks[0]).toBe(t1);
|
|
649
1255
|
|
|
650
|
-
|
|
1256
|
+
// Complete t1
|
|
1257
|
+
queue.running = 0;
|
|
1258
|
+
queue.processQueue();
|
|
651
1259
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
expect(
|
|
1260
|
+
// Both barriers should be consumed, t2 starts
|
|
1261
|
+
expect(startedTasks.length).toBe(2);
|
|
1262
|
+
expect(startedTasks[1]).toBe(t2);
|
|
655
1263
|
});
|
|
656
1264
|
});
|
|
1265
|
+
|
|
1266
|
+
describe('urgentChunk() bypasses barriers', () => {
|
|
1267
|
+
it('should move urgent chunk to front of queue past barriers', () => {
|
|
1268
|
+
const queue = new DownloadQueue({ concurrency: 1 });
|
|
1269
|
+
// Prevent processQueue from actually starting tasks
|
|
1270
|
+
queue.processQueue = vi.fn();
|
|
1271
|
+
|
|
1272
|
+
const file = new FileDownload({ id: '1', type: 'media', path: 'http://test.com/1.mp4' });
|
|
1273
|
+
file._runningCount = 0;
|
|
1274
|
+
file.totalChunks = 5;
|
|
1275
|
+
queue.active.set('media/1', file);
|
|
1276
|
+
|
|
1277
|
+
// Create tasks with barrier between them
|
|
1278
|
+
const chunk0 = new DownloadTask(
|
|
1279
|
+
{ id: '1', type: 'media', path: 'http://test.com/1.mp4' },
|
|
1280
|
+
{ chunkIndex: 0 }
|
|
1281
|
+
);
|
|
1282
|
+
chunk0._parentFile = file;
|
|
1283
|
+
const chunk3 = new DownloadTask(
|
|
1284
|
+
{ id: '1', type: 'media', path: 'http://test.com/1.mp4' },
|
|
1285
|
+
{ chunkIndex: 3 }
|
|
1286
|
+
);
|
|
1287
|
+
chunk3._parentFile = file;
|
|
1288
|
+
|
|
1289
|
+
queue.queue = [chunk0, BARRIER, chunk3];
|
|
1290
|
+
|
|
1291
|
+
// Urgent chunk 3 — should move to front, past barrier
|
|
1292
|
+
const acted = queue.urgentChunk('media', '1', 3);
|
|
1293
|
+
|
|
1294
|
+
expect(acted).toBe(true);
|
|
1295
|
+
expect(queue.queue[0]).toBe(chunk3);
|
|
1296
|
+
expect(queue.queue[0]._priority).toBe(PRIORITY.urgent);
|
|
1297
|
+
// chunk0 and barrier should still be there
|
|
1298
|
+
expect(queue.queue.length).toBe(3);
|
|
1299
|
+
});
|
|
1300
|
+
});
|
|
1301
|
+
|
|
657
1302
|
});
|
|
658
1303
|
|
|
659
1304
|
// ============================================================================
|
|
660
|
-
//
|
|
1305
|
+
// LayoutTaskBuilder — Smart builder, dumb queue
|
|
661
1306
|
// ============================================================================
|
|
662
1307
|
|
|
663
|
-
describe('
|
|
1308
|
+
describe('LayoutTaskBuilder', () => {
|
|
664
1309
|
afterEach(() => {
|
|
665
1310
|
vi.restoreAllMocks();
|
|
666
1311
|
});
|
|
667
1312
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
1313
|
+
/**
|
|
1314
|
+
* Helper: create a queue for LayoutTaskBuilder tests.
|
|
1315
|
+
* concurrency=0 prevents auto-processing of any tasks that leak into the real queue.
|
|
1316
|
+
*/
|
|
1317
|
+
function createTestQueue(opts = {}) {
|
|
1318
|
+
return new DownloadQueue({ concurrency: 0, ...opts });
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
describe('addFile()', () => {
|
|
1322
|
+
it('should register file in queue.active and return FileDownload', () => {
|
|
1323
|
+
const queue = createTestQueue();
|
|
1324
|
+
const builder = new LayoutTaskBuilder(queue);
|
|
1325
|
+
|
|
1326
|
+
const file = builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.mp4' });
|
|
1327
|
+
|
|
1328
|
+
expect(file).toBeInstanceOf(FileDownload);
|
|
1329
|
+
expect(file.state).toBe('pending');
|
|
1330
|
+
expect(queue.active.has('media/1')).toBe(true);
|
|
1331
|
+
expect(queue.active.get('media/1')).toBe(file);
|
|
1332
|
+
});
|
|
671
1333
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
// Instead, create tasks manually
|
|
676
|
-
const task1 = new DownloadTask({ id: '1', type: 'media', path: 'http://a' });
|
|
677
|
-
const task2 = new DownloadTask({ id: '2', type: 'media', path: 'http://b' });
|
|
678
|
-
const task3 = new DownloadTask({ id: '3', type: 'media', path: 'http://c' });
|
|
1334
|
+
it('should deduplicate same file', () => {
|
|
1335
|
+
const queue = createTestQueue();
|
|
1336
|
+
const builder = new LayoutTaskBuilder(queue);
|
|
679
1337
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
queue.active.set('http://b', task2);
|
|
683
|
-
queue.active.set('http://c', task3);
|
|
1338
|
+
const file1 = builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.mp4' });
|
|
1339
|
+
const file2 = builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.mp4' });
|
|
684
1340
|
|
|
685
|
-
|
|
686
|
-
|
|
1341
|
+
expect(file1).toBe(file2);
|
|
1342
|
+
expect(queue.active.size).toBe(1);
|
|
1343
|
+
});
|
|
687
1344
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
1345
|
+
it('should refresh URL with later expiry on dedup', () => {
|
|
1346
|
+
const queue = createTestQueue();
|
|
1347
|
+
const builder = new LayoutTaskBuilder(queue);
|
|
1348
|
+
|
|
1349
|
+
builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.mp4?X-Amz-Expires=1000' });
|
|
1350
|
+
const file2 = builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.mp4?X-Amz-Expires=2000' });
|
|
1351
|
+
|
|
1352
|
+
expect(file2.fileInfo.path).toContain('X-Amz-Expires=2000');
|
|
692
1353
|
});
|
|
1354
|
+
});
|
|
693
1355
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
1356
|
+
describe('build()', () => {
|
|
1357
|
+
it('should run HEAD requests and return sorted tasks', async () => {
|
|
1358
|
+
const queue = createTestQueue();
|
|
1359
|
+
const builder = new LayoutTaskBuilder(queue);
|
|
1360
|
+
|
|
1361
|
+
const testBlob = createTestBlob(1024);
|
|
1362
|
+
mockFetch({
|
|
1363
|
+
'HEAD http://test.com/1.mp4': { headers: { 'Content-Length': '1024' } },
|
|
1364
|
+
'HEAD http://test.com/2.mp4': { headers: { 'Content-Length': '2048' } }
|
|
1365
|
+
});
|
|
699
1366
|
|
|
700
|
-
|
|
1367
|
+
builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.mp4' });
|
|
1368
|
+
builder.addFile({ id: '2', type: 'media', path: 'http://test.com/2.mp4' });
|
|
701
1369
|
|
|
702
|
-
|
|
703
|
-
|
|
1370
|
+
const tasks = await builder.build();
|
|
1371
|
+
|
|
1372
|
+
expect(tasks.length).toBe(2);
|
|
1373
|
+
// Sorted smallest→largest
|
|
1374
|
+
expect(tasks[0].fileInfo.id).toBe('1');
|
|
1375
|
+
expect(tasks[1].fileInfo.id).toBe('2');
|
|
704
1376
|
});
|
|
705
1377
|
|
|
706
|
-
it('should
|
|
707
|
-
const queue =
|
|
1378
|
+
it('should sort non-chunked smallest→largest', async () => {
|
|
1379
|
+
const queue = createTestQueue();
|
|
1380
|
+
const builder = new LayoutTaskBuilder(queue);
|
|
708
1381
|
|
|
709
|
-
|
|
1382
|
+
mockFetch({
|
|
1383
|
+
'HEAD http://test.com/big.jpg': { headers: { 'Content-Length': '50000' } },
|
|
1384
|
+
'HEAD http://test.com/small.jpg': { headers: { 'Content-Length': '500' } },
|
|
1385
|
+
'HEAD http://test.com/med.jpg': { headers: { 'Content-Length': '5000' } }
|
|
1386
|
+
});
|
|
710
1387
|
|
|
711
|
-
|
|
1388
|
+
builder.addFile({ id: 'big', type: 'media', path: 'http://test.com/big.jpg' });
|
|
1389
|
+
builder.addFile({ id: 'small', type: 'media', path: 'http://test.com/small.jpg' });
|
|
1390
|
+
builder.addFile({ id: 'med', type: 'media', path: 'http://test.com/med.jpg' });
|
|
1391
|
+
|
|
1392
|
+
const tasks = await builder.build();
|
|
1393
|
+
|
|
1394
|
+
expect(tasks.length).toBe(3);
|
|
1395
|
+
expect(tasks[0].fileInfo.id).toBe('small');
|
|
1396
|
+
expect(tasks[1].fileInfo.id).toBe('med');
|
|
1397
|
+
expect(tasks[2].fileInfo.id).toBe('big');
|
|
712
1398
|
});
|
|
713
1399
|
|
|
714
|
-
it('should
|
|
715
|
-
const queue =
|
|
716
|
-
const
|
|
717
|
-
task.state = 'downloading';
|
|
718
|
-
queue.active.set('http://x', task);
|
|
719
|
-
// Not in queue (already started)
|
|
1400
|
+
it('should place chunk-0 and chunk-last before BARRIER, remaining after', async () => {
|
|
1401
|
+
const queue = createTestQueue();
|
|
1402
|
+
const builder = new LayoutTaskBuilder(queue);
|
|
720
1403
|
|
|
721
|
-
|
|
1404
|
+
// 200MB file = 4 chunks (50MB each, threshold 100MB)
|
|
1405
|
+
const fileSize = 200 * 1024 * 1024;
|
|
1406
|
+
const sourceBlob = createTestBlob(fileSize, 'video/mp4');
|
|
1407
|
+
mockChunkedFetch(sourceBlob);
|
|
722
1408
|
|
|
723
|
-
|
|
1409
|
+
builder.addFile({ id: '1', type: 'media', path: 'http://test.com/big.mp4' });
|
|
1410
|
+
|
|
1411
|
+
const tasks = await builder.build();
|
|
1412
|
+
|
|
1413
|
+
// Should have: chunk-0, chunk-3(last), BARRIER, chunk-1, chunk-2
|
|
1414
|
+
const barrierIdx = tasks.indexOf(BARRIER);
|
|
1415
|
+
expect(barrierIdx).toBe(2); // After chunk-0 and chunk-3
|
|
1416
|
+
|
|
1417
|
+
// Before barrier: chunk-0 and chunk-3 (last)
|
|
1418
|
+
expect(tasks[0].chunkIndex).toBe(0);
|
|
1419
|
+
expect(tasks[1].chunkIndex).toBe(3);
|
|
1420
|
+
|
|
1421
|
+
// After barrier: remaining chunks (1, 2) sorted by index
|
|
1422
|
+
const afterBarrier = tasks.slice(barrierIdx + 1).filter(t => t !== BARRIER);
|
|
1423
|
+
expect(afterBarrier.map(t => t.chunkIndex)).toEqual([1, 2]);
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
it('should not add BARRIER when no chunked files', async () => {
|
|
1427
|
+
const queue = createTestQueue();
|
|
1428
|
+
const builder = new LayoutTaskBuilder(queue);
|
|
1429
|
+
|
|
1430
|
+
mockFetch({
|
|
1431
|
+
'HEAD http://test.com/1.jpg': { headers: { 'Content-Length': '1024' } }
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.jpg' });
|
|
1435
|
+
|
|
1436
|
+
const tasks = await builder.build();
|
|
1437
|
+
|
|
1438
|
+
expect(tasks.length).toBe(1);
|
|
1439
|
+
expect(tasks.includes(BARRIER)).toBe(false);
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
it('should return empty array for empty layout', async () => {
|
|
1443
|
+
const queue = createTestQueue();
|
|
1444
|
+
const builder = new LayoutTaskBuilder(queue);
|
|
1445
|
+
|
|
1446
|
+
const tasks = await builder.build();
|
|
1447
|
+
|
|
1448
|
+
expect(tasks).toEqual([]);
|
|
1449
|
+
});
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
describe('build() with size from RequiredFiles (no HEAD)', () => {
|
|
1453
|
+
it('should build instantly when fileInfo.size is provided', async () => {
|
|
1454
|
+
const queue = createTestQueue();
|
|
1455
|
+
const builder = new LayoutTaskBuilder(queue);
|
|
1456
|
+
|
|
1457
|
+
// No fetch mock needed — size is provided, HEAD is skipped
|
|
1458
|
+
const fetchSpy = vi.fn();
|
|
1459
|
+
global.fetch = fetchSpy;
|
|
1460
|
+
|
|
1461
|
+
builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.jpg', size: 1024 });
|
|
1462
|
+
builder.addFile({ id: '2', type: 'media', path: 'http://test.com/2.jpg', size: 2048 });
|
|
1463
|
+
|
|
1464
|
+
const tasks = await builder.build();
|
|
1465
|
+
|
|
1466
|
+
// No HEAD requests
|
|
1467
|
+
const headCalls = fetchSpy.mock.calls.filter(c => c[1]?.method === 'HEAD');
|
|
1468
|
+
expect(headCalls.length).toBe(0);
|
|
1469
|
+
|
|
1470
|
+
// Tasks still sorted smallest→largest
|
|
1471
|
+
expect(tasks.length).toBe(2);
|
|
1472
|
+
expect(tasks[0].fileInfo.id).toBe('1');
|
|
1473
|
+
expect(tasks[1].fileInfo.id).toBe('2');
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
it('should chunk large files using declared size', async () => {
|
|
1477
|
+
const queue = createTestQueue();
|
|
1478
|
+
const builder = new LayoutTaskBuilder(queue);
|
|
1479
|
+
|
|
1480
|
+
const fetchSpy = vi.fn();
|
|
1481
|
+
global.fetch = fetchSpy;
|
|
1482
|
+
|
|
1483
|
+
// 200MB file declared via size — should chunk without HEAD
|
|
1484
|
+
const fileSize = 200 * 1024 * 1024;
|
|
1485
|
+
builder.addFile({ id: '1', type: 'media', path: 'http://test.com/big.mp4', size: fileSize });
|
|
1486
|
+
|
|
1487
|
+
const tasks = await builder.build();
|
|
1488
|
+
|
|
1489
|
+
// No HEAD
|
|
1490
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
1491
|
+
|
|
1492
|
+
// Should have chunks: chunk-0, chunk-3(last), BARRIER, chunk-1, chunk-2
|
|
1493
|
+
const barrierIdx = tasks.indexOf(BARRIER);
|
|
1494
|
+
expect(barrierIdx).toBe(2);
|
|
1495
|
+
expect(tasks[0].chunkIndex).toBe(0);
|
|
1496
|
+
expect(tasks[1].chunkIndex).toBe(3);
|
|
1497
|
+
});
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
describe('integration with enqueueOrderedTasks()', () => {
|
|
1501
|
+
it('should produce tasks that the queue can process', async () => {
|
|
1502
|
+
const queue = new DownloadQueue({ concurrency: 6 });
|
|
1503
|
+
const startedTasks = [];
|
|
1504
|
+
|
|
1505
|
+
// Track which tasks get started
|
|
1506
|
+
queue._startTask = (task) => {
|
|
1507
|
+
queue.running++;
|
|
1508
|
+
task._parentFile._runningCount++;
|
|
1509
|
+
startedTasks.push(task);
|
|
1510
|
+
};
|
|
1511
|
+
|
|
1512
|
+
const builder = new LayoutTaskBuilder(queue);
|
|
1513
|
+
|
|
1514
|
+
mockFetch({
|
|
1515
|
+
'HEAD http://test.com/1.jpg': { headers: { 'Content-Length': '1024' } },
|
|
1516
|
+
'HEAD http://test.com/2.jpg': { headers: { 'Content-Length': '2048' } }
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.jpg' });
|
|
1520
|
+
builder.addFile({ id: '2', type: 'media', path: 'http://test.com/2.jpg' });
|
|
1521
|
+
|
|
1522
|
+
const tasks = await builder.build();
|
|
1523
|
+
queue.enqueueOrderedTasks(tasks);
|
|
1524
|
+
|
|
1525
|
+
expect(startedTasks.length).toBe(2);
|
|
1526
|
+
// Smallest file starts first (sorted by builder)
|
|
1527
|
+
expect(startedTasks[0].fileInfo.id).toBe('1');
|
|
1528
|
+
expect(startedTasks[1].fileInfo.id).toBe('2');
|
|
724
1529
|
});
|
|
725
1530
|
});
|
|
726
1531
|
});
|