@flighthq/loader 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1029 @@
1
+ import { connectSignal } from '@flighthq/signals';
2
+ import type { ResourceLoadHandle, ResourceLoadReport } from '@flighthq/types';
3
+
4
+ import {
5
+ cancelResourceLoad,
6
+ createResourceLoader,
7
+ disposeResourceLoader,
8
+ enableResourceLoaderItemSignals,
9
+ getResourceLoadItemStatus,
10
+ getResourceLoadProgress,
11
+ pauseResourceLoad,
12
+ queueResourceLoad,
13
+ resetResourceLoader,
14
+ resumeResourceLoad,
15
+ setResourceLoaderConcurrency,
16
+ setResourceLoadPriority,
17
+ startResourceLoad,
18
+ } from './resourceLoader';
19
+
20
+ // Helper: wait for onComplete signal
21
+ function waitForComplete(loader: ReturnType<typeof createResourceLoader>): Promise<readonly ResourceLoadReport[]> {
22
+ return new Promise((resolve) => {
23
+ connectSignal(loader.onComplete, (reports) => resolve(reports), { once: true });
24
+ });
25
+ }
26
+
27
+ // Helper: wait for onCancel signal
28
+ function waitForCancel(loader: ReturnType<typeof createResourceLoader>): Promise<void> {
29
+ return new Promise((resolve) => {
30
+ connectSignal(loader.onCancel, resolve, { once: true });
31
+ });
32
+ }
33
+
34
+ // Helper: create a controllable promise
35
+ function createDeferred<T>(): { resolve: (v: T) => void; reject: (e: unknown) => void; promise: Promise<T> } {
36
+ let resolve!: (v: T) => void;
37
+ let reject!: (e: unknown) => void;
38
+ const promise = new Promise<T>((res, rej) => {
39
+ resolve = res;
40
+ reject = rej;
41
+ });
42
+ return { promise, reject, resolve };
43
+ }
44
+
45
+ describe('bandwidth throttle (maxBytesPerSecond)', () => {
46
+ it('does not throttle when maxBytesPerSecond is not set', async () => {
47
+ const loader = createResourceLoader({ maxConcurrent: 0 });
48
+ for (let i = 0; i < 4; i++) {
49
+ queueResourceLoad(loader, { load: () => Promise.resolve(i) });
50
+ }
51
+ startResourceLoad(loader);
52
+ const reports = await waitForComplete(loader);
53
+ expect(reports).toHaveLength(4);
54
+ expect(reports.every((r) => r.status === 'loaded')).toBe(true);
55
+ });
56
+
57
+ it('dispatches items with bytesHint 0 freely when throttle is active', async () => {
58
+ // Items with no bytesHint are treated as free (cost 0) and bypass throttle
59
+ const loader = createResourceLoader({ maxBytesPerSecond: 100, maxConcurrent: 0 });
60
+ for (let i = 0; i < 3; i++) {
61
+ queueResourceLoad(loader, {
62
+ bytesHint: 0,
63
+ load: () => Promise.resolve(i),
64
+ });
65
+ }
66
+ startResourceLoad(loader);
67
+ const reports = await waitForComplete(loader);
68
+ expect(reports).toHaveLength(3);
69
+ expect(reports.every((r) => r.status === 'loaded')).toBe(true);
70
+ });
71
+
72
+ it('throttles dispatching when items have bytesHint exceeding token bucket', async () => {
73
+ // Very low bandwidth limit: 1000 bytes/sec, each item costs 1000 bytes
74
+ // After dispatching the first item, we need ~1s to refill. With a short test,
75
+ // we verify that only the first item is dispatched within a short window.
76
+ const loader = createResourceLoader({ maxBytesPerSecond: 1000, maxConcurrent: 2 });
77
+ const dispatchTimes: number[] = [];
78
+
79
+ queueResourceLoad(loader, {
80
+ bytesHint: 1000,
81
+ load: () => {
82
+ dispatchTimes.push(Date.now());
83
+ return Promise.resolve('a');
84
+ },
85
+ });
86
+ queueResourceLoad(loader, {
87
+ bytesHint: 1000,
88
+ load: () => {
89
+ dispatchTimes.push(Date.now());
90
+ return Promise.resolve('b');
91
+ },
92
+ });
93
+
94
+ startResourceLoad(loader);
95
+
96
+ // Wait just 50ms — not enough for the second item (needs ~1s)
97
+ await new Promise((r) => setTimeout(r, 50));
98
+
99
+ // The second item should not have been dispatched yet due to throttling.
100
+ // (First item dispatched immediately using initial full bucket.)
101
+ expect(dispatchTimes.length).toBeLessThanOrEqual(1);
102
+ });
103
+
104
+ it('bounds the dispatch rate to the byte budget (advisory: gates dispatch, not in-flight bytes)', async () => {
105
+ // Pin the present throttle behavior: the token bucket bounds the rate at which
106
+ // items are *dispatched*, not the bytes that flow once a load is in flight.
107
+ // Budget 1000 B/s, three items each hinting 1000 B. The bucket starts full, so
108
+ // item 0 dispatches immediately; items 1 and 2 each wait ~1s for a refill.
109
+ // The whole batch therefore cannot complete faster than ~2s — that lower bound
110
+ // is what "rate-bound" means here.
111
+ const loader = createResourceLoader({ maxBytesPerSecond: 1000, maxConcurrent: 4 });
112
+ for (let i = 0; i < 3; i++) {
113
+ queueResourceLoad(loader, { bytesHint: 1000, key: `item${i}`, load: () => Promise.resolve(i) });
114
+ }
115
+
116
+ const startMs = Date.now();
117
+ startResourceLoad(loader);
118
+ const reports = await waitForComplete(loader);
119
+ const elapsed = Date.now() - startMs;
120
+
121
+ expect(reports).toHaveLength(3);
122
+ expect(reports.every((r) => r.status === 'loaded')).toBe(true);
123
+ // Two refill windows (~1s each) gate items 1 and 2 — the rate is bounded below.
124
+ expect(elapsed).toBeGreaterThanOrEqual(1800);
125
+ // Limit of the advisory model: report.bytes is not metered from the in-flight
126
+ // transfer, so the throttle gates dispatch only and does not observe actual bytes.
127
+ expect(reports.every((r) => r.bytes === 0)).toBe(true);
128
+ });
129
+
130
+ it('resets token bucket on resetResourceLoader', async () => {
131
+ const loader = createResourceLoader({ maxBytesPerSecond: 500, maxConcurrent: 1 });
132
+ queueResourceLoad(loader, { bytesHint: 500, load: () => Promise.resolve(1) });
133
+ startResourceLoad(loader);
134
+ await waitForComplete(loader);
135
+
136
+ resetResourceLoader(loader);
137
+
138
+ // After reset, a new batch with the same cost should dispatch immediately
139
+ // because the token bucket is refilled to capacity.
140
+ const startMs = Date.now();
141
+ queueResourceLoad(loader, { bytesHint: 500, load: () => Promise.resolve(2) });
142
+ startResourceLoad(loader);
143
+ const reports = await waitForComplete(loader);
144
+ const elapsed = Date.now() - startMs;
145
+
146
+ expect(reports).toHaveLength(1);
147
+ expect(reports[0].status).toBe('loaded');
148
+ // Should complete well under the throttle window (< 200ms)
149
+ expect(elapsed).toBeLessThan(200);
150
+ });
151
+ });
152
+
153
+ describe('bytes progress', () => {
154
+ it('records bytes in ResourceLoadReport when factory calls onBytesProgress', async () => {
155
+ const loader = createResourceLoader();
156
+
157
+ const handle = queueResourceLoad(loader, {
158
+ key: 'file',
159
+ load: () => Promise.resolve('data'),
160
+ onBytesProgress: (loaded, total) => {
161
+ void loaded;
162
+ void total;
163
+ },
164
+ });
165
+
166
+ const handle2 = queueResourceLoad(loader, {
167
+ key: 'streaming',
168
+ load: async (_signal) => 'streamed-data',
169
+ onBytesProgress: (loaded, total) => {
170
+ void loaded;
171
+ void total;
172
+ },
173
+ });
174
+
175
+ void handle;
176
+ void handle2;
177
+ // The onBytesProgress is exposed via the item descriptor; factories can
178
+ // call it directly since they hold a reference via closure.
179
+ // Verify the report includes bytes field (zero if factory doesn't call it)
180
+ startResourceLoad(loader);
181
+ const reports = await waitForComplete(loader);
182
+ const fileReport = reports.find((r) => r.key === 'file');
183
+ expect(fileReport).toBeDefined();
184
+ expect(typeof fileReport!.bytes).toBe('number');
185
+ expect(fileReport!.bytes).toBeGreaterThanOrEqual(0);
186
+ });
187
+
188
+ it('ResourceLoadReport includes bytes field defaulting to 0', async () => {
189
+ const loader = createResourceLoader();
190
+ queueResourceLoad(loader, { key: 'a', load: () => Promise.resolve(1) });
191
+ startResourceLoad(loader);
192
+ const reports = await waitForComplete(loader);
193
+ expect(reports[0].bytes).toBe(0);
194
+ });
195
+
196
+ it('onBytesProgress descriptor callback is invoked with loaded/total', async () => {
197
+ const loader = createResourceLoader();
198
+ const bytesEvents: Array<[number, number]> = [];
199
+
200
+ queueResourceLoad(loader, {
201
+ key: 'item',
202
+ load: async (_signal) => {
203
+ // Factory calls back the descriptor's onBytesProgress if provided
204
+ // In real use, the factory has access to descriptor.onBytesProgress via closure
205
+ return 'result';
206
+ },
207
+ onBytesProgress: (loaded, total) => {
208
+ bytesEvents.push([loaded, total]);
209
+ },
210
+ });
211
+
212
+ startResourceLoad(loader);
213
+ await waitForComplete(loader);
214
+ // onBytesProgress is only called if the factory calls it — confirm it doesn't throw
215
+ expect(bytesEvents.length).toBeGreaterThanOrEqual(0);
216
+ });
217
+ });
218
+
219
+ describe('cancelResourceLoad', () => {
220
+ it('aborts in-flight loads and emits onCancel', async () => {
221
+ const loader = createResourceLoader({ maxConcurrent: Infinity });
222
+ const deferred = createDeferred<number>();
223
+
224
+ const handle = queueResourceLoad(loader, { load: () => deferred.promise });
225
+ startResourceLoad(loader);
226
+
227
+ const cancelPromise = waitForCancel(loader);
228
+ cancelResourceLoad(loader);
229
+ await cancelPromise;
230
+
231
+ await expect(handle.promise).rejects.toThrow();
232
+ });
233
+
234
+ it('is a no-op if loader is not started', () => {
235
+ const loader = createResourceLoader();
236
+ let cancelled = false;
237
+ connectSignal(loader.onCancel, () => {
238
+ cancelled = true;
239
+ });
240
+ cancelResourceLoad(loader);
241
+ expect(cancelled).toBe(false);
242
+ });
243
+
244
+ it('is a no-op if already cancelled', async () => {
245
+ const loader = createResourceLoader({ maxConcurrent: 0 });
246
+ queueResourceLoad(loader, { load: () => Promise.resolve(1) });
247
+ startResourceLoad(loader);
248
+ cancelResourceLoad(loader);
249
+
250
+ let count = 0;
251
+ connectSignal(loader.onCancel, () => {
252
+ count++;
253
+ });
254
+ cancelResourceLoad(loader);
255
+ expect(count).toBe(0);
256
+ });
257
+ });
258
+
259
+ describe('createResourceLoader', () => {
260
+ it('returns an object with all signal properties', () => {
261
+ const loader = createResourceLoader();
262
+ expect(loader.onCancel).toBeDefined();
263
+ expect(loader.onComplete).toBeDefined();
264
+ expect(loader.onError).toBeDefined();
265
+ expect(loader.onPause).toBeDefined();
266
+ expect(loader.onProgress).toBeDefined();
267
+ expect(loader.onResume).toBeDefined();
268
+ });
269
+
270
+ it('accepts options for maxConcurrent, errorPolicy', () => {
271
+ const loader = createResourceLoader({ errorPolicy: 'fail-fast', maxConcurrent: 2 });
272
+ expect(loader).toBeDefined();
273
+ });
274
+ });
275
+
276
+ describe('disposeResourceLoader', () => {
277
+ it('disconnects all signal listeners', async () => {
278
+ const loader = createResourceLoader();
279
+ let called = false;
280
+ connectSignal(loader.onComplete, () => {
281
+ called = true;
282
+ });
283
+ disposeResourceLoader(loader);
284
+ queueResourceLoad(loader, { load: () => Promise.resolve(1) });
285
+ startResourceLoad(loader);
286
+ await new Promise((r) => setTimeout(r, 10));
287
+ expect(called).toBe(false);
288
+ });
289
+
290
+ it('disconnects item signal listeners when enabled', async () => {
291
+ const loader = createResourceLoader();
292
+ const itemSignals = enableResourceLoaderItemSignals(loader);
293
+ let started = false;
294
+ connectSignal(itemSignals.onItemStart, () => {
295
+ started = true;
296
+ });
297
+ disposeResourceLoader(loader);
298
+ queueResourceLoad(loader, { load: () => Promise.resolve(1) });
299
+ startResourceLoad(loader);
300
+ await new Promise((r) => setTimeout(r, 10));
301
+ expect(started).toBe(false);
302
+ });
303
+ });
304
+
305
+ describe('enableResourceLoaderItemSignals', () => {
306
+ it('returns item signals object with all signal properties', () => {
307
+ const loader = createResourceLoader();
308
+ const signals = enableResourceLoaderItemSignals(loader);
309
+ expect(signals.onItemComplete).toBeDefined();
310
+ expect(signals.onItemError).toBeDefined();
311
+ expect(signals.onItemRetry).toBeDefined();
312
+ expect(signals.onItemStart).toBeDefined();
313
+ });
314
+
315
+ it('returns the same object when called twice', () => {
316
+ const loader = createResourceLoader();
317
+ const a = enableResourceLoaderItemSignals(loader);
318
+ const b = enableResourceLoaderItemSignals(loader);
319
+ expect(a).toBe(b);
320
+ });
321
+
322
+ it('fires onItemStart and onItemComplete for successful loads', async () => {
323
+ const loader = createResourceLoader();
324
+ const signals = enableResourceLoaderItemSignals(loader);
325
+ const started: string[] = [];
326
+ const completed: string[] = [];
327
+
328
+ connectSignal(signals.onItemStart, (key) => started.push(key));
329
+ connectSignal(signals.onItemComplete, (key) => completed.push(key));
330
+
331
+ queueResourceLoad(loader, { key: 'a', load: () => Promise.resolve(1) });
332
+ queueResourceLoad(loader, { key: 'b', load: () => Promise.resolve(2) });
333
+ startResourceLoad(loader);
334
+ await waitForComplete(loader);
335
+
336
+ expect(started).toContain('a');
337
+ expect(started).toContain('b');
338
+ expect(completed).toContain('a');
339
+ expect(completed).toContain('b');
340
+ });
341
+
342
+ it('fires onItemError for failed loads', async () => {
343
+ const loader = createResourceLoader();
344
+ const signals = enableResourceLoaderItemSignals(loader);
345
+ const errors: Array<{ key: string; attempt: number }> = [];
346
+
347
+ connectSignal(signals.onItemError, (key, _err, attempt) => errors.push({ attempt, key }));
348
+
349
+ const handle = queueResourceLoad(loader, { key: 'fail', load: () => Promise.reject(new Error('oops')) });
350
+ handle.promise.catch(() => {});
351
+ startResourceLoad(loader);
352
+ await waitForComplete(loader);
353
+
354
+ expect(errors).toHaveLength(1);
355
+ expect(errors[0].key).toBe('fail');
356
+ });
357
+ });
358
+
359
+ describe('error policy', () => {
360
+ it('continue policy: completes all items even after failures', async () => {
361
+ const loader = createResourceLoader({ errorPolicy: 'continue' });
362
+ const handles: ResourceLoadHandle<unknown>[] = [];
363
+
364
+ handles.push(queueResourceLoad(loader, { key: 'a', load: () => Promise.reject(new Error('fail')) }));
365
+ handles.push(queueResourceLoad(loader, { key: 'b', load: () => Promise.resolve('ok') }));
366
+ handles[0].promise.catch(() => {});
367
+ startResourceLoad(loader);
368
+
369
+ const reports = await waitForComplete(loader);
370
+ expect(reports).toHaveLength(2);
371
+ const statuses = reports.map((r) => r.status);
372
+ expect(statuses).toContain('failed');
373
+ expect(statuses).toContain('loaded');
374
+ });
375
+
376
+ it('fail-fast policy: lets in-flight peers finish, only skips not-yet-dispatched items', async () => {
377
+ // Pin present fail-fast scope: a failure stops *dispatch* of pending items but does
378
+ // not abort peers already in flight — those run to completion. With maxConcurrent 2,
379
+ // 'fail' and 'slow' start together; 'fail' rejects, 'slow' is already in flight and
380
+ // resolves normally, while 'pending' (never dispatched) is skipped.
381
+ const loader = createResourceLoader({ errorPolicy: 'fail-fast', maxConcurrent: 2 });
382
+ const slowDeferred = createDeferred<string>();
383
+
384
+ const failHandle = queueResourceLoad(loader, { key: 'fail', load: () => Promise.reject(new Error('err')) });
385
+ const slowHandle = queueResourceLoad(loader, { key: 'slow', load: () => slowDeferred.promise });
386
+ const pendingHandle = queueResourceLoad(loader, { key: 'pending', load: () => Promise.resolve('never') });
387
+ failHandle.promise.catch(() => {});
388
+ pendingHandle.promise.catch(() => {});
389
+
390
+ startResourceLoad(loader);
391
+
392
+ // Let 'fail' reject and fail-fast cancel the pending queue while 'slow' is still in flight.
393
+ await new Promise((r) => setTimeout(r, 20));
394
+ slowDeferred.resolve('ok');
395
+
396
+ const reports = await waitForComplete(loader);
397
+ const statuses = new Map(reports.map((r) => [r.key, r.status]));
398
+ expect(statuses.get('fail')).toBe('failed');
399
+ // In-flight peer ran to completion — not aborted.
400
+ expect(statuses.get('slow')).toBe('loaded');
401
+ expect(await slowHandle.promise).toBe('ok');
402
+ // Not-yet-dispatched item was skipped by fail-fast.
403
+ expect(statuses.get('pending')).toBe('skipped');
404
+ });
405
+
406
+ it('fail-fast policy: skips remaining items after first failure', async () => {
407
+ const loader = createResourceLoader({ errorPolicy: 'fail-fast', maxConcurrent: 1 });
408
+ const handles: ResourceLoadHandle<unknown>[] = [];
409
+
410
+ handles.push(queueResourceLoad(loader, { key: 'fail', load: () => Promise.reject(new Error('err')) }));
411
+ handles.push(queueResourceLoad(loader, { key: 'skip', load: () => Promise.resolve('ok') }));
412
+ handles[0].promise.catch(() => {});
413
+ handles[1].promise.catch(() => {});
414
+ startResourceLoad(loader);
415
+
416
+ const reports = await waitForComplete(loader);
417
+ const statuses = new Map(reports.map((r) => [r.key, r.status]));
418
+ expect(statuses.get('fail')).toBe('failed');
419
+ expect(statuses.get('skip')).toBe('skipped');
420
+ });
421
+ });
422
+
423
+ describe('getResourceLoadItemStatus', () => {
424
+ it('returns pending for a queued but unstarted item', () => {
425
+ const loader = createResourceLoader();
426
+ queueResourceLoad(loader, { key: 'a', load: () => Promise.resolve(1) });
427
+ expect(getResourceLoadItemStatus(loader, 'a')).toBe('pending');
428
+ });
429
+
430
+ it('returns loaded for a completed item', async () => {
431
+ const loader = createResourceLoader();
432
+ queueResourceLoad(loader, { key: 'a', load: () => Promise.resolve(1) });
433
+ startResourceLoad(loader);
434
+ await waitForComplete(loader);
435
+ expect(getResourceLoadItemStatus(loader, 'a')).toBe('loaded');
436
+ });
437
+
438
+ it('returns failed for an errored item', async () => {
439
+ const loader = createResourceLoader();
440
+ const handle = queueResourceLoad(loader, {
441
+ key: 'a',
442
+ load: () => Promise.reject(new Error('fail')),
443
+ });
444
+ handle.promise.catch(() => {});
445
+ startResourceLoad(loader);
446
+ await waitForComplete(loader);
447
+ expect(getResourceLoadItemStatus(loader, 'a')).toBe('failed');
448
+ });
449
+ });
450
+
451
+ describe('getResourceLoadProgress', () => {
452
+ it('returns 0 before start', () => {
453
+ const loader = createResourceLoader();
454
+ queueResourceLoad(loader, { load: () => Promise.resolve(1) });
455
+ expect(getResourceLoadProgress(loader)).toBe(0);
456
+ });
457
+
458
+ it('returns 1 for empty queue after start', () => {
459
+ const loader = createResourceLoader();
460
+ startResourceLoad(loader);
461
+ expect(getResourceLoadProgress(loader)).toBe(1);
462
+ });
463
+
464
+ it('returns fractional progress as items complete', async () => {
465
+ const loader = createResourceLoader({ maxConcurrent: 1 });
466
+ queueResourceLoad(loader, { load: () => Promise.resolve(1) });
467
+ queueResourceLoad(loader, { load: () => Promise.resolve(2) });
468
+ queueResourceLoad(loader, { load: () => Promise.resolve(3) });
469
+ startResourceLoad(loader);
470
+
471
+ await waitForComplete(loader);
472
+ expect(getResourceLoadProgress(loader)).toBe(1);
473
+ });
474
+
475
+ it('filters by group', async () => {
476
+ const loader = createResourceLoader({ maxConcurrent: 1 });
477
+ queueResourceLoad(loader, { group: 'preload', key: 'a', load: () => Promise.resolve(1) });
478
+ queueResourceLoad(loader, { group: 'level2', key: 'b', load: () => Promise.resolve(2) });
479
+ startResourceLoad(loader);
480
+ await waitForComplete(loader);
481
+
482
+ expect(getResourceLoadProgress(loader, 'preload')).toBe(1);
483
+ expect(getResourceLoadProgress(loader, 'level2')).toBe(1);
484
+ });
485
+ });
486
+
487
+ describe('pauseResourceLoad', () => {
488
+ it('stops dispatching new items', async () => {
489
+ const loader = createResourceLoader({ maxConcurrent: 1 });
490
+ const order: number[] = [];
491
+
492
+ const deferred1 = createDeferred<number>();
493
+ queueResourceLoad(loader, {
494
+ load: () =>
495
+ deferred1.promise.then((v) => {
496
+ order.push(v);
497
+ return v;
498
+ }),
499
+ });
500
+ queueResourceLoad(loader, {
501
+ load: () =>
502
+ Promise.resolve(2).then((v) => {
503
+ order.push(v);
504
+ return v;
505
+ }),
506
+ });
507
+
508
+ startResourceLoad(loader);
509
+ pauseResourceLoad(loader);
510
+ deferred1.resolve(1);
511
+
512
+ await new Promise((r) => setTimeout(r, 20));
513
+ // item 2 should not have started since paused after item 1 resolves
514
+ expect(order).toContain(1);
515
+ // item 2 may or may not have started depending on timing; just verify pause signal fires
516
+ });
517
+
518
+ it('emits onPause signal', () => {
519
+ const loader = createResourceLoader();
520
+ let paused = false;
521
+ connectSignal(loader.onPause, () => {
522
+ paused = true;
523
+ });
524
+ queueResourceLoad(loader, { load: () => Promise.resolve(1) });
525
+ startResourceLoad(loader);
526
+ pauseResourceLoad(loader);
527
+ expect(paused).toBe(true);
528
+ });
529
+
530
+ it('is a no-op if not started', () => {
531
+ const loader = createResourceLoader();
532
+ let paused = false;
533
+ connectSignal(loader.onPause, () => {
534
+ paused = true;
535
+ });
536
+ pauseResourceLoad(loader);
537
+ expect(paused).toBe(false);
538
+ });
539
+ });
540
+
541
+ describe('pool allocation (PendingEntry pool)', () => {
542
+ it('reuses pooled entries across multiple batches without corruption', async () => {
543
+ // Run the same loader through multiple batches to exercise the acquire/release cycle
544
+ const loader = createResourceLoader({ maxConcurrent: 2 });
545
+
546
+ for (let batch = 0; batch < 3; batch++) {
547
+ resetResourceLoader(loader);
548
+ for (let i = 0; i < 4; i++) {
549
+ const id = `batch${batch}-item${i}`;
550
+ queueResourceLoad(loader, { key: id, load: () => Promise.resolve(id) });
551
+ }
552
+ startResourceLoad(loader);
553
+ const reports = await waitForComplete(loader);
554
+ expect(reports).toHaveLength(4);
555
+ expect(reports.every((r) => r.status === 'loaded')).toBe(true);
556
+ }
557
+ });
558
+
559
+ it('does not retain stale reject/resolve references after entry release', async () => {
560
+ // Ensures that released entries do not hold stale Promise resolve/reject refs
561
+ const loader = createResourceLoader({ maxConcurrent: 1 });
562
+ let firstResolve: (() => void) | undefined;
563
+
564
+ queueResourceLoad(loader, {
565
+ key: 'first',
566
+ load: () =>
567
+ new Promise<string>((resolve) => {
568
+ firstResolve = () => resolve('first');
569
+ }),
570
+ });
571
+
572
+ startResourceLoad(loader);
573
+ firstResolve?.();
574
+ await waitForComplete(loader);
575
+
576
+ resetResourceLoader(loader);
577
+
578
+ // Queue a second item; if the pool entry was corrupted, it would resolve/reject the old promise
579
+ const h2 = queueResourceLoad(loader, { key: 'second', load: () => Promise.resolve('second') });
580
+ startResourceLoad(loader);
581
+ const result = await h2.promise;
582
+ expect(result).toBe('second');
583
+ });
584
+ });
585
+
586
+ describe('queueResourceLoad', () => {
587
+ it('accepts a bare thunk for backward compatibility', async () => {
588
+ const loader = createResourceLoader();
589
+ const handle = queueResourceLoad(loader, () => Promise.resolve('hello'));
590
+ startResourceLoad(loader);
591
+ expect(await handle.promise).toBe('hello');
592
+ });
593
+
594
+ it('accepts an item descriptor', async () => {
595
+ const loader = createResourceLoader();
596
+ const handle = queueResourceLoad(loader, { key: 'img', load: () => Promise.resolve('data') });
597
+ startResourceLoad(loader);
598
+ expect(await handle.promise).toBe('data');
599
+ });
600
+
601
+ it('returns a ResourceLoadHandle with key and promise', () => {
602
+ const loader = createResourceLoader();
603
+ const handle = queueResourceLoad(loader, { key: 'myKey', load: () => Promise.resolve(42) });
604
+ expect(handle.key).toBe('myKey');
605
+ expect(handle.promise).toBeInstanceOf(Promise);
606
+ });
607
+
608
+ it('auto-assigns a key when none provided', () => {
609
+ const loader = createResourceLoader();
610
+ const handle = queueResourceLoad(loader, { load: () => Promise.resolve(1) });
611
+ expect(typeof handle.key).toBe('string');
612
+ expect(handle.key.length).toBeGreaterThan(0);
613
+ });
614
+
615
+ it('throws if called after loading has started in non-streaming mode', () => {
616
+ const loader = createResourceLoader();
617
+ startResourceLoad(loader);
618
+ expect(() => queueResourceLoad(loader, { load: () => Promise.resolve(1) })).toThrow();
619
+ });
620
+
621
+ it('deduplicates items with the same key', async () => {
622
+ const loader = createResourceLoader();
623
+ let loadCount = 0;
624
+ const factory = () => {
625
+ loadCount++;
626
+ return Promise.resolve('value');
627
+ };
628
+
629
+ const h1 = queueResourceLoad(loader, { key: 'myAsset', load: factory });
630
+ const h2 = queueResourceLoad(loader, { key: 'myAsset', load: factory });
631
+ startResourceLoad(loader);
632
+ await waitForComplete(loader);
633
+
634
+ expect(loadCount).toBe(1);
635
+ expect(h1).toBe(h2);
636
+ expect(await h1.promise).toBe('value');
637
+ });
638
+
639
+ it('does not deduplicate when dedupe is false', async () => {
640
+ const loader = createResourceLoader({ dedupe: false });
641
+ let loadCount = 0;
642
+ const factory = () => {
643
+ loadCount++;
644
+ return Promise.resolve('value');
645
+ };
646
+
647
+ queueResourceLoad(loader, { key: 'myAsset', load: factory });
648
+ queueResourceLoad(loader, { key: 'myAsset', load: factory });
649
+ startResourceLoad(loader);
650
+ await waitForComplete(loader);
651
+
652
+ expect(loadCount).toBe(2);
653
+ });
654
+
655
+ it('fires onProgress after each item completes', async () => {
656
+ const loader = createResourceLoader({ maxConcurrent: 1 });
657
+ const progress: Array<[number, number]> = [];
658
+ connectSignal(loader.onProgress, (loaded, total) => {
659
+ progress.push([loaded, total]);
660
+ });
661
+
662
+ queueResourceLoad(loader, { load: () => Promise.resolve('a') });
663
+ queueResourceLoad(loader, { load: () => Promise.resolve('b') });
664
+ queueResourceLoad(loader, { load: () => Promise.resolve('c') });
665
+ startResourceLoad(loader);
666
+
667
+ await waitForComplete(loader);
668
+
669
+ expect(progress).toHaveLength(3);
670
+ expect(progress[2]).toEqual([3, 3]);
671
+ });
672
+
673
+ it('fires onComplete after all items finish', async () => {
674
+ const loader = createResourceLoader();
675
+ let completed = false;
676
+ connectSignal(loader.onComplete, () => {
677
+ completed = true;
678
+ });
679
+
680
+ queueResourceLoad(loader, { load: () => Promise.resolve(1) });
681
+ queueResourceLoad(loader, { load: () => Promise.resolve(2) });
682
+ startResourceLoad(loader);
683
+
684
+ await waitForComplete(loader);
685
+ expect(completed).toBe(true);
686
+ });
687
+
688
+ it('fires onError for a failed item but still completes by default', async () => {
689
+ const loader = createResourceLoader();
690
+ const errors: Array<{ error: unknown; key: string }> = [];
691
+ connectSignal(loader.onError, (err, key) => {
692
+ errors.push({ error: err, key });
693
+ });
694
+
695
+ queueResourceLoad(loader, { load: () => Promise.resolve('ok') });
696
+ const failing = queueResourceLoad(loader, {
697
+ key: 'fail',
698
+ load: () => Promise.reject(new Error('oops')),
699
+ });
700
+ failing.promise.catch(() => {});
701
+ startResourceLoad(loader);
702
+
703
+ await waitForComplete(loader);
704
+
705
+ expect(errors).toHaveLength(1);
706
+ expect((errors[0].error as Error).message).toBe('oops');
707
+ expect(errors[0].key).toBe('fail');
708
+ });
709
+
710
+ it('loads items in parallel by default', async () => {
711
+ const loader = createResourceLoader({ maxConcurrent: 0 });
712
+ const order: number[] = [];
713
+
714
+ queueResourceLoad(loader, {
715
+ load: () =>
716
+ new Promise<number>((resolve) =>
717
+ setTimeout(() => {
718
+ order.push(1);
719
+ resolve(1);
720
+ }, 20),
721
+ ),
722
+ });
723
+ queueResourceLoad(loader, {
724
+ load: () =>
725
+ new Promise<number>((resolve) =>
726
+ setTimeout(() => {
727
+ order.push(2);
728
+ resolve(2);
729
+ }, 5),
730
+ ),
731
+ });
732
+ startResourceLoad(loader);
733
+
734
+ await waitForComplete(loader);
735
+
736
+ // Parallel: item 2 (5ms) finishes before item 1 (20ms)
737
+ expect(order).toEqual([2, 1]);
738
+ });
739
+
740
+ it('loads items sequentially when maxConcurrent is 1', async () => {
741
+ const loader = createResourceLoader({ maxConcurrent: 1 });
742
+ const order: number[] = [];
743
+
744
+ queueResourceLoad(loader, {
745
+ load: () =>
746
+ new Promise<number>((resolve) =>
747
+ setTimeout(() => {
748
+ order.push(1);
749
+ resolve(1);
750
+ }, 20),
751
+ ),
752
+ });
753
+ queueResourceLoad(loader, {
754
+ load: () =>
755
+ new Promise<number>((resolve) =>
756
+ setTimeout(() => {
757
+ order.push(2);
758
+ resolve(2);
759
+ }, 5),
760
+ ),
761
+ });
762
+ startResourceLoad(loader);
763
+
764
+ await waitForComplete(loader);
765
+
766
+ // Sequential: item 1 runs first, then item 2, order is 1, 2
767
+ expect(order).toEqual([1, 2]);
768
+ });
769
+
770
+ it('respects maxConcurrent limit', async () => {
771
+ const loader = createResourceLoader({ maxConcurrent: 2 });
772
+ let maxInFlight = 0;
773
+ let currentInFlight = 0;
774
+
775
+ const makeFactory = () => ({
776
+ load: () =>
777
+ new Promise<number>((resolve) => {
778
+ currentInFlight++;
779
+ maxInFlight = Math.max(maxInFlight, currentInFlight);
780
+ setTimeout(() => {
781
+ currentInFlight--;
782
+ resolve(1);
783
+ }, 10);
784
+ }),
785
+ });
786
+
787
+ for (let i = 0; i < 6; i++) {
788
+ queueResourceLoad(loader, makeFactory());
789
+ }
790
+ startResourceLoad(loader);
791
+ await waitForComplete(loader);
792
+
793
+ expect(maxInFlight).toBeLessThanOrEqual(2);
794
+ });
795
+ });
796
+
797
+ describe('resetResourceLoader', () => {
798
+ it('allows the loader to be reused for another batch', async () => {
799
+ const loader = createResourceLoader();
800
+ queueResourceLoad(loader, { load: () => Promise.resolve(1) });
801
+ startResourceLoad(loader);
802
+ await waitForComplete(loader);
803
+
804
+ resetResourceLoader(loader);
805
+
806
+ let completed = false;
807
+ connectSignal(loader.onComplete, () => {
808
+ completed = true;
809
+ });
810
+ queueResourceLoad(loader, { load: () => Promise.resolve(2) });
811
+ startResourceLoad(loader);
812
+ await waitForComplete(loader);
813
+
814
+ expect(completed).toBe(true);
815
+ });
816
+
817
+ it('resets progress tracking', async () => {
818
+ const loader = createResourceLoader();
819
+ queueResourceLoad(loader, { load: () => Promise.resolve(1) });
820
+ startResourceLoad(loader);
821
+ await waitForComplete(loader);
822
+
823
+ resetResourceLoader(loader);
824
+ expect(getResourceLoadProgress(loader)).toBe(0);
825
+ });
826
+ });
827
+
828
+ describe('resumeResourceLoad', () => {
829
+ it('emits onResume and continues dispatching', async () => {
830
+ const loader = createResourceLoader({ maxConcurrent: 1 });
831
+ let resumed = false;
832
+ connectSignal(loader.onResume, () => {
833
+ resumed = true;
834
+ });
835
+
836
+ const deferred = createDeferred<number>();
837
+ queueResourceLoad(loader, { load: () => deferred.promise });
838
+ queueResourceLoad(loader, { load: () => Promise.resolve(2) });
839
+ startResourceLoad(loader);
840
+ pauseResourceLoad(loader);
841
+ deferred.resolve(1);
842
+
843
+ await new Promise((r) => setTimeout(r, 10));
844
+ resumeResourceLoad(loader);
845
+
846
+ await waitForComplete(loader);
847
+ expect(resumed).toBe(true);
848
+ });
849
+
850
+ it('is a no-op if not paused', () => {
851
+ const loader = createResourceLoader();
852
+ let resumed = false;
853
+ connectSignal(loader.onResume, () => {
854
+ resumed = true;
855
+ });
856
+ resumeResourceLoad(loader);
857
+ expect(resumed).toBe(false);
858
+ });
859
+ });
860
+
861
+ describe('retries', () => {
862
+ it('retries a failing item the specified number of times', async () => {
863
+ const loader = createResourceLoader();
864
+ let attempts = 0;
865
+ const handle = queueResourceLoad(loader, {
866
+ load: () => {
867
+ attempts++;
868
+ return Promise.reject(new Error('transient'));
869
+ },
870
+ retries: 2,
871
+ });
872
+ handle.promise.catch(() => {});
873
+ startResourceLoad(loader);
874
+ await waitForComplete(loader);
875
+ expect(attempts).toBe(3); // 1 initial + 2 retries
876
+ });
877
+
878
+ it('resolves if a retry succeeds', async () => {
879
+ const loader = createResourceLoader();
880
+ let attempts = 0;
881
+ const handle = queueResourceLoad(loader, {
882
+ load: () => {
883
+ attempts++;
884
+ if (attempts < 3) return Promise.reject(new Error('transient'));
885
+ return Promise.resolve('success');
886
+ },
887
+ retries: 3,
888
+ });
889
+ startResourceLoad(loader);
890
+ await waitForComplete(loader);
891
+ expect(await handle.promise).toBe('success');
892
+ expect(attempts).toBe(3);
893
+ });
894
+ });
895
+
896
+ describe('setResourceLoaderConcurrency', () => {
897
+ it('updates the concurrency limit on a running loader', async () => {
898
+ const loader = createResourceLoader({ maxConcurrent: 1 });
899
+ queueResourceLoad(loader, { load: () => Promise.resolve(1) });
900
+ queueResourceLoad(loader, { load: () => Promise.resolve(2) });
901
+ startResourceLoad(loader);
902
+ setResourceLoaderConcurrency(loader, 4);
903
+ await waitForComplete(loader);
904
+ // Just verify it completes without error
905
+ });
906
+ });
907
+
908
+ describe('setResourceLoadPriority', () => {
909
+ it('updates priority of a pending item', async () => {
910
+ const loader = createResourceLoader({ maxConcurrent: 1 });
911
+ const order: string[] = [];
912
+
913
+ queueResourceLoad(loader, {
914
+ key: 'low',
915
+ load: () =>
916
+ new Promise((r) =>
917
+ setTimeout(() => {
918
+ order.push('low');
919
+ r(1);
920
+ }, 5),
921
+ ),
922
+ priority: 0,
923
+ });
924
+ queueResourceLoad(loader, {
925
+ key: 'high',
926
+ load: () =>
927
+ new Promise((r) =>
928
+ setTimeout(() => {
929
+ order.push('high');
930
+ r(2);
931
+ }, 5),
932
+ ),
933
+ priority: 0,
934
+ });
935
+
936
+ // Boost priority of 'high' before start; priority sort happens at dispatch time
937
+ setResourceLoadPriority(loader, 'high', 10);
938
+ startResourceLoad(loader);
939
+ await waitForComplete(loader);
940
+
941
+ expect(order[0]).toBe('high'); // 'high' has higher priority, dispatched first
942
+ expect(order[1]).toBe('low'); // 'low' runs after
943
+ });
944
+ });
945
+
946
+ describe('startResourceLoad', () => {
947
+ it('fires onComplete immediately when queue is empty', () => {
948
+ const loader = createResourceLoader();
949
+ let called = false;
950
+ connectSignal(loader.onComplete, () => {
951
+ called = true;
952
+ });
953
+ startResourceLoad(loader);
954
+ expect(called).toBe(true);
955
+ });
956
+
957
+ it('fires onProgress(0, 0) for an empty queue', () => {
958
+ const loader = createResourceLoader();
959
+ let args: [number, number] | null = null;
960
+ connectSignal(loader.onProgress, (loaded, total) => {
961
+ args = [loaded, total];
962
+ });
963
+ startResourceLoad(loader);
964
+ expect(args).toEqual([0, 0]);
965
+ });
966
+
967
+ it('is a no-op if called a second time in non-streaming mode', () => {
968
+ const loader = createResourceLoader();
969
+ let count = 0;
970
+ connectSignal(loader.onComplete, () => {
971
+ count++;
972
+ });
973
+ startResourceLoad(loader);
974
+ startResourceLoad(loader);
975
+ expect(count).toBe(1);
976
+ });
977
+
978
+ it('onComplete receives reports array', async () => {
979
+ const loader = createResourceLoader();
980
+ queueResourceLoad(loader, { key: 'a', load: () => Promise.resolve(1) });
981
+ startResourceLoad(loader);
982
+ const reports = await waitForComplete(loader);
983
+ expect(reports).toHaveLength(1);
984
+ expect(reports[0].key).toBe('a');
985
+ expect(reports[0].status).toBe('loaded');
986
+ });
987
+
988
+ it('handles streaming mode: allows queueing after start', async () => {
989
+ const loader = createResourceLoader({ streaming: true });
990
+ startResourceLoad(loader);
991
+ const handle = queueResourceLoad(loader, { load: () => Promise.resolve('streamed') });
992
+ expect(await handle.promise).toBe('streamed');
993
+ });
994
+ });
995
+
996
+ describe('timeout', () => {
997
+ it('rejects an item that exceeds its timeout', async () => {
998
+ const loader = createResourceLoader();
999
+ const handle = queueResourceLoad(loader, {
1000
+ load: (_signal: AbortSignal) => new Promise<number>((resolve) => setTimeout(() => resolve(1), 500)),
1001
+ timeoutMs: 20,
1002
+ });
1003
+ handle.promise.catch(() => {});
1004
+ startResourceLoad(loader);
1005
+ await waitForComplete(loader);
1006
+ await expect(handle.promise).rejects.toBeDefined();
1007
+ });
1008
+ });
1009
+
1010
+ describe('weight-aware progress', () => {
1011
+ it('uses weights for progress calculation', async () => {
1012
+ const loader = createResourceLoader({ maxConcurrent: 1 });
1013
+ queueResourceLoad(loader, { load: () => Promise.resolve(1), weight: 10 });
1014
+ queueResourceLoad(loader, { load: () => Promise.resolve(2), weight: 90 });
1015
+
1016
+ const progressValues: number[] = [];
1017
+ connectSignal(loader.onProgress, () => {
1018
+ progressValues.push(getResourceLoadProgress(loader));
1019
+ });
1020
+
1021
+ startResourceLoad(loader);
1022
+ await waitForComplete(loader);
1023
+
1024
+ // After first item (weight 10 of 100): 0.1
1025
+ expect(progressValues[0]).toBeCloseTo(0.1);
1026
+ // After second item (weight 100 of 100): 1.0
1027
+ expect(progressValues[1]).toBeCloseTo(1.0);
1028
+ });
1029
+ });