@blockcast/fec-worker 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.
Files changed (53) hide show
  1. package/README.md +213 -0
  2. package/dist/.build-stamp +0 -0
  3. package/dist/fec-worker-client.d.ts +80 -0
  4. package/dist/fec-worker-client.d.ts.map +1 -0
  5. package/dist/fec-worker-client.js +270 -0
  6. package/dist/fec-worker-client.js.map +1 -0
  7. package/dist/fec-worker-types.d.ts +252 -0
  8. package/dist/fec-worker-types.d.ts.map +1 -0
  9. package/dist/fec-worker-types.js +11 -0
  10. package/dist/fec-worker-types.js.map +1 -0
  11. package/dist/fec-worker.d.ts +14 -0
  12. package/dist/fec-worker.d.ts.map +1 -0
  13. package/dist/fec-worker.js +565 -0
  14. package/dist/fec-worker.js.map +7 -0
  15. package/dist/index.d.ts +10 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +8 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/shred-fec-worker.d.ts +20 -0
  20. package/dist/shred-fec-worker.d.ts.map +1 -0
  21. package/dist/shred-fec-worker.js +349 -0
  22. package/dist/shred-fec-worker.js.map +7 -0
  23. package/dist/shred-worker-client.d.ts +104 -0
  24. package/dist/shred-worker-client.d.ts.map +1 -0
  25. package/dist/shred-worker-client.js +181 -0
  26. package/dist/shred-worker-client.js.map +1 -0
  27. package/dist/shred-worker-types.d.ts +179 -0
  28. package/dist/shred-worker-types.d.ts.map +1 -0
  29. package/dist/shred-worker-types.js +47 -0
  30. package/dist/shred-worker-types.js.map +1 -0
  31. package/dist/transfer.d.ts +9 -0
  32. package/dist/transfer.d.ts.map +1 -0
  33. package/dist/transfer.js +13 -0
  34. package/dist/transfer.js.map +1 -0
  35. package/package.json +54 -0
  36. package/src/__tests__/fec-worker-alta.test.ts +231 -0
  37. package/src/__tests__/fec-worker-cleanup-integration.test.ts +250 -0
  38. package/src/__tests__/fec-worker-pending-queue.test.ts +315 -0
  39. package/src/__tests__/fec-worker-repair-size.test.ts +1029 -0
  40. package/src/__tests__/fec-worker-wasm-contract.test.ts +84 -0
  41. package/src/__tests__/shred-fec-worker.test.ts +448 -0
  42. package/src/fec-worker-client.test.ts +530 -0
  43. package/src/fec-worker-client.ts +309 -0
  44. package/src/fec-worker-types.test.ts +400 -0
  45. package/src/fec-worker-types.ts +268 -0
  46. package/src/fec-worker.ts +1048 -0
  47. package/src/index.ts +32 -0
  48. package/src/shred-fec-worker.ts +558 -0
  49. package/src/shred-worker-client.test.ts +182 -0
  50. package/src/shred-worker-client.ts +222 -0
  51. package/src/shred-worker-types.ts +194 -0
  52. package/src/transfer.test.ts +24 -0
  53. package/src/transfer.ts +12 -0
