@tribe-nest/media-client 0.1.0 → 0.2.1
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/README.md +20 -4
- package/build/core/reconnect.d.ts +8 -2
- package/build/core/reconnect.d.ts.map +1 -1
- package/build/core/reconnect.js +8 -2
- package/build/core/reconnect.js.map +1 -1
- package/build/core/signal.d.ts +10 -1
- package/build/core/signal.d.ts.map +1 -1
- package/build/core/signal.js +39 -10
- package/build/core/signal.js.map +1 -1
- package/build/core/state.d.ts +3 -0
- package/build/core/state.d.ts.map +1 -1
- package/build/core/state.js +21 -4
- package/build/core/state.js.map +1 -1
- package/build/react/index.d.ts +38 -7
- package/build/react/index.d.ts.map +1 -1
- package/build/react/index.js +86 -7
- 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 +13 -0
- package/build/room/device.d.ts.map +1 -1
- package/build/room/room.d.ts +129 -5
- package/build/room/room.d.ts.map +1 -1
- package/build/room/room.js +330 -58
- package/build/room/room.js.map +1 -1
- package/package.json +3 -1
- package/src/core/_tests/signal.spec.ts +115 -22
- package/src/core/_tests/state.spec.ts +68 -2
- package/src/core/reconnect.ts +8 -2
- package/src/core/signal.ts +48 -12
- package/src/core/state.ts +31 -5
- package/src/react/_tests/hooks.spec.tsx +91 -3
- package/src/react/index.tsx +117 -20
- package/src/room/_tests/room.spec.ts +747 -12
- package/src/room/browserDevice.ts +14 -4
- package/src/room/device.ts +13 -0
- package/src/room/room.ts +416 -61
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { MediaError, type EventFrame, type RequestFrame } from "@tribe-nest/media-protocol";
|
|
1
2
|
import { describe, expect, it, vi } from "vitest";
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import type { DisconnectCause } from "../../core";
|
|
5
|
+
import { FakeSignalServer, flush, type FakeSocket } from "../../core/_tests/fakeSignalServer";
|
|
6
|
+
import { MediaRoom, type ConnectionState } from "../room";
|
|
5
7
|
import type {
|
|
6
8
|
IceServer,
|
|
7
9
|
MediaConsumerHandle,
|
|
@@ -24,7 +26,15 @@ import type {
|
|
|
24
26
|
type Recorded = { calls: string[]; transports: FakeTransport[] };
|
|
25
27
|
|
|
26
28
|
class FakeTransport implements MediaTransport {
|
|
27
|
-
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;
|
|
28
38
|
readonly consumed: string[] = [];
|
|
29
39
|
closed = false;
|
|
30
40
|
handlers: Partial<TransportHandlers> = {};
|
|
@@ -35,8 +45,19 @@ class FakeTransport implements MediaTransport {
|
|
|
35
45
|
private readonly recorded: Recorded,
|
|
36
46
|
) {}
|
|
37
47
|
|
|
38
|
-
async produce(input: {
|
|
39
|
-
|
|
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
|
+
});
|
|
40
61
|
// The real transport asks the application to tell the node, and the node's
|
|
41
62
|
// answer is the producer id. Modelled, because the round trip is the thing
|
|
42
63
|
// that can be got wrong.
|
|
@@ -53,6 +74,10 @@ class FakeTransport implements MediaTransport {
|
|
|
53
74
|
pause: vi.fn(),
|
|
54
75
|
resume: vi.fn(),
|
|
55
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,
|
|
56
81
|
};
|
|
57
82
|
}
|
|
58
83
|
|
|
@@ -93,7 +118,14 @@ function fakeDevice(recorded: Recorded) {
|
|
|
93
118
|
return { codecs: [] } as never;
|
|
94
119
|
},
|
|
95
120
|
canProduce: () => true,
|
|
96
|
-
createSendTransport({
|
|
121
|
+
createSendTransport({
|
|
122
|
+
description,
|
|
123
|
+
handlers,
|
|
124
|
+
}: {
|
|
125
|
+
description: TransportDescription;
|
|
126
|
+
iceServers: IceServer[];
|
|
127
|
+
handlers: TransportHandlers;
|
|
128
|
+
}) {
|
|
97
129
|
const transport = new FakeTransport(description.transportId, "send", recorded);
|
|
98
130
|
transport.handlers = handlers;
|
|
99
131
|
recorded.transports.push(transport);
|
|
@@ -118,14 +150,24 @@ function fakeDevice(recorded: Recorded) {
|
|
|
118
150
|
* the room is exercised against the same wire the signal client is.
|
|
119
151
|
*/
|
|
120
152
|
function nodeWith(input: {
|
|
121
|
-
producers?: { producerId: string; identity: string; kind: "audio" | "video" }[];
|
|
153
|
+
producers?: { producerId: string; identity: string; kind: "audio" | "video"; source?: string }[];
|
|
122
154
|
iceServers?: IceServer[];
|
|
155
|
+
/**
|
|
156
|
+
* Events delivered on EVERY connection after the join reply and BEFORE the
|
|
157
|
+
* `joined` snapshot. The contract fixes no order between `joined` and the
|
|
158
|
+
* node's first broadcasts, and a real node sends its active set the moment a
|
|
159
|
+
* participant is in the room.
|
|
160
|
+
*/
|
|
161
|
+
beforeJoined?: EventFrame[];
|
|
162
|
+
/** Answers a request itself by returning true; anything else falls through to the defaults. */
|
|
163
|
+
intercept?: (frame: RequestFrame, socket: FakeSocket, server: FakeSignalServer) => boolean;
|
|
123
164
|
}): FakeSignalServer {
|
|
124
165
|
let transportSeq = 0;
|
|
125
166
|
let producerSeq = 0;
|
|
126
167
|
let consumerSeq = 0;
|
|
127
168
|
|
|
128
169
|
const server: FakeSignalServer = new FakeSignalServer({
|
|
170
|
+
...(input.beforeJoined ? { autoJoin: false } : {}),
|
|
129
171
|
joined: {
|
|
130
172
|
identity: "me",
|
|
131
173
|
room: "matter-1",
|
|
@@ -135,6 +177,7 @@ function nodeWith(input: {
|
|
|
135
177
|
grants: { canPublish: true, canSubscribe: true, canPublishData: false },
|
|
136
178
|
},
|
|
137
179
|
onRequest: (frame, socket) => {
|
|
180
|
+
if (input.intercept?.(frame, socket, server)) return;
|
|
138
181
|
switch (frame.method) {
|
|
139
182
|
case "createTransport":
|
|
140
183
|
return server.reply(
|
|
@@ -153,6 +196,11 @@ function nodeWith(input: {
|
|
|
153
196
|
socket,
|
|
154
197
|
);
|
|
155
198
|
case "join":
|
|
199
|
+
if (input.beforeJoined) {
|
|
200
|
+
server.reply(frame.id, { accepted: true }, socket);
|
|
201
|
+
for (const event of input.beforeJoined) server.event(event, socket);
|
|
202
|
+
server.event(server.joinedFrame(), socket);
|
|
203
|
+
}
|
|
156
204
|
return;
|
|
157
205
|
default:
|
|
158
206
|
return server.reply(frame.id, {}, socket);
|
|
@@ -203,7 +251,8 @@ const recoveringRoom = (
|
|
|
203
251
|
return { room, tickets };
|
|
204
252
|
};
|
|
205
253
|
|
|
206
|
-
const track = () =>
|
|
254
|
+
const track = () =>
|
|
255
|
+
({ kind: "audio", stop: vi.fn() }) as unknown as MediaStreamTrack & { stop: ReturnType<typeof vi.fn> };
|
|
207
256
|
|
|
208
257
|
describe("rule 1: the device loads before anything needs its capabilities", () => {
|
|
209
258
|
it("loads on join, before any transport", async () => {
|
|
@@ -257,6 +306,39 @@ describe("rule 2: one transport per direction, created lazily", () => {
|
|
|
257
306
|
await room.close();
|
|
258
307
|
});
|
|
259
308
|
|
|
309
|
+
it("keeps the PROGRAM feed's resolution, giving up frame rate instead", async () => {
|
|
310
|
+
/**
|
|
311
|
+
* Chrome's default scales the encode down under a low bandwidth estimate
|
|
312
|
+
* or CPU pressure. For the program feed that is wrong twice: it is the
|
|
313
|
+
* broadcast itself, so 1080p arriving as 270p is the product; and the RTMP
|
|
314
|
+
* leg muxes it with `-c:v copy`, where an FLV header carries the dimensions
|
|
315
|
+
* once. A mid-stream resolution change leaves the SPS describing one size
|
|
316
|
+
* and the header another, and the player refuses it with "error #3000"
|
|
317
|
+
* while ffmpeg reports a healthy bitrate and zero dropped frames.
|
|
318
|
+
*/
|
|
319
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
320
|
+
const room = roomFor(nodeWith({}), recorded, false);
|
|
321
|
+
await room.connect();
|
|
322
|
+
|
|
323
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "program");
|
|
324
|
+
|
|
325
|
+
const transport = recorded.transports[0] as unknown as { senderParams: Record<string, unknown> };
|
|
326
|
+
expect(transport.senderParams.degradationPreference).toBe("maintain-resolution");
|
|
327
|
+
await room.close();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("leaves a CAMERA alone, which is better off shrinking than freezing", async () => {
|
|
331
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
332
|
+
const room = roomFor(nodeWith({}), recorded, false);
|
|
333
|
+
await room.connect();
|
|
334
|
+
|
|
335
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
|
|
336
|
+
|
|
337
|
+
const transport = recorded.transports[0] as unknown as { senderParams: Record<string, unknown> };
|
|
338
|
+
expect(transport.senderParams.degradationPreference).toBeUndefined();
|
|
339
|
+
await room.close();
|
|
340
|
+
});
|
|
341
|
+
|
|
260
342
|
it("reuses the recv transport across subscriptions", async () => {
|
|
261
343
|
const recorded: Recorded = { calls: [], transports: [] };
|
|
262
344
|
const server = nodeWith({
|
|
@@ -274,6 +356,92 @@ describe("rule 2: one transport per direction, created lazily", () => {
|
|
|
274
356
|
expect(recorded.calls.filter((c) => c === "create-recv-transport")).toHaveLength(1);
|
|
275
357
|
await room.close();
|
|
276
358
|
});
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* "Once" under CONCURRENCY, which is the shape that actually occurs.
|
|
362
|
+
*
|
|
363
|
+
* `syncSubscriptions` fans out with `Promise.all`, so one `activeSpeakers`
|
|
364
|
+
* frame naming four producers puts four subscribes through
|
|
365
|
+
* `ensureRecvTransport` in the same tick. Checking the finished transport and
|
|
366
|
+
* then awaiting the round trip let every one of them pass the check and send
|
|
367
|
+
* its own `createTransport(recv)`. The node caps transports per session, so
|
|
368
|
+
* the burst spent the whole allowance on duplicates and the person's own
|
|
369
|
+
* Unmute was refused with `capacity` for the rest of the call.
|
|
370
|
+
*/
|
|
371
|
+
it("sends ONE createTransport per direction when several subscribes and publishes land in the same tick", async () => {
|
|
372
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
373
|
+
const server = nodeWith({
|
|
374
|
+
producers: [
|
|
375
|
+
{ producerId: "p-a", identity: "a", kind: "audio" },
|
|
376
|
+
{ producerId: "p-b", identity: "b", kind: "audio" },
|
|
377
|
+
],
|
|
378
|
+
});
|
|
379
|
+
const room = roomFor(server, recorded, false);
|
|
380
|
+
await room.connect();
|
|
381
|
+
|
|
382
|
+
// Fired, not awaited in turn: all four are in flight before any reply.
|
|
383
|
+
await Promise.all([
|
|
384
|
+
room.subscribe("p-a"),
|
|
385
|
+
room.subscribe("p-b"),
|
|
386
|
+
room.publish({ kind: "audio", stop: vi.fn() } as never, "microphone"),
|
|
387
|
+
room.publish({ kind: "video", stop: vi.fn() } as never, "camera"),
|
|
388
|
+
]);
|
|
389
|
+
|
|
390
|
+
// Asserted on the WIRE, because that is what the node counts.
|
|
391
|
+
const created = requestsOf(server, "createTransport") as { direction: "send" | "recv" }[];
|
|
392
|
+
expect(created.filter((r) => r.direction === "recv")).toHaveLength(1);
|
|
393
|
+
expect(created.filter((r) => r.direction === "send")).toHaveLength(1);
|
|
394
|
+
expect(recorded.calls.filter((c) => c === "create-recv-transport")).toHaveLength(1);
|
|
395
|
+
expect(recorded.calls.filter((c) => c === "create-send-transport")).toHaveLength(1);
|
|
396
|
+
// And everything still landed on the one transport per direction.
|
|
397
|
+
expect(room.tracks.map((t) => t.producerId).sort()).toEqual(["p-a", "p-b"]);
|
|
398
|
+
expect(room.localPublications).toHaveLength(2);
|
|
399
|
+
await room.close();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("sends ONE createTransport(recv) for an activeSpeakers frame naming several producers", async () => {
|
|
403
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
404
|
+
const producers = ["p-1", "p-2", "p-3", "p-4"].map((producerId) => ({
|
|
405
|
+
producerId,
|
|
406
|
+
identity: producerId,
|
|
407
|
+
kind: "audio" as const,
|
|
408
|
+
}));
|
|
409
|
+
const server = nodeWith({ producers });
|
|
410
|
+
const room = roomFor(server, recorded);
|
|
411
|
+
await room.connect();
|
|
412
|
+
|
|
413
|
+
server.event({ event: "activeSpeakers", producerIds: producers.map((p) => p.producerId) });
|
|
414
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(4));
|
|
415
|
+
|
|
416
|
+
expect(requestsOf(server, "createTransport")).toHaveLength(1);
|
|
417
|
+
await room.close();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("forgets a creation the node refused, so the next caller tries again", async () => {
|
|
421
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
422
|
+
let refuseNext = true;
|
|
423
|
+
const server = nodeWith({
|
|
424
|
+
producers: [{ producerId: "p-a", identity: "a", kind: "audio" }],
|
|
425
|
+
intercept: (frame, socket, srv) => {
|
|
426
|
+
if (frame.method !== "createTransport" || !refuseNext) return false;
|
|
427
|
+
refuseNext = false;
|
|
428
|
+
srv.fail(frame.id, "capacity", "no", socket);
|
|
429
|
+
return true;
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
const room = roomFor(server, recorded, false);
|
|
433
|
+
await room.connect();
|
|
434
|
+
|
|
435
|
+
await expect(room.subscribe("p-a")).rejects.toMatchObject({ code: "capacity" });
|
|
436
|
+
// A memo that kept the rejection would refuse every later subscribe with
|
|
437
|
+
// the same stale answer, when the node might well say yes now.
|
|
438
|
+
await room.subscribe("p-a");
|
|
439
|
+
|
|
440
|
+
expect(requestsOf(server, "createTransport")).toHaveLength(2);
|
|
441
|
+
expect(recorded.calls.filter((c) => c === "create-recv-transport")).toHaveLength(1);
|
|
442
|
+
expect(room.tracks).toHaveLength(1);
|
|
443
|
+
await room.close();
|
|
444
|
+
});
|
|
277
445
|
});
|
|
278
446
|
|
|
279
447
|
describe("rule 3: a consumer resumes only after its track exists", () => {
|
|
@@ -309,6 +477,48 @@ describe("rule 3: a consumer resumes only after its track exists", () => {
|
|
|
309
477
|
});
|
|
310
478
|
});
|
|
311
479
|
|
|
480
|
+
/**
|
|
481
|
+
* The publisher's label reaches the tile.
|
|
482
|
+
*
|
|
483
|
+
* The node relays `appData.source` as `source` on the snapshot and on every
|
|
484
|
+
* `producerAppeared`, and the room keeps it on the entry. A consumed track
|
|
485
|
+
* carries it so a screen share, a camera and a program feed can be laid out
|
|
486
|
+
* differently; a producer the node relayed no label for carries none.
|
|
487
|
+
*/
|
|
488
|
+
describe("a consumed track carries the publisher's source", () => {
|
|
489
|
+
it("from the join snapshot", async () => {
|
|
490
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
491
|
+
const server = nodeWith({
|
|
492
|
+
producers: [
|
|
493
|
+
{ producerId: "p-a", identity: "anwalt", kind: "video", source: "screen" },
|
|
494
|
+
{ producerId: "p-b", identity: "anwalt", kind: "audio" },
|
|
495
|
+
],
|
|
496
|
+
});
|
|
497
|
+
const room = roomFor(server, recorded, false);
|
|
498
|
+
await room.connect();
|
|
499
|
+
await room.subscribe("p-a");
|
|
500
|
+
await room.subscribe("p-b");
|
|
501
|
+
|
|
502
|
+
expect(room.tracks.find((t) => t.producerId === "p-a")).toMatchObject({ identity: "anwalt", source: "screen" });
|
|
503
|
+
expect(room.tracks.find((t) => t.producerId === "p-b")).not.toHaveProperty("source");
|
|
504
|
+
await room.close();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("from a producerAppeared announced after the join", async () => {
|
|
508
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
509
|
+
const server = nodeWith({});
|
|
510
|
+
const room = roomFor(server, recorded, false);
|
|
511
|
+
await room.connect();
|
|
512
|
+
|
|
513
|
+
server.event({ event: "producerAppeared", producerId: "p-c", identity: "studio", kind: "video", source: "program" });
|
|
514
|
+
await flush();
|
|
515
|
+
await room.subscribe("p-c");
|
|
516
|
+
|
|
517
|
+
expect(room.tracks[0]).toMatchObject({ producerId: "p-c", identity: "studio", source: "program" });
|
|
518
|
+
await room.close();
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
312
522
|
describe("rule 4: autoSubscribe follows the ACTIVE SET", () => {
|
|
313
523
|
it("subscribes only to what the node put in the set", async () => {
|
|
314
524
|
const recorded: Recorded = { calls: [], transports: [] };
|
|
@@ -330,10 +540,11 @@ describe("rule 4: autoSubscribe follows the ACTIVE SET", () => {
|
|
|
330
540
|
|
|
331
541
|
// Subscribing to everything would produce one `subscription_limit` refusal
|
|
332
542
|
// per producer outside the set, and show nothing for the trouble.
|
|
333
|
-
expect(
|
|
334
|
-
"
|
|
335
|
-
|
|
336
|
-
|
|
543
|
+
expect(
|
|
544
|
+
requestsOf(server, "consume")
|
|
545
|
+
.map((r) => (r as { producerId: string }).producerId)
|
|
546
|
+
.sort(),
|
|
547
|
+
).toEqual(["p-1", "p-3"]);
|
|
337
548
|
await room.close();
|
|
338
549
|
});
|
|
339
550
|
|
|
@@ -408,6 +619,53 @@ describe("publishing", () => {
|
|
|
408
619
|
});
|
|
409
620
|
});
|
|
410
621
|
|
|
622
|
+
/**
|
|
623
|
+
* A publication can say how it wants to be encoded.
|
|
624
|
+
*
|
|
625
|
+
* A camera takes the browser's defaults and that is right. A composed program
|
|
626
|
+
* feed does not: an RTMP restream needs H.264, because RTMP carries nothing
|
|
627
|
+
* else and the egress copies rather than transcodes, and a 1080p canvas at the
|
|
628
|
+
* default bitrate is a smear. Both are facts about ONE publication, so they
|
|
629
|
+
* travel with the `publish` call rather than with the room.
|
|
630
|
+
*/
|
|
631
|
+
describe("publishing with options", () => {
|
|
632
|
+
it("passes the codec and a bitrate cap to the transport", async () => {
|
|
633
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
634
|
+
const server = nodeWith({});
|
|
635
|
+
const room = roomFor(server, recorded, false);
|
|
636
|
+
await room.connect();
|
|
637
|
+
|
|
638
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "program", { codec: "h264", maxBitrateKbps: 3500 });
|
|
639
|
+
|
|
640
|
+
const [produced] = recorded.transports.find((t) => t.direction === "send")!.produced;
|
|
641
|
+
expect(produced).toEqual({
|
|
642
|
+
appData: { source: "program" },
|
|
643
|
+
codec: "h264",
|
|
644
|
+
// kbit/s at the API, bit/s at the encoder. The unit is the whole bug.
|
|
645
|
+
encodings: [{ maxBitrate: 3_500_000 }],
|
|
646
|
+
});
|
|
647
|
+
// And the node still hears the label it checks grants against.
|
|
648
|
+
const produce = requestsOf(server, "produce")[0] as { appData?: { source?: string } };
|
|
649
|
+
expect(produce.appData?.source).toBe("program");
|
|
650
|
+
await room.close();
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("passes neither when no options are given, so the defaults stay the browser's", async () => {
|
|
654
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
655
|
+
const server = nodeWith({});
|
|
656
|
+
const room = roomFor(server, recorded, false);
|
|
657
|
+
await room.connect();
|
|
658
|
+
|
|
659
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
|
|
660
|
+
|
|
661
|
+
const [produced] = recorded.transports.find((t) => t.direction === "send")!.produced;
|
|
662
|
+
expect(produced).toEqual({ appData: { source: "camera" } });
|
|
663
|
+
expect(produced).not.toHaveProperty("codec");
|
|
664
|
+
expect(produced).not.toHaveProperty("encodings");
|
|
665
|
+
await room.close();
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
411
669
|
/**
|
|
412
670
|
* A state called "reconnecting" has to mean something is reconnecting.
|
|
413
671
|
*
|
|
@@ -513,6 +771,146 @@ describe("the room gets itself back in", () => {
|
|
|
513
771
|
});
|
|
514
772
|
});
|
|
515
773
|
|
|
774
|
+
/**
|
|
775
|
+
* A ticket the room could not get is a failed attempt, not a frozen one.
|
|
776
|
+
*
|
|
777
|
+
* `getCredentials` is a network round trip to the ticket endpoint, and it
|
|
778
|
+
* fails for ordinary reasons: a 409 at the edge of the booking window because
|
|
779
|
+
* the client's clock is ahead, a 401 after the session expired, a flaky
|
|
780
|
+
* network. Before this, that rejection happened before a socket existed, so
|
|
781
|
+
* nothing ever closed, `onClose` never fired, and the room stayed on
|
|
782
|
+
* "connecting" (first join) or "reconnecting" (ladder) with no retry booked, no
|
|
783
|
+
* error to show and every later `connect()` refused as "already connecting".
|
|
784
|
+
* The screen above drew a spinner for the rest of the session.
|
|
785
|
+
*/
|
|
786
|
+
describe("a credential fetch that fails", () => {
|
|
787
|
+
/** A ticket endpoint that fails on the attempts named, and counts its calls. */
|
|
788
|
+
const ticketEndpoint = (failOn: number[], error: () => unknown = () => new Error("HTTP 409: window not open")) => {
|
|
789
|
+
let calls = 0;
|
|
790
|
+
return {
|
|
791
|
+
get calls() {
|
|
792
|
+
return calls;
|
|
793
|
+
},
|
|
794
|
+
getCredentials: async () => {
|
|
795
|
+
calls += 1;
|
|
796
|
+
if (failOn.includes(calls)) throw error();
|
|
797
|
+
return { mediaUrl: "wss://media.example", token: `t-${calls}` };
|
|
798
|
+
},
|
|
799
|
+
};
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
it("on the FIRST join surfaces the error and books a retry rather than wedging on connecting", async () => {
|
|
803
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
804
|
+
const server = nodeWith({});
|
|
805
|
+
const tickets = ticketEndpoint([1]);
|
|
806
|
+
const { room } = recoveringRoom(server, recorded, { getCredentials: tickets.getCredentials });
|
|
807
|
+
|
|
808
|
+
await expect(room.connect()).rejects.toThrow(/HTTP 409/);
|
|
809
|
+
|
|
810
|
+
// Not "connecting": nothing is connecting. A retryable failure with a retry
|
|
811
|
+
// booked, and the cause on the room for the screen to show.
|
|
812
|
+
expect(room.connectionState).toBe("reconnecting");
|
|
813
|
+
expect(room.error).toEqual({ type: "socket_closed", reason: "HTTP 409: window not open" });
|
|
814
|
+
expect(room.isRecovering).toBe(true);
|
|
815
|
+
// No socket was ever opened for the failed attempt.
|
|
816
|
+
expect(server.sockets).toHaveLength(0);
|
|
817
|
+
|
|
818
|
+
// The booked retry fetches a FRESH ticket and gets in.
|
|
819
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
820
|
+
expect(tickets.calls).toBe(2);
|
|
821
|
+
expect(server.sockets).toHaveLength(1);
|
|
822
|
+
expect(room.error).toBeUndefined();
|
|
823
|
+
await room.close();
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it("with retries off leaves the room retryable BY HAND rather than refusing the next connect", async () => {
|
|
827
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
828
|
+
const server = nodeWith({});
|
|
829
|
+
const tickets = ticketEndpoint([1]);
|
|
830
|
+
const { room } = recoveringRoom(server, recorded, {
|
|
831
|
+
getCredentials: tickets.getCredentials,
|
|
832
|
+
reconnect: { maxAttempts: 0 },
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
await expect(room.connect()).rejects.toThrow(/HTTP 409/);
|
|
836
|
+
expect(room.connectionState).toBe("reconnecting");
|
|
837
|
+
expect(room.isRecovering).toBe(false);
|
|
838
|
+
expect(room.error).toMatchObject({ type: "socket_closed" });
|
|
839
|
+
|
|
840
|
+
// The person presses "Try again". Before the fix this was refused with
|
|
841
|
+
// "already connecting or connected" for the rest of the session.
|
|
842
|
+
await room.connect();
|
|
843
|
+
|
|
844
|
+
expect(room.connectionState).toBe("connected");
|
|
845
|
+
expect(room.error).toBeUndefined();
|
|
846
|
+
expect(tickets.calls).toBe(2);
|
|
847
|
+
await room.close();
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it("on a LATER rung of the ladder is one failed attempt, and the ladder carries on", async () => {
|
|
851
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
852
|
+
const server = nodeWith({});
|
|
853
|
+
const tickets = ticketEndpoint([2]);
|
|
854
|
+
const { room } = recoveringRoom(server, recorded, { getCredentials: tickets.getCredentials });
|
|
855
|
+
const seen: { connectionState: ConnectionState; error: DisconnectCause | undefined; recovering: boolean }[] = [];
|
|
856
|
+
room.onChange(() =>
|
|
857
|
+
seen.push({ connectionState: room.connectionState, error: room.error, recovering: room.isRecovering }),
|
|
858
|
+
);
|
|
859
|
+
await room.connect();
|
|
860
|
+
|
|
861
|
+
server.socket.dropFromServer();
|
|
862
|
+
|
|
863
|
+
await vi.waitFor(() => expect(server.sockets.length).toBe(2));
|
|
864
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
865
|
+
// Three tickets: the original, the one the endpoint refused, and the one
|
|
866
|
+
// that got back in.
|
|
867
|
+
expect(tickets.calls).toBe(3);
|
|
868
|
+
// While the endpoint was refusing, the room said so and said it was still
|
|
869
|
+
// trying, rather than sitting on the drop's cause with nothing booked.
|
|
870
|
+
expect(seen).toContainEqual({
|
|
871
|
+
connectionState: "reconnecting",
|
|
872
|
+
error: { type: "socket_closed", reason: "HTTP 409: window not open" },
|
|
873
|
+
recovering: true,
|
|
874
|
+
});
|
|
875
|
+
await room.close();
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it("with a refusal the policy will not retry, shows the refusal and stops", async () => {
|
|
879
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
880
|
+
const server = nodeWith({});
|
|
881
|
+
// The endpoint saying "you may not join this room" is `refused`, and a
|
|
882
|
+
// terminal code is not retried: a loop against the ticket endpoint would
|
|
883
|
+
// only repeat the answer.
|
|
884
|
+
const tickets = ticketEndpoint([1, 2, 3], () => new MediaError("forbidden", "this seat is sealed"));
|
|
885
|
+
const { room } = recoveringRoom(server, recorded, { getCredentials: tickets.getCredentials });
|
|
886
|
+
|
|
887
|
+
await expect(room.connect()).rejects.toMatchObject({ code: "forbidden" });
|
|
888
|
+
|
|
889
|
+
expect(room.connectionState).toBe("reconnecting");
|
|
890
|
+
expect(room.isRecovering).toBe(false);
|
|
891
|
+
expect(room.error).toEqual({ type: "refused", code: "forbidden", message: "this seat is sealed" });
|
|
892
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
893
|
+
expect(tickets.calls).toBe(1);
|
|
894
|
+
expect(server.sockets).toHaveLength(0);
|
|
895
|
+
await room.close();
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it("does not put a live room back to connecting when connect() is called twice", async () => {
|
|
899
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
900
|
+
const room = roomFor(nodeWith({}), recorded, false);
|
|
901
|
+
await room.connect();
|
|
902
|
+
|
|
903
|
+
await expect(room.connect()).rejects.toThrow(/already connecting or connected/);
|
|
904
|
+
|
|
905
|
+
// The refusal is not a disconnect: the first connection is still up and the
|
|
906
|
+
// room has to keep saying so.
|
|
907
|
+
expect(room.connectionState).toBe("connected");
|
|
908
|
+
expect(room.error).toBeUndefined();
|
|
909
|
+
expect(room.state.phase).toBe("joined");
|
|
910
|
+
await room.close();
|
|
911
|
+
});
|
|
912
|
+
});
|
|
913
|
+
|
|
516
914
|
/**
|
|
517
915
|
* The camera light goes out whenever the connection does.
|
|
518
916
|
*
|
|
@@ -593,3 +991,340 @@ describe("what the room carries for rendering", () => {
|
|
|
593
991
|
await room.close();
|
|
594
992
|
});
|
|
595
993
|
});
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* A track that can end on its own, the way a browser's does.
|
|
997
|
+
*
|
|
998
|
+
* `MediaStreamTrack` is an `EventTarget`, and `ended` is what fires when the
|
|
999
|
+
* SOURCE goes away: the browser's own "Stop sharing" bar, a camera unplugged.
|
|
1000
|
+
* It does not fire for our own `stop()`, which the fake also honours.
|
|
1001
|
+
*/
|
|
1002
|
+
const endableTrack = (kind: "audio" | "video" = "video") => {
|
|
1003
|
+
const target = new EventTarget() as EventTarget & { kind: string; stop: ReturnType<typeof vi.fn> };
|
|
1004
|
+
target.kind = kind;
|
|
1005
|
+
target.stop = vi.fn();
|
|
1006
|
+
return target as unknown as MediaStreamTrack & { stop: ReturnType<typeof vi.fn> };
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* The node's first active set can land BEFORE the join snapshot.
|
|
1011
|
+
*
|
|
1012
|
+
* The reducer used to reset `activeSpeakers` to `[]` on `joined`, so a set the
|
|
1013
|
+
* node had already sent was discarded and the client consumed nothing until
|
|
1014
|
+
* the next speaker change. In a quiet 1:1 call that change is the other person
|
|
1015
|
+
* starting to talk, which is precisely when a missing subscription is noticed.
|
|
1016
|
+
*/
|
|
1017
|
+
describe("an active set that arrives ahead of joined", () => {
|
|
1018
|
+
it("is applied once the room is in, rather than discarded", async () => {
|
|
1019
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1020
|
+
const server = nodeWith({
|
|
1021
|
+
producers: [{ producerId: "p-a", identity: "a", kind: "audio" }],
|
|
1022
|
+
beforeJoined: [{ event: "activeSpeakers", producerIds: ["p-a"] }],
|
|
1023
|
+
});
|
|
1024
|
+
const room = roomFor(server, recorded);
|
|
1025
|
+
|
|
1026
|
+
await room.connect();
|
|
1027
|
+
|
|
1028
|
+
expect(room.state.activeSpeakers).toEqual(["p-a"]);
|
|
1029
|
+
await vi.waitFor(() => expect(room.tracks.map((t) => t.producerId)).toEqual(["p-a"]));
|
|
1030
|
+
await room.close();
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
it("does not carry a set from BEFORE a drop into the rejoin", async () => {
|
|
1034
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1035
|
+
const server = nodeWith({ producers: [{ producerId: "p-a", identity: "a", kind: "audio" }] });
|
|
1036
|
+
const { room } = recoveringRoom(server, recorded);
|
|
1037
|
+
await room.connect();
|
|
1038
|
+
server.event({ event: "activeSpeakers", producerIds: ["p-a"] });
|
|
1039
|
+
await flush();
|
|
1040
|
+
expect(room.state.activeSpeakers).toEqual(["p-a"]);
|
|
1041
|
+
|
|
1042
|
+
server.socket.dropFromServer();
|
|
1043
|
+
await flush();
|
|
1044
|
+
// Gone with the consumers it described. Kept, the next `joined` would read
|
|
1045
|
+
// it as a set the NEW node had sent ahead of its snapshot.
|
|
1046
|
+
expect(room.state.activeSpeakers).toEqual([]);
|
|
1047
|
+
|
|
1048
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1049
|
+
expect(room.state.activeSpeakers).toEqual([]);
|
|
1050
|
+
await room.close();
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Leave means leave, even while the node is asking to be left.
|
|
1056
|
+
*
|
|
1057
|
+
* `draining` is recorded as the terminal cause the moment the frame arrives,
|
|
1058
|
+
* seconds before the node hangs up. A person who pressed Leave inside that
|
|
1059
|
+
* window had their close reported as the DRAIN: the room went to
|
|
1060
|
+
* "reconnecting", the policy declined because the room was disposed, and the
|
|
1061
|
+
* screen read "Could not move you to another server" with a Try again, over a
|
|
1062
|
+
* call they had just deliberately ended.
|
|
1063
|
+
*/
|
|
1064
|
+
describe("a close inside a drain window", () => {
|
|
1065
|
+
it("is a client close, and the room stays closed", async () => {
|
|
1066
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1067
|
+
const server = nodeWith({});
|
|
1068
|
+
const { room } = recoveringRoom(server, recorded);
|
|
1069
|
+
await room.connect();
|
|
1070
|
+
|
|
1071
|
+
server.event({ event: "draining", reconnectAfterMs: 5_000 });
|
|
1072
|
+
await flush();
|
|
1073
|
+
await room.close();
|
|
1074
|
+
|
|
1075
|
+
expect(room.connectionState).toBe("closed");
|
|
1076
|
+
expect(room.error).toEqual({ type: "closed_by_client" });
|
|
1077
|
+
expect(room.isRecovering).toBe(false);
|
|
1078
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
1079
|
+
expect(server.sockets).toHaveLength(1);
|
|
1080
|
+
});
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* A recovered connection is not a recovered call: the microphone is off.
|
|
1085
|
+
*
|
|
1086
|
+
* The drop stops every local capture, and that is right (a camera light left
|
|
1087
|
+
* on over a dead session is retained capture). What was wrong is that nothing
|
|
1088
|
+
* then said so. The room came back, the screen read "Connected", the button
|
|
1089
|
+
* read "Unmute", and a coach who had been talking for ten minutes was talking
|
|
1090
|
+
* to nobody until the client asked whether they were still there. The room
|
|
1091
|
+
* cannot republish by itself: capture is a browser permission, and turning a
|
|
1092
|
+
* camera back on without a press is not this layer's decision. So it says what
|
|
1093
|
+
* was lost, and stops saying it once each source is back.
|
|
1094
|
+
*/
|
|
1095
|
+
describe("publications lost to a drop", () => {
|
|
1096
|
+
it("names what was being sent, and clears each source once it is republished", async () => {
|
|
1097
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1098
|
+
const server = nodeWith({});
|
|
1099
|
+
const { room } = recoveringRoom(server, recorded);
|
|
1100
|
+
await room.connect();
|
|
1101
|
+
expect(room.lostPublicationSources).toEqual([]);
|
|
1102
|
+
|
|
1103
|
+
await room.publish(track(), "microphone");
|
|
1104
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
|
|
1105
|
+
server.socket.dropFromServer();
|
|
1106
|
+
await flush();
|
|
1107
|
+
expect(room.connectionState).toBe("reconnecting");
|
|
1108
|
+
|
|
1109
|
+
await vi.waitFor(() => expect(server.sockets.length).toBe(2));
|
|
1110
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1111
|
+
expect(room.localPublications).toHaveLength(0);
|
|
1112
|
+
expect(room.lostPublicationSources).toEqual(["microphone", "camera"]);
|
|
1113
|
+
|
|
1114
|
+
// The person presses Unmute. The microphone is no longer lost; the camera is.
|
|
1115
|
+
await room.publish(track(), "microphone");
|
|
1116
|
+
expect(room.lostPublicationSources).toEqual(["camera"]);
|
|
1117
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
|
|
1118
|
+
expect(room.lostPublicationSources).toEqual([]);
|
|
1119
|
+
await room.close();
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
it("never names a program feed, which is a studio's output and not a person's capture", async () => {
|
|
1123
|
+
// Nobody should be asked to "turn the program back on": it is not a
|
|
1124
|
+
// device, and `useLocalMedia`'s controls never touch it. Only the camera
|
|
1125
|
+
// is a lost source here, and republishing the program clears nothing.
|
|
1126
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1127
|
+
const server = nodeWith({});
|
|
1128
|
+
const { room } = recoveringRoom(server, recorded);
|
|
1129
|
+
await room.connect();
|
|
1130
|
+
|
|
1131
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
|
|
1132
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "program", { codec: "h264" });
|
|
1133
|
+
server.socket.dropFromServer();
|
|
1134
|
+
await flush();
|
|
1135
|
+
|
|
1136
|
+
await vi.waitFor(() => expect(server.sockets.length).toBe(2));
|
|
1137
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1138
|
+
expect(room.lostPublicationSources).toEqual(["camera"]);
|
|
1139
|
+
|
|
1140
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "program", { codec: "h264" });
|
|
1141
|
+
expect(room.lostPublicationSources).toEqual(["camera"]);
|
|
1142
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
|
|
1143
|
+
expect(room.lostPublicationSources).toEqual([]);
|
|
1144
|
+
await room.close();
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
it("says nothing after a leave, which lost nothing", async () => {
|
|
1148
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1149
|
+
const server = nodeWith({});
|
|
1150
|
+
const { room } = recoveringRoom(server, recorded);
|
|
1151
|
+
await room.connect();
|
|
1152
|
+
await room.publish(track(), "microphone");
|
|
1153
|
+
|
|
1154
|
+
await room.close();
|
|
1155
|
+
|
|
1156
|
+
expect(room.lostPublicationSources).toEqual([]);
|
|
1157
|
+
});
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* The browser can end a track without asking us.
|
|
1162
|
+
*
|
|
1163
|
+
* Chrome and Firefox draw their own "Stop sharing" bar over a screen share, and
|
|
1164
|
+
* a camera can be unplugged. Neither goes anywhere near `unpublish`, so the
|
|
1165
|
+
* node kept a producer nobody was feeding, every other participant kept a
|
|
1166
|
+
* frozen tile for it, and the control above read "Stop sharing" about a share
|
|
1167
|
+
* that had ended.
|
|
1168
|
+
*/
|
|
1169
|
+
describe("a track that ends on its own", () => {
|
|
1170
|
+
it("is unpublished, and the node is told", async () => {
|
|
1171
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1172
|
+
const server = nodeWith({});
|
|
1173
|
+
const room = roomFor(server, recorded, false);
|
|
1174
|
+
await room.connect();
|
|
1175
|
+
|
|
1176
|
+
const screen = endableTrack("video");
|
|
1177
|
+
await room.publish(screen, "screen");
|
|
1178
|
+
expect(room.localPublications).toHaveLength(1);
|
|
1179
|
+
|
|
1180
|
+
screen.dispatchEvent(new Event("ended"));
|
|
1181
|
+
|
|
1182
|
+
await vi.waitFor(() => expect(room.localPublications).toHaveLength(0));
|
|
1183
|
+
expect(requestsOf(server, "closeProducer")).toHaveLength(1);
|
|
1184
|
+
await room.close();
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
it("is not listened to after an ordinary unpublish or a teardown", async () => {
|
|
1188
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1189
|
+
const server = nodeWith({});
|
|
1190
|
+
const room = roomFor(server, recorded, false);
|
|
1191
|
+
await room.connect();
|
|
1192
|
+
|
|
1193
|
+
const screen = endableTrack("video");
|
|
1194
|
+
const publication = await room.publish(screen, "screen");
|
|
1195
|
+
await room.unpublish(publication.producerId);
|
|
1196
|
+
// Our own `stop()` does not fire `ended`; this is a source ending later,
|
|
1197
|
+
// after the room has already let go of the track. Nothing must happen.
|
|
1198
|
+
screen.dispatchEvent(new Event("ended"));
|
|
1199
|
+
await flush();
|
|
1200
|
+
expect(requestsOf(server, "closeProducer")).toHaveLength(1);
|
|
1201
|
+
|
|
1202
|
+
const camera = endableTrack("video");
|
|
1203
|
+
await room.publish(camera, "camera");
|
|
1204
|
+
await room.close();
|
|
1205
|
+
camera.dispatchEvent(new Event("ended"));
|
|
1206
|
+
await flush();
|
|
1207
|
+
// Two: the ordinary unpublish above and nothing else. A watcher that
|
|
1208
|
+
// outlived the teardown would have tried a third against a dead socket.
|
|
1209
|
+
expect(requestsOf(server, "closeProducer")).toHaveLength(1);
|
|
1210
|
+
});
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Mute keeps the microphone; it does not give it back.
|
|
1215
|
+
*
|
|
1216
|
+
* A toggle built as unpublish-then-publish starts every unmute with
|
|
1217
|
+
* `getUserMedia`, and Safari asks permission on EVERY call to it. So the room's
|
|
1218
|
+
* pause is what a Mute button uses: the producer is paused, the node is told,
|
|
1219
|
+
* the capture stays open, and the snapshot says which is which so a screen can
|
|
1220
|
+
* draw a muted state without a second source of truth.
|
|
1221
|
+
*/
|
|
1222
|
+
describe("pausing a publication", () => {
|
|
1223
|
+
it("keeps the capture and the producer, tells the node, and moves the snapshot", async () => {
|
|
1224
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1225
|
+
const server = nodeWith({});
|
|
1226
|
+
const room = roomFor(server, recorded, false);
|
|
1227
|
+
await room.connect();
|
|
1228
|
+
|
|
1229
|
+
const microphone = track();
|
|
1230
|
+
const publication = await room.publish(microphone, "microphone");
|
|
1231
|
+
const before = room.localPublications;
|
|
1232
|
+
expect(before[0]?.paused).toBe(false);
|
|
1233
|
+
|
|
1234
|
+
await room.setPaused(publication.producerId, true);
|
|
1235
|
+
|
|
1236
|
+
expect(publication.handle.pause).toHaveBeenCalledTimes(1);
|
|
1237
|
+
expect(microphone.stop).not.toHaveBeenCalled();
|
|
1238
|
+
expect(requestsOf(server, "pauseProducer")).toHaveLength(1);
|
|
1239
|
+
expect(requestsOf(server, "closeProducer")).toHaveLength(0);
|
|
1240
|
+
// A NEW snapshot with the flag, so `useSyncExternalStore` sees the change.
|
|
1241
|
+
expect(room.localPublications).not.toBe(before);
|
|
1242
|
+
expect(room.localPublications[0]?.paused).toBe(true);
|
|
1243
|
+
// And the way back asks nobody anything.
|
|
1244
|
+
await room.setPaused(publication.producerId, false);
|
|
1245
|
+
expect(publication.handle.resume).toHaveBeenCalledTimes(1);
|
|
1246
|
+
expect(requestsOf(server, "resumeProducer")).toHaveLength(1);
|
|
1247
|
+
expect(room.localPublications[0]?.paused).toBe(false);
|
|
1248
|
+
await room.close();
|
|
1249
|
+
// Leaving still releases the capture: pause is a mute, not a way of keeping
|
|
1250
|
+
// a microphone open past the call.
|
|
1251
|
+
expect(microphone.stop).toHaveBeenCalledTimes(1);
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
it("is idempotent on the wire", async () => {
|
|
1255
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1256
|
+
const server = nodeWith({});
|
|
1257
|
+
const room = roomFor(server, recorded, false);
|
|
1258
|
+
await room.connect();
|
|
1259
|
+
const publication = await room.publish(track(), "microphone");
|
|
1260
|
+
|
|
1261
|
+
await room.setPaused(publication.producerId, true);
|
|
1262
|
+
await room.setPaused(publication.producerId, true);
|
|
1263
|
+
|
|
1264
|
+
expect(requestsOf(server, "pauseProducer")).toHaveLength(1);
|
|
1265
|
+
await room.close();
|
|
1266
|
+
});
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
describe("replaceTrack: a device switch keeps its producer", () => {
|
|
1270
|
+
it("swaps the capture on the handle, stops the old track, keeps the producer id", async () => {
|
|
1271
|
+
const server = nodeWith({ producers: [] });
|
|
1272
|
+
const room = roomFor(server, { calls: [], transports: [] });
|
|
1273
|
+
await room.connect();
|
|
1274
|
+
const first = { kind: "video", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
|
|
1275
|
+
const second = { kind: "video", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
|
|
1276
|
+
const published = await room.publish(first, "camera");
|
|
1277
|
+
|
|
1278
|
+
await room.replaceTrack(published.producerId, second);
|
|
1279
|
+
|
|
1280
|
+
expect(published.handle.replaceTrack).toHaveBeenCalledWith(second);
|
|
1281
|
+
expect(first.stop).toHaveBeenCalled();
|
|
1282
|
+
expect(second.stop).not.toHaveBeenCalled();
|
|
1283
|
+
expect(room.localPublications).toHaveLength(1);
|
|
1284
|
+
expect(room.localPublications[0]).toMatchObject({ producerId: published.producerId, track: second, source: "camera" });
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
it("publishes three layers when simulcast is asked for, splitting the caller's cap", async () => {
|
|
1288
|
+
const server = nodeWith({ producers: [] });
|
|
1289
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1290
|
+
const room = roomFor(server, recorded);
|
|
1291
|
+
await room.connect();
|
|
1292
|
+
const track = { kind: "video", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
|
|
1293
|
+
|
|
1294
|
+
await room.publish(track, "program", { codec: "vp8", simulcast: true, maxBitrateKbps: 3500 });
|
|
1295
|
+
|
|
1296
|
+
const produced = recorded.transports.find((t) => t.direction === "send")!.produced[0]!;
|
|
1297
|
+
expect(produced.codec).toBe("vp8");
|
|
1298
|
+
expect(produced.encodings).toEqual([
|
|
1299
|
+
{ rid: "q", scaleResolutionDownBy: 4, scalabilityMode: "L1T1", maxBitrate: 350_000 },
|
|
1300
|
+
{ rid: "h", scaleResolutionDownBy: 2, scalabilityMode: "L1T1", maxBitrate: 875_000 },
|
|
1301
|
+
{ rid: "f", scaleResolutionDownBy: 1, scalabilityMode: "L1T1", maxBitrate: 3_500_000 },
|
|
1302
|
+
]);
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
it("still publishes ONE encoding when simulcast is not asked for", async () => {
|
|
1306
|
+
// The small-call case: everybody can carry the one encoding, and layers
|
|
1307
|
+
// nobody consumes are encoder time spent on nobody.
|
|
1308
|
+
const server = nodeWith({ producers: [] });
|
|
1309
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1310
|
+
const room = roomFor(server, recorded);
|
|
1311
|
+
await room.connect();
|
|
1312
|
+
const track = { kind: "video", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
|
|
1313
|
+
|
|
1314
|
+
await room.publish(track, "camera", { maxBitrateKbps: 1200 });
|
|
1315
|
+
|
|
1316
|
+
const produced = recorded.transports.find((t) => t.direction === "send")!.produced[0]!;
|
|
1317
|
+
expect(produced.encodings).toEqual([{ maxBitrate: 1_200_000 }]);
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
it("refuses a track of the other kind and an unknown publication", async () => {
|
|
1321
|
+
const server = nodeWith({ producers: [] });
|
|
1322
|
+
const room = roomFor(server, { calls: [], transports: [] });
|
|
1323
|
+
await room.connect();
|
|
1324
|
+
const video = { kind: "video", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
|
|
1325
|
+
const audio = { kind: "audio", stop: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn() } as unknown as MediaStreamTrack;
|
|
1326
|
+
const published = await room.publish(video, "camera");
|
|
1327
|
+
await expect(room.replaceTrack(published.producerId, audio)).rejects.toThrow(/cannot carry/);
|
|
1328
|
+
await expect(room.replaceTrack("nope", video)).rejects.toThrow(/no such publication/);
|
|
1329
|
+
});
|
|
1330
|
+
});
|