@tribe-nest/media-client 0.1.0 → 0.1.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.map +1 -1
- package/build/core/state.js +14 -3
- package/build/core/state.js.map +1 -1
- package/build/react/index.d.ts +23 -2
- package/build/react/index.d.ts.map +1 -1
- package/build/react/index.js +26 -0
- package/build/react/index.js.map +1 -1
- package/build/room/room.d.ts +68 -5
- package/build/room/room.d.ts.map +1 -1
- package/build/room/room.js +213 -56
- 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 +22 -2
- package/src/core/reconnect.ts +8 -2
- package/src/core/signal.ts +48 -12
- package/src/core/state.ts +21 -4
- package/src/react/_tests/hooks.spec.tsx +91 -3
- package/src/react/index.tsx +55 -8
- package/src/room/_tests/room.spec.ts +510 -8
- package/src/room/room.ts +236 -60
|
@@ -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,
|
|
@@ -93,7 +95,14 @@ function fakeDevice(recorded: Recorded) {
|
|
|
93
95
|
return { codecs: [] } as never;
|
|
94
96
|
},
|
|
95
97
|
canProduce: () => true,
|
|
96
|
-
createSendTransport({
|
|
98
|
+
createSendTransport({
|
|
99
|
+
description,
|
|
100
|
+
handlers,
|
|
101
|
+
}: {
|
|
102
|
+
description: TransportDescription;
|
|
103
|
+
iceServers: IceServer[];
|
|
104
|
+
handlers: TransportHandlers;
|
|
105
|
+
}) {
|
|
97
106
|
const transport = new FakeTransport(description.transportId, "send", recorded);
|
|
98
107
|
transport.handlers = handlers;
|
|
99
108
|
recorded.transports.push(transport);
|
|
@@ -120,12 +129,22 @@ function fakeDevice(recorded: Recorded) {
|
|
|
120
129
|
function nodeWith(input: {
|
|
121
130
|
producers?: { producerId: string; identity: string; kind: "audio" | "video" }[];
|
|
122
131
|
iceServers?: IceServer[];
|
|
132
|
+
/**
|
|
133
|
+
* Events delivered on EVERY connection after the join reply and BEFORE the
|
|
134
|
+
* `joined` snapshot. The contract fixes no order between `joined` and the
|
|
135
|
+
* node's first broadcasts, and a real node sends its active set the moment a
|
|
136
|
+
* participant is in the room.
|
|
137
|
+
*/
|
|
138
|
+
beforeJoined?: EventFrame[];
|
|
139
|
+
/** Answers a request itself by returning true; anything else falls through to the defaults. */
|
|
140
|
+
intercept?: (frame: RequestFrame, socket: FakeSocket, server: FakeSignalServer) => boolean;
|
|
123
141
|
}): FakeSignalServer {
|
|
124
142
|
let transportSeq = 0;
|
|
125
143
|
let producerSeq = 0;
|
|
126
144
|
let consumerSeq = 0;
|
|
127
145
|
|
|
128
146
|
const server: FakeSignalServer = new FakeSignalServer({
|
|
147
|
+
...(input.beforeJoined ? { autoJoin: false } : {}),
|
|
129
148
|
joined: {
|
|
130
149
|
identity: "me",
|
|
131
150
|
room: "matter-1",
|
|
@@ -135,6 +154,7 @@ function nodeWith(input: {
|
|
|
135
154
|
grants: { canPublish: true, canSubscribe: true, canPublishData: false },
|
|
136
155
|
},
|
|
137
156
|
onRequest: (frame, socket) => {
|
|
157
|
+
if (input.intercept?.(frame, socket, server)) return;
|
|
138
158
|
switch (frame.method) {
|
|
139
159
|
case "createTransport":
|
|
140
160
|
return server.reply(
|
|
@@ -153,6 +173,11 @@ function nodeWith(input: {
|
|
|
153
173
|
socket,
|
|
154
174
|
);
|
|
155
175
|
case "join":
|
|
176
|
+
if (input.beforeJoined) {
|
|
177
|
+
server.reply(frame.id, { accepted: true }, socket);
|
|
178
|
+
for (const event of input.beforeJoined) server.event(event, socket);
|
|
179
|
+
server.event(server.joinedFrame(), socket);
|
|
180
|
+
}
|
|
156
181
|
return;
|
|
157
182
|
default:
|
|
158
183
|
return server.reply(frame.id, {}, socket);
|
|
@@ -203,7 +228,8 @@ const recoveringRoom = (
|
|
|
203
228
|
return { room, tickets };
|
|
204
229
|
};
|
|
205
230
|
|
|
206
|
-
const track = () =>
|
|
231
|
+
const track = () =>
|
|
232
|
+
({ kind: "audio", stop: vi.fn() }) as unknown as MediaStreamTrack & { stop: ReturnType<typeof vi.fn> };
|
|
207
233
|
|
|
208
234
|
describe("rule 1: the device loads before anything needs its capabilities", () => {
|
|
209
235
|
it("loads on join, before any transport", async () => {
|
|
@@ -274,6 +300,92 @@ describe("rule 2: one transport per direction, created lazily", () => {
|
|
|
274
300
|
expect(recorded.calls.filter((c) => c === "create-recv-transport")).toHaveLength(1);
|
|
275
301
|
await room.close();
|
|
276
302
|
});
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* "Once" under CONCURRENCY, which is the shape that actually occurs.
|
|
306
|
+
*
|
|
307
|
+
* `syncSubscriptions` fans out with `Promise.all`, so one `activeSpeakers`
|
|
308
|
+
* frame naming four producers puts four subscribes through
|
|
309
|
+
* `ensureRecvTransport` in the same tick. Checking the finished transport and
|
|
310
|
+
* then awaiting the round trip let every one of them pass the check and send
|
|
311
|
+
* its own `createTransport(recv)`. The node caps transports per session, so
|
|
312
|
+
* the burst spent the whole allowance on duplicates and the person's own
|
|
313
|
+
* Unmute was refused with `capacity` for the rest of the call.
|
|
314
|
+
*/
|
|
315
|
+
it("sends ONE createTransport per direction when several subscribes and publishes land in the same tick", async () => {
|
|
316
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
317
|
+
const server = nodeWith({
|
|
318
|
+
producers: [
|
|
319
|
+
{ producerId: "p-a", identity: "a", kind: "audio" },
|
|
320
|
+
{ producerId: "p-b", identity: "b", kind: "audio" },
|
|
321
|
+
],
|
|
322
|
+
});
|
|
323
|
+
const room = roomFor(server, recorded, false);
|
|
324
|
+
await room.connect();
|
|
325
|
+
|
|
326
|
+
// Fired, not awaited in turn: all four are in flight before any reply.
|
|
327
|
+
await Promise.all([
|
|
328
|
+
room.subscribe("p-a"),
|
|
329
|
+
room.subscribe("p-b"),
|
|
330
|
+
room.publish({ kind: "audio", stop: vi.fn() } as never, "microphone"),
|
|
331
|
+
room.publish({ kind: "video", stop: vi.fn() } as never, "camera"),
|
|
332
|
+
]);
|
|
333
|
+
|
|
334
|
+
// Asserted on the WIRE, because that is what the node counts.
|
|
335
|
+
const created = requestsOf(server, "createTransport") as { direction: "send" | "recv" }[];
|
|
336
|
+
expect(created.filter((r) => r.direction === "recv")).toHaveLength(1);
|
|
337
|
+
expect(created.filter((r) => r.direction === "send")).toHaveLength(1);
|
|
338
|
+
expect(recorded.calls.filter((c) => c === "create-recv-transport")).toHaveLength(1);
|
|
339
|
+
expect(recorded.calls.filter((c) => c === "create-send-transport")).toHaveLength(1);
|
|
340
|
+
// And everything still landed on the one transport per direction.
|
|
341
|
+
expect(room.tracks.map((t) => t.producerId).sort()).toEqual(["p-a", "p-b"]);
|
|
342
|
+
expect(room.localPublications).toHaveLength(2);
|
|
343
|
+
await room.close();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("sends ONE createTransport(recv) for an activeSpeakers frame naming several producers", async () => {
|
|
347
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
348
|
+
const producers = ["p-1", "p-2", "p-3", "p-4"].map((producerId) => ({
|
|
349
|
+
producerId,
|
|
350
|
+
identity: producerId,
|
|
351
|
+
kind: "audio" as const,
|
|
352
|
+
}));
|
|
353
|
+
const server = nodeWith({ producers });
|
|
354
|
+
const room = roomFor(server, recorded);
|
|
355
|
+
await room.connect();
|
|
356
|
+
|
|
357
|
+
server.event({ event: "activeSpeakers", producerIds: producers.map((p) => p.producerId) });
|
|
358
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(4));
|
|
359
|
+
|
|
360
|
+
expect(requestsOf(server, "createTransport")).toHaveLength(1);
|
|
361
|
+
await room.close();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("forgets a creation the node refused, so the next caller tries again", async () => {
|
|
365
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
366
|
+
let refuseNext = true;
|
|
367
|
+
const server = nodeWith({
|
|
368
|
+
producers: [{ producerId: "p-a", identity: "a", kind: "audio" }],
|
|
369
|
+
intercept: (frame, socket, srv) => {
|
|
370
|
+
if (frame.method !== "createTransport" || !refuseNext) return false;
|
|
371
|
+
refuseNext = false;
|
|
372
|
+
srv.fail(frame.id, "capacity", "no", socket);
|
|
373
|
+
return true;
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
const room = roomFor(server, recorded, false);
|
|
377
|
+
await room.connect();
|
|
378
|
+
|
|
379
|
+
await expect(room.subscribe("p-a")).rejects.toMatchObject({ code: "capacity" });
|
|
380
|
+
// A memo that kept the rejection would refuse every later subscribe with
|
|
381
|
+
// the same stale answer, when the node might well say yes now.
|
|
382
|
+
await room.subscribe("p-a");
|
|
383
|
+
|
|
384
|
+
expect(requestsOf(server, "createTransport")).toHaveLength(2);
|
|
385
|
+
expect(recorded.calls.filter((c) => c === "create-recv-transport")).toHaveLength(1);
|
|
386
|
+
expect(room.tracks).toHaveLength(1);
|
|
387
|
+
await room.close();
|
|
388
|
+
});
|
|
277
389
|
});
|
|
278
390
|
|
|
279
391
|
describe("rule 3: a consumer resumes only after its track exists", () => {
|
|
@@ -330,10 +442,11 @@ describe("rule 4: autoSubscribe follows the ACTIVE SET", () => {
|
|
|
330
442
|
|
|
331
443
|
// Subscribing to everything would produce one `subscription_limit` refusal
|
|
332
444
|
// per producer outside the set, and show nothing for the trouble.
|
|
333
|
-
expect(
|
|
334
|
-
"
|
|
335
|
-
|
|
336
|
-
|
|
445
|
+
expect(
|
|
446
|
+
requestsOf(server, "consume")
|
|
447
|
+
.map((r) => (r as { producerId: string }).producerId)
|
|
448
|
+
.sort(),
|
|
449
|
+
).toEqual(["p-1", "p-3"]);
|
|
337
450
|
await room.close();
|
|
338
451
|
});
|
|
339
452
|
|
|
@@ -513,6 +626,146 @@ describe("the room gets itself back in", () => {
|
|
|
513
626
|
});
|
|
514
627
|
});
|
|
515
628
|
|
|
629
|
+
/**
|
|
630
|
+
* A ticket the room could not get is a failed attempt, not a frozen one.
|
|
631
|
+
*
|
|
632
|
+
* `getCredentials` is a network round trip to the ticket endpoint, and it
|
|
633
|
+
* fails for ordinary reasons: a 409 at the edge of the booking window because
|
|
634
|
+
* the client's clock is ahead, a 401 after the session expired, a flaky
|
|
635
|
+
* network. Before this, that rejection happened before a socket existed, so
|
|
636
|
+
* nothing ever closed, `onClose` never fired, and the room stayed on
|
|
637
|
+
* "connecting" (first join) or "reconnecting" (ladder) with no retry booked, no
|
|
638
|
+
* error to show and every later `connect()` refused as "already connecting".
|
|
639
|
+
* The screen above drew a spinner for the rest of the session.
|
|
640
|
+
*/
|
|
641
|
+
describe("a credential fetch that fails", () => {
|
|
642
|
+
/** A ticket endpoint that fails on the attempts named, and counts its calls. */
|
|
643
|
+
const ticketEndpoint = (failOn: number[], error: () => unknown = () => new Error("HTTP 409: window not open")) => {
|
|
644
|
+
let calls = 0;
|
|
645
|
+
return {
|
|
646
|
+
get calls() {
|
|
647
|
+
return calls;
|
|
648
|
+
},
|
|
649
|
+
getCredentials: async () => {
|
|
650
|
+
calls += 1;
|
|
651
|
+
if (failOn.includes(calls)) throw error();
|
|
652
|
+
return { mediaUrl: "wss://media.example", token: `t-${calls}` };
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
it("on the FIRST join surfaces the error and books a retry rather than wedging on connecting", async () => {
|
|
658
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
659
|
+
const server = nodeWith({});
|
|
660
|
+
const tickets = ticketEndpoint([1]);
|
|
661
|
+
const { room } = recoveringRoom(server, recorded, { getCredentials: tickets.getCredentials });
|
|
662
|
+
|
|
663
|
+
await expect(room.connect()).rejects.toThrow(/HTTP 409/);
|
|
664
|
+
|
|
665
|
+
// Not "connecting": nothing is connecting. A retryable failure with a retry
|
|
666
|
+
// booked, and the cause on the room for the screen to show.
|
|
667
|
+
expect(room.connectionState).toBe("reconnecting");
|
|
668
|
+
expect(room.error).toEqual({ type: "socket_closed", reason: "HTTP 409: window not open" });
|
|
669
|
+
expect(room.isRecovering).toBe(true);
|
|
670
|
+
// No socket was ever opened for the failed attempt.
|
|
671
|
+
expect(server.sockets).toHaveLength(0);
|
|
672
|
+
|
|
673
|
+
// The booked retry fetches a FRESH ticket and gets in.
|
|
674
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
675
|
+
expect(tickets.calls).toBe(2);
|
|
676
|
+
expect(server.sockets).toHaveLength(1);
|
|
677
|
+
expect(room.error).toBeUndefined();
|
|
678
|
+
await room.close();
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it("with retries off leaves the room retryable BY HAND rather than refusing the next connect", async () => {
|
|
682
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
683
|
+
const server = nodeWith({});
|
|
684
|
+
const tickets = ticketEndpoint([1]);
|
|
685
|
+
const { room } = recoveringRoom(server, recorded, {
|
|
686
|
+
getCredentials: tickets.getCredentials,
|
|
687
|
+
reconnect: { maxAttempts: 0 },
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
await expect(room.connect()).rejects.toThrow(/HTTP 409/);
|
|
691
|
+
expect(room.connectionState).toBe("reconnecting");
|
|
692
|
+
expect(room.isRecovering).toBe(false);
|
|
693
|
+
expect(room.error).toMatchObject({ type: "socket_closed" });
|
|
694
|
+
|
|
695
|
+
// The person presses "Try again". Before the fix this was refused with
|
|
696
|
+
// "already connecting or connected" for the rest of the session.
|
|
697
|
+
await room.connect();
|
|
698
|
+
|
|
699
|
+
expect(room.connectionState).toBe("connected");
|
|
700
|
+
expect(room.error).toBeUndefined();
|
|
701
|
+
expect(tickets.calls).toBe(2);
|
|
702
|
+
await room.close();
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it("on a LATER rung of the ladder is one failed attempt, and the ladder carries on", async () => {
|
|
706
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
707
|
+
const server = nodeWith({});
|
|
708
|
+
const tickets = ticketEndpoint([2]);
|
|
709
|
+
const { room } = recoveringRoom(server, recorded, { getCredentials: tickets.getCredentials });
|
|
710
|
+
const seen: { connectionState: ConnectionState; error: DisconnectCause | undefined; recovering: boolean }[] = [];
|
|
711
|
+
room.onChange(() =>
|
|
712
|
+
seen.push({ connectionState: room.connectionState, error: room.error, recovering: room.isRecovering }),
|
|
713
|
+
);
|
|
714
|
+
await room.connect();
|
|
715
|
+
|
|
716
|
+
server.socket.dropFromServer();
|
|
717
|
+
|
|
718
|
+
await vi.waitFor(() => expect(server.sockets.length).toBe(2));
|
|
719
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
720
|
+
// Three tickets: the original, the one the endpoint refused, and the one
|
|
721
|
+
// that got back in.
|
|
722
|
+
expect(tickets.calls).toBe(3);
|
|
723
|
+
// While the endpoint was refusing, the room said so and said it was still
|
|
724
|
+
// trying, rather than sitting on the drop's cause with nothing booked.
|
|
725
|
+
expect(seen).toContainEqual({
|
|
726
|
+
connectionState: "reconnecting",
|
|
727
|
+
error: { type: "socket_closed", reason: "HTTP 409: window not open" },
|
|
728
|
+
recovering: true,
|
|
729
|
+
});
|
|
730
|
+
await room.close();
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("with a refusal the policy will not retry, shows the refusal and stops", async () => {
|
|
734
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
735
|
+
const server = nodeWith({});
|
|
736
|
+
// The endpoint saying "you may not join this room" is `refused`, and a
|
|
737
|
+
// terminal code is not retried: a loop against the ticket endpoint would
|
|
738
|
+
// only repeat the answer.
|
|
739
|
+
const tickets = ticketEndpoint([1, 2, 3], () => new MediaError("forbidden", "this seat is sealed"));
|
|
740
|
+
const { room } = recoveringRoom(server, recorded, { getCredentials: tickets.getCredentials });
|
|
741
|
+
|
|
742
|
+
await expect(room.connect()).rejects.toMatchObject({ code: "forbidden" });
|
|
743
|
+
|
|
744
|
+
expect(room.connectionState).toBe("reconnecting");
|
|
745
|
+
expect(room.isRecovering).toBe(false);
|
|
746
|
+
expect(room.error).toEqual({ type: "refused", code: "forbidden", message: "this seat is sealed" });
|
|
747
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
748
|
+
expect(tickets.calls).toBe(1);
|
|
749
|
+
expect(server.sockets).toHaveLength(0);
|
|
750
|
+
await room.close();
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it("does not put a live room back to connecting when connect() is called twice", async () => {
|
|
754
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
755
|
+
const room = roomFor(nodeWith({}), recorded, false);
|
|
756
|
+
await room.connect();
|
|
757
|
+
|
|
758
|
+
await expect(room.connect()).rejects.toThrow(/already connecting or connected/);
|
|
759
|
+
|
|
760
|
+
// The refusal is not a disconnect: the first connection is still up and the
|
|
761
|
+
// room has to keep saying so.
|
|
762
|
+
expect(room.connectionState).toBe("connected");
|
|
763
|
+
expect(room.error).toBeUndefined();
|
|
764
|
+
expect(room.state.phase).toBe("joined");
|
|
765
|
+
await room.close();
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
|
|
516
769
|
/**
|
|
517
770
|
* The camera light goes out whenever the connection does.
|
|
518
771
|
*
|
|
@@ -593,3 +846,252 @@ describe("what the room carries for rendering", () => {
|
|
|
593
846
|
await room.close();
|
|
594
847
|
});
|
|
595
848
|
});
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* A track that can end on its own, the way a browser's does.
|
|
852
|
+
*
|
|
853
|
+
* `MediaStreamTrack` is an `EventTarget`, and `ended` is what fires when the
|
|
854
|
+
* SOURCE goes away: the browser's own "Stop sharing" bar, a camera unplugged.
|
|
855
|
+
* It does not fire for our own `stop()`, which the fake also honours.
|
|
856
|
+
*/
|
|
857
|
+
const endableTrack = (kind: "audio" | "video" = "video") => {
|
|
858
|
+
const target = new EventTarget() as EventTarget & { kind: string; stop: ReturnType<typeof vi.fn> };
|
|
859
|
+
target.kind = kind;
|
|
860
|
+
target.stop = vi.fn();
|
|
861
|
+
return target as unknown as MediaStreamTrack & { stop: ReturnType<typeof vi.fn> };
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* The node's first active set can land BEFORE the join snapshot.
|
|
866
|
+
*
|
|
867
|
+
* The reducer used to reset `activeSpeakers` to `[]` on `joined`, so a set the
|
|
868
|
+
* node had already sent was discarded and the client consumed nothing until
|
|
869
|
+
* the next speaker change. In a quiet 1:1 call that change is the other person
|
|
870
|
+
* starting to talk, which is precisely when a missing subscription is noticed.
|
|
871
|
+
*/
|
|
872
|
+
describe("an active set that arrives ahead of joined", () => {
|
|
873
|
+
it("is applied once the room is in, rather than discarded", async () => {
|
|
874
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
875
|
+
const server = nodeWith({
|
|
876
|
+
producers: [{ producerId: "p-a", identity: "a", kind: "audio" }],
|
|
877
|
+
beforeJoined: [{ event: "activeSpeakers", producerIds: ["p-a"] }],
|
|
878
|
+
});
|
|
879
|
+
const room = roomFor(server, recorded);
|
|
880
|
+
|
|
881
|
+
await room.connect();
|
|
882
|
+
|
|
883
|
+
expect(room.state.activeSpeakers).toEqual(["p-a"]);
|
|
884
|
+
await vi.waitFor(() => expect(room.tracks.map((t) => t.producerId)).toEqual(["p-a"]));
|
|
885
|
+
await room.close();
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
it("does not carry a set from BEFORE a drop into the rejoin", async () => {
|
|
889
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
890
|
+
const server = nodeWith({ producers: [{ producerId: "p-a", identity: "a", kind: "audio" }] });
|
|
891
|
+
const { room } = recoveringRoom(server, recorded);
|
|
892
|
+
await room.connect();
|
|
893
|
+
server.event({ event: "activeSpeakers", producerIds: ["p-a"] });
|
|
894
|
+
await flush();
|
|
895
|
+
expect(room.state.activeSpeakers).toEqual(["p-a"]);
|
|
896
|
+
|
|
897
|
+
server.socket.dropFromServer();
|
|
898
|
+
await flush();
|
|
899
|
+
// Gone with the consumers it described. Kept, the next `joined` would read
|
|
900
|
+
// it as a set the NEW node had sent ahead of its snapshot.
|
|
901
|
+
expect(room.state.activeSpeakers).toEqual([]);
|
|
902
|
+
|
|
903
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
904
|
+
expect(room.state.activeSpeakers).toEqual([]);
|
|
905
|
+
await room.close();
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Leave means leave, even while the node is asking to be left.
|
|
911
|
+
*
|
|
912
|
+
* `draining` is recorded as the terminal cause the moment the frame arrives,
|
|
913
|
+
* seconds before the node hangs up. A person who pressed Leave inside that
|
|
914
|
+
* window had their close reported as the DRAIN: the room went to
|
|
915
|
+
* "reconnecting", the policy declined because the room was disposed, and the
|
|
916
|
+
* screen read "Could not move you to another server" with a Try again, over a
|
|
917
|
+
* call they had just deliberately ended.
|
|
918
|
+
*/
|
|
919
|
+
describe("a close inside a drain window", () => {
|
|
920
|
+
it("is a client close, and the room stays closed", async () => {
|
|
921
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
922
|
+
const server = nodeWith({});
|
|
923
|
+
const { room } = recoveringRoom(server, recorded);
|
|
924
|
+
await room.connect();
|
|
925
|
+
|
|
926
|
+
server.event({ event: "draining", reconnectAfterMs: 5_000 });
|
|
927
|
+
await flush();
|
|
928
|
+
await room.close();
|
|
929
|
+
|
|
930
|
+
expect(room.connectionState).toBe("closed");
|
|
931
|
+
expect(room.error).toEqual({ type: "closed_by_client" });
|
|
932
|
+
expect(room.isRecovering).toBe(false);
|
|
933
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
934
|
+
expect(server.sockets).toHaveLength(1);
|
|
935
|
+
});
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* A recovered connection is not a recovered call: the microphone is off.
|
|
940
|
+
*
|
|
941
|
+
* The drop stops every local capture, and that is right (a camera light left
|
|
942
|
+
* on over a dead session is retained capture). What was wrong is that nothing
|
|
943
|
+
* then said so. The room came back, the screen read "Connected", the button
|
|
944
|
+
* read "Unmute", and a coach who had been talking for ten minutes was talking
|
|
945
|
+
* to nobody until the client asked whether they were still there. The room
|
|
946
|
+
* cannot republish by itself: capture is a browser permission, and turning a
|
|
947
|
+
* camera back on without a press is not this layer's decision. So it says what
|
|
948
|
+
* was lost, and stops saying it once each source is back.
|
|
949
|
+
*/
|
|
950
|
+
describe("publications lost to a drop", () => {
|
|
951
|
+
it("names what was being sent, and clears each source once it is republished", async () => {
|
|
952
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
953
|
+
const server = nodeWith({});
|
|
954
|
+
const { room } = recoveringRoom(server, recorded);
|
|
955
|
+
await room.connect();
|
|
956
|
+
expect(room.lostPublicationSources).toEqual([]);
|
|
957
|
+
|
|
958
|
+
await room.publish(track(), "microphone");
|
|
959
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
|
|
960
|
+
server.socket.dropFromServer();
|
|
961
|
+
await flush();
|
|
962
|
+
expect(room.connectionState).toBe("reconnecting");
|
|
963
|
+
|
|
964
|
+
await vi.waitFor(() => expect(server.sockets.length).toBe(2));
|
|
965
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
966
|
+
expect(room.localPublications).toHaveLength(0);
|
|
967
|
+
expect(room.lostPublicationSources).toEqual(["microphone", "camera"]);
|
|
968
|
+
|
|
969
|
+
// The person presses Unmute. The microphone is no longer lost; the camera is.
|
|
970
|
+
await room.publish(track(), "microphone");
|
|
971
|
+
expect(room.lostPublicationSources).toEqual(["camera"]);
|
|
972
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
|
|
973
|
+
expect(room.lostPublicationSources).toEqual([]);
|
|
974
|
+
await room.close();
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
it("says nothing after a leave, which lost nothing", async () => {
|
|
978
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
979
|
+
const server = nodeWith({});
|
|
980
|
+
const { room } = recoveringRoom(server, recorded);
|
|
981
|
+
await room.connect();
|
|
982
|
+
await room.publish(track(), "microphone");
|
|
983
|
+
|
|
984
|
+
await room.close();
|
|
985
|
+
|
|
986
|
+
expect(room.lostPublicationSources).toEqual([]);
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* The browser can end a track without asking us.
|
|
992
|
+
*
|
|
993
|
+
* Chrome and Firefox draw their own "Stop sharing" bar over a screen share, and
|
|
994
|
+
* a camera can be unplugged. Neither goes anywhere near `unpublish`, so the
|
|
995
|
+
* node kept a producer nobody was feeding, every other participant kept a
|
|
996
|
+
* frozen tile for it, and the control above read "Stop sharing" about a share
|
|
997
|
+
* that had ended.
|
|
998
|
+
*/
|
|
999
|
+
describe("a track that ends on its own", () => {
|
|
1000
|
+
it("is unpublished, and the node is told", async () => {
|
|
1001
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1002
|
+
const server = nodeWith({});
|
|
1003
|
+
const room = roomFor(server, recorded, false);
|
|
1004
|
+
await room.connect();
|
|
1005
|
+
|
|
1006
|
+
const screen = endableTrack("video");
|
|
1007
|
+
await room.publish(screen, "screen");
|
|
1008
|
+
expect(room.localPublications).toHaveLength(1);
|
|
1009
|
+
|
|
1010
|
+
screen.dispatchEvent(new Event("ended"));
|
|
1011
|
+
|
|
1012
|
+
await vi.waitFor(() => expect(room.localPublications).toHaveLength(0));
|
|
1013
|
+
expect(requestsOf(server, "closeProducer")).toHaveLength(1);
|
|
1014
|
+
await room.close();
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
it("is not listened to after an ordinary unpublish or a teardown", async () => {
|
|
1018
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1019
|
+
const server = nodeWith({});
|
|
1020
|
+
const room = roomFor(server, recorded, false);
|
|
1021
|
+
await room.connect();
|
|
1022
|
+
|
|
1023
|
+
const screen = endableTrack("video");
|
|
1024
|
+
const publication = await room.publish(screen, "screen");
|
|
1025
|
+
await room.unpublish(publication.producerId);
|
|
1026
|
+
// Our own `stop()` does not fire `ended`; this is a source ending later,
|
|
1027
|
+
// after the room has already let go of the track. Nothing must happen.
|
|
1028
|
+
screen.dispatchEvent(new Event("ended"));
|
|
1029
|
+
await flush();
|
|
1030
|
+
expect(requestsOf(server, "closeProducer")).toHaveLength(1);
|
|
1031
|
+
|
|
1032
|
+
const camera = endableTrack("video");
|
|
1033
|
+
await room.publish(camera, "camera");
|
|
1034
|
+
await room.close();
|
|
1035
|
+
camera.dispatchEvent(new Event("ended"));
|
|
1036
|
+
await flush();
|
|
1037
|
+
// Two: the ordinary unpublish above and nothing else. A watcher that
|
|
1038
|
+
// outlived the teardown would have tried a third against a dead socket.
|
|
1039
|
+
expect(requestsOf(server, "closeProducer")).toHaveLength(1);
|
|
1040
|
+
});
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Mute keeps the microphone; it does not give it back.
|
|
1045
|
+
*
|
|
1046
|
+
* A toggle built as unpublish-then-publish starts every unmute with
|
|
1047
|
+
* `getUserMedia`, and Safari asks permission on EVERY call to it. So the room's
|
|
1048
|
+
* pause is what a Mute button uses: the producer is paused, the node is told,
|
|
1049
|
+
* the capture stays open, and the snapshot says which is which so a screen can
|
|
1050
|
+
* draw a muted state without a second source of truth.
|
|
1051
|
+
*/
|
|
1052
|
+
describe("pausing a publication", () => {
|
|
1053
|
+
it("keeps the capture and the producer, tells the node, and moves the snapshot", async () => {
|
|
1054
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1055
|
+
const server = nodeWith({});
|
|
1056
|
+
const room = roomFor(server, recorded, false);
|
|
1057
|
+
await room.connect();
|
|
1058
|
+
|
|
1059
|
+
const microphone = track();
|
|
1060
|
+
const publication = await room.publish(microphone, "microphone");
|
|
1061
|
+
const before = room.localPublications;
|
|
1062
|
+
expect(before[0]?.paused).toBe(false);
|
|
1063
|
+
|
|
1064
|
+
await room.setPaused(publication.producerId, true);
|
|
1065
|
+
|
|
1066
|
+
expect(publication.handle.pause).toHaveBeenCalledTimes(1);
|
|
1067
|
+
expect(microphone.stop).not.toHaveBeenCalled();
|
|
1068
|
+
expect(requestsOf(server, "pauseProducer")).toHaveLength(1);
|
|
1069
|
+
expect(requestsOf(server, "closeProducer")).toHaveLength(0);
|
|
1070
|
+
// A NEW snapshot with the flag, so `useSyncExternalStore` sees the change.
|
|
1071
|
+
expect(room.localPublications).not.toBe(before);
|
|
1072
|
+
expect(room.localPublications[0]?.paused).toBe(true);
|
|
1073
|
+
// And the way back asks nobody anything.
|
|
1074
|
+
await room.setPaused(publication.producerId, false);
|
|
1075
|
+
expect(publication.handle.resume).toHaveBeenCalledTimes(1);
|
|
1076
|
+
expect(requestsOf(server, "resumeProducer")).toHaveLength(1);
|
|
1077
|
+
expect(room.localPublications[0]?.paused).toBe(false);
|
|
1078
|
+
await room.close();
|
|
1079
|
+
// Leaving still releases the capture: pause is a mute, not a way of keeping
|
|
1080
|
+
// a microphone open past the call.
|
|
1081
|
+
expect(microphone.stop).toHaveBeenCalledTimes(1);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it("is idempotent on the wire", async () => {
|
|
1085
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1086
|
+
const server = nodeWith({});
|
|
1087
|
+
const room = roomFor(server, recorded, false);
|
|
1088
|
+
await room.connect();
|
|
1089
|
+
const publication = await room.publish(track(), "microphone");
|
|
1090
|
+
|
|
1091
|
+
await room.setPaused(publication.producerId, true);
|
|
1092
|
+
await room.setPaused(publication.producerId, true);
|
|
1093
|
+
|
|
1094
|
+
expect(requestsOf(server, "pauseProducer")).toHaveLength(1);
|
|
1095
|
+
await room.close();
|
|
1096
|
+
});
|
|
1097
|
+
});
|