@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.
- package/CHANGELOG.md +186 -0
- package/LICENSE +369 -0
- package/README.md +112 -0
- package/dist/__generated__/types.d.ts +529 -0
- package/dist/__generated__/types.js +50 -0
- package/dist/client.d.ts +433 -0
- package/dist/client.js +821 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/noise.d.ts +34 -0
- package/dist/noise.js +274 -0
- package/dist/panZoom.d.ts +190 -0
- package/dist/panZoom.js +302 -0
- package/dist/testing/index.d.ts +217 -0
- package/dist/testing/index.js +606 -0
- package/package.json +57 -0
- package/src/__generated__/types.ts +488 -0
- package/src/client.test.ts +1673 -0
- package/src/client.ts +1219 -0
- package/src/domStubs.test.ts +49 -0
- package/src/index.ts +29 -0
- package/src/noise.test.ts +340 -0
- package/src/noise.ts +317 -0
- package/src/panZoom.test.ts +404 -0
- package/src/panZoom.ts +405 -0
- package/src/testing/index.ts +689 -0
- package/src/testing.test.ts +156 -0
|
@@ -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
|
+
});
|