@ksp-gonogo/kerbcast 1.4.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,1673 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ CameraLifecycle,
4
+ ErrorSource,
5
+ Layer,
6
+ QualityPreset,
7
+ } from "./__generated__/types";
8
+ import {
9
+ BrowserKerbcastTransport,
10
+ type KerbcastConnectionState,
11
+ type KerbcastDataChannel,
12
+ type KerbcastPeer,
13
+ type KerbcastTransport,
14
+ KerbcastClient,
15
+ } from "./client";
16
+ import { MockSidecar } from "./testing";
17
+ import * as noise from "./noise";
18
+
19
+ interface FakeChannel extends KerbcastDataChannel {
20
+ sent: string[];
21
+ _open: () => void;
22
+ _msg: (raw: string) => void;
23
+ _close: () => void;
24
+ }
25
+
26
+ function makeFakeTransport() {
27
+ const captured: {
28
+ dc?: FakeChannel;
29
+ onTrack?: (t: MediaStreamTrack, idx: number) => void;
30
+ onState?: (s: KerbcastConnectionState) => void;
31
+ closed: boolean;
32
+ } = { closed: false };
33
+
34
+ function makeFakeChannel(): FakeChannel {
35
+ const ch: FakeChannel = {
36
+ sent: [],
37
+ send: (s) => ch.sent.push(s),
38
+ onOpen: (h) => {
39
+ ch._open = h;
40
+ },
41
+ onMessage: (h) => {
42
+ ch._msg = h;
43
+ },
44
+ onClose: (h) => {
45
+ ch._close = h;
46
+ },
47
+ _open: () => {},
48
+ _msg: () => {},
49
+ _close: () => {},
50
+ };
51
+ return ch;
52
+ }
53
+
54
+ const transport: KerbcastTransport = {
55
+ createPeer: () => {
56
+ const peer: KerbcastPeer = {
57
+ addRecvOnlyTransceiver: () => {},
58
+ createDataChannel: () => {
59
+ const ch = makeFakeChannel();
60
+ captured.dc = ch;
61
+ return ch;
62
+ },
63
+ onTrack: (h) => {
64
+ captured.onTrack = h;
65
+ },
66
+ onStateChange: (h) => {
67
+ captured.onState = h;
68
+ },
69
+ createOffer: async () => "v=0\r\n…fake-sdp…\r\n",
70
+ setLocalDescription: async () => {},
71
+ setRemoteAnswer: async () => {},
72
+ waitForIceComplete: async () => {},
73
+ localSdp: () => "v=0\r\n…fake-sdp…\r\n",
74
+ close: () => {
75
+ captured.closed = true;
76
+ },
77
+ };
78
+ return peer;
79
+ },
80
+ };
81
+
82
+ return { transport, captured };
83
+ }
84
+
85
+ function fakeAnswer(cameras: number[] = []) {
86
+ return new Response(JSON.stringify({ sdp: "answer-sdp", cameras }), {
87
+ status: 200,
88
+ });
89
+ }
90
+
91
+ function fakeCameraState(flightId: number, overrides: Record<string, unknown> = {}) {
92
+ return {
93
+ flightId,
94
+ partName: "navCam1",
95
+ partTitle: "NavCam",
96
+ cameraName: "NavCam",
97
+ vesselName: "Perf Test 1",
98
+ layers: [Layer.Near],
99
+ operatorLayers: [Layer.Near, Layer.Scaled, Layer.Galaxy],
100
+ renderWidth: 768,
101
+ renderHeight: 768,
102
+ operatorWidth: 768,
103
+ operatorHeight: 768,
104
+ supportsZoom: true,
105
+ fov: 60,
106
+ fovMin: 30,
107
+ fovMax: 100,
108
+ supportsPan: false,
109
+ panYaw: 0,
110
+ panPitch: 0,
111
+ panYawMin: 0,
112
+ panYawMax: 0,
113
+ panPitchMin: 0,
114
+ panPitchMax: 0,
115
+ encoderBitrateBps: 1_500_000,
116
+ targetBitrateBps: 0,
117
+ degradeLevel: 0,
118
+ ...overrides,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Install a stubbed canvas pipeline so `tryCreateNoisePipeline` succeeds in
124
+ * jsdom (which has no real `captureStream`). Returns the spies + a `restore`
125
+ * to call in a `finally`. Each `captureStream()` call yields a fresh
126
+ * MediaStream identity so a destroy→recreate is observable, while a persistent
127
+ * pipeline keeps emitting the same one across source swaps.
128
+ */
129
+ function installNoisePipelineMock() {
130
+ const captureStream = vi.fn(() => new MediaStream());
131
+ const fakeCtx = {
132
+ drawImage: vi.fn(),
133
+ fillRect: vi.fn(),
134
+ fillStyle: "",
135
+ createImageData: vi
136
+ .fn()
137
+ .mockReturnValue({ data: new Uint8ClampedArray(4) }),
138
+ putImageData: vi.fn(),
139
+ };
140
+ const origGetContext = HTMLCanvasElement.prototype.getContext;
141
+ // @ts-expect-error — jsdom augmentation
142
+ HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(fakeCtx);
143
+ // @ts-expect-error — jsdom augmentation
144
+ HTMLCanvasElement.prototype.captureStream = captureStream;
145
+ const rafSpy = vi
146
+ .spyOn(globalThis, "requestAnimationFrame")
147
+ .mockReturnValue(0);
148
+ const cancelSpy = vi
149
+ .spyOn(globalThis, "cancelAnimationFrame")
150
+ .mockImplementation(() => {});
151
+ return {
152
+ captureStream,
153
+ rafSpy,
154
+ cancelSpy,
155
+ restore() {
156
+ HTMLCanvasElement.prototype.getContext = origGetContext;
157
+ // @ts-expect-error — cleanup
158
+ delete HTMLCanvasElement.prototype.captureStream;
159
+ rafSpy.mockRestore();
160
+ cancelSpy.mockRestore();
161
+ },
162
+ };
163
+ }
164
+
165
+ beforeEach(() => {
166
+ // jsdom doesn't ship MediaStream by default — provide the bare
167
+ // minimum the client needs (constructor that accepts a track list).
168
+ if (typeof MediaStream === "undefined") {
169
+ // @ts-expect-error — augmenting globals for the test env
170
+ globalThis.MediaStream = class FakeMediaStream {
171
+ private _tracks: MediaStreamTrack[];
172
+ constructor(tracks: MediaStreamTrack[] = []) {
173
+ this._tracks = [...tracks];
174
+ }
175
+ getTracks() {
176
+ return this._tracks;
177
+ }
178
+ };
179
+ }
180
+ if (typeof fetch === "undefined") {
181
+ // @ts-expect-error — augmenting globals for the test env
182
+ globalThis.fetch = vi.fn();
183
+ }
184
+ });
185
+
186
+ describe("KerbcastClient", () => {
187
+ it("starts disconnected and emits state-change on connect", async () => {
188
+ const { transport, captured } = makeFakeTransport();
189
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
190
+ expect(client.state).toBe("disconnected");
191
+
192
+ const states: KerbcastConnectionState[] = [];
193
+ client.on("state-change", (s) => states.push(s));
194
+
195
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer([42]));
196
+
197
+ await client.connect([42]);
198
+ captured.onState?.("connected");
199
+
200
+ expect(states).toContain("connecting");
201
+ expect(states).toContain("connected");
202
+ expect(client.state).toBe("connected");
203
+ });
204
+
205
+ it("sends `hello` automatically once the control channel opens", async () => {
206
+ const { transport, captured } = makeFakeTransport();
207
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
208
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
209
+
210
+ await client.connect();
211
+ captured.dc?._open();
212
+
213
+ expect(captured.dc?.sent).toContain(JSON.stringify({ type: "hello" }));
214
+ });
215
+
216
+ it("populates the camera registry from a snapshot push", async () => {
217
+ const { transport, captured } = makeFakeTransport();
218
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
219
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
220
+
221
+ await client.connect();
222
+ captured.dc?._open();
223
+ captured.dc?._msg(
224
+ JSON.stringify({
225
+ type: "camera-snapshot",
226
+ content: {
227
+ cameras: [
228
+ {
229
+ flightId: 42,
230
+ partName: "navCam1",
231
+ partTitle: "NavCam",
232
+ cameraName: "NavCam",
233
+ vesselName: "Perf Test 1",
234
+ layers: [Layer.Near],
235
+ operatorLayers: [Layer.Near, Layer.Scaled, Layer.Galaxy],
236
+ renderWidth: 768,
237
+ renderHeight: 768,
238
+ operatorWidth: 768,
239
+ operatorHeight: 768,
240
+ supportsZoom: true,
241
+ fov: 60,
242
+ fovMin: 30,
243
+ fovMax: 100,
244
+ supportsPan: false,
245
+ panYaw: 0,
246
+ panPitch: 0,
247
+ panYawMin: 0,
248
+ panYawMax: 0,
249
+ panPitchMin: 0,
250
+ panPitchMax: 0,
251
+ encoderBitrateBps: 1_500_000,
252
+ targetBitrateBps: 0,
253
+ degradeLevel: 0,
254
+ },
255
+ ],
256
+ },
257
+ }),
258
+ );
259
+
260
+ expect(client.cameras).toHaveLength(1);
261
+ expect(client.camera(42).state?.partTitle).toBe("NavCam");
262
+ });
263
+
264
+ it("camera handles are stable across calls", () => {
265
+ const { transport } = makeFakeTransport();
266
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
267
+ expect(client.camera(42)).toBe(client.camera(42));
268
+ });
269
+
270
+ it("set-degrade routes onto the control channel via the handle", async () => {
271
+ const { transport, captured } = makeFakeTransport();
272
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
273
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
274
+
275
+ await client.connect();
276
+ captured.dc?._open();
277
+ captured.dc!.sent.length = 0; // drop the hello
278
+
279
+ await client.camera(42).setDegrade(0.7);
280
+
281
+ expect(captured.dc?.sent[0]).toBe(
282
+ JSON.stringify({
283
+ type: "set-degrade",
284
+ content: { flightId: 42, level: 0.7 },
285
+ }),
286
+ );
287
+ });
288
+
289
+ it("set-quality routes onto the control channel; null preset means auto", async () => {
290
+ const { transport, captured } = makeFakeTransport();
291
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
292
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
293
+
294
+ await client.connect();
295
+ captured.dc?._open();
296
+ captured.dc!.sent.length = 0; // drop the hello
297
+
298
+ await client.camera(42).setQuality(QualityPreset.Half);
299
+ await client.camera(42).setQuality(null);
300
+
301
+ expect(captured.dc?.sent).toEqual([
302
+ JSON.stringify({
303
+ type: "set-quality",
304
+ content: { flightId: 42, preset: "half" },
305
+ }),
306
+ // Auto: the preset field is omitted entirely (sidecar reads None).
307
+ JSON.stringify({ type: "set-quality", content: { flightId: 42 } }),
308
+ ]);
309
+ });
310
+
311
+ it("set-quality roundtrips through MockSidecar into camera state", async () => {
312
+ const sidecar = new MockSidecar();
313
+ sidecar.addCamera({
314
+ flightId: 42,
315
+ operatorWidth: 1024,
316
+ operatorHeight: 576,
317
+ renderWidth: 1024,
318
+ renderHeight: 576,
319
+ });
320
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
321
+ MockSidecar.makeOfferResponse([42]),
322
+ );
323
+
324
+ const client = new KerbcastClient(
325
+ { host: "h", port: 1 },
326
+ sidecar.createTransport(),
327
+ );
328
+ await client.connect([42]);
329
+ sidecar.open();
330
+
331
+ await client.camera(42).setQuality(QualityPreset.Half);
332
+ expect(sidecar.lastCommand("set-quality", 42)?.content.preset).toBe(
333
+ QualityPreset.Half,
334
+ );
335
+ // The mock models the honored case: effective dims follow the preset
336
+ // and the change event carries the authoritative quality state.
337
+ const state = client.camera(42).state;
338
+ expect(state?.viewerQuality).toBe(QualityPreset.Half);
339
+ expect(state?.renderWidth).toBe(512);
340
+ expect(state?.renderHeight).toBe(288);
341
+ expect(state?.qualityLimitedBy).toBeUndefined();
342
+
343
+ // Throttled-below-request is simulated by the test driving the state
344
+ // push directly, exactly like the real sidecar would broadcast it.
345
+ sidecar.updateCamera(42, {
346
+ renderWidth: 256,
347
+ renderHeight: 144,
348
+ qualityLimitedBy: "throttled",
349
+ });
350
+ expect(client.camera(42).state?.qualityLimitedBy).toBe("throttled");
351
+
352
+ await client.camera(42).setQuality(null);
353
+ expect(client.camera(42).state?.viewerQuality).toBeUndefined();
354
+ expect(client.camera(42).state?.renderWidth).toBe(1024);
355
+ });
356
+
357
+ it("setFov / setLayers / setRenderSize routing", async () => {
358
+ const { transport, captured } = makeFakeTransport();
359
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
360
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
361
+
362
+ await client.connect();
363
+ captured.dc?._open();
364
+ captured.dc!.sent.length = 0;
365
+
366
+ const cam = client.camera(7);
367
+ await cam.setFov(35.5);
368
+ await cam.setLayers([Layer.Near, Layer.Scaled]);
369
+ await cam.setRenderSize(384, 384);
370
+
371
+ expect(captured.dc?.sent).toEqual([
372
+ JSON.stringify({ type: "set-fov", content: { flightId: 7, fov: 35.5 } }),
373
+ JSON.stringify({
374
+ type: "set-layers",
375
+ content: { flightId: 7, layers: [Layer.Near, Layer.Scaled] },
376
+ }),
377
+ JSON.stringify({
378
+ type: "set-render-size",
379
+ content: { flightId: 7, width: 384, height: 384 },
380
+ }),
381
+ ]);
382
+ });
383
+
384
+ it("disconnect tears down peer + emits null MediaStream events", async () => {
385
+ const { transport, captured } = makeFakeTransport();
386
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
387
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer([42]));
388
+
389
+ await client.connect([42]);
390
+
391
+ const streams: (MediaStream | null)[] = [];
392
+ client.camera(42).on("stream", (s) => streams.push(s));
393
+
394
+ // Simulate a track arrival so we have a non-null mediaStream first.
395
+ const fakeTrack = {} as MediaStreamTrack;
396
+ captured.onTrack?.(fakeTrack, 0);
397
+ expect(client.camera(42).mediaStream).not.toBeNull();
398
+
399
+ client.disconnect();
400
+ expect(captured.closed).toBe(true);
401
+ expect(client.state).toBe("disconnected");
402
+ expect(streams[streams.length - 1]).toBeNull();
403
+ });
404
+
405
+ it("non-OK /offer response throws with status code and transitions to failed", async () => {
406
+ const { transport } = makeFakeTransport();
407
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
408
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
409
+ new Response("service unavailable", { status: 503 }),
410
+ );
411
+
412
+ const states: KerbcastConnectionState[] = [];
413
+ client.on("state-change", (s) => states.push(s));
414
+
415
+ await expect(client.connect()).rejects.toThrow("503");
416
+ expect(states).toContain("failed");
417
+ expect(client.state).toBe("failed");
418
+ });
419
+
420
+ it("discover() returns the camera list from /cameras", async () => {
421
+ const { transport } = makeFakeTransport();
422
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
423
+ const mockCameras = [
424
+ {
425
+ flightId: 99,
426
+ partName: "navCam1",
427
+ partTitle: "NavCam",
428
+ cameraName: "NavCam",
429
+ vesselName: "Test Vessel",
430
+ maxWidth: 1280,
431
+ maxHeight: 720,
432
+ supportsZoom: true,
433
+ fov: 60,
434
+ fovMin: 30,
435
+ fovMax: 100,
436
+ supportsPan: false,
437
+ },
438
+ ];
439
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
440
+ new Response(JSON.stringify({ cameras: mockCameras }), { status: 200 }),
441
+ );
442
+
443
+ const result = await client.discover();
444
+ expect(result).toHaveLength(1);
445
+ expect(result[0].flightId).toBe(99);
446
+ expect(result[0].partTitle).toBe("NavCam");
447
+ expect(result[0].supportsZoom).toBe(true);
448
+ });
449
+
450
+ it("sidecar error message emits error event with correct payload including source", async () => {
451
+ const { transport, captured } = makeFakeTransport();
452
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
453
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
454
+
455
+ await client.connect();
456
+ captured.dc?._open();
457
+
458
+ const errors: { message: string; source?: string }[] = [];
459
+ client.on("error", (e) => errors.push(e));
460
+
461
+ captured.dc?._msg(
462
+ JSON.stringify({ type: "error", content: { message: "boom", source: "sidecar" } }),
463
+ );
464
+
465
+ expect(errors).toHaveLength(1);
466
+ expect(errors[0].message).toBe("boom");
467
+ expect(errors[0].source).toBe("sidecar");
468
+ });
469
+
470
+ it("malformed JSON emits error event with source: client", async () => {
471
+ const { transport, captured } = makeFakeTransport();
472
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
473
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
474
+
475
+ await client.connect();
476
+ captured.dc?._open();
477
+
478
+ const errors: { message: string; source?: string }[] = [];
479
+ client.on("error", (e) => errors.push(e));
480
+
481
+ captured.dc?._msg("not valid json {{{{");
482
+
483
+ expect(errors).toHaveLength(1);
484
+ expect(errors[0].source).toBe(ErrorSource.Client);
485
+ expect(typeof errors[0].message).toBe("string");
486
+ expect(errors[0].message.length).toBeGreaterThan(0);
487
+ });
488
+
489
+ it("_send rejects when control channel is not open", async () => {
490
+ const { transport } = makeFakeTransport();
491
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
492
+
493
+ // camera() and setFov() before connect — control channel is null
494
+ await expect(client.camera(1).setFov(30)).rejects.toThrow(
495
+ "[kerbcast] control channel not open",
496
+ );
497
+ });
498
+
499
+ it("adaptive-shed message emits adaptive-shed event with correct payload", async () => {
500
+ const { transport, captured } = makeFakeTransport();
501
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
502
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
503
+
504
+ await client.connect();
505
+ captured.dc?._open();
506
+
507
+ const events: { level: number; kspFps: number; reason: string }[] = [];
508
+ client.on("adaptive-shed", (e) => events.push(e));
509
+
510
+ captured.dc?._msg(
511
+ JSON.stringify({
512
+ type: "adaptive-shed",
513
+ content: { level: 2, kspFps: 14.0, reason: "ksp-fps-low" },
514
+ }),
515
+ );
516
+
517
+ expect(events).toHaveLength(1);
518
+ expect(events[0].level).toBe(2);
519
+ expect(events[0].kspFps).toBe(14.0);
520
+ expect(events[0].reason).toBe("ksp-fps-low");
521
+ });
522
+
523
+ it("cameras-change fires and camera state updates on camera-state-changed", async () => {
524
+ const { transport, captured } = makeFakeTransport();
525
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
526
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
527
+
528
+ await client.connect();
529
+ captured.dc?._open();
530
+
531
+ // Push initial snapshot with camera 42
532
+ captured.dc?._msg(
533
+ JSON.stringify({
534
+ type: "camera-snapshot",
535
+ content: { cameras: [fakeCameraState(42, { fov: 60 })] },
536
+ }),
537
+ );
538
+
539
+ const camerasChanges: unknown[][] = [];
540
+ client.on("cameras-change", (cams) => camerasChanges.push([...cams]));
541
+
542
+ // Push a state change that updates fov to 45
543
+ captured.dc?._msg(
544
+ JSON.stringify({
545
+ type: "camera-state-changed",
546
+ content: { state: fakeCameraState(42, { fov: 45 }) },
547
+ }),
548
+ );
549
+
550
+ expect(camerasChanges).toHaveLength(1);
551
+ expect(client.cameras[0].fov).toBe(45);
552
+ });
553
+
554
+ it("setPan and requestKeyframe route correct JSON onto the control channel", async () => {
555
+ const { transport, captured } = makeFakeTransport();
556
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
557
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
558
+
559
+ await client.connect();
560
+ captured.dc?._open();
561
+ captured.dc!.sent.length = 0;
562
+
563
+ const cam = client.camera(5);
564
+ await cam.setPan(10.5, -3.2);
565
+ await cam.requestKeyframe();
566
+
567
+ expect(captured.dc?.sent[0]).toBe(
568
+ JSON.stringify({ type: "set-pan", content: { flightId: 5, yaw: 10.5, pitch: -3.2 } }),
569
+ );
570
+ expect(captured.dc?.sent[1]).toBe(
571
+ JSON.stringify({ type: "request-keyframe", content: { flightId: 5 } }),
572
+ );
573
+ });
574
+
575
+ it("setPanRate and setZoomRate route correct JSON onto the control channel", async () => {
576
+ const { transport, captured } = makeFakeTransport();
577
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
578
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
579
+
580
+ await client.connect();
581
+ captured.dc?._open();
582
+ captured.dc!.sent.length = 0;
583
+
584
+ const cam = client.camera(5);
585
+ await cam.setPanRate(0.5, -1);
586
+ await cam.setZoomRate(1);
587
+
588
+ expect(captured.dc?.sent[0]).toBe(
589
+ JSON.stringify({
590
+ type: "set-pan-rate",
591
+ content: { flightId: 5, yawRate: 0.5, pitchRate: -1 },
592
+ }),
593
+ );
594
+ expect(captured.dc?.sent[1]).toBe(
595
+ JSON.stringify({ type: "set-zoom-rate", content: { flightId: 5, rate: 1 } }),
596
+ );
597
+ });
598
+
599
+ it("peer failed state emits failed and tears down streams", async () => {
600
+ const { transport, captured } = makeFakeTransport();
601
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
602
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer([42]));
603
+
604
+ await client.connect([42]);
605
+
606
+ // Simulate track arrival to get a non-null mediaStream
607
+ const fakeTrack = {} as MediaStreamTrack;
608
+ captured.onTrack?.(fakeTrack, 0);
609
+ expect(client.camera(42).mediaStream).not.toBeNull();
610
+
611
+ const states: KerbcastConnectionState[] = [];
612
+ client.on("state-change", (s) => states.push(s));
613
+
614
+ // Trigger peer failed state
615
+ captured.onState?.("failed");
616
+
617
+ expect(states).toContain("failed");
618
+ expect(client.state).toBe("failed");
619
+ expect(client.camera(42).mediaStream).toBeNull();
620
+ });
621
+
622
+ describe("noise config", () => {
623
+ // jsdom has no captureStream, so tryCreateNoisePipeline returns null and
624
+ // the raw stream is surfaced. Tests cover the config resolution logic and
625
+ // ensure that flipping noise on/off via configure() replaces the stream.
626
+
627
+ it("noise is enabled by default (no explicit config)", async () => {
628
+ const { transport, captured } = makeFakeTransport();
629
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
630
+ // _resolveNoise with null override and no cfg.noise should return true
631
+ expect(client._resolveNoise(null)).toBe(true);
632
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer([1]));
633
+ await client.connect([1]);
634
+ captured.onTrack?.({} as MediaStreamTrack, 0);
635
+ // In jsdom: captureStream absent → pipeline skipped → raw stream exposed
636
+ expect(client.camera(1).mediaStream).not.toBeNull();
637
+ });
638
+
639
+ it("noise can be disabled at the client level", () => {
640
+ const { transport } = makeFakeTransport();
641
+ const client = new KerbcastClient({ host: "h", port: 1, noise: { enabled: false } }, transport);
642
+ expect(client._resolveNoise(null)).toBe(false);
643
+ });
644
+
645
+ it("per-camera configure() overrides client default", () => {
646
+ const { transport } = makeFakeTransport();
647
+ const clientOn = new KerbcastClient({ host: "h", port: 1 }, transport);
648
+ const clientOff = new KerbcastClient({ host: "h", port: 1, noise: { enabled: false } }, transport);
649
+
650
+ // Per-camera enable overrides client-off
651
+ expect(clientOff._resolveNoise({ enabled: true })).toBe(true);
652
+ // Per-camera disable overrides client-on (default)
653
+ expect(clientOn._resolveNoise({ enabled: false })).toBe(false);
654
+ });
655
+
656
+ it("configure() triggers stream re-emit", async () => {
657
+ const { transport, captured } = makeFakeTransport();
658
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
659
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer([5]));
660
+
661
+ await client.connect([5]);
662
+ const cam = client.camera(5);
663
+ const streams: (MediaStream | null)[] = [];
664
+ cam.on("stream", (s) => streams.push(s));
665
+
666
+ captured.onTrack?.({} as MediaStreamTrack, 0);
667
+ expect(streams).toHaveLength(1);
668
+ expect(streams[0]).not.toBeNull();
669
+
670
+ // Calling configure re-builds the pipeline → re-emits the stream
671
+ cam.configure({ noise: { enabled: false } });
672
+ expect(streams).toHaveLength(2);
673
+ expect(streams[1]).not.toBeNull();
674
+ });
675
+
676
+ it("_setState updates noise intensity and does not break without a stream", async () => {
677
+ const { transport, captured } = makeFakeTransport();
678
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
679
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
680
+
681
+ await client.connect();
682
+ captured.dc?._open();
683
+
684
+ // Push a state update before any track arrives — must not throw
685
+ captured.dc?._msg(
686
+ JSON.stringify({
687
+ type: "camera-state-changed",
688
+ content: { state: fakeCameraState(99, { degradeLevel: 0.6 }) },
689
+ }),
690
+ );
691
+
692
+ expect(client.camera(99).state?.degradeLevel).toBe(0.6);
693
+ });
694
+
695
+ it("disconnect destroys noise pipeline and sets stream to null", async () => {
696
+ const { transport, captured } = makeFakeTransport();
697
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
698
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer([7]));
699
+
700
+ await client.connect([7]);
701
+ captured.onTrack?.({} as MediaStreamTrack, 0);
702
+ expect(client.camera(7).mediaStream).not.toBeNull();
703
+
704
+ const streams: (MediaStream | null)[] = [];
705
+ client.camera(7).on("stream", (s) => streams.push(s));
706
+
707
+ client.disconnect();
708
+ expect(client.camera(7).mediaStream).toBeNull();
709
+ expect(streams[streams.length - 1]).toBeNull();
710
+ });
711
+
712
+ it("noise pipeline is used when captureStream is available", async () => {
713
+ const fakeProcessed = new MediaStream();
714
+ const mockCaptureStream = vi.fn().mockReturnValue(fakeProcessed);
715
+ const fakeCtx = {
716
+ drawImage: vi.fn(),
717
+ createImageData: vi.fn().mockReturnValue({ data: new Uint8ClampedArray(4) }),
718
+ putImageData: vi.fn(),
719
+ };
720
+ const origGetContext = HTMLCanvasElement.prototype.getContext;
721
+ // @ts-expect-error — jsdom augmentation
722
+ HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(fakeCtx);
723
+ // @ts-expect-error — jsdom augmentation
724
+ HTMLCanvasElement.prototype.captureStream = mockCaptureStream;
725
+ const rafSpy = vi.spyOn(globalThis, "requestAnimationFrame").mockReturnValue(0);
726
+
727
+ try {
728
+ const { transport, captured } = makeFakeTransport();
729
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
730
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer([3]));
731
+
732
+ await client.connect([3]);
733
+ captured.onTrack?.({} as MediaStreamTrack, 0);
734
+
735
+ expect(client.camera(3).mediaStream).toBe(fakeProcessed);
736
+ expect(mockCaptureStream).toHaveBeenCalledWith(30);
737
+ } finally {
738
+ HTMLCanvasElement.prototype.getContext = origGetContext;
739
+ // @ts-expect-error — cleanup
740
+ delete HTMLCanvasElement.prototype.captureStream;
741
+ rafSpy.mockRestore();
742
+ }
743
+ });
744
+ });
745
+
746
+ describe("sourceless static (signal-loss / switch gap)", () => {
747
+ it("keeps mediaStream non-null when a destroyed camera's source stops", async () => {
748
+ const mock = installNoisePipelineMock();
749
+ try {
750
+ const sidecar = new MockSidecar();
751
+ sidecar.addCamera({ flightId: 42 });
752
+ const client = new KerbcastClient(
753
+ { host: "h", port: 1 },
754
+ sidecar.createTransport(),
755
+ );
756
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
757
+ Promise.resolve(MockSidecar.makeOfferResponse([42])),
758
+ );
759
+
760
+ await client.connect([42]);
761
+ sidecar.open();
762
+ sidecar.deliverTrack("0", {} as MediaStreamTrack);
763
+
764
+ const live = client.camera(42).mediaStream;
765
+ expect(live).not.toBeNull();
766
+
767
+ // Destruction sends only camera-state-changed(Destroyed); the track
768
+ // never ends. The handle must still go to live static, not null.
769
+ sidecar.destroyCamera(42);
770
+ expect(client.camera(42).mediaStream).not.toBeNull();
771
+ // Persistent pipeline → same output stream, never a gap.
772
+ expect(client.camera(42).mediaStream).toBe(live);
773
+ } finally {
774
+ mock.restore();
775
+ }
776
+ });
777
+
778
+ it("re-sources a resurrected camera from its retained track (no refresh)", async () => {
779
+ // KSP reuses part.flightID across revert, so the sidecar tombstones a
780
+ // camera (handle goes sourceless / static) then resurrects the SAME id
781
+ // and rebinds the SAME track WITHOUT a new slot-map. The handle only
782
+ // re-sources on a slot-map, so it must re-source itself when the
783
+ // lifecycle flips back to Active, or the feed is stuck on static until a
784
+ // page refresh.
785
+ const setIntensity = vi.fn();
786
+ const setSource = vi.fn();
787
+ const processedStream = new MediaStream();
788
+ const createSpy = vi
789
+ .spyOn(noise, "tryCreateNoisePipeline")
790
+ .mockReturnValue({
791
+ processedStream,
792
+ setIntensity,
793
+ setSource,
794
+ setShowStatic: vi.fn(),
795
+ destroy: vi.fn(),
796
+ });
797
+ try {
798
+ const sidecar = new MockSidecar().withSlots(["a"]);
799
+ sidecar.addCamera({ flightId: 42 });
800
+ const client = new KerbcastClient(
801
+ { host: "h", port: 1 },
802
+ sidecar.createTransport(),
803
+ );
804
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
805
+ Promise.resolve(MockSidecar.makeOfferResponse([])),
806
+ );
807
+
808
+ await client.connect([], { slots: 1 });
809
+ sidecar.open();
810
+ await client.subscribe(42);
811
+ sidecar.deliverTrack("a", {} as MediaStreamTrack);
812
+
813
+ // Live: sourced from the delivered track.
814
+ expect(setSource.mock.calls.at(-1)?.[0]).not.toBeNull();
815
+
816
+ // Part destroyed: source detaches, feed shows full static. The slot is
817
+ // NOT freed (no slot-map), so the client keeps the track for the mid.
818
+ sidecar.destroyCamera(42);
819
+ expect(setSource).toHaveBeenLastCalledWith(null);
820
+
821
+ // Resurrection: lifecycle flips back to Active with no new slot-map.
822
+ // The handle must re-source from the track it still holds.
823
+ setSource.mockClear();
824
+ sidecar.updateCamera(42, { lifecycle: CameraLifecycle.Active });
825
+ expect(setSource).toHaveBeenCalledTimes(1);
826
+ expect(setSource.mock.calls[0][0]).not.toBeNull();
827
+ } finally {
828
+ createSpy.mockRestore();
829
+ }
830
+ });
831
+
832
+ it("drives static to full intensity on signal loss", async () => {
833
+ const setIntensity = vi.fn();
834
+ const setSource = vi.fn();
835
+ const processedStream = new MediaStream();
836
+ const createSpy = vi
837
+ .spyOn(noise, "tryCreateNoisePipeline")
838
+ .mockReturnValue({
839
+ processedStream,
840
+ setIntensity,
841
+ setSource,
842
+ setShowStatic: vi.fn(),
843
+ destroy: vi.fn(),
844
+ });
845
+ try {
846
+ const sidecar = new MockSidecar();
847
+ sidecar.addCamera({ flightId: 42, degradeLevel: 0.3 });
848
+ const client = new KerbcastClient(
849
+ { host: "h", port: 1 },
850
+ sidecar.createTransport(),
851
+ );
852
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
853
+ Promise.resolve(MockSidecar.makeOfferResponse([42])),
854
+ );
855
+
856
+ await client.connect([42]);
857
+ sidecar.open();
858
+ sidecar.deliverTrack("0", {} as MediaStreamTrack);
859
+
860
+ // Live feed tracks degrade (0.3), not full.
861
+ expect(setIntensity).toHaveBeenLastCalledWith(0.3);
862
+
863
+ sidecar.destroyCamera(42);
864
+ // Sourceless → full static, and the source was detached.
865
+ expect(setSource).toHaveBeenLastCalledWith(null);
866
+ expect(setIntensity).toHaveBeenLastCalledWith(1.0);
867
+
868
+ // A later degrade=0 state update must NOT lower the full static.
869
+ setIntensity.mockClear();
870
+ sidecar.updateCamera(42, { degradeLevel: 0 });
871
+ expect(setIntensity).not.toHaveBeenCalledWith(0.05);
872
+ } finally {
873
+ createSpy.mockRestore();
874
+ }
875
+ });
876
+
877
+ it("restores video when a source returns after loss", async () => {
878
+ const setSource = vi.fn();
879
+ const processedStream = new MediaStream();
880
+ const createSpy = vi
881
+ .spyOn(noise, "tryCreateNoisePipeline")
882
+ .mockReturnValue({
883
+ processedStream,
884
+ setIntensity: vi.fn(),
885
+ setSource,
886
+ setShowStatic: vi.fn(),
887
+ destroy: vi.fn(),
888
+ });
889
+ try {
890
+ const sidecar = new MockSidecar().withSlots(["a"]);
891
+ sidecar.addCamera({ flightId: 1 });
892
+ const client = new KerbcastClient(
893
+ { host: "h", port: 1 },
894
+ sidecar.createTransport(),
895
+ );
896
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
897
+ Promise.resolve(MockSidecar.makeOfferResponse([])),
898
+ );
899
+
900
+ await client.connect([], { slots: 1 });
901
+ sidecar.open();
902
+ await client.subscribe(1);
903
+ sidecar.deliverTrack("a", {} as MediaStreamTrack);
904
+
905
+ // Free the slot → outgoing camera goes sourceless (static).
906
+ await client.unsubscribe(1);
907
+ expect(setSource).toHaveBeenLastCalledWith(null);
908
+ expect(client.camera(1).mediaStream).toBe(processedStream);
909
+
910
+ // Resubscribe + new track → source restored on the SAME pipeline.
911
+ await client.subscribe(1);
912
+ sidecar.deliverTrack("a", {} as MediaStreamTrack);
913
+ const last = setSource.mock.calls.at(-1)?.[0];
914
+ expect(last).not.toBeNull();
915
+ // Reused the persistent pipeline rather than building a new one.
916
+ expect(createSpy).toHaveBeenCalledTimes(1);
917
+ } finally {
918
+ createSpy.mockRestore();
919
+ }
920
+ });
921
+
922
+ it("sourceless draw() clears to black and composites static (no drawImage)", () => {
923
+ const captureStream = vi.fn(() => new MediaStream());
924
+ const fakeCtx = {
925
+ drawImage: vi.fn(),
926
+ fillRect: vi.fn(),
927
+ fillStyle: "",
928
+ createImageData: vi
929
+ .fn()
930
+ .mockReturnValue({ data: new Uint8ClampedArray(4) }),
931
+ putImageData: vi.fn(),
932
+ };
933
+ const origGetContext = HTMLCanvasElement.prototype.getContext;
934
+ // @ts-expect-error — jsdom augmentation
935
+ HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(fakeCtx);
936
+ // @ts-expect-error — jsdom augmentation
937
+ HTMLCanvasElement.prototype.captureStream = captureStream;
938
+ // Run the rAF callback exactly once so draw() executes its sourceless
939
+ // branch, then stop (return 0, ignore the re-schedule).
940
+ let ran = false;
941
+ const rafSpy = vi
942
+ .spyOn(globalThis, "requestAnimationFrame")
943
+ .mockImplementation((cb) => {
944
+ if (!ran) {
945
+ ran = true;
946
+ cb(0);
947
+ }
948
+ return 0;
949
+ });
950
+ try {
951
+ const pipeline = noise.tryCreateNoisePipeline(null, 1.0);
952
+ expect(pipeline).not.toBeNull();
953
+ // No source → never drawImage the (absent) video.
954
+ expect(fakeCtx.drawImage).toHaveBeenCalledTimes(1); // only the noise canvas
955
+ // Sourceless branch clears the field to black.
956
+ expect(fakeCtx.fillRect).toHaveBeenCalledTimes(1);
957
+ expect(fakeCtx.putImageData).toHaveBeenCalledTimes(1);
958
+ pipeline?.destroy();
959
+ } finally {
960
+ HTMLCanvasElement.prototype.getContext = origGetContext;
961
+ // @ts-expect-error — cleanup
962
+ delete HTMLCanvasElement.prototype.captureStream;
963
+ rafSpy.mockRestore();
964
+ }
965
+ });
966
+
967
+ it("shows static for a subscribed camera whose track hasn't arrived (switch gap)", async () => {
968
+ const mock = installNoisePipelineMock();
969
+ try {
970
+ const sidecar = new MockSidecar().withSlots(["a"]);
971
+ sidecar.addCamera({ flightId: 9 });
972
+ const client = new KerbcastClient(
973
+ { host: "h", port: 1 },
974
+ sidecar.createTransport(),
975
+ );
976
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
977
+ Promise.resolve(MockSidecar.makeOfferResponse([])),
978
+ );
979
+
980
+ await client.connect([], { slots: 1 });
981
+ sidecar.open();
982
+
983
+ // Slot bound (slot-map arrives) but no track yet — the incoming
984
+ // camera must show live static, not blank.
985
+ await client.subscribe(9);
986
+ expect(client.camera(9).mediaStream).not.toBeNull();
987
+
988
+ // When the track lands the source is restored on the same pipeline.
989
+ sidecar.deliverTrack("a", {} as MediaStreamTrack);
990
+ expect(client.camera(9).mediaStream).not.toBeNull();
991
+ } finally {
992
+ mock.restore();
993
+ }
994
+ });
995
+
996
+ it("teardown (disconnect) destroys the pipeline and nulls the stream", async () => {
997
+ const destroy = vi.fn();
998
+ const processedStream = new MediaStream();
999
+ const createSpy = vi
1000
+ .spyOn(noise, "tryCreateNoisePipeline")
1001
+ .mockReturnValue({
1002
+ processedStream,
1003
+ setIntensity: vi.fn(),
1004
+ setSource: vi.fn(),
1005
+ setShowStatic: vi.fn(),
1006
+ destroy,
1007
+ });
1008
+ try {
1009
+ const sidecar = new MockSidecar();
1010
+ sidecar.addCamera({ flightId: 42 });
1011
+ const client = new KerbcastClient(
1012
+ { host: "h", port: 1 },
1013
+ sidecar.createTransport(),
1014
+ );
1015
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
1016
+ Promise.resolve(MockSidecar.makeOfferResponse([42])),
1017
+ );
1018
+
1019
+ await client.connect([42]);
1020
+ sidecar.open();
1021
+ sidecar.deliverTrack("0", {} as MediaStreamTrack);
1022
+ expect(client.camera(42).mediaStream).toBe(processedStream);
1023
+
1024
+ client.disconnect();
1025
+ expect(destroy).toHaveBeenCalledTimes(1);
1026
+ expect(client.camera(42).mediaStream).toBeNull();
1027
+ } finally {
1028
+ createSpy.mockRestore();
1029
+ }
1030
+ });
1031
+ });
1032
+
1033
+ describe("stall surface (handle mirror of the pipeline's stall detector)", () => {
1034
+ /*
1035
+ * Stub pipeline that captures the options the handle creates it with, so
1036
+ * tests can fire the onStallChange callback and inspect the showStatic
1037
+ * seed without a real canvas.
1038
+ */
1039
+ function makePipelineSpy() {
1040
+ const setShowStatic = vi.fn();
1041
+ const pipeline = {
1042
+ processedStream: new MediaStream(),
1043
+ setIntensity: vi.fn(),
1044
+ setSource: vi.fn(),
1045
+ setShowStatic,
1046
+ destroy: vi.fn(),
1047
+ };
1048
+ let opts: Parameters<typeof noise.tryCreateNoisePipeline>[2];
1049
+ const createSpy = vi
1050
+ .spyOn(noise, "tryCreateNoisePipeline")
1051
+ .mockImplementation((_raw, _intensity, options) => {
1052
+ opts = options;
1053
+ return pipeline;
1054
+ });
1055
+ return {
1056
+ setShowStatic,
1057
+ createSpy,
1058
+ fireStall: (stalled: boolean) => opts?.onStallChange?.(stalled),
1059
+ creationOptions: () => opts,
1060
+ };
1061
+ }
1062
+
1063
+ /** Connected client with camera 42 registered; track not yet delivered. */
1064
+ async function connectCamera42(
1065
+ sidecar: MockSidecar,
1066
+ ): Promise<KerbcastClient> {
1067
+ sidecar.addCamera({ flightId: 42 });
1068
+ const client = new KerbcastClient(
1069
+ { host: "h", port: 1 },
1070
+ sidecar.createTransport(),
1071
+ );
1072
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
1073
+ Promise.resolve(MockSidecar.makeOfferResponse([42])),
1074
+ );
1075
+ await client.connect([42]);
1076
+ sidecar.open();
1077
+ return client;
1078
+ }
1079
+
1080
+ it("stalled getter and stall events mirror the pipeline's transitions", async () => {
1081
+ const spy = makePipelineSpy();
1082
+ try {
1083
+ const sidecar = new MockSidecar();
1084
+ const client = await connectCamera42(sidecar);
1085
+ sidecar.deliverTrack("0", {} as MediaStreamTrack);
1086
+
1087
+ const cam = client.camera(42);
1088
+ expect(cam.stalled).toBe(false);
1089
+ const events: boolean[] = [];
1090
+ cam.on("stall", (s) => events.push(s));
1091
+
1092
+ spy.fireStall(true);
1093
+ expect(cam.stalled).toBe(true);
1094
+ expect(events).toEqual([true]);
1095
+
1096
+ // Repeating the same state is deduped: no extra event.
1097
+ spy.fireStall(true);
1098
+ expect(events).toEqual([true]);
1099
+
1100
+ spy.fireStall(false);
1101
+ expect(cam.stalled).toBe(false);
1102
+ expect(events).toEqual([true, false]);
1103
+ } finally {
1104
+ spy.createSpy.mockRestore();
1105
+ }
1106
+ });
1107
+
1108
+ it("setShowStatic seeds pipeline creation and forwards live changes", async () => {
1109
+ const spy = makePipelineSpy();
1110
+ try {
1111
+ const sidecar = new MockSidecar();
1112
+ const client = await connectCamera42(sidecar);
1113
+ const cam = client.camera(42);
1114
+
1115
+ // Default on; flipping before any pipeline exists just stores it.
1116
+ expect(cam.showStatic).toBe(true);
1117
+ cam.setShowStatic(false);
1118
+ expect(cam.showStatic).toBe(false);
1119
+ expect(spy.creationOptions()).toBeUndefined();
1120
+
1121
+ // The pipeline built on track arrival carries the stored setting.
1122
+ sidecar.deliverTrack("0", {} as MediaStreamTrack);
1123
+ expect(spy.creationOptions()?.showStatic).toBe(false);
1124
+
1125
+ // Runtime flips forward to the live pipeline.
1126
+ cam.setShowStatic(true);
1127
+ expect(cam.showStatic).toBe(true);
1128
+ expect(spy.setShowStatic).toHaveBeenLastCalledWith(true);
1129
+ } finally {
1130
+ spy.createSpy.mockRestore();
1131
+ }
1132
+ });
1133
+
1134
+ it("teardown (disconnect) clears a latched stall", async () => {
1135
+ const spy = makePipelineSpy();
1136
+ try {
1137
+ const sidecar = new MockSidecar();
1138
+ const client = await connectCamera42(sidecar);
1139
+ sidecar.deliverTrack("0", {} as MediaStreamTrack);
1140
+
1141
+ const cam = client.camera(42);
1142
+ const events: boolean[] = [];
1143
+ cam.on("stall", (s) => events.push(s));
1144
+
1145
+ spy.fireStall(true);
1146
+ expect(cam.stalled).toBe(true);
1147
+
1148
+ // The pipeline (and its stall detector) dies with the connection; a
1149
+ // handle must not report a stale stall afterwards.
1150
+ client.disconnect();
1151
+ expect(cam.stalled).toBe(false);
1152
+ expect(events).toEqual([true, false]);
1153
+ } finally {
1154
+ spy.createSpy.mockRestore();
1155
+ }
1156
+ });
1157
+ });
1158
+ });
1159
+
1160
+ describe("KerbcastClient — dynamic slot subscription", () => {
1161
+ it("subscribe binds a slot and routes its track; unsubscribe clears it", async () => {
1162
+ const sidecar = new MockSidecar();
1163
+ const client = new KerbcastClient(
1164
+ { host: "h", port: 1 },
1165
+ sidecar.createTransport(),
1166
+ );
1167
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
1168
+ Promise.resolve(MockSidecar.makeOfferResponse([])),
1169
+ );
1170
+
1171
+ await client.connect([], { slots: 4 });
1172
+ sidecar.open();
1173
+
1174
+ await client.subscribe(42);
1175
+ expect(sidecar.lastCommand("subscribe")?.content.flightId).toBe(42);
1176
+ const mid = sidecar.slotMidFor(42);
1177
+ expect(mid).toBeDefined();
1178
+
1179
+ // Mapping known but media not arrived yet → still null.
1180
+ expect(client.camera(42).mediaStream).toBeNull();
1181
+
1182
+ sidecar.deliverTrack(mid as string, {} as MediaStreamTrack);
1183
+ expect(client.camera(42).mediaStream).not.toBeNull();
1184
+
1185
+ await client.unsubscribe(42);
1186
+ expect(client.camera(42).mediaStream).toBeNull();
1187
+ });
1188
+
1189
+ it("reuses a freed slot for a different camera (the switch path)", async () => {
1190
+ const sidecar = new MockSidecar().withSlots(["a"]); // single slot
1191
+ const client = new KerbcastClient(
1192
+ { host: "h", port: 1 },
1193
+ sidecar.createTransport(),
1194
+ );
1195
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
1196
+ Promise.resolve(MockSidecar.makeOfferResponse([])),
1197
+ );
1198
+
1199
+ await client.connect([], { slots: 1 });
1200
+ sidecar.open();
1201
+
1202
+ await client.subscribe(1);
1203
+ const mid = sidecar.slotMidFor(1) as string;
1204
+ sidecar.deliverTrack(mid, {} as MediaStreamTrack);
1205
+ expect(client.camera(1).mediaStream).not.toBeNull();
1206
+
1207
+ await client.unsubscribe(1);
1208
+ expect(client.camera(1).mediaStream).toBeNull();
1209
+
1210
+ // The one slot is free again → camera 2 reuses it and inherits its track.
1211
+ await client.subscribe(2);
1212
+ expect(sidecar.slotMidFor(2)).toBe(mid);
1213
+ expect(client.camera(2).mediaStream).not.toBeNull();
1214
+ expect(client.camera(1).mediaStream).toBeNull();
1215
+ });
1216
+
1217
+ it("surfaces a sidecar error when no slot is free", async () => {
1218
+ const sidecar = new MockSidecar().withSlots(["only"]);
1219
+ const client = new KerbcastClient(
1220
+ { host: "h", port: 1 },
1221
+ sidecar.createTransport(),
1222
+ );
1223
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
1224
+ Promise.resolve(MockSidecar.makeOfferResponse([])),
1225
+ );
1226
+
1227
+ await client.connect([], { slots: 1 });
1228
+ sidecar.open();
1229
+
1230
+ const errors: { message: string }[] = [];
1231
+ client.on("error", (e) => errors.push(e));
1232
+
1233
+ await client.subscribe(1); // takes the only slot
1234
+ await client.subscribe(2); // no free slot → sidecar error
1235
+ expect(errors.some((e) => /no free slot/.test(e.message))).toBe(true);
1236
+ });
1237
+
1238
+ it("uses an injected negotiate (signaling seam) instead of HTTP /offer", async () => {
1239
+ const sidecar = new MockSidecar();
1240
+ const negotiate = vi.fn(
1241
+ (offer: { sdp: string; cameras: number[]; slots?: number }) =>
1242
+ sidecar.negotiate(offer),
1243
+ );
1244
+ const client = new KerbcastClient(
1245
+ { host: "h", port: 1, negotiate },
1246
+ sidecar.createTransport(),
1247
+ );
1248
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
1249
+ fetchSpy.mockClear(); // the global fetch spy accumulates across tests
1250
+
1251
+ await client.connect([], { slots: 2 });
1252
+ sidecar.open();
1253
+
1254
+ expect(negotiate).toHaveBeenCalledOnce();
1255
+ expect(negotiate.mock.calls[0][0].slots).toBe(2);
1256
+ expect(fetchSpy).not.toHaveBeenCalled(); // brokered — no HTTP /offer
1257
+
1258
+ // The control channel + dynamic subscribe still work over the direct peer.
1259
+ await client.subscribe(7);
1260
+ const mid = sidecar.slotMidFor(7) as string;
1261
+ sidecar.deliverTrack(mid, {} as MediaStreamTrack);
1262
+ expect(client.camera(7).mediaStream).not.toBeNull();
1263
+ });
1264
+ });
1265
+
1266
+ // ---------------------------------------------------------------------------
1267
+ // BrowserKerbcastTransport: ICE gathering timeout
1268
+ // ---------------------------------------------------------------------------
1269
+
1270
+ /**
1271
+ * Minimal RTCPeerConnection stub with controllable iceGatheringState.
1272
+ * Tracks event listeners so tests can fire or skip the gathering event.
1273
+ */
1274
+ function makeFakeRTCPeerConnection(
1275
+ initialState: RTCIceGatheringState = "gathering",
1276
+ ) {
1277
+ const listeners = new Map<string, (() => void)[]>();
1278
+ const pc = {
1279
+ iceGatheringState: initialState as RTCIceGatheringState,
1280
+ localDescription: null as { sdp: string } | null,
1281
+ connectionState: "new" as RTCPeerConnectionState,
1282
+ ontrack: null as ((ev: RTCTrackEvent) => void) | null,
1283
+ onconnectionstatechange: null as (() => void) | null,
1284
+ addTransceiver: () => {},
1285
+ createDataChannel: () => ({
1286
+ onopen: null,
1287
+ onclose: null,
1288
+ onmessage: null,
1289
+ send: () => {},
1290
+ }),
1291
+ createOffer: async () => ({ sdp: "fake-sdp", type: "offer" as RTCSdpType }),
1292
+ setLocalDescription: async () => {
1293
+ pc.localDescription = { sdp: "fake-sdp" };
1294
+ },
1295
+ setRemoteDescription: async () => {},
1296
+ close: () => {},
1297
+ addEventListener(type: string, handler: () => void) {
1298
+ const list = listeners.get(type) ?? [];
1299
+ list.push(handler);
1300
+ listeners.set(type, list);
1301
+ },
1302
+ removeEventListener(type: string, handler: () => void) {
1303
+ const list = listeners.get(type) ?? [];
1304
+ listeners.set(
1305
+ type,
1306
+ list.filter((h) => h !== handler),
1307
+ );
1308
+ },
1309
+ /** Test helper: simulate gathering state change. */
1310
+ fireGatheringComplete() {
1311
+ pc.iceGatheringState = "complete";
1312
+ for (const h of listeners.get("icegatheringstatechange") ?? []) h();
1313
+ },
1314
+ listenerCount(type: string) {
1315
+ return (listeners.get(type) ?? []).length;
1316
+ },
1317
+ };
1318
+ return pc;
1319
+ }
1320
+
1321
+ describe("BrowserKerbcastTransport: waitForIceComplete", () => {
1322
+ it("resolves immediately when already complete", async () => {
1323
+ const pc = makeFakeRTCPeerConnection("complete");
1324
+ // @ts-expect-error -- stub replaces the real constructor
1325
+ globalThis.RTCPeerConnection = class { constructor() { return pc; } };
1326
+ try {
1327
+ const transport = new BrowserKerbcastTransport({ iceGatheringTimeoutMs: 500 });
1328
+ const peer = transport.createPeer([]);
1329
+ const start = Date.now();
1330
+ await peer.waitForIceComplete();
1331
+ expect(Date.now() - start).toBeLessThan(100);
1332
+ } finally {
1333
+ // @ts-expect-error -- cleanup
1334
+ delete globalThis.RTCPeerConnection;
1335
+ }
1336
+ });
1337
+
1338
+ it("resolves after the timeout when gathering never completes", async () => {
1339
+ vi.useFakeTimers();
1340
+ const pc = makeFakeRTCPeerConnection("gathering");
1341
+ // @ts-expect-error -- stub replaces the real constructor
1342
+ globalThis.RTCPeerConnection = class { constructor() { return pc; } };
1343
+ try {
1344
+ const transport = new BrowserKerbcastTransport({ iceGatheringTimeoutMs: 2000 });
1345
+ const peer = transport.createPeer([]);
1346
+ const done = vi.fn();
1347
+ void peer.waitForIceComplete().then(done);
1348
+
1349
+ expect(done).not.toHaveBeenCalled();
1350
+ await vi.advanceTimersByTimeAsync(2000);
1351
+ expect(done).toHaveBeenCalledOnce();
1352
+ // Listener must have been cleaned up after the timeout fires.
1353
+ expect(pc.listenerCount("icegatheringstatechange")).toBe(0);
1354
+ } finally {
1355
+ vi.useRealTimers();
1356
+ // @ts-expect-error -- cleanup
1357
+ delete globalThis.RTCPeerConnection;
1358
+ }
1359
+ });
1360
+
1361
+ it("resolves when gathering completes before the timeout and cleans up the timer", async () => {
1362
+ vi.useFakeTimers();
1363
+ const pc = makeFakeRTCPeerConnection("gathering");
1364
+ // @ts-expect-error -- stub replaces the real constructor
1365
+ globalThis.RTCPeerConnection = class { constructor() { return pc; } };
1366
+ try {
1367
+ const transport = new BrowserKerbcastTransport({ iceGatheringTimeoutMs: 2000 });
1368
+ const peer = transport.createPeer([]);
1369
+ const done = vi.fn();
1370
+ void peer.waitForIceComplete().then(done);
1371
+
1372
+ // Gathering completes before the timeout.
1373
+ await vi.advanceTimersByTimeAsync(500);
1374
+ pc.fireGatheringComplete();
1375
+ await Promise.resolve(); // flush microtasks
1376
+ expect(done).toHaveBeenCalledOnce();
1377
+ // No listener remains; timer was cancelled.
1378
+ expect(pc.listenerCount("icegatheringstatechange")).toBe(0);
1379
+ // Advancing past the original timeout must not call done again.
1380
+ await vi.advanceTimersByTimeAsync(2000);
1381
+ expect(done).toHaveBeenCalledOnce();
1382
+ } finally {
1383
+ vi.useRealTimers();
1384
+ // @ts-expect-error -- cleanup
1385
+ delete globalThis.RTCPeerConnection;
1386
+ }
1387
+ });
1388
+ });
1389
+
1390
+ // ---------------------------------------------------------------------------
1391
+ // KerbcastClient.inboundVideoStats
1392
+ // ---------------------------------------------------------------------------
1393
+
1394
+ describe("KerbcastClient.inboundVideoStats", () => {
1395
+ it("returns empty map when disconnected", async () => {
1396
+ const { transport } = makeFakeTransport();
1397
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
1398
+ const stats = await client.inboundVideoStats();
1399
+ expect(stats.size).toBe(0);
1400
+ });
1401
+
1402
+ it("returns empty map when the transport has no getStats", async () => {
1403
+ const { transport } = makeFakeTransport();
1404
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
1405
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer([42]));
1406
+ await client.connect([42]);
1407
+ // makeFakeTransport does not implement getStats, so the peer lacks it.
1408
+ const stats = await client.inboundVideoStats();
1409
+ expect(stats.size).toBe(0);
1410
+ });
1411
+
1412
+ it("returns stats keyed by flightId in legacy mode (trackIdentifier match)", async () => {
1413
+ const sidecar = new MockSidecar();
1414
+ sidecar.addCamera({ flightId: 42 });
1415
+ const client = new KerbcastClient(
1416
+ { host: "h", port: 1 },
1417
+ sidecar.createTransport(),
1418
+ );
1419
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
1420
+ Promise.resolve(MockSidecar.makeOfferResponse([42])),
1421
+ );
1422
+
1423
+ await client.connect([42]);
1424
+ sidecar.open();
1425
+ // Deliver a track with a known id so the client records it in flightByTrackId.
1426
+ const fakeTrack = { id: "track-42" } as MediaStreamTrack;
1427
+ sidecar.deliverTrack("0", fakeTrack);
1428
+
1429
+ sidecar.setInboundStats(42, {
1430
+ packetsReceived: 1200,
1431
+ bytesReceived: 500_000,
1432
+ framesDecoded: 360,
1433
+ jitter: 0.002,
1434
+ framesPerSecond: 30,
1435
+ });
1436
+
1437
+ const stats = await client.inboundVideoStats();
1438
+ expect(stats.size).toBe(1);
1439
+ const cam42 = stats.get(42);
1440
+ expect(cam42?.packetsReceived).toBe(1200);
1441
+ expect(cam42?.bytesReceived).toBe(500_000);
1442
+ expect(cam42?.framesDecoded).toBe(360);
1443
+ expect(cam42?.jitter).toBeCloseTo(0.002);
1444
+ expect(cam42?.framesPerSecond).toBe(30);
1445
+ });
1446
+
1447
+ it("returns stats keyed by flightId in dynamic mode (mid match)", async () => {
1448
+ const sidecar = new MockSidecar().withSlots(["a"]);
1449
+ sidecar.addCamera({ flightId: 7 });
1450
+ const client = new KerbcastClient(
1451
+ { host: "h", port: 1 },
1452
+ sidecar.createTransport(),
1453
+ );
1454
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
1455
+ Promise.resolve(MockSidecar.makeOfferResponse([])),
1456
+ );
1457
+
1458
+ await client.connect([], { slots: 1 });
1459
+ sidecar.open();
1460
+ await client.subscribe(7);
1461
+ // Deliver a track; dynamic mode matches by mid, not trackIdentifier.
1462
+ sidecar.deliverTrack("a", {} as MediaStreamTrack);
1463
+
1464
+ sidecar.setInboundStats(7, {
1465
+ packetsReceived: 800,
1466
+ bytesReceived: 200_000,
1467
+ framesDecoded: 240,
1468
+ });
1469
+
1470
+ const stats = await client.inboundVideoStats();
1471
+ expect(stats.size).toBe(1);
1472
+ const cam7 = stats.get(7);
1473
+ expect(cam7?.packetsReceived).toBe(800);
1474
+ expect(cam7?.framesDecoded).toBe(240);
1475
+ // Fields not set default to 0 or undefined.
1476
+ expect(cam7?.framesPerSecond).toBeUndefined();
1477
+ });
1478
+
1479
+ it("returns empty map after disconnect", async () => {
1480
+ const sidecar = new MockSidecar();
1481
+ sidecar.addCamera({ flightId: 5 });
1482
+ const client = new KerbcastClient(
1483
+ { host: "h", port: 1 },
1484
+ sidecar.createTransport(),
1485
+ );
1486
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
1487
+ Promise.resolve(MockSidecar.makeOfferResponse([5])),
1488
+ );
1489
+
1490
+ await client.connect([5]);
1491
+ sidecar.open();
1492
+ sidecar.deliverTrack("0", { id: "track-5" } as MediaStreamTrack);
1493
+ sidecar.setInboundStats(5, { packetsReceived: 100 });
1494
+
1495
+ client.disconnect();
1496
+ const stats = await client.inboundVideoStats();
1497
+ expect(stats.size).toBe(0);
1498
+ });
1499
+
1500
+ it("throttleMainScreen defaults to false before hello", () => {
1501
+ const sidecar = new MockSidecar();
1502
+ const client = new KerbcastClient(
1503
+ { host: "h", port: 1 },
1504
+ sidecar.createTransport(),
1505
+ );
1506
+ expect(client.throttleMainScreen).toBe(false);
1507
+ });
1508
+
1509
+ it("throttleMainScreen reflects the settings-state pushed on hello", async () => {
1510
+ const sidecar = new MockSidecar();
1511
+ /* Seed the mock sidecar with throttle on before opening. */
1512
+ sidecar.fireSettingsState({ throttleMainScreen: true });
1513
+ const client = new KerbcastClient(
1514
+ { host: "h", port: 1 },
1515
+ sidecar.createTransport(),
1516
+ );
1517
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
1518
+ Promise.resolve(MockSidecar.makeOfferResponse([])),
1519
+ );
1520
+ await client.connect();
1521
+ sidecar.open();
1522
+ expect(client.throttleMainScreen).toBe(true);
1523
+ });
1524
+
1525
+ it("setThrottleMainScreen sends the correct command", async () => {
1526
+ const sidecar = new MockSidecar();
1527
+ const client = new KerbcastClient(
1528
+ { host: "h", port: 1 },
1529
+ sidecar.createTransport(),
1530
+ );
1531
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
1532
+ Promise.resolve(MockSidecar.makeOfferResponse([])),
1533
+ );
1534
+ await client.connect();
1535
+ sidecar.open();
1536
+
1537
+ await client.setThrottleMainScreen(true);
1538
+
1539
+ const cmd = sidecar.lastCommand("set-throttle-main-screen");
1540
+ expect(cmd).toBeDefined();
1541
+ expect(cmd?.content.enabled).toBe(true);
1542
+ });
1543
+
1544
+ it("settings-change event fires with SettingsState payload", async () => {
1545
+ const sidecar = new MockSidecar();
1546
+ const client = new KerbcastClient(
1547
+ { host: "h", port: 1 },
1548
+ sidecar.createTransport(),
1549
+ );
1550
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
1551
+ Promise.resolve(MockSidecar.makeOfferResponse([])),
1552
+ );
1553
+ await client.connect();
1554
+ sidecar.open();
1555
+
1556
+ const events: { throttleMainScreen: boolean }[] = [];
1557
+ client.on("settings-change", (p) => events.push(p));
1558
+
1559
+ await client.setThrottleMainScreen(true);
1560
+
1561
+ expect(events.length).toBeGreaterThan(0);
1562
+ expect(events[events.length - 1].throttleMainScreen).toBe(true);
1563
+ expect(client.throttleMainScreen).toBe(true);
1564
+ });
1565
+
1566
+ it("clock.captureUt is null before any settings-state", () => {
1567
+ const sidecar = new MockSidecar();
1568
+ const client = new KerbcastClient(
1569
+ { host: "h", port: 1 },
1570
+ sidecar.createTransport(),
1571
+ );
1572
+ expect(client.clock.captureUt).toBeNull();
1573
+ expect(client.clock.epoch).toBe(0);
1574
+ expect(client.clock.warpRate).toBe(1);
1575
+ });
1576
+
1577
+ it("clock reflects a fired capture clock", async () => {
1578
+ const sidecar = new MockSidecar();
1579
+ const client = new KerbcastClient(
1580
+ { host: "h", port: 1 },
1581
+ sidecar.createTransport(),
1582
+ );
1583
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
1584
+ Promise.resolve(MockSidecar.makeOfferResponse([])),
1585
+ );
1586
+ await client.connect();
1587
+ sidecar.open();
1588
+
1589
+ sidecar.fireSettingsState({
1590
+ throttleMainScreen: false,
1591
+ captureUt: 12345.5,
1592
+ captureEpoch: 7,
1593
+ timeWarpRate: 4,
1594
+ });
1595
+
1596
+ expect(client.clock).toEqual({
1597
+ captureUt: 12345.5,
1598
+ epoch: 7,
1599
+ warpRate: 4,
1600
+ });
1601
+ });
1602
+
1603
+ it("clock warpRate defaults to 1 and epoch is retained when fields are absent", async () => {
1604
+ const sidecar = new MockSidecar();
1605
+ const client = new KerbcastClient(
1606
+ { host: "h", port: 1 },
1607
+ sidecar.createTransport(),
1608
+ );
1609
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
1610
+ Promise.resolve(MockSidecar.makeOfferResponse([])),
1611
+ );
1612
+ await client.connect();
1613
+ sidecar.open();
1614
+
1615
+ sidecar.fireSettingsState({
1616
+ throttleMainScreen: false,
1617
+ captureUt: 100,
1618
+ captureEpoch: 5,
1619
+ timeWarpRate: 50,
1620
+ });
1621
+ /* A later push omitting epoch/warp: epoch retained, warp back to 1. */
1622
+ sidecar.fireSettingsState({ throttleMainScreen: false, captureUt: 101 });
1623
+
1624
+ expect(client.clock.captureUt).toBe(101);
1625
+ expect(client.clock.epoch).toBe(5);
1626
+ expect(client.clock.warpRate).toBe(1);
1627
+ });
1628
+
1629
+ it("MockSidecar.throttleMainScreen reflects set-throttle-main-screen command", async () => {
1630
+ const sidecar = new MockSidecar();
1631
+ const client = new KerbcastClient(
1632
+ { host: "h", port: 1 },
1633
+ sidecar.createTransport(),
1634
+ );
1635
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
1636
+ Promise.resolve(MockSidecar.makeOfferResponse([])),
1637
+ );
1638
+ await client.connect();
1639
+ sidecar.open();
1640
+
1641
+ expect(sidecar.throttleMainScreen).toBe(false);
1642
+ await client.setThrottleMainScreen(true);
1643
+ expect(sidecar.throttleMainScreen).toBe(true);
1644
+ await client.setThrottleMainScreen(false);
1645
+ expect(sidecar.throttleMainScreen).toBe(false);
1646
+ });
1647
+
1648
+ it("fireSettingsState pushes settings-change to the client", async () => {
1649
+ const sidecar = new MockSidecar();
1650
+ const client = new KerbcastClient(
1651
+ { host: "h", port: 1 },
1652
+ sidecar.createTransport(),
1653
+ );
1654
+ vi.spyOn(globalThis, "fetch").mockImplementation(() =>
1655
+ Promise.resolve(MockSidecar.makeOfferResponse([])),
1656
+ );
1657
+ /* Register before open() so the open-time settings-state is also captured. */
1658
+ const events: { throttleMainScreen: boolean }[] = [];
1659
+ client.on("settings-change", (p) => events.push(p));
1660
+
1661
+ await client.connect();
1662
+ sidecar.open();
1663
+
1664
+ /* One event from open() (false), then one more from fireSettingsState (true). */
1665
+ expect(events).toHaveLength(1);
1666
+ expect(events[0].throttleMainScreen).toBe(false);
1667
+
1668
+ sidecar.fireSettingsState({ throttleMainScreen: true });
1669
+ expect(events).toHaveLength(2);
1670
+ expect(events[1].throttleMainScreen).toBe(true);
1671
+ expect(client.throttleMainScreen).toBe(true);
1672
+ });
1673
+ });