@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.
@@ -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 { FakeSignalServer, flush } from "../../core/_tests/fakeSignalServer";
4
- import { MediaRoom } from "../room";
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({ description, handlers }: { description: TransportDescription; iceServers: IceServer[]; handlers: TransportHandlers }) {
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 = () => ({ kind: "audio", stop: vi.fn() }) as unknown as MediaStreamTrack & { stop: ReturnType<typeof vi.fn> };
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(requestsOf(server, "consume").map((r) => (r as { producerId: string }).producerId).sort()).toEqual([
334
- "p-1",
335
- "p-3",
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
+ });