@@ -0,0 +1,530 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import type {
3
+ FecWorkerCommand,
4
+ FecWorkerEvent,
5
+ FecTrackConfig,
6
+ FecTrackStats,
7
+ FecBlockSnapshot,
8
+ FrameMeta,
9
+ } from "./fec-worker-types.js";
10
+ import { FecWorkerClient } from "./fec-worker-client.js";
11
+
12
+ // --- Mock Worker ---
13
+
14
+ class MockWorker {
15
+ onmessage: ((e: MessageEvent) => void) | null = null;
16
+ onerror: ((e: ErrorEvent) => void) | null = null;
17
+ onmessageerror: ((e: MessageEvent) => void) | null = null;
18
+ posted: Array<{ data: unknown; transfer: Transferable[] }> = [];
19
+ terminated = false;
20
+
21
+ postMessage(data: unknown, transfer?: Transferable[]): void {
22
+ this.posted.push({ data, transfer: transfer ?? [] });
23
+ }
24
+
25
+ terminate(): void {
26
+ this.terminated = true;
27
+ }
28
+
29
+ // Test helper: simulate Worker sending event back
30
+ simulateMessage(event: FecWorkerEvent): void {
31
+ this.onmessage?.({ data: event } as MessageEvent);
32
+ }
33
+
34
+ simulateError(message: string): void {
35
+ this.onerror?.({ message } as ErrorEvent);
36
+ }
37
+
38
+ simulateDetailedError(event: Partial<ErrorEvent>): void {
39
+ this.onerror?.(event as ErrorEvent);
40
+ }
41
+
42
+ simulateMessageError(data?: unknown): void {
43
+ this.onmessageerror?.({ data } as MessageEvent);
44
+ }
45
+ }
46
+
47
+ function makeConfig(overrides?: Partial<FecTrackConfig>): FecTrackConfig {
48
+ return {
49
+ codec: "avc1.64001f",
50
+ trackName: "video",
51
+ trackType: "video",
52
+ algorithm: "raptor",
53
+ k: 32,
54
+ repairCount: 8,
55
+ symbolSize: 1320,
56
+ interleaveDepth: 4,
57
+ interleaveMs: 133,
58
+ deliveryWindowMs: 1000,
59
+ fecMode: "subframe",
60
+ altaEnabled: false,
61
+ relayBlockSigEnabled: false,
62
+ ...overrides,
63
+ };
64
+ }
65
+
66
+ describe("FecWorkerClient", () => {
67
+ let mockWorker: MockWorker;
68
+ let client: FecWorkerClient;
69
+
70
+ beforeEach(() => {
71
+ mockWorker = new MockWorker();
72
+ client = new FecWorkerClient(mockWorker as unknown as Worker);
73
+ });
74
+
75
+ it("constructor accepts a Worker instance", () => {
76
+ expect(mockWorker.onmessage).toBeTypeOf("function");
77
+ });
78
+
79
+ describe("configure", () => {
80
+ it("posts configure command with wasmModule in message body (NOT transfer list — Module is structured-cloneable)", () => {
81
+ const config = makeConfig();
82
+ const wasmModule = {} as WebAssembly.Module;
83
+ client.configure(config, wasmModule);
84
+
85
+ expect(mockWorker.posted).toHaveLength(1);
86
+ const msg = mockWorker.posted[0];
87
+ const cmd = msg.data as Extract<FecWorkerCommand, { type: "configure" }>;
88
+ expect(cmd.type).toBe("configure");
89
+ expect(cmd.config).toBe(config);
90
+ expect(cmd.wasmModule).toBe(wasmModule);
91
+ // WebAssembly.Module is structured-cloneable, NOT transferable
92
+ expect(msg.transfer).not.toContain(wasmModule);
93
+ });
94
+
95
+ it("includes altaPublicKey in transferList but NOT altaWasmModule (AUTH-03, Plan 08-03)", () => {
96
+ const config = makeConfig({ altaEnabled: true });
97
+ const wasmModule = {} as WebAssembly.Module;
98
+ const altaKey = new ArrayBuffer(32);
99
+ const altaWasmModule = {} as WebAssembly.Module;
100
+ client.configure(config, wasmModule, altaKey, altaWasmModule);
101
+
102
+ const msg = mockWorker.posted[0];
103
+ const cmd = msg.data as Extract<FecWorkerCommand, { type: "configure" }>;
104
+ expect(cmd.altaPublicKey).toBe(altaKey);
105
+ expect(cmd.altaWasmModule).toBe(altaWasmModule);
106
+ // ArrayBuffer (altaPublicKey) is transferable
107
+ expect(msg.transfer).toContain(altaKey);
108
+ // WebAssembly.Module is structured-cloneable — must NOT be in transfer list
109
+ expect(msg.transfer).not.toContain(wasmModule);
110
+ expect(msg.transfer).not.toContain(altaWasmModule);
111
+ });
112
+
113
+ it("throws if disposed", () => {
114
+ client.dispose();
115
+ expect(() => client.configure(makeConfig(), {} as WebAssembly.Module)).toThrow(
116
+ "disposed",
117
+ );
118
+ });
119
+ });
120
+
121
+ describe("feedSource", () => {
122
+ it("posts feedSource carrying the flat SS_ID (matches WASM add_source signature)", () => {
123
+ const data = new ArrayBuffer(1320);
124
+ // SS_ID = SBN * K + ESI (e.g. K=4, SBN=5, ESI=3 → ss_id = 23)
125
+ client.feedSource(23, data, 1000);
126
+
127
+ expect(mockWorker.posted).toHaveLength(1);
128
+ const msg = mockWorker.posted[0];
129
+ const cmd = msg.data as Extract<FecWorkerCommand, { type: "feedSource" }>;
130
+ expect(cmd.type).toBe("feedSource");
131
+ expect(cmd.ssId).toBe(23);
132
+ expect(cmd.data).toBe(data);
133
+ expect(cmd.ts).toBe(1000);
134
+ expect(msg.transfer).toContain(data);
135
+ });
136
+
137
+ it("does not serialize caller-supplied transport provenance", () => {
138
+ const data = new ArrayBuffer(1320);
139
+ (client.feedSource as unknown as (...args: unknown[]) => void)(
140
+ 23,
141
+ data,
142
+ 1000,
143
+ undefined,
144
+ "multicast",
145
+ );
146
+
147
+ expect(mockWorker.posted[0]?.data).not.toHaveProperty("source");
148
+ });
149
+
150
+ it("silently returns if disposed", () => {
151
+ client.dispose();
152
+ client.feedSource(0, new ArrayBuffer(0), 0);
153
+ // Only the dispose command should be posted, not feedSource
154
+ const feedCmds = mockWorker.posted.filter(
155
+ (p) => (p.data as FecWorkerCommand).type === "feedSource",
156
+ );
157
+ expect(feedCmds).toHaveLength(0);
158
+ });
159
+ });
160
+
161
+ describe("feedRepair", () => {
162
+ it("posts feedRepair carrying SS_Start/SSB_length/RS_ID (matches WASM add_repair)", () => {
163
+ const data = new ArrayBuffer(1320);
164
+ // SS_Start=20 (SBN=5, K=4), SSB_length=4 (K), RS_ID=2 (2nd repair symbol)
165
+ client.feedRepair(20, 4, 2, data, 1000);
166
+
167
+ expect(mockWorker.posted).toHaveLength(1);
168
+ const msg = mockWorker.posted[0];
169
+ const cmd = msg.data as Extract<FecWorkerCommand, { type: "feedRepair" }>;
170
+ expect(cmd.type).toBe("feedRepair");
171
+ expect(cmd.ssStart).toBe(20);
172
+ expect(cmd.ssbLength).toBe(4);
173
+ expect(cmd.rsId).toBe(2);
174
+ expect(cmd.data).toBe(data);
175
+ expect(cmd.ts).toBe(1000);
176
+ expect(msg.transfer).toContain(data);
177
+ });
178
+
179
+ it("does not serialize caller-supplied transport provenance", () => {
180
+ const data = new ArrayBuffer(1320);
181
+ (client.feedRepair as unknown as (...args: unknown[]) => void)(
182
+ 20,
183
+ 4,
184
+ 2,
185
+ data,
186
+ 1000,
187
+ "moq",
188
+ );
189
+
190
+ expect(mockWorker.posted[0]?.data).not.toHaveProperty("source");
191
+ });
192
+ });
193
+
194
+ describe("relayBlockSig", () => {
195
+ it("posts relayBlockSig with sig and hash in transferList", () => {
196
+ const sig = new ArrayBuffer(64);
197
+ const hash = new ArrayBuffer(32);
198
+ client.relayBlockSig(10, sig, hash);
199
+
200
+ expect(mockWorker.posted).toHaveLength(1);
201
+ const msg = mockWorker.posted[0];
202
+ const cmd = msg.data as Extract<FecWorkerCommand, { type: "relayBlockSig" }>;
203
+ expect(cmd.type).toBe("relayBlockSig");
204
+ expect(cmd.sbn).toBe(10);
205
+ expect(msg.transfer).toContain(sig);
206
+ expect(msg.transfer).toContain(hash);
207
+ });
208
+ });
209
+
210
+ describe("cleanup", () => {
211
+ it("posts cleanup command with the timed-out SBN", () => {
212
+ client.cleanup(15);
213
+
214
+ expect(mockWorker.posted).toHaveLength(1);
215
+ const cmd = mockWorker.posted[0].data as Extract<
216
+ FecWorkerCommand,
217
+ { type: "cleanup" }
218
+ >;
219
+ expect(cmd.type).toBe("cleanup");
220
+ expect(cmd.sbns).toEqual([15]);
221
+ });
222
+
223
+ it("rejects an invalid SBN without blocking the next valid cleanup", () => {
224
+ expect(() => client.cleanup(0x1_0000_0000)).toThrow(
225
+ "[FecWorkerClient] cleanup: SBN must be a uint32",
226
+ );
227
+ expect(mockWorker.posted).toHaveLength(0);
228
+
229
+ client.cleanup(15);
230
+
231
+ expect(mockWorker.posted).toHaveLength(1);
232
+ expect(mockWorker.posted[0]?.data).toEqual({
233
+ type: "cleanup",
234
+ sbns: [15],
235
+ });
236
+ });
237
+
238
+ it("coalesces cleanup while preserving per-SBN fill and acknowledgement ownership", () => {
239
+ const cb = vi.fn();
240
+ const fills = vi.fn();
241
+ client.onCleanupComplete(cb);
242
+ client.onGreenFill(fills);
243
+
244
+ client.cleanup(15);
245
+ client.cleanup(16);
246
+ client.cleanup(18);
247
+
248
+ let cleanupMessages = mockWorker.posted.filter(
249
+ (p) => (p.data as FecWorkerCommand).type === "cleanup",
250
+ );
251
+ expect(cleanupMessages).toHaveLength(1);
252
+
253
+ mockWorker.simulateMessage({ type: "cleanupComplete", sbns: [15] });
254
+ expect(cb.mock.calls.map(([sbn]) => sbn)).toEqual([15]);
255
+
256
+ cleanupMessages = mockWorker.posted.filter(
257
+ (p) => (p.data as FecWorkerCommand).type === "cleanup",
258
+ );
259
+ expect(cleanupMessages).toHaveLength(2);
260
+ expect(
261
+ (cleanupMessages[1]?.data as Extract<FecWorkerCommand, { type: "cleanup" }>).sbns,
262
+ ).toEqual([16, 18]);
263
+
264
+ const fill16 = new ArrayBuffer(5);
265
+ const fill18 = new ArrayBuffer(5);
266
+ mockWorker.simulateMessage({ type: "greenFill", sbn: 16, data: fill16 });
267
+ mockWorker.simulateMessage({ type: "greenFill", sbn: 18, data: fill18 });
268
+ mockWorker.simulateMessage({ type: "cleanupComplete", sbns: [16, 18] });
269
+ expect(fills.mock.calls).toEqual([
270
+ [16, fill16],
271
+ [18, fill18],
272
+ ]);
273
+ expect(cb.mock.calls.map(([sbn]) => sbn)).toEqual([15, 16, 18]);
274
+ });
275
+ });
276
+
277
+ describe("event dispatch", () => {
278
+ it("dispatches frame events to onFrame callback with ArrayBuffer data (FEC-03)", () => {
279
+ const cb = vi.fn();
280
+ client.onFrame(cb);
281
+
282
+ const frameData = new ArrayBuffer(1320);
283
+ const meta: FrameMeta = { altaVerified: null, recovered: false, sbn: 5 };
284
+ mockWorker.simulateMessage({ type: "frame", data: frameData, meta });
285
+
286
+ expect(cb).toHaveBeenCalledOnce();
287
+ expect(cb).toHaveBeenCalledWith(frameData, meta);
288
+ // Verify data is ArrayBuffer (not Uint8Array) for zero-copy
289
+ expect(cb.mock.calls[0][0]).toBeInstanceOf(ArrayBuffer);
290
+ });
291
+
292
+ it("dispatches frame events with altaVerified metadata (AUTH-04)", () => {
293
+ const cb = vi.fn();
294
+ client.onFrame(cb);
295
+
296
+ const meta: FrameMeta = { altaVerified: true, recovered: true, sbn: 3 };
297
+ mockWorker.simulateMessage({
298
+ type: "frame",
299
+ data: new ArrayBuffer(100),
300
+ meta,
301
+ });
302
+
303
+ expect(cb.mock.calls[0][1].altaVerified).toBe(true);
304
+ expect(cb.mock.calls[0][1].recovered).toBe(true);
305
+ });
306
+
307
+ it("dispatches snapshot events to onSnapshot callback (FEC-04)", () => {
308
+ const cb = vi.fn();
309
+ client.onSnapshot(cb);
310
+
311
+ const stats: FecTrackStats = {
312
+ config: makeConfig(),
313
+ sourceSymbols: 100,
314
+ repairSymbols: 25,
315
+ blocksComplete: 3,
316
+ blocksRecovered: 1,
317
+ blocksFailed: 0,
318
+ greenFillFrames: 0,
319
+ recoveryRate: 100,
320
+ altaVerified: 0,
321
+ altaFailed: 0,
322
+ relayBlocksVerified: 0,
323
+ relayBlocksFailed: 0,
324
+ liveSbn: 4,
325
+ blocks: [],
326
+ interleaveWindowBlocks: 4,
327
+ avgRecoveryMs: 30,
328
+ maxRecoveryMs: 60,
329
+ };
330
+ mockWorker.simulateMessage({ type: "snapshot", stats });
331
+
332
+ expect(cb).toHaveBeenCalledOnce();
333
+ expect(cb).toHaveBeenCalledWith(stats);
334
+ });
335
+
336
+ it("dispatches blockUpdate events to onBlockUpdate callback", () => {
337
+ const cb = vi.fn();
338
+ client.onBlockUpdate(cb);
339
+
340
+ const block: FecBlockSnapshot = {
341
+ sbn: 7,
342
+ state: "recovered",
343
+ sourceReceived: 28,
344
+ repairReceived: 6,
345
+ k: 32,
346
+ repairCount: 8,
347
+ firstSymbolTs: 100,
348
+ lastSymbolTs: 200,
349
+ completedTs: 210,
350
+ deadlineTs: 500,
351
+ altaVerified: 28,
352
+ relayBlockSigValid: true,
353
+ };
354
+ mockWorker.simulateMessage({ type: "blockUpdate", block });
355
+
356
+ expect(cb).toHaveBeenCalledOnce();
357
+ expect(cb).toHaveBeenCalledWith(block);
358
+ });
359
+
360
+ it("dispatches greenFill events to onGreenFill callback", () => {
361
+ const cb = vi.fn();
362
+ client.onGreenFill(cb);
363
+
364
+ const data = new ArrayBuffer(0);
365
+ mockWorker.simulateMessage({ type: "greenFill", sbn: 3, data });
366
+
367
+ expect(cb).toHaveBeenCalledOnce();
368
+ expect(cb).toHaveBeenCalledWith(3, data);
369
+ });
370
+
371
+ it("dispatches cleanupComplete events after queued cleanup work", () => {
372
+ const cb = vi.fn();
373
+ client.onCleanupComplete(cb);
374
+
375
+ mockWorker.simulateMessage({ type: "cleanupComplete", sbns: [3] });
376
+
377
+ expect(cb).toHaveBeenCalledOnce();
378
+ expect(cb).toHaveBeenCalledWith(3);
379
+ });
380
+
381
+ it("dispatches error events to onError callback", () => {
382
+ const cb = vi.fn();
383
+ client.onError(cb);
384
+
385
+ mockWorker.simulateMessage({ type: "error", message: "WASM decode failed" });
386
+
387
+ expect(cb).toHaveBeenCalledOnce();
388
+ expect(cb).toHaveBeenCalledWith("WASM decode failed");
389
+ });
390
+
391
+ it("preserves stable error codes for messages with volatile details", () => {
392
+ const cb = vi.fn();
393
+ client.onError(cb);
394
+
395
+ mockWorker.simulateMessage({
396
+ type: "error",
397
+ code: "repair-symbol-size-mismatch",
398
+ message: "invalid repair size (ssStart=320)",
399
+ });
400
+
401
+ expect(cb).toHaveBeenCalledWith(
402
+ "invalid repair size (ssStart=320)",
403
+ "repair-symbol-size-mismatch",
404
+ );
405
+ });
406
+
407
+ it("dispatches Worker-level errors to onError callback", () => {
408
+ const cb = vi.fn();
409
+ client.onError(cb);
410
+
411
+ mockWorker.simulateError("Script error");
412
+
413
+ expect(cb).toHaveBeenCalledOnce();
414
+ expect(cb.mock.calls[0][0]).toContain("Script error");
415
+ });
416
+
417
+ it("includes Worker script location and stack for load/runtime failures", () => {
418
+ const cb = vi.fn();
419
+ client.onError(cb);
420
+ const error = new Error("import failed");
421
+ error.stack = "Error: import failed\n at /vendor/fec-worker.js:7:3";
422
+
423
+ mockWorker.simulateDetailedError({
424
+ message: "Failed to fetch dynamically imported module",
425
+ filename: "/vendor/node_modules/@blockcast/fec-worker/fec-worker.js",
426
+ lineno: 7,
427
+ colno: 3,
428
+ error,
429
+ });
430
+
431
+ expect(cb).toHaveBeenCalledOnce();
432
+ const message = cb.mock.calls[0][0];
433
+ expect(message).toContain("Failed to fetch dynamically imported module");
434
+ expect(message).toContain("/vendor/node_modules/@blockcast/fec-worker/fec-worker.js:7:3");
435
+ expect(message).toContain("import failed");
436
+ });
437
+
438
+ it("dispatches Worker messageerror to onError callback", () => {
439
+ const cb = vi.fn();
440
+ client.onError(cb);
441
+
442
+ mockWorker.simulateMessageError({ type: "frame" });
443
+
444
+ expect(cb).toHaveBeenCalledOnce();
445
+ expect(cb.mock.calls[0][0]).toContain("Worker messageerror");
446
+ });
447
+ });
448
+
449
+ describe("dispose", () => {
450
+ it("posts dispose command and terminates Worker", () => {
451
+ client.dispose();
452
+
453
+ const disposeCmds = mockWorker.posted.filter(
454
+ (p) => (p.data as FecWorkerCommand).type === "dispose",
455
+ );
456
+ expect(disposeCmds).toHaveLength(1);
457
+ expect(mockWorker.terminated).toBe(true);
458
+ });
459
+
460
+ it("is idempotent", () => {
461
+ client.dispose();
462
+ client.dispose();
463
+
464
+ const disposeCmds = mockWorker.posted.filter(
465
+ (p) => (p.data as FecWorkerCommand).type === "dispose",
466
+ );
467
+ expect(disposeCmds).toHaveLength(1);
468
+ });
469
+ });
470
+
471
+ describe("reset", () => {
472
+ it("suppresses stale events until the Worker acknowledges the new epoch", () => {
473
+ const onFrame = vi.fn();
474
+ client.onFrame(onFrame);
475
+
476
+ client.reset();
477
+ expect(mockWorker.posted.at(-1)?.data).toEqual({ type: "reset" });
478
+ mockWorker.simulateMessage({
479
+ type: "frame",
480
+ data: new ArrayBuffer(1),
481
+ meta: { recovered: true, sbn: 1, esi: 0 },
482
+ });
483
+ expect(onFrame).not.toHaveBeenCalled();
484
+
485
+ mockWorker.simulateMessage({ type: "resetComplete" });
486
+ mockWorker.simulateMessage({
487
+ type: "frame",
488
+ data: new ArrayBuffer(1),
489
+ meta: { recovered: true, sbn: 2, esi: 0 },
490
+ });
491
+ expect(onFrame).toHaveBeenCalledOnce();
492
+ });
493
+
494
+ it("quarantines a failed reset until a later reset succeeds", () => {
495
+ const onError = vi.fn();
496
+ const onFrame = vi.fn();
497
+ client.onError(onError);
498
+ client.onFrame(onFrame);
499
+
500
+ client.reset();
501
+ mockWorker.simulateMessage({
502
+ type: "error",
503
+ code: "reset-failed",
504
+ message: "[FecWorker] reset failed: flush failed",
505
+ });
506
+ mockWorker.simulateMessage({
507
+ type: "frame",
508
+ data: new ArrayBuffer(1),
509
+ meta: { recovered: true, sbn: 1, esi: 0 },
510
+ });
511
+
512
+ expect(onError).toHaveBeenCalledWith(
513
+ "[FecWorker] reset failed: flush failed",
514
+ "reset-failed",
515
+ );
516
+ expect(onFrame).not.toHaveBeenCalled();
517
+
518
+ client.reset();
519
+ mockWorker.simulateMessage({ type: "resetComplete" });
520
+ mockWorker.simulateMessage({
521
+ type: "frame",
522
+ data: new ArrayBuffer(1),
523
+ meta: { recovered: true, sbn: 2, esi: 0 },
524
+ });
525
+
526
+ expect(onFrame).toHaveBeenCalledOnce();
527
+ });
528
+ });
529
+
530
+ });