@tribe-nest/media-client 0.1.1 → 0.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/build/core/index.d.ts +1 -1
- package/build/core/index.d.ts.map +1 -1
- package/build/core/index.js.map +1 -1
- package/build/core/reconnect.d.ts +29 -0
- package/build/core/reconnect.d.ts.map +1 -1
- package/build/core/reconnect.js +49 -9
- package/build/core/reconnect.js.map +1 -1
- package/build/core/signal.d.ts +9 -0
- package/build/core/signal.d.ts.map +1 -1
- package/build/core/signal.js +18 -2
- package/build/core/signal.js.map +1 -1
- package/build/core/state.d.ts +31 -0
- package/build/core/state.d.ts.map +1 -1
- package/build/core/state.js +113 -8
- package/build/core/state.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js.map +1 -1
- package/build/react/index.d.ts +78 -16
- package/build/react/index.d.ts.map +1 -1
- package/build/react/index.js +289 -12
- package/build/react/index.js.map +1 -1
- package/build/room/browserDevice.d.ts.map +1 -1
- package/build/room/browserDevice.js +13 -4
- package/build/room/browserDevice.js.map +1 -1
- package/build/room/device.d.ts +19 -0
- package/build/room/device.d.ts.map +1 -1
- package/build/room/room.d.ts +298 -3
- package/build/room/room.d.ts.map +1 -1
- package/build/room/room.js +742 -24
- package/build/room/room.js.map +1 -1
- package/package.json +2 -2
- package/src/core/_tests/reconnect.spec.ts +92 -0
- package/src/core/_tests/state.spec.ts +138 -0
- package/src/core/index.ts +2 -0
- package/src/core/reconnect.ts +76 -9
- package/src/core/signal.ts +16 -2
- package/src/core/state.ts +163 -11
- package/src/index.ts +2 -0
- package/src/react/index.tsx +323 -19
- package/src/room/_tests/room.spec.ts +954 -4
- package/src/room/browserDevice.ts +14 -4
- package/src/room/device.ts +19 -0
- package/src/room/room.ts +913 -26
|
@@ -26,7 +26,15 @@ import type {
|
|
|
26
26
|
type Recorded = { calls: string[]; transports: FakeTransport[] };
|
|
27
27
|
|
|
28
28
|
class FakeTransport implements MediaTransport {
|
|
29
|
-
readonly produced: { appData?: Record<string, unknown
|
|
29
|
+
readonly produced: { appData?: Record<string, unknown>; codec?: "h264" | "vp8"; encodings?: unknown[] }[] = [];
|
|
30
|
+
/** Records what the room asked the sender for. */
|
|
31
|
+
readonly senderParams: Record<string, unknown> = {};
|
|
32
|
+
readonly sender = {
|
|
33
|
+
getParameters: () => ({ encodings: [] }) as unknown as RTCRtpSendParameters,
|
|
34
|
+
setParameters: async (params: RTCRtpSendParameters) => {
|
|
35
|
+
Object.assign(this.senderParams, params);
|
|
36
|
+
},
|
|
37
|
+
} as unknown as RTCRtpSender;
|
|
30
38
|
readonly consumed: string[] = [];
|
|
31
39
|
closed = false;
|
|
32
40
|
handlers: Partial<TransportHandlers> = {};
|
|
@@ -37,8 +45,19 @@ class FakeTransport implements MediaTransport {
|
|
|
37
45
|
private readonly recorded: Recorded,
|
|
38
46
|
) {}
|
|
39
47
|
|
|
40
|
-
async produce(input: {
|
|
41
|
-
|
|
48
|
+
async produce(input: {
|
|
49
|
+
track: MediaStreamTrack;
|
|
50
|
+
appData?: Record<string, unknown>;
|
|
51
|
+
encodings?: unknown[];
|
|
52
|
+
codec?: "h264" | "vp8";
|
|
53
|
+
}): Promise<MediaProducerHandle> {
|
|
54
|
+
// Recorded EXACTLY as given: a key that is absent and a key set to
|
|
55
|
+
// undefined are different things to mediasoup-client.
|
|
56
|
+
this.produced.push({
|
|
57
|
+
...(input.appData ? { appData: input.appData } : {}),
|
|
58
|
+
...("codec" in input ? { codec: input.codec } : {}),
|
|
59
|
+
...("encodings" in input ? { encodings: input.encodings } : {}),
|
|
60
|
+
});
|
|
42
61
|
// The real transport asks the application to tell the node, and the node's
|
|
43
62
|
// answer is the producer id. Modelled, because the round trip is the thing
|
|
44
63
|
// that can be got wrong.
|
|
@@ -55,6 +74,10 @@ class FakeTransport implements MediaTransport {
|
|
|
55
74
|
pause: vi.fn(),
|
|
56
75
|
resume: vi.fn(),
|
|
57
76
|
close: vi.fn(),
|
|
77
|
+
replaceTrack: vi.fn(async () => undefined),
|
|
78
|
+
// A real browser producer carries one, and the program feed's
|
|
79
|
+
// degradation preference is set through it.
|
|
80
|
+
rtpSender: this.sender,
|
|
58
81
|
};
|
|
59
82
|
}
|
|
60
83
|
|
|
@@ -62,6 +85,7 @@ class FakeTransport implements MediaTransport {
|
|
|
62
85
|
this.consumed.push(input.producerId);
|
|
63
86
|
this.recorded.calls.push(`consume:${input.producerId}`);
|
|
64
87
|
const resume = vi.fn(() => this.recorded.calls.push(`track-resume:${input.producerId}`));
|
|
88
|
+
let statBytes = 0;
|
|
65
89
|
return {
|
|
66
90
|
id: input.id,
|
|
67
91
|
producerId: input.producerId,
|
|
@@ -70,6 +94,10 @@ class FakeTransport implements MediaTransport {
|
|
|
70
94
|
pause: vi.fn(),
|
|
71
95
|
resume,
|
|
72
96
|
close: vi.fn(),
|
|
97
|
+
// 50 kilobytes more per read: two reads a second apart show 400 kbps.
|
|
98
|
+
getStats: async () => [
|
|
99
|
+
{ type: "inbound-rtp", frameWidth: 320, frameHeight: 180, framesPerSecond: 24, bytesReceived: (statBytes += 50_000) },
|
|
100
|
+
],
|
|
73
101
|
};
|
|
74
102
|
}
|
|
75
103
|
|
|
@@ -127,7 +155,7 @@ function fakeDevice(recorded: Recorded) {
|
|
|
127
155
|
* the room is exercised against the same wire the signal client is.
|
|
128
156
|
*/
|
|
129
157
|
function nodeWith(input: {
|
|
130
|
-
producers?: { producerId: string; identity: string; kind: "audio" | "video" }[];
|
|
158
|
+
producers?: { producerId: string; identity: string; kind: "audio" | "video"; source?: string }[];
|
|
131
159
|
iceServers?: IceServer[];
|
|
132
160
|
/**
|
|
133
161
|
* Events delivered on EVERY connection after the join reply and BEFORE the
|
|
@@ -283,6 +311,39 @@ describe("rule 2: one transport per direction, created lazily", () => {
|
|
|
283
311
|
await room.close();
|
|
284
312
|
});
|
|
285
313
|
|
|
314
|
+
it("keeps the PROGRAM feed's resolution, giving up frame rate instead", async () => {
|
|
315
|
+
/**
|
|
316
|
+
* Chrome's default scales the encode down under a low bandwidth estimate
|
|
317
|
+
* or CPU pressure. For the program feed that is wrong twice: it is the
|
|
318
|
+
* broadcast itself, so 1080p arriving as 270p is the product; and the RTMP
|
|
319
|
+
* leg muxes it with `-c:v copy`, where an FLV header carries the dimensions
|
|
320
|
+
* once. A mid-stream resolution change leaves the SPS describing one size
|
|
321
|
+
* and the header another, and the player refuses it with "error #3000"
|
|
322
|
+
* while ffmpeg reports a healthy bitrate and zero dropped frames.
|
|
323
|
+
*/
|
|
324
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
325
|
+
const room = roomFor(nodeWith({}), recorded, false);
|
|
326
|
+
await room.connect();
|
|
327
|
+
|
|
328
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "program");
|
|
329
|
+
|
|
330
|
+
const transport = recorded.transports[0] as unknown as { senderParams: Record<string, unknown> };
|
|
331
|
+
expect(transport.senderParams.degradationPreference).toBe("maintain-resolution");
|
|
332
|
+
await room.close();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("leaves a CAMERA alone, which is better off shrinking than freezing", async () => {
|
|
336
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
337
|
+
const room = roomFor(nodeWith({}), recorded, false);
|
|
338
|
+
await room.connect();
|
|
339
|
+
|
|
340
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
|
|
341
|
+
|
|
342
|
+
const transport = recorded.transports[0] as unknown as { senderParams: Record<string, unknown> };
|
|
343
|
+
expect(transport.senderParams.degradationPreference).toBeUndefined();
|
|
344
|
+
await room.close();
|
|
345
|
+
});
|
|
346
|
+
|
|
286
347
|
it("reuses the recv transport across subscriptions", async () => {
|
|
287
348
|
const recorded: Recorded = { calls: [], transports: [] };
|
|
288
349
|
const server = nodeWith({
|
|
@@ -421,6 +482,48 @@ describe("rule 3: a consumer resumes only after its track exists", () => {
|
|
|
421
482
|
});
|
|
422
483
|
});
|
|
423
484
|
|
|
485
|
+
/**
|
|
486
|
+
* The publisher's label reaches the tile.
|
|
487
|
+
*
|
|
488
|
+
* The node relays `appData.source` as `source` on the snapshot and on every
|
|
489
|
+
* `producerAppeared`, and the room keeps it on the entry. A consumed track
|
|
490
|
+
* carries it so a screen share, a camera and a program feed can be laid out
|
|
491
|
+
* differently; a producer the node relayed no label for carries none.
|
|
492
|
+
*/
|
|
493
|
+
describe("a consumed track carries the publisher's source", () => {
|
|
494
|
+
it("from the join snapshot", async () => {
|
|
495
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
496
|
+
const server = nodeWith({
|
|
497
|
+
producers: [
|
|
498
|
+
{ producerId: "p-a", identity: "anwalt", kind: "video", source: "screen" },
|
|
499
|
+
{ producerId: "p-b", identity: "anwalt", kind: "audio" },
|
|
500
|
+
],
|
|
501
|
+
});
|
|
502
|
+
const room = roomFor(server, recorded, false);
|
|
503
|
+
await room.connect();
|
|
504
|
+
await room.subscribe("p-a");
|
|
505
|
+
await room.subscribe("p-b");
|
|
506
|
+
|
|
507
|
+
expect(room.tracks.find((t) => t.producerId === "p-a")).toMatchObject({ identity: "anwalt", source: "screen" });
|
|
508
|
+
expect(room.tracks.find((t) => t.producerId === "p-b")).not.toHaveProperty("source");
|
|
509
|
+
await room.close();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("from a producerAppeared announced after the join", async () => {
|
|
513
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
514
|
+
const server = nodeWith({});
|
|
515
|
+
const room = roomFor(server, recorded, false);
|
|
516
|
+
await room.connect();
|
|
517
|
+
|
|
518
|
+
server.event({ event: "producerAppeared", producerId: "p-c", identity: "studio", kind: "video", source: "program" });
|
|
519
|
+
await flush();
|
|
520
|
+
await room.subscribe("p-c");
|
|
521
|
+
|
|
522
|
+
expect(room.tracks[0]).toMatchObject({ producerId: "p-c", identity: "studio", source: "program" });
|
|
523
|
+
await room.close();
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
424
527
|
describe("rule 4: autoSubscribe follows the ACTIVE SET", () => {
|
|
425
528
|
it("subscribes only to what the node put in the set", async () => {
|
|
426
529
|
const recorded: Recorded = { calls: [], transports: [] };
|
|
@@ -521,6 +624,53 @@ describe("publishing", () => {
|
|
|
521
624
|
});
|
|
522
625
|
});
|
|
523
626
|
|
|
627
|
+
/**
|
|
628
|
+
* A publication can say how it wants to be encoded.
|
|
629
|
+
*
|
|
630
|
+
* A camera takes the browser's defaults and that is right. A composed program
|
|
631
|
+
* feed does not: an RTMP restream needs H.264, because RTMP carries nothing
|
|
632
|
+
* else and the egress copies rather than transcodes, and a 1080p canvas at the
|
|
633
|
+
* default bitrate is a smear. Both are facts about ONE publication, so they
|
|
634
|
+
* travel with the `publish` call rather than with the room.
|
|
635
|
+
*/
|
|
636
|
+
describe("publishing with options", () => {
|
|
637
|
+
it("passes the codec and a bitrate cap to the transport", async () => {
|
|
638
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
639
|
+
const server = nodeWith({});
|
|
640
|
+
const room = roomFor(server, recorded, false);
|
|
641
|
+
await room.connect();
|
|
642
|
+
|
|
643
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "program", { codec: "h264", maxBitrateKbps: 3500 });
|
|
644
|
+
|
|
645
|
+
const [produced] = recorded.transports.find((t) => t.direction === "send")!.produced;
|
|
646
|
+
expect(produced).toEqual({
|
|
647
|
+
appData: { source: "program" },
|
|
648
|
+
codec: "h264",
|
|
649
|
+
// kbit/s at the API, bit/s at the encoder. The unit is the whole bug.
|
|
650
|
+
encodings: [{ maxBitrate: 3_500_000 }],
|
|
651
|
+
});
|
|
652
|
+
// And the node still hears the label it checks grants against.
|
|
653
|
+
const produce = requestsOf(server, "produce")[0] as { appData?: { source?: string } };
|
|
654
|
+
expect(produce.appData?.source).toBe("program");
|
|
655
|
+
await room.close();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("passes neither when no options are given, so the defaults stay the browser's", async () => {
|
|
659
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
660
|
+
const server = nodeWith({});
|
|
661
|
+
const room = roomFor(server, recorded, false);
|
|
662
|
+
await room.connect();
|
|
663
|
+
|
|
664
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
|
|
665
|
+
|
|
666
|
+
const [produced] = recorded.transports.find((t) => t.direction === "send")!.produced;
|
|
667
|
+
expect(produced).toEqual({ appData: { source: "camera" } });
|
|
668
|
+
expect(produced).not.toHaveProperty("codec");
|
|
669
|
+
expect(produced).not.toHaveProperty("encodings");
|
|
670
|
+
await room.close();
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
524
674
|
/**
|
|
525
675
|
* A state called "reconnecting" has to mean something is reconnecting.
|
|
526
676
|
*
|
|
@@ -974,6 +1124,31 @@ describe("publications lost to a drop", () => {
|
|
|
974
1124
|
await room.close();
|
|
975
1125
|
});
|
|
976
1126
|
|
|
1127
|
+
it("never names a program feed, which is a studio's output and not a person's capture", async () => {
|
|
1128
|
+
// Nobody should be asked to "turn the program back on": it is not a
|
|
1129
|
+
// device, and `useLocalMedia`'s controls never touch it. Only the camera
|
|
1130
|
+
// is a lost source here, and republishing the program clears nothing.
|
|
1131
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1132
|
+
const server = nodeWith({});
|
|
1133
|
+
const { room } = recoveringRoom(server, recorded);
|
|
1134
|
+
await room.connect();
|
|
1135
|
+
|
|
1136
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
|
|
1137
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "program", { codec: "h264" });
|
|
1138
|
+
server.socket.dropFromServer();
|
|
1139
|
+
await flush();
|
|
1140
|
+
|
|
1141
|
+
await vi.waitFor(() => expect(server.sockets.length).toBe(2));
|
|
1142
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1143
|
+
expect(room.lostPublicationSources).toEqual(["camera"]);
|
|
1144
|
+
|
|
1145
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "program", { codec: "h264" });
|
|
1146
|
+
expect(room.lostPublicationSources).toEqual(["camera"]);
|
|
1147
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
|
|
1148
|
+
expect(room.lostPublicationSources).toEqual([]);
|
|
1149
|
+
await room.close();
|
|
1150
|
+
});
|
|
1151
|
+
|
|
977
1152
|
it("says nothing after a leave, which lost nothing", async () => {
|
|
978
1153
|
const recorded: Recorded = { calls: [], transports: [] };
|
|
979
1154
|
const server = nodeWith({});
|
|
@@ -1095,3 +1270,778 @@ describe("pausing a publication", () => {
|
|
|
1095
1270
|
await room.close();
|
|
1096
1271
|
});
|
|
1097
1272
|
});
|
|
1273
|
+
|
|
1274
|
+
describe("replaceTrack: a device switch keeps its producer", () => {
|
|
1275
|
+
it("swaps the capture on the handle, stops the old track, keeps the producer id", async () => {
|
|
1276
|
+
const server = nodeWith({ producers: [] });
|
|
1277
|
+
const room = roomFor(server, { calls: [], transports: [] });
|
|
1278
|
+
await room.connect();
|
|
1279
|
+
const first = { kind: "video", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
|
|
1280
|
+
const second = { kind: "video", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
|
|
1281
|
+
const published = await room.publish(first, "camera");
|
|
1282
|
+
|
|
1283
|
+
await room.replaceTrack(published.producerId, second);
|
|
1284
|
+
|
|
1285
|
+
expect(published.handle.replaceTrack).toHaveBeenCalledWith(second);
|
|
1286
|
+
expect(first.stop).toHaveBeenCalled();
|
|
1287
|
+
expect(second.stop).not.toHaveBeenCalled();
|
|
1288
|
+
expect(room.localPublications).toHaveLength(1);
|
|
1289
|
+
expect(room.localPublications[0]).toMatchObject({ producerId: published.producerId, track: second, source: "camera" });
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
it("publishes three layers when simulcast is asked for, splitting the caller's cap", async () => {
|
|
1293
|
+
const server = nodeWith({ producers: [] });
|
|
1294
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1295
|
+
const room = roomFor(server, recorded);
|
|
1296
|
+
await room.connect();
|
|
1297
|
+
const track = { kind: "video", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
|
|
1298
|
+
|
|
1299
|
+
await room.publish(track, "program", { codec: "vp8", simulcast: true, maxBitrateKbps: 3500 });
|
|
1300
|
+
|
|
1301
|
+
const produced = recorded.transports.find((t) => t.direction === "send")!.produced[0]!;
|
|
1302
|
+
expect(produced.codec).toBe("vp8");
|
|
1303
|
+
expect(produced.encodings).toEqual([
|
|
1304
|
+
{ rid: "q", scaleResolutionDownBy: 4, scalabilityMode: "L1T3", maxBitrate: 350_000 },
|
|
1305
|
+
{ rid: "h", scaleResolutionDownBy: 2, scalabilityMode: "L1T3", maxBitrate: 875_000 },
|
|
1306
|
+
{ rid: "f", scaleResolutionDownBy: 1, scalabilityMode: "L1T3", maxBitrate: 3_500_000 },
|
|
1307
|
+
]);
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
it("still publishes ONE encoding when simulcast is not asked for", async () => {
|
|
1311
|
+
// The small-call case: everybody can carry the one encoding, and layers
|
|
1312
|
+
// nobody consumes are encoder time spent on nobody.
|
|
1313
|
+
const server = nodeWith({ producers: [] });
|
|
1314
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1315
|
+
const room = roomFor(server, recorded);
|
|
1316
|
+
await room.connect();
|
|
1317
|
+
const track = { kind: "video", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
|
|
1318
|
+
|
|
1319
|
+
await room.publish(track, "camera", { maxBitrateKbps: 1200 });
|
|
1320
|
+
|
|
1321
|
+
const produced = recorded.transports.find((t) => t.direction === "send")!.produced[0]!;
|
|
1322
|
+
expect(produced.encodings).toEqual([{ maxBitrate: 1_200_000 }]);
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
it("refuses a track of the other kind and an unknown publication", async () => {
|
|
1326
|
+
const server = nodeWith({ producers: [] });
|
|
1327
|
+
const room = roomFor(server, { calls: [], transports: [] });
|
|
1328
|
+
await room.connect();
|
|
1329
|
+
const video = { kind: "video", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
|
|
1330
|
+
const audio = { kind: "audio", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
|
|
1331
|
+
const published = await room.publish(video, "camera");
|
|
1332
|
+
await expect(room.replaceTrack(published.producerId, audio)).rejects.toThrow(/cannot carry/);
|
|
1333
|
+
await expect(room.replaceTrack("nope", video)).rejects.toThrow(/no such publication/);
|
|
1334
|
+
});
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* A node whose consume replies echo the producer's REAL kind, so viewport
|
|
1339
|
+
* tests get video consumers where the fixture above answers "audio" for
|
|
1340
|
+
* everything.
|
|
1341
|
+
*/
|
|
1342
|
+
function kindAwareNode(input: {
|
|
1343
|
+
producers: { producerId: string; identity: string; kind: "audio" | "video"; source?: string }[];
|
|
1344
|
+
refuse?: (method: string) => boolean;
|
|
1345
|
+
}): FakeSignalServer {
|
|
1346
|
+
let transportSeq = 0;
|
|
1347
|
+
let consumerSeq = 0;
|
|
1348
|
+
const kinds = new Map(input.producers.map((p) => [p.producerId, p.kind]));
|
|
1349
|
+
const server: FakeSignalServer = new FakeSignalServer({
|
|
1350
|
+
joined: {
|
|
1351
|
+
identity: "me",
|
|
1352
|
+
room: "matter-1",
|
|
1353
|
+
routerRtpCapabilities: { codecs: [] },
|
|
1354
|
+
producers: input.producers,
|
|
1355
|
+
grants: { canPublish: true, canSubscribe: true, canPublishData: true },
|
|
1356
|
+
},
|
|
1357
|
+
onRequest: (frame, socket) => {
|
|
1358
|
+
if (input.refuse?.(frame.method)) return server.fail(frame.id, "bad_request", "unknown method", socket);
|
|
1359
|
+
switch (frame.method) {
|
|
1360
|
+
case "createTransport":
|
|
1361
|
+
return server.reply(
|
|
1362
|
+
frame.id,
|
|
1363
|
+
{ transportId: `t-${transportSeq++}`, iceParameters: {}, iceCandidates: [], dtlsParameters: {} },
|
|
1364
|
+
socket,
|
|
1365
|
+
);
|
|
1366
|
+
case "connectTransport":
|
|
1367
|
+
return server.reply(frame.id, { connected: true }, socket);
|
|
1368
|
+
case "consume":
|
|
1369
|
+
return server.reply(
|
|
1370
|
+
frame.id,
|
|
1371
|
+
{
|
|
1372
|
+
consumerId: `c-${consumerSeq++}`,
|
|
1373
|
+
producerId: frame.producerId,
|
|
1374
|
+
kind: kinds.get(frame.producerId) ?? "audio",
|
|
1375
|
+
rtpParameters: {},
|
|
1376
|
+
},
|
|
1377
|
+
socket,
|
|
1378
|
+
);
|
|
1379
|
+
default:
|
|
1380
|
+
return server.reply(frame.id, {}, socket);
|
|
1381
|
+
}
|
|
1382
|
+
},
|
|
1383
|
+
});
|
|
1384
|
+
return server;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const GRID = [
|
|
1388
|
+
{ producerId: "a-1", identity: "a", kind: "audio" as const },
|
|
1389
|
+
{ producerId: "v-1", identity: "a", kind: "video" as const },
|
|
1390
|
+
{ producerId: "v-2", identity: "b", kind: "video" as const },
|
|
1391
|
+
{ producerId: "v-3", identity: "c", kind: "video" as const },
|
|
1392
|
+
];
|
|
1393
|
+
const GRID_SET: EventFrame = {
|
|
1394
|
+
event: "activeSpeakers",
|
|
1395
|
+
producerIds: ["a-1", "v-1", "v-2", "v-3"],
|
|
1396
|
+
audio: ["a-1"],
|
|
1397
|
+
video: ["v-1", "v-2", "v-3"],
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
const viewportRoom = (server: FakeSignalServer, recorded: Recorded, graceMs = 150) =>
|
|
1401
|
+
new MediaRoom({
|
|
1402
|
+
getCredentials: () => ({ mediaUrl: "wss://media.example", token: "t" }),
|
|
1403
|
+
device: () => fakeDevice(recorded),
|
|
1404
|
+
webSocket: server.factory,
|
|
1405
|
+
viewportCloseGraceMs: graceMs,
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
describe("viewport narrowing (capacity layer 2)", () => {
|
|
1409
|
+
it("subscribes video to the viewport's intersection with the set, and audio to ALL of it", async () => {
|
|
1410
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1411
|
+
const server = kindAwareNode({ producers: GRID });
|
|
1412
|
+
const room = viewportRoom(server, recorded);
|
|
1413
|
+
await room.connect();
|
|
1414
|
+
|
|
1415
|
+
room.setViewport([{ producerId: "v-1", widthPx: 320 }]);
|
|
1416
|
+
server.event(GRID_SET);
|
|
1417
|
+
await vi.waitFor(() => expect(requestsOf(server, "consume")).toHaveLength(2));
|
|
1418
|
+
|
|
1419
|
+
// v-2 and v-3 are in the set and NOT consumed; a-1 is consumed although
|
|
1420
|
+
// no viewport entry names it. Audio never narrows by viewport.
|
|
1421
|
+
expect(
|
|
1422
|
+
requestsOf(server, "consume")
|
|
1423
|
+
.map((r) => (r as { producerId: string }).producerId)
|
|
1424
|
+
.sort(),
|
|
1425
|
+
).toEqual(["a-1", "v-1"]);
|
|
1426
|
+
await room.close();
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
it("keeps every audio consumer on an EMPTY viewport (a hidden tab hears the call)", async () => {
|
|
1430
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1431
|
+
const server = kindAwareNode({ producers: GRID });
|
|
1432
|
+
const room = viewportRoom(server, recorded, 100);
|
|
1433
|
+
await room.connect();
|
|
1434
|
+
server.event(GRID_SET);
|
|
1435
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(4));
|
|
1436
|
+
|
|
1437
|
+
room.setViewport([]);
|
|
1438
|
+
// Paused at once, closed after the grace; the audio consumer stays.
|
|
1439
|
+
await vi.waitFor(() => expect(requestsOf(server, "closeConsumer")).toHaveLength(3));
|
|
1440
|
+
expect(room.tracks.map((t) => t.producerId)).toEqual(["a-1"]);
|
|
1441
|
+
expect(requestsOf(server, "pauseConsumer")).toHaveLength(3);
|
|
1442
|
+
await room.close();
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
it("never narrows without a viewport: the legacy full-set behavior stands", async () => {
|
|
1446
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1447
|
+
const server = kindAwareNode({ producers: GRID });
|
|
1448
|
+
const room = viewportRoom(server, recorded);
|
|
1449
|
+
await room.connect();
|
|
1450
|
+
server.event(GRID_SET);
|
|
1451
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(4));
|
|
1452
|
+
expect(requestsOf(server, "pauseConsumer")).toHaveLength(0);
|
|
1453
|
+
await room.close();
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
it("pauses immediately, and a flip back inside the grace resumes with a keyframe and NO new consume", async () => {
|
|
1457
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1458
|
+
const server = kindAwareNode({ producers: GRID });
|
|
1459
|
+
const room = viewportRoom(server, recorded, 60_000);
|
|
1460
|
+
await room.connect();
|
|
1461
|
+
room.setViewport([
|
|
1462
|
+
{ producerId: "v-1", widthPx: 320 },
|
|
1463
|
+
{ producerId: "v-2", widthPx: 320 },
|
|
1464
|
+
]);
|
|
1465
|
+
server.event(GRID_SET);
|
|
1466
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(3));
|
|
1467
|
+
const consumesBefore = requestsOf(server, "consume").length;
|
|
1468
|
+
|
|
1469
|
+
// Page flip: v-2 scrolls off...
|
|
1470
|
+
room.setViewport([{ producerId: "v-1", widthPx: 320 }]);
|
|
1471
|
+
await vi.waitFor(() => expect(requestsOf(server, "pauseConsumer")).toHaveLength(1));
|
|
1472
|
+
expect(requestsOf(server, "closeConsumer")).toHaveLength(0);
|
|
1473
|
+
|
|
1474
|
+
// ...and back, inside the generous grace.
|
|
1475
|
+
room.setViewport([
|
|
1476
|
+
{ producerId: "v-1", widthPx: 320 },
|
|
1477
|
+
{ producerId: "v-2", widthPx: 320 },
|
|
1478
|
+
]);
|
|
1479
|
+
await vi.waitFor(() => expect(requestsOf(server, "resumeConsumer").length).toBeGreaterThanOrEqual(4));
|
|
1480
|
+
// A resumed video consumer shows garbage until an I-frame, so one is asked for.
|
|
1481
|
+
expect(requestsOf(server, "requestKeyFrame")).toHaveLength(1);
|
|
1482
|
+
expect(requestsOf(server, "consume")).toHaveLength(consumesBefore);
|
|
1483
|
+
await room.close();
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
it("asks for the layer the tile width implies on consume, and switches on a bucket change without closing", async () => {
|
|
1487
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1488
|
+
const server = kindAwareNode({ producers: GRID });
|
|
1489
|
+
const room = viewportRoom(server, recorded);
|
|
1490
|
+
await room.connect();
|
|
1491
|
+
room.setViewport([{ producerId: "v-1", widthPx: 200 }]);
|
|
1492
|
+
server.event(GRID_SET);
|
|
1493
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(2));
|
|
1494
|
+
|
|
1495
|
+
const consume = requestsOf(server, "consume").find((r) => (r as { producerId: string }).producerId === "v-1");
|
|
1496
|
+
expect((consume as { preferredLayers?: unknown }).preferredLayers).toEqual({ spatialLayer: 0 });
|
|
1497
|
+
|
|
1498
|
+
// A resize inside the bucket says nothing; crossing a boundary speaks once.
|
|
1499
|
+
room.setViewport([{ producerId: "v-1", widthPx: 230 }]);
|
|
1500
|
+
room.setViewport([{ producerId: "v-1", widthPx: 700 }]);
|
|
1501
|
+
await vi.waitFor(() => expect(requestsOf(server, "setPreferredLayers")).toHaveLength(1));
|
|
1502
|
+
expect(requestsOf(server, "setPreferredLayers")[0]).toMatchObject({ consumerId: expect.any(String), spatialLayer: 2 });
|
|
1503
|
+
expect(requestsOf(server, "closeConsumer")).toHaveLength(0);
|
|
1504
|
+
await room.close();
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
it("disables a verb an OLD node refuses instead of retrying into the frame budget", async () => {
|
|
1508
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1509
|
+
const server = kindAwareNode({ producers: GRID, refuse: (method) => method === "pauseConsumer" });
|
|
1510
|
+
const room = viewportRoom(server, recorded, 60_000);
|
|
1511
|
+
await room.connect();
|
|
1512
|
+
room.setViewport([{ producerId: "v-1", widthPx: 320 }, { producerId: "v-2", widthPx: 320 }]);
|
|
1513
|
+
server.event(GRID_SET);
|
|
1514
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(3));
|
|
1515
|
+
|
|
1516
|
+
room.setViewport([{ producerId: "v-2", widthPx: 320 }]);
|
|
1517
|
+
await vi.waitFor(() => expect(requestsOf(server, "pauseConsumer")).toHaveLength(1));
|
|
1518
|
+
// A second narrow does not ask again: the verb is off for the session.
|
|
1519
|
+
room.setViewport([]);
|
|
1520
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
1521
|
+
expect(requestsOf(server, "pauseConsumer")).toHaveLength(1);
|
|
1522
|
+
await room.close();
|
|
1523
|
+
});
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
describe("ephemeral signals: reactions and hands", () => {
|
|
1527
|
+
it("sends a reaction and hears one back through onBroadcast", async () => {
|
|
1528
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1529
|
+
const server = kindAwareNode({ producers: [] });
|
|
1530
|
+
const room = viewportRoom(server, recorded);
|
|
1531
|
+
const heard: unknown[] = [];
|
|
1532
|
+
room.onBroadcast((event) => heard.push(event));
|
|
1533
|
+
await room.connect();
|
|
1534
|
+
|
|
1535
|
+
await room.sendReaction({ emoji: "clap" });
|
|
1536
|
+
expect(requestsOf(server, "broadcast")[0]).toMatchObject({ type: "reaction", data: { emoji: "clap" } });
|
|
1537
|
+
|
|
1538
|
+
server.event({ event: "broadcast", type: "reaction", identity: "peer-1", data: { emoji: "clap" }, at: 5 });
|
|
1539
|
+
await flush();
|
|
1540
|
+
expect(heard).toEqual([{ type: "reaction", identity: "peer-1", data: { emoji: "clap" }, at: 5 }]);
|
|
1541
|
+
// Ephemeral means ephemeral: nothing about it lands in RoomState.
|
|
1542
|
+
expect(room.state.raisedHands).toEqual([]);
|
|
1543
|
+
await room.close();
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
it("raises a hand over the wire and renders hands from room STATE", async () => {
|
|
1547
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1548
|
+
const server = kindAwareNode({ producers: [] });
|
|
1549
|
+
const room = viewportRoom(server, recorded);
|
|
1550
|
+
await room.connect();
|
|
1551
|
+
|
|
1552
|
+
await room.setHandRaised(true);
|
|
1553
|
+
expect(requestsOf(server, "setHand")[0]).toMatchObject({ raised: true });
|
|
1554
|
+
|
|
1555
|
+
server.event({ event: "handChanged", identity: "peer-1", raisedAt: 100 });
|
|
1556
|
+
server.event({ event: "handChanged", identity: "peer-0", raisedAt: 200 });
|
|
1557
|
+
await flush();
|
|
1558
|
+
// Oldest first: the order is the queue, not the alphabet.
|
|
1559
|
+
expect(room.state.raisedHands).toEqual([
|
|
1560
|
+
{ identity: "peer-1", raisedAt: 100 },
|
|
1561
|
+
{ identity: "peer-0", raisedAt: 200 },
|
|
1562
|
+
]);
|
|
1563
|
+
|
|
1564
|
+
server.event({ event: "handChanged", identity: "peer-1", raisedAt: null });
|
|
1565
|
+
await flush();
|
|
1566
|
+
expect(room.state.raisedHands).toEqual([{ identity: "peer-0", raisedAt: 200 }]);
|
|
1567
|
+
await room.close();
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
it("sends lowerHand as a courtesy broadcast, never as state", async () => {
|
|
1571
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1572
|
+
const server = kindAwareNode({ producers: [] });
|
|
1573
|
+
const room = viewportRoom(server, recorded);
|
|
1574
|
+
await room.connect();
|
|
1575
|
+
await room.sendLowerHand("peer-1");
|
|
1576
|
+
expect(requestsOf(server, "broadcast")[0]).toMatchObject({ type: "lowerHand", data: { target: "peer-1" } });
|
|
1577
|
+
await room.close();
|
|
1578
|
+
});
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
describe("the simulcast/h264 exclusivity is enforced", () => {
|
|
1582
|
+
it("drops simulcast for an h264 publish rather than sending layers that will not exist", async () => {
|
|
1583
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1584
|
+
const server = kindAwareNode({ producers: [] });
|
|
1585
|
+
const warnings: string[] = [];
|
|
1586
|
+
const room = new MediaRoom({
|
|
1587
|
+
getCredentials: () => ({ mediaUrl: "wss://media.example", token: "t" }),
|
|
1588
|
+
device: () => fakeDevice(recorded),
|
|
1589
|
+
webSocket: server.factory,
|
|
1590
|
+
onLog: (level, message) => {
|
|
1591
|
+
if (level === "warn") warnings.push(message);
|
|
1592
|
+
},
|
|
1593
|
+
});
|
|
1594
|
+
await room.connect();
|
|
1595
|
+
|
|
1596
|
+
await room.publish(track(), "program", { simulcast: true, codec: "h264", maxBitrateKbps: 2500 });
|
|
1597
|
+
const produced = recorded.transports.find((t) => t.direction === "send")!.produced[0]!;
|
|
1598
|
+
expect(produced.codec).toBe("h264");
|
|
1599
|
+
// ONE encoding, capped: the simulcast layers were dropped, loudly.
|
|
1600
|
+
expect(produced.encodings).toEqual([{ maxBitrate: 2_500_000 }]);
|
|
1601
|
+
expect(warnings.join(" ")).toMatch(/simulcast dropped/);
|
|
1602
|
+
await room.close();
|
|
1603
|
+
});
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
describe("the planned move: a drain is a migration, not a drop", () => {
|
|
1607
|
+
/** A capture whose liveness the stash can read; `track()` has no readyState. */
|
|
1608
|
+
const liveTrack = (kind: "audio" | "video" = "audio") =>
|
|
1609
|
+
({ kind, readyState: "live", stop: vi.fn() }) as unknown as MediaStreamTrack & { stop: ReturnType<typeof vi.fn> };
|
|
1610
|
+
|
|
1611
|
+
const movingRoom = (server: FakeSignalServer, recorded: Recorded) => {
|
|
1612
|
+
const tickets: string[] = [];
|
|
1613
|
+
const room = new MediaRoom({
|
|
1614
|
+
getCredentials: () => {
|
|
1615
|
+
tickets.push(`t-${tickets.length}`);
|
|
1616
|
+
return { mediaUrl: "wss://media.example", token: tickets[tickets.length - 1]! };
|
|
1617
|
+
},
|
|
1618
|
+
device: () => fakeDevice(recorded),
|
|
1619
|
+
webSocket: server.factory,
|
|
1620
|
+
autoSubscribe: false,
|
|
1621
|
+
reconnect: { baseMs: 1, maxMs: 4, factor: 1, jitter: "none", drainingRefusalFloorMs: 5 },
|
|
1622
|
+
});
|
|
1623
|
+
return { room, tickets };
|
|
1624
|
+
};
|
|
1625
|
+
|
|
1626
|
+
it("moves at the hinted delay: leaves the old node, rejoins with a fresh ticket, republishes the SAME captures", async () => {
|
|
1627
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1628
|
+
const server = nodeWith({});
|
|
1629
|
+
const { room, tickets } = movingRoom(server, recorded);
|
|
1630
|
+
await room.connect();
|
|
1631
|
+
|
|
1632
|
+
const mic = liveTrack("audio");
|
|
1633
|
+
await room.publish(mic, "microphone", { maxBitrateKbps: 64 });
|
|
1634
|
+
const published = room.localPublications[0]!;
|
|
1635
|
+
await room.setPaused(published.producerId, true);
|
|
1636
|
+
|
|
1637
|
+
server.event({ event: "draining", reconnectAfterMs: 30 });
|
|
1638
|
+
// The call continues untouched during the jittered wait.
|
|
1639
|
+
expect(room.connectionState).toBe("connected");
|
|
1640
|
+
expect(mic.stop).not.toHaveBeenCalled();
|
|
1641
|
+
|
|
1642
|
+
await vi.waitFor(() => expect(tickets.length).toBe(2));
|
|
1643
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1644
|
+
|
|
1645
|
+
// The old node was LEFT, not abandoned: the leave request is what tears
|
|
1646
|
+
// our record down before the rejoin can meet its own ghost.
|
|
1647
|
+
expect(requestsOf(server, "leave").length).toBe(1);
|
|
1648
|
+
// The capture never stopped, and the republish carried the same source,
|
|
1649
|
+
// the same options, and the same paused state.
|
|
1650
|
+
expect(mic.stop).not.toHaveBeenCalled();
|
|
1651
|
+
await vi.waitFor(() => expect(room.localPublications).toHaveLength(1));
|
|
1652
|
+
expect(room.localPublications[0]).toMatchObject({ source: "microphone", paused: true });
|
|
1653
|
+
const produces = requestsOf(server, "produce");
|
|
1654
|
+
expect(produces).toHaveLength(2);
|
|
1655
|
+
expect(requestsOf(server, "pauseProducer").length).toBeGreaterThanOrEqual(2);
|
|
1656
|
+
// Nothing was "lost": the person is never asked to re-enable anything.
|
|
1657
|
+
expect(room.lostPublicationSources).toEqual([]);
|
|
1658
|
+
await room.close();
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
it("ignores a second draining frame: one move, however many times the node asks", async () => {
|
|
1662
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1663
|
+
const server = nodeWith({});
|
|
1664
|
+
const { room, tickets } = movingRoom(server, recorded);
|
|
1665
|
+
await room.connect();
|
|
1666
|
+
|
|
1667
|
+
server.event({ event: "draining", reconnectAfterMs: 40 });
|
|
1668
|
+
server.event({ event: "draining", reconnectAfterMs: 1 });
|
|
1669
|
+
await vi.waitFor(() => expect(tickets.length).toBe(2));
|
|
1670
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1671
|
+
await settleMs(60);
|
|
1672
|
+
|
|
1673
|
+
// Had the second frame booked its own move, a third ticket would exist.
|
|
1674
|
+
expect(tickets.length).toBe(2);
|
|
1675
|
+
await room.close();
|
|
1676
|
+
});
|
|
1677
|
+
|
|
1678
|
+
it("keeps the smooth path when the server CUTS at the deadline, honoring the jittered hint", async () => {
|
|
1679
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1680
|
+
const server = nodeWith({});
|
|
1681
|
+
const { room, tickets } = movingRoom(server, recorded);
|
|
1682
|
+
await room.connect();
|
|
1683
|
+
const camera = liveTrack("video");
|
|
1684
|
+
await room.publish(camera, "camera");
|
|
1685
|
+
|
|
1686
|
+
// The deadline eviction: the frame and the close arrive together.
|
|
1687
|
+
const before = Date.now();
|
|
1688
|
+
server.event({ event: "draining", reconnectAfterMs: 60 });
|
|
1689
|
+
server.socket.dropFromServer();
|
|
1690
|
+
|
|
1691
|
+
await vi.waitFor(() => expect(tickets.length).toBe(2));
|
|
1692
|
+
// The first attempt waited the server's hint (not the backoff ladder).
|
|
1693
|
+
expect(Date.now() - before).toBeGreaterThanOrEqual(55);
|
|
1694
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1695
|
+
expect(camera.stop).not.toHaveBeenCalled();
|
|
1696
|
+
await vi.waitFor(() => expect(room.localPublications).toHaveLength(1));
|
|
1697
|
+
await room.close();
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
it("hands a capture that ENDED during the move to the lost-sources offer instead of publishing a dead track", async () => {
|
|
1701
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1702
|
+
const server = nodeWith({});
|
|
1703
|
+
const { room } = movingRoom(server, recorded);
|
|
1704
|
+
await room.connect();
|
|
1705
|
+
const mic = liveTrack("audio");
|
|
1706
|
+
await room.publish(mic, "microphone");
|
|
1707
|
+
|
|
1708
|
+
server.event({ event: "draining", reconnectAfterMs: 10 });
|
|
1709
|
+
(mic as unknown as { readyState: string }).readyState = "ended";
|
|
1710
|
+
|
|
1711
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1712
|
+
await vi.waitFor(() => expect(room.lostPublicationSources).toEqual(["microphone"]));
|
|
1713
|
+
expect(room.localPublications).toHaveLength(0);
|
|
1714
|
+
await room.close();
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
it("cancels a scheduled move when the room ENDS first: the ordinary path owns the close", async () => {
|
|
1718
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1719
|
+
const server = nodeWith({});
|
|
1720
|
+
const { room, tickets } = movingRoom(server, recorded);
|
|
1721
|
+
await room.connect();
|
|
1722
|
+
const mic = liveTrack("audio");
|
|
1723
|
+
await room.publish(mic, "microphone");
|
|
1724
|
+
|
|
1725
|
+
// A move is booked, and then the HOST ends the call before it fires.
|
|
1726
|
+
// (A close after a draining frame reports the draining cause and takes
|
|
1727
|
+
// the smooth path by design; roomClosed is what overrides it.)
|
|
1728
|
+
server.event({ event: "draining", reconnectAfterMs: 5_000 });
|
|
1729
|
+
server.event({ event: "roomClosed", reason: "the host ended it" });
|
|
1730
|
+
server.socket.dropFromServer();
|
|
1731
|
+
|
|
1732
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("reconnecting"));
|
|
1733
|
+
expect(room.isRecovering).toBe(false);
|
|
1734
|
+
// Ordinary end-of-call semantics exactly: the capture is stopped, and
|
|
1735
|
+
// the cancelled move never books a rejoin into a finished room.
|
|
1736
|
+
expect(mic.stop).toHaveBeenCalled();
|
|
1737
|
+
await settleMs(30);
|
|
1738
|
+
expect(tickets.length).toBe(1);
|
|
1739
|
+
expect(server.sockets.length).toBe(1);
|
|
1740
|
+
await room.close();
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
it("waits out a restarting single node: patient node_draining probes, then rejoin WITH the captures", async () => {
|
|
1744
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1745
|
+
// The standalone topology: MEDIA_URL is the one node, and during its
|
|
1746
|
+
// restart every join is refused node_draining. The ladder must not burn
|
|
1747
|
+
// out against a node that was always going to come back.
|
|
1748
|
+
let refusals = 0;
|
|
1749
|
+
let seq = 0;
|
|
1750
|
+
const server: FakeSignalServer = new FakeSignalServer({
|
|
1751
|
+
autoJoin: false,
|
|
1752
|
+
onRequest: (frame, socket) => {
|
|
1753
|
+
if (frame.method === "join") {
|
|
1754
|
+
if (refusals > 0) {
|
|
1755
|
+
refusals -= 1;
|
|
1756
|
+
server.fail(frame.id, "node_draining", "restarting", socket);
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
server.completeJoin(socket, frame.id);
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
if (frame.method === "createTransport") {
|
|
1763
|
+
server.reply(
|
|
1764
|
+
frame.id,
|
|
1765
|
+
{ transportId: `t-${seq++}`, iceParameters: {}, iceCandidates: [], dtlsParameters: {} },
|
|
1766
|
+
socket,
|
|
1767
|
+
);
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
if (frame.method === "produce") {
|
|
1771
|
+
server.reply(frame.id, { producerId: `p-${seq++}` }, socket);
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
server.reply(frame.id, {}, socket);
|
|
1775
|
+
},
|
|
1776
|
+
});
|
|
1777
|
+
const { room, tickets } = movingRoom(server, recorded);
|
|
1778
|
+
await room.connect();
|
|
1779
|
+
const mic = liveTrack("audio");
|
|
1780
|
+
await room.publish(mic, "microphone");
|
|
1781
|
+
|
|
1782
|
+
// The node announces the restart, then refuses the rejoins for a while.
|
|
1783
|
+
refusals = 3;
|
|
1784
|
+
server.event({ event: "draining", reconnectAfterMs: 5 });
|
|
1785
|
+
|
|
1786
|
+
// Refusal, refusal, refusal, success: four join tickets after the first.
|
|
1787
|
+
await vi.waitFor(() => expect(tickets.length).toBe(5), { timeout: 3_000 });
|
|
1788
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1789
|
+
expect(refusals).toBe(0);
|
|
1790
|
+
// The whole wait happened with the captures alive; the rejoin brought
|
|
1791
|
+
// them back without anybody pressing anything.
|
|
1792
|
+
expect(mic.stop).not.toHaveBeenCalled();
|
|
1793
|
+
await vi.waitFor(() => expect(room.localPublications).toHaveLength(1));
|
|
1794
|
+
expect(room.lostPublicationSources).toEqual([]);
|
|
1795
|
+
await room.close();
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
it("stashes at CLOSE time, so a mute during the leave round trip survives the move", async () => {
|
|
1799
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1800
|
+
let heldLeave: { id: number; socket: FakeSocket } | undefined;
|
|
1801
|
+
const server = nodeWith({
|
|
1802
|
+
intercept: (frame, socket) => {
|
|
1803
|
+
// Hold the leave reply open: this is the window in which the person
|
|
1804
|
+
// can still change what they are sending.
|
|
1805
|
+
if (frame.method === "leave" && !heldLeave) {
|
|
1806
|
+
heldLeave = { id: frame.id, socket };
|
|
1807
|
+
return true;
|
|
1808
|
+
}
|
|
1809
|
+
return false;
|
|
1810
|
+
},
|
|
1811
|
+
});
|
|
1812
|
+
const { room } = movingRoom(server, recorded);
|
|
1813
|
+
await room.connect();
|
|
1814
|
+
const mic = liveTrack("audio");
|
|
1815
|
+
const published = await room.publish(mic, "microphone");
|
|
1816
|
+
|
|
1817
|
+
server.event({ event: "draining", reconnectAfterMs: 5 });
|
|
1818
|
+
await vi.waitFor(() => expect(heldLeave).toBeDefined());
|
|
1819
|
+
|
|
1820
|
+
// Mid-window: the mic is muted and a SECOND capture starts.
|
|
1821
|
+
await room.setPaused(published.producerId, true);
|
|
1822
|
+
const camera = liveTrack("video");
|
|
1823
|
+
await room.publish(camera, "camera");
|
|
1824
|
+
server.reply(heldLeave!.id, {}, heldLeave!.socket);
|
|
1825
|
+
|
|
1826
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1827
|
+
await vi.waitFor(() => expect(room.localPublications).toHaveLength(2));
|
|
1828
|
+
// The muted state and the late capture both crossed: no hot mic on the
|
|
1829
|
+
// new node, no orphaned camera with its light on. (The republished pause
|
|
1830
|
+
// lands one round trip after the produce, hence the wait.)
|
|
1831
|
+
await vi.waitFor(() =>
|
|
1832
|
+
expect(room.localPublications.find((p) => p.source === "microphone")?.paused).toBe(true),
|
|
1833
|
+
);
|
|
1834
|
+
expect(room.localPublications.find((p) => p.source === "camera")).toBeDefined();
|
|
1835
|
+
expect(camera.stop).not.toHaveBeenCalled();
|
|
1836
|
+
expect(room.lostPublicationSources).toEqual([]);
|
|
1837
|
+
await room.close();
|
|
1838
|
+
});
|
|
1839
|
+
|
|
1840
|
+
it("never says 'connecting' during a move: the person is mid-call, the word is 'reconnecting'", async () => {
|
|
1841
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1842
|
+
const server = nodeWith({});
|
|
1843
|
+
const { room } = movingRoom(server, recorded);
|
|
1844
|
+
await room.connect();
|
|
1845
|
+
|
|
1846
|
+
const states: string[] = [];
|
|
1847
|
+
const stop = room.onChange(() => states.push(room.connectionState));
|
|
1848
|
+
server.event({ event: "draining", reconnectAfterMs: 5 });
|
|
1849
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1850
|
+
await vi.waitFor(() => expect(states).toContain("reconnecting"));
|
|
1851
|
+
|
|
1852
|
+
expect(states).not.toContain("connecting");
|
|
1853
|
+
stop();
|
|
1854
|
+
await room.close();
|
|
1855
|
+
});
|
|
1856
|
+
|
|
1857
|
+
it("still MOVES a room whose failure recovery is off: a drain is an order, not a failure", async () => {
|
|
1858
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1859
|
+
const server = nodeWith({});
|
|
1860
|
+
const tickets: string[] = [];
|
|
1861
|
+
const room = new MediaRoom({
|
|
1862
|
+
getCredentials: () => {
|
|
1863
|
+
tickets.push(`t-${tickets.length}`);
|
|
1864
|
+
return { mediaUrl: "wss://media.example", token: tickets[tickets.length - 1]! };
|
|
1865
|
+
},
|
|
1866
|
+
device: () => fakeDevice(recorded),
|
|
1867
|
+
webSocket: server.factory,
|
|
1868
|
+
autoSubscribe: false,
|
|
1869
|
+
reconnect: { maxAttempts: 0 },
|
|
1870
|
+
});
|
|
1871
|
+
await room.connect();
|
|
1872
|
+
|
|
1873
|
+
server.event({ event: "draining", reconnectAfterMs: 5 });
|
|
1874
|
+
await vi.waitFor(() => expect(tickets.length).toBe(2));
|
|
1875
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1876
|
+
|
|
1877
|
+
// The move happened; a later ORDINARY drop still gets no automatic retry.
|
|
1878
|
+
server.socket.dropFromServer(1006);
|
|
1879
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("reconnecting"));
|
|
1880
|
+
expect(room.isRecovering).toBe(false);
|
|
1881
|
+
await settleMs(30);
|
|
1882
|
+
expect(tickets.length).toBe(2);
|
|
1883
|
+
await room.close();
|
|
1884
|
+
});
|
|
1885
|
+
|
|
1886
|
+
it("reports a move that dies for good AS a failed move, not as the refusal that ended it", async () => {
|
|
1887
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1888
|
+
let joins = 0;
|
|
1889
|
+
const server: FakeSignalServer = new FakeSignalServer({
|
|
1890
|
+
autoJoin: false,
|
|
1891
|
+
onRequest: (frame, socket) => {
|
|
1892
|
+
if (frame.method === "join") {
|
|
1893
|
+
joins += 1;
|
|
1894
|
+
if (joins > 1) {
|
|
1895
|
+
server.fail(frame.id, "capacity", "the fleet is full", socket);
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
server.completeJoin(socket, frame.id);
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
server.reply(frame.id, {}, socket);
|
|
1902
|
+
},
|
|
1903
|
+
});
|
|
1904
|
+
const room = new MediaRoom({
|
|
1905
|
+
getCredentials: () => ({ mediaUrl: "wss://media.example", token: "t" }),
|
|
1906
|
+
device: () => fakeDevice(recorded),
|
|
1907
|
+
webSocket: server.factory,
|
|
1908
|
+
autoSubscribe: false,
|
|
1909
|
+
// Failure recovery OFF: the one refused rejoin ends the move for good.
|
|
1910
|
+
reconnect: { maxAttempts: 0 },
|
|
1911
|
+
});
|
|
1912
|
+
await room.connect();
|
|
1913
|
+
|
|
1914
|
+
server.event({ event: "draining", reconnectAfterMs: 5 });
|
|
1915
|
+
await vi.waitFor(() => expect(joins).toBe(2));
|
|
1916
|
+
await vi.waitFor(() => expect(room.isRecovering).toBe(false));
|
|
1917
|
+
|
|
1918
|
+
// The screen's story is "could not move you", with a way back, because
|
|
1919
|
+
// that is what happened to the PERSON; the capacity refusal is a detail.
|
|
1920
|
+
expect(room.connectionState).toBe("reconnecting");
|
|
1921
|
+
expect(room.error).toMatchObject({ type: "draining" });
|
|
1922
|
+
await room.close();
|
|
1923
|
+
});
|
|
1924
|
+
|
|
1925
|
+
it("stops the stashed captures when the recovery gives up for good: a light never outlives its migration", async () => {
|
|
1926
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1927
|
+
const server = nodeWith({});
|
|
1928
|
+
const { room } = movingRoom(server, recorded);
|
|
1929
|
+
await room.connect();
|
|
1930
|
+
const mic = liveTrack("audio");
|
|
1931
|
+
await room.publish(mic, "microphone");
|
|
1932
|
+
|
|
1933
|
+
// Move, then make every rejoin fail terminally: the room is closed by the
|
|
1934
|
+
// person, which is the one unambiguous "stop everything".
|
|
1935
|
+
server.event({ event: "draining", reconnectAfterMs: 5 });
|
|
1936
|
+
await vi.waitFor(() => expect(requestsOf(server, "leave").length).toBe(1));
|
|
1937
|
+
await room.close();
|
|
1938
|
+
|
|
1939
|
+
expect(mic.stop).toHaveBeenCalled();
|
|
1940
|
+
expect(room.connectionState).toBe("closed");
|
|
1941
|
+
});
|
|
1942
|
+
});
|
|
1943
|
+
|
|
1944
|
+
/** Real-timer settle for paths whose timers are a few ms. */
|
|
1945
|
+
const settleMs = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1946
|
+
|
|
1947
|
+
describe("getDiagnostics: the call explained without webrtc-internals", () => {
|
|
1948
|
+
const nodeAnswer = {
|
|
1949
|
+
transports: [{ transportId: "t-1", availableOutgoingBitrate: 600_000, iceState: "connected", dtlsState: "connected" }],
|
|
1950
|
+
producers: [
|
|
1951
|
+
{
|
|
1952
|
+
producerId: "p-mine",
|
|
1953
|
+
kind: "video",
|
|
1954
|
+
paused: false,
|
|
1955
|
+
source: "camera",
|
|
1956
|
+
layers: [
|
|
1957
|
+
{ rid: "q", score: 10 },
|
|
1958
|
+
{ rid: "h", score: 7 },
|
|
1959
|
+
{ rid: "f", score: 0 },
|
|
1960
|
+
],
|
|
1961
|
+
},
|
|
1962
|
+
],
|
|
1963
|
+
consumers: [
|
|
1964
|
+
{
|
|
1965
|
+
consumerId: "c-1",
|
|
1966
|
+
producerId: "p-1",
|
|
1967
|
+
kind: "video",
|
|
1968
|
+
paused: false,
|
|
1969
|
+
preferredLayers: { spatialLayer: 2, temporalLayer: 2 },
|
|
1970
|
+
currentLayers: { spatialLayer: 0, temporalLayer: 0 },
|
|
1971
|
+
},
|
|
1972
|
+
],
|
|
1973
|
+
};
|
|
1974
|
+
|
|
1975
|
+
it("merges the node's answer into the tiles: asked, given, decoded, all in one place", async () => {
|
|
1976
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1977
|
+
const server = nodeWith({
|
|
1978
|
+
producers: [{ producerId: "p-1", identity: "a", kind: "video", source: "camera" }],
|
|
1979
|
+
intercept: (frame, socket, srv) => {
|
|
1980
|
+
if (frame.method !== "diagnostics") return false;
|
|
1981
|
+
srv.reply(frame.id, nodeAnswer, socket);
|
|
1982
|
+
return true;
|
|
1983
|
+
},
|
|
1984
|
+
});
|
|
1985
|
+
const room = roomFor(server, recorded);
|
|
1986
|
+
await room.connect();
|
|
1987
|
+
room.setViewport([{ producerId: "p-1", widthPx: 900 }]);
|
|
1988
|
+
server.event({ event: "activeSpeakers", producerIds: ["p-1"] });
|
|
1989
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(1));
|
|
1990
|
+
|
|
1991
|
+
const first = await room.getDiagnostics();
|
|
1992
|
+
const tile = first.tiles[0]!;
|
|
1993
|
+
// The three-way answer on one line: this client asked full (900px tile),
|
|
1994
|
+
// the node is giving quarter, and the estimate names the culprit.
|
|
1995
|
+
expect(tile).toMatchObject({
|
|
1996
|
+
producerId: "p-1",
|
|
1997
|
+
identity: "a",
|
|
1998
|
+
source: "camera",
|
|
1999
|
+
requestedLayer: 2,
|
|
2000
|
+
currentLayer: 0,
|
|
2001
|
+
viewportWidthPx: 900,
|
|
2002
|
+
frameWidth: 320,
|
|
2003
|
+
frameHeight: 180,
|
|
2004
|
+
framesPerSecond: 24,
|
|
2005
|
+
});
|
|
2006
|
+
expect(first.availableOutgoingBitrate).toBe(600_000);
|
|
2007
|
+
// The publisher-side half: my own camera's f layer is not arriving.
|
|
2008
|
+
expect(first.localProducers?.[0]?.layers).toEqual([
|
|
2009
|
+
{ rid: "q", score: 10 },
|
|
2010
|
+
{ rid: "h", score: 7 },
|
|
2011
|
+
{ rid: "f", score: 0 },
|
|
2012
|
+
]);
|
|
2013
|
+
// Bitrate needs a delta, so the FIRST read has none and the second does.
|
|
2014
|
+
expect(tile.bitrateKbps).toBeUndefined();
|
|
2015
|
+
await settleMs(15);
|
|
2016
|
+
const second = await room.getDiagnostics();
|
|
2017
|
+
expect(second.tiles[0]?.bitrateKbps).toBeGreaterThan(0);
|
|
2018
|
+
await room.close();
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
it("still answers the LOCAL half against a node too old for the verb", async () => {
|
|
2022
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
2023
|
+
const server = nodeWith({
|
|
2024
|
+
producers: [{ producerId: "p-1", identity: "a", kind: "video" }],
|
|
2025
|
+
intercept: (frame, socket, srv) => {
|
|
2026
|
+
if (frame.method !== "diagnostics") return false;
|
|
2027
|
+
srv.fail(frame.id, "bad_request", "unknown method", socket);
|
|
2028
|
+
return true;
|
|
2029
|
+
},
|
|
2030
|
+
});
|
|
2031
|
+
const room = roomFor(server, recorded);
|
|
2032
|
+
await room.connect();
|
|
2033
|
+
room.setViewport([{ producerId: "p-1", widthPx: 200 }]);
|
|
2034
|
+
server.event({ event: "activeSpeakers", producerIds: ["p-1"] });
|
|
2035
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(1));
|
|
2036
|
+
|
|
2037
|
+
const diagnostics = await room.getDiagnostics();
|
|
2038
|
+
expect(diagnostics.tiles[0]).toMatchObject({ producerId: "p-1", requestedLayer: 0, frameWidth: 320 });
|
|
2039
|
+
expect(diagnostics.availableOutgoingBitrate).toBeUndefined();
|
|
2040
|
+
expect(diagnostics.node).toBeUndefined();
|
|
2041
|
+
|
|
2042
|
+
// And the verb is remembered as unsupported: no second ask.
|
|
2043
|
+
await room.getDiagnostics();
|
|
2044
|
+
expect(requestsOf(server, "diagnostics")).toHaveLength(1);
|
|
2045
|
+
await room.close();
|
|
2046
|
+
});
|
|
2047
|
+
});
|