@tribe-nest/media-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/build/core/index.d.ts +17 -0
- package/build/core/index.d.ts.map +1 -0
- package/build/core/index.js +41 -0
- package/build/core/index.js.map +1 -0
- package/build/core/reconnect.d.ts +95 -0
- package/build/core/reconnect.d.ts.map +1 -0
- package/build/core/reconnect.js +160 -0
- package/build/core/reconnect.js.map +1 -0
- package/build/core/signal.d.ts +184 -0
- package/build/core/signal.d.ts.map +1 -0
- package/build/core/signal.js +416 -0
- package/build/core/signal.js.map +1 -0
- package/build/core/socket.d.ts +57 -0
- package/build/core/socket.d.ts.map +1 -0
- package/build/core/socket.js +37 -0
- package/build/core/socket.js.map +1 -0
- package/build/core/state.d.ts +67 -0
- package/build/core/state.d.ts.map +1 -0
- package/build/core/state.js +193 -0
- package/build/core/state.js.map +1 -0
- package/build/index.d.ts +29 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +51 -0
- package/build/index.js.map +1 -0
- package/build/protocol.d.ts +10 -0
- package/build/protocol.d.ts.map +1 -0
- package/build/protocol.js +26 -0
- package/build/protocol.js.map +1 -0
- package/build/react/index.d.ts +147 -0
- package/build/react/index.d.ts.map +1 -0
- package/build/react/index.js +319 -0
- package/build/react/index.js.map +1 -0
- package/build/room/browserDevice.d.ts +3 -0
- package/build/room/browserDevice.d.ts.map +1 -0
- package/build/room/browserDevice.js +94 -0
- package/build/room/browserDevice.js.map +1 -0
- package/build/room/device.d.ts +114 -0
- package/build/room/device.d.ts.map +1 -0
- package/build/room/device.js +3 -0
- package/build/room/device.js.map +1 -0
- package/build/room/room.d.ts +219 -0
- package/build/room/room.d.ts.map +1 -0
- package/build/room/room.js +438 -0
- package/build/room/room.js.map +1 -0
- package/package.json +69 -0
- package/src/_tests/clientBoundary.spec.ts +110 -0
- package/src/core/_tests/coreBoundary.spec.ts +70 -0
- package/src/core/_tests/fakeSignalServer.ts +188 -0
- package/src/core/_tests/reconnect.spec.ts +180 -0
- package/src/core/_tests/signal.spec.ts +347 -0
- package/src/core/_tests/state.spec.ts +226 -0
- package/src/core/index.ts +63 -0
- package/src/core/reconnect.ts +233 -0
- package/src/core/signal.ts +527 -0
- package/src/core/socket.ts +58 -0
- package/src/core/state.ts +251 -0
- package/src/index.ts +54 -0
- package/src/protocol.ts +9 -0
- package/src/react/_tests/hooks.spec.tsx +509 -0
- package/src/react/index.tsx +439 -0
- package/src/room/_tests/room.spec.ts +595 -0
- package/src/room/browserDevice.ts +114 -0
- package/src/room/device.ts +119 -0
- package/src/room/room.ts +600 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { FakeSignalServer } from "../../core/_tests/fakeSignalServer";
|
|
7
|
+
import type { MediaDevice, MediaTransport } from "../../room/device";
|
|
8
|
+
import {
|
|
9
|
+
MediaRoomProvider,
|
|
10
|
+
useLocalMedia,
|
|
11
|
+
useParticipants,
|
|
12
|
+
useRemoteTrack,
|
|
13
|
+
useRoomState,
|
|
14
|
+
useVisibleProducers,
|
|
15
|
+
} from "../index";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The hooks, rendered.
|
|
19
|
+
*
|
|
20
|
+
* ## The bug this file exists for
|
|
21
|
+
*
|
|
22
|
+
* `useSyncExternalStore` compares snapshots with `Object.is`. A selector that
|
|
23
|
+
* builds a new array on every call therefore reports a change on every check,
|
|
24
|
+
* and React re-renders for ever - "The result of getSnapshot should be cached
|
|
25
|
+
* to avoid an infinite loop". `useRemoteTrack` and `useLocalMedia` both did
|
|
26
|
+
* exactly that, because `room.tracks` and `room.localPublications` were getters
|
|
27
|
+
* that spread a Map.
|
|
28
|
+
*
|
|
29
|
+
* It is not a subtle degradation: the hooks were unusable, and nothing in the
|
|
30
|
+
* type system or the unit tests could see it, because the loop only exists once
|
|
31
|
+
* React is actually driving them.
|
|
32
|
+
*
|
|
33
|
+
* So every test here counts RENDERS. A hook that settles is the property; a
|
|
34
|
+
* hook that returns the right value while looping is not passing.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// Not configured globally in this package, so a render from one test would
|
|
38
|
+
// otherwise still be mounted during the next and every query would find two.
|
|
39
|
+
afterEach(cleanup);
|
|
40
|
+
|
|
41
|
+
beforeAll(() => {
|
|
42
|
+
// jsdom has neither, and `attach` builds a `MediaStream` to put on an
|
|
43
|
+
// element. Stand-ins are enough: what is asserted is WHICH tracks reach the
|
|
44
|
+
// element and when, not what the browser does with them afterwards.
|
|
45
|
+
if (!("MediaStream" in globalThis)) {
|
|
46
|
+
(globalThis as { MediaStream?: unknown }).MediaStream = class {
|
|
47
|
+
constructor(readonly tracks: unknown[] = []) {}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const fakeDevice = (): MediaDevice => ({
|
|
53
|
+
async load() {},
|
|
54
|
+
loaded: true,
|
|
55
|
+
rtpCapabilities: { codecs: [] } as never,
|
|
56
|
+
canProduce: () => true,
|
|
57
|
+
createSendTransport: () => fakeTransport(),
|
|
58
|
+
createRecvTransport: () => fakeTransport(),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const fakeTransport = (): MediaTransport => ({
|
|
62
|
+
id: "t-1",
|
|
63
|
+
async produce() {
|
|
64
|
+
return { id: "p-1", kind: "audio", closed: false, pause: vi.fn(), resume: vi.fn(), close: vi.fn() };
|
|
65
|
+
},
|
|
66
|
+
async consume(input) {
|
|
67
|
+
return {
|
|
68
|
+
id: "c-1",
|
|
69
|
+
producerId: input.producerId,
|
|
70
|
+
kind: input.kind,
|
|
71
|
+
track: { kind: input.kind, stop: vi.fn() } as unknown as MediaStreamTrack,
|
|
72
|
+
pause: vi.fn(),
|
|
73
|
+
resume: vi.fn(),
|
|
74
|
+
close: vi.fn(),
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
close: vi.fn(),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
function nodeWith(producers: { producerId: string; identity: string; kind: "audio" | "video" }[] = []) {
|
|
81
|
+
const server: FakeSignalServer = new FakeSignalServer({
|
|
82
|
+
joined: {
|
|
83
|
+
identity: "me",
|
|
84
|
+
room: "matter-1",
|
|
85
|
+
routerRtpCapabilities: { codecs: [] },
|
|
86
|
+
producers,
|
|
87
|
+
grants: { canPublish: true, canSubscribe: true, canPublishData: false },
|
|
88
|
+
},
|
|
89
|
+
onRequest: (frame, socket) => {
|
|
90
|
+
if (frame.method === "join") return;
|
|
91
|
+
if (frame.method === "createTransport") {
|
|
92
|
+
return server.reply(frame.id, { transportId: "t-1", iceParameters: {}, iceCandidates: [], dtlsParameters: {} }, socket);
|
|
93
|
+
}
|
|
94
|
+
if (frame.method === "consume") {
|
|
95
|
+
return server.reply(frame.id, { consumerId: "c-1", producerId: frame.producerId, kind: "audio", rtpParameters: {} }, socket);
|
|
96
|
+
}
|
|
97
|
+
return server.reply(frame.id, {}, socket);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
return server;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Counts every render of the component it is used in. */
|
|
104
|
+
function useRenderCount(): { current: number } {
|
|
105
|
+
const count = useRef(0);
|
|
106
|
+
count.current++;
|
|
107
|
+
return count;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const wrap = (server: FakeSignalServer, children: React.ReactNode) => (
|
|
111
|
+
<MediaRoomProvider
|
|
112
|
+
getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
|
|
113
|
+
device={fakeDevice}
|
|
114
|
+
webSocket={server.factory}
|
|
115
|
+
autoSubscribe={false}
|
|
116
|
+
>
|
|
117
|
+
{children}
|
|
118
|
+
</MediaRoomProvider>
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
describe("hooks settle instead of looping", () => {
|
|
122
|
+
it("useRemoteTrack does not re-render for ever", async () => {
|
|
123
|
+
const renders: number[] = [];
|
|
124
|
+
function Probe() {
|
|
125
|
+
const count = useRenderCount();
|
|
126
|
+
const { track } = useRemoteTrack("p-a");
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
renders.push(count.current);
|
|
129
|
+
});
|
|
130
|
+
return <div data-testid="track">{track ? "yes" : "no"}</div>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const server = nodeWith([{ producerId: "p-a", identity: "a", kind: "audio" }]);
|
|
134
|
+
render(wrap(server, <Probe />));
|
|
135
|
+
|
|
136
|
+
// Settled, not merely correct. A looping hook also renders "no".
|
|
137
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
138
|
+
const settled = renders.at(-1) ?? 0;
|
|
139
|
+
expect(settled).toBeLessThan(15);
|
|
140
|
+
|
|
141
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
142
|
+
expect(renders.at(-1)).toBe(settled);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("useLocalMedia does not re-render for ever", async () => {
|
|
146
|
+
const renders: number[] = [];
|
|
147
|
+
function Probe() {
|
|
148
|
+
const count = useRenderCount();
|
|
149
|
+
const { isCameraEnabled } = useLocalMedia();
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
renders.push(count.current);
|
|
152
|
+
});
|
|
153
|
+
return <div>{String(isCameraEnabled)}</div>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
render(wrap(nodeWith(), <Probe />));
|
|
157
|
+
|
|
158
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
159
|
+
const settled = renders.at(-1) ?? 0;
|
|
160
|
+
expect(settled).toBeLessThan(15);
|
|
161
|
+
|
|
162
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
163
|
+
expect(renders.at(-1)).toBe(settled);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("useRoomState and useParticipants settle too", async () => {
|
|
167
|
+
const renders: number[] = [];
|
|
168
|
+
function Probe() {
|
|
169
|
+
const count = useRenderCount();
|
|
170
|
+
const state = useRoomState();
|
|
171
|
+
const peers = useParticipants();
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
renders.push(count.current);
|
|
174
|
+
});
|
|
175
|
+
return <div>{`${state.phase}:${peers.length}`}</div>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
render(wrap(nodeWith(), <Probe />));
|
|
179
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
180
|
+
const settled = renders.at(-1) ?? 0;
|
|
181
|
+
expect(settled).toBeLessThan(15);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("the room reaches the hooks", () => {
|
|
186
|
+
it("reports the joined state", async () => {
|
|
187
|
+
function Probe() {
|
|
188
|
+
const state = useRoomState();
|
|
189
|
+
return <div data-testid="phase">{state.phase}</div>;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const { getByTestId } = render(wrap(nodeWith(), <Probe />));
|
|
193
|
+
await waitFor(() => expect(getByTestId("phase").textContent).toBe("joined"));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("exposes a track once it has been subscribed", async () => {
|
|
197
|
+
function Probe() {
|
|
198
|
+
const { track } = useRemoteTrack("p-a");
|
|
199
|
+
return <div data-testid="track">{track?.identity ?? "none"}</div>;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const server = nodeWith([{ producerId: "p-a", identity: "anwalt", kind: "audio" }]);
|
|
203
|
+
const { getByTestId } = render(
|
|
204
|
+
<MediaRoomProvider
|
|
205
|
+
getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
|
|
206
|
+
device={fakeDevice}
|
|
207
|
+
webSocket={server.factory}
|
|
208
|
+
>
|
|
209
|
+
<Probe />
|
|
210
|
+
</MediaRoomProvider>,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// The socket exists only once the provider's effect has run, so the event
|
|
214
|
+
// has to wait for the join rather than racing it.
|
|
215
|
+
await waitFor(() => expect(server.sockets.length).toBeGreaterThan(0));
|
|
216
|
+
await waitFor(() => expect(server.received.some((f) => f.method === "join")).toBe(true));
|
|
217
|
+
|
|
218
|
+
// autoSubscribe on, and the node has said the producer is in the set.
|
|
219
|
+
server.event({ event: "activeSpeakers", producerIds: ["p-a"] });
|
|
220
|
+
await waitFor(() => expect(getByTestId("track").textContent).toBe("anwalt"));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The element is mounted BEFORE the track exists, which is the ordinary case.
|
|
225
|
+
*
|
|
226
|
+
* A producer reaches room state the moment the node announces it; the consume
|
|
227
|
+
* round trip that yields a track finishes some milliseconds later. An element
|
|
228
|
+
* rendered unconditionally - which is what remote audio has to be, since a
|
|
229
|
+
* participant with the camera off still has a voice - therefore takes its ref
|
|
230
|
+
* callback while `tracks` is still empty. Keyed on `[room, producerId]`, both
|
|
231
|
+
* stable, that callback set `srcObject` to `null` once and was never invoked
|
|
232
|
+
* again: the whole call was silent, in both directions, on a screen that
|
|
233
|
+
* looked completely correct.
|
|
234
|
+
*/
|
|
235
|
+
it("attaches a track that arrives AFTER the element mounted", async () => {
|
|
236
|
+
function Probe() {
|
|
237
|
+
const { attach } = useRemoteTrack("p-a");
|
|
238
|
+
return <audio ref={attach} data-testid="audio" />;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const server = nodeWith([{ producerId: "p-a", identity: "anwalt", kind: "audio" }]);
|
|
242
|
+
const { getByTestId } = render(
|
|
243
|
+
<MediaRoomProvider
|
|
244
|
+
getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
|
|
245
|
+
device={fakeDevice}
|
|
246
|
+
webSocket={server.factory}
|
|
247
|
+
>
|
|
248
|
+
<Probe />
|
|
249
|
+
</MediaRoomProvider>,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
await waitFor(() => expect(server.received.some((f) => f.method === "join")).toBe(true));
|
|
253
|
+
const element = getByTestId("audio") as HTMLAudioElement;
|
|
254
|
+
// Nothing yet, and this is the state the bug froze the element in.
|
|
255
|
+
expect(element.srcObject).toBeNull();
|
|
256
|
+
|
|
257
|
+
server.event({ event: "activeSpeakers", producerIds: ["p-a"] });
|
|
258
|
+
|
|
259
|
+
await waitFor(() => expect(getByTestId("audio").srcObject).not.toBeNull());
|
|
260
|
+
// The SAME element. A test that re-queried and found a freshly mounted one
|
|
261
|
+
// would prove nothing about the element that was already on the page.
|
|
262
|
+
expect(getByTestId("audio")).toBe(element);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* A capture the room never took is still a live camera.
|
|
268
|
+
*
|
|
269
|
+
* `getUserMedia` turns the light on. If the publish that follows fails, no
|
|
270
|
+
* publication is recorded, so the UI still reads "Start camera" and
|
|
271
|
+
* `unpublish("camera")` has nothing to close: the light stays on with no
|
|
272
|
+
* control that turns it off, and every retry opens another capture.
|
|
273
|
+
*/
|
|
274
|
+
describe("a publish that fails does not leave the device running", () => {
|
|
275
|
+
const captureWith = (tracks: MediaStreamTrack[]) => {
|
|
276
|
+
const getUserMedia = vi.fn(async () => ({ getTracks: () => tracks }) as unknown as MediaStream);
|
|
277
|
+
const getDisplayMedia = vi.fn(async () => ({ getTracks: () => tracks }) as unknown as MediaStream);
|
|
278
|
+
Object.defineProperty(navigator, "mediaDevices", {
|
|
279
|
+
configurable: true,
|
|
280
|
+
value: { getUserMedia, getDisplayMedia },
|
|
281
|
+
});
|
|
282
|
+
return { getUserMedia, getDisplayMedia };
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const fakeTrack = (kind: "audio" | "video") =>
|
|
286
|
+
({ kind, stop: vi.fn(), enabled: true }) as unknown as MediaStreamTrack & { stop: ReturnType<typeof vi.fn> };
|
|
287
|
+
|
|
288
|
+
afterEach(() => {
|
|
289
|
+
delete (navigator as { mediaDevices?: unknown }).mediaDevices;
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
/** A device whose transport refuses to produce, as the node refusing a grant. */
|
|
293
|
+
const refusingDevice = (): MediaDevice => ({
|
|
294
|
+
...fakeDevice(),
|
|
295
|
+
createSendTransport: () => ({
|
|
296
|
+
...fakeTransport(),
|
|
297
|
+
async produce() {
|
|
298
|
+
throw new Error("this token may not publish camera");
|
|
299
|
+
},
|
|
300
|
+
}),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("stops the camera when the produce is refused", async () => {
|
|
304
|
+
const track = fakeTrack("video");
|
|
305
|
+
captureWith([track]);
|
|
306
|
+
|
|
307
|
+
let controls: ReturnType<typeof useLocalMedia> | undefined;
|
|
308
|
+
function Probe() {
|
|
309
|
+
controls = useLocalMedia();
|
|
310
|
+
return <div>{String(controls.isCameraEnabled)}</div>;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
render(
|
|
314
|
+
<MediaRoomProvider
|
|
315
|
+
getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
|
|
316
|
+
device={refusingDevice}
|
|
317
|
+
webSocket={nodeWith().factory}
|
|
318
|
+
autoSubscribe={false}
|
|
319
|
+
>
|
|
320
|
+
<Probe />
|
|
321
|
+
</MediaRoomProvider>,
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
await waitFor(() => expect(controls).toBeDefined());
|
|
325
|
+
await act(async () => {
|
|
326
|
+
await controls?.publishCamera();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// The refusal is shown, AND the light is out. Only the first of those was
|
|
330
|
+
// true, and the second is the one the person sitting in front of it sees.
|
|
331
|
+
await waitFor(() => expect(controls?.error?.message).toContain("may not publish"));
|
|
332
|
+
expect(track.stop).toHaveBeenCalledTimes(1);
|
|
333
|
+
expect(controls?.isCameraEnabled).toBe(false);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("stops the extra tracks a capture carries but a publish never takes", async () => {
|
|
337
|
+
// Chrome adds a system-audio track to a screen share when the person ticks
|
|
338
|
+
// "share audio". One track is published; the other is ours to close.
|
|
339
|
+
const video = fakeTrack("video");
|
|
340
|
+
const audio = fakeTrack("audio");
|
|
341
|
+
captureWith([video, audio]);
|
|
342
|
+
|
|
343
|
+
let controls: ReturnType<typeof useLocalMedia> | undefined;
|
|
344
|
+
function Probe() {
|
|
345
|
+
controls = useLocalMedia();
|
|
346
|
+
return <div>{String(controls.screenSharing)}</div>;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
render(
|
|
350
|
+
<MediaRoomProvider
|
|
351
|
+
getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
|
|
352
|
+
device={fakeDevice}
|
|
353
|
+
webSocket={nodeWith().factory}
|
|
354
|
+
autoSubscribe={false}
|
|
355
|
+
>
|
|
356
|
+
<Probe />
|
|
357
|
+
</MediaRoomProvider>,
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
await waitFor(() => expect(controls).toBeDefined());
|
|
361
|
+
await act(async () => {
|
|
362
|
+
await controls?.publishScreen();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
await waitFor(() => expect(controls?.screenSharing).toBe(true));
|
|
366
|
+
// The published one belongs to the room now, and the room's teardown stops
|
|
367
|
+
// it on every path out of a call, not only a deliberate leave.
|
|
368
|
+
expect(video.stop).not.toHaveBeenCalled();
|
|
369
|
+
expect(audio.stop).toHaveBeenCalledTimes(1);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The second press, which is the ordinary one.
|
|
375
|
+
*
|
|
376
|
+
* Every control over this hook is a read-then-write: it reads
|
|
377
|
+
* `isMicrophoneEnabled` and publishes or unpublishes accordingly. That flag
|
|
378
|
+
* comes from `publications`, which does not move until the publish RESOLVES,
|
|
379
|
+
* and a publish opens with a `getUserMedia` permission prompt that sits on
|
|
380
|
+
* screen for seconds. So a person who presses Unmute, sees nothing change, and
|
|
381
|
+
* presses it again used to put two captures through two `room.publish` calls:
|
|
382
|
+
* two audio producers under one identity, everyone else mounting two `<audio>`
|
|
383
|
+
* elements and hearing them doubled, and unpublishing both afterwards does not
|
|
384
|
+
* give back the seconds of garbled audio. For a screen share it opens a second
|
|
385
|
+
* picker.
|
|
386
|
+
*/
|
|
387
|
+
describe("one press, one publish", () => {
|
|
388
|
+
/** A capture that hangs, as a permission prompt does. Released by hand. */
|
|
389
|
+
const promptThatHangs = () => {
|
|
390
|
+
let release!: () => void;
|
|
391
|
+
const answered = new Promise<void>((resolve) => {
|
|
392
|
+
release = resolve;
|
|
393
|
+
});
|
|
394
|
+
const track = { kind: "audio", stop: vi.fn(), enabled: true } as unknown as MediaStreamTrack;
|
|
395
|
+
const getUserMedia = vi.fn(async () => {
|
|
396
|
+
await answered;
|
|
397
|
+
return { getTracks: () => [track] } as unknown as MediaStream;
|
|
398
|
+
});
|
|
399
|
+
Object.defineProperty(navigator, "mediaDevices", {
|
|
400
|
+
configurable: true,
|
|
401
|
+
value: { getUserMedia, getDisplayMedia: getUserMedia },
|
|
402
|
+
});
|
|
403
|
+
return { getUserMedia, release };
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const countingDevice = (produced: string[]): MediaDevice => ({
|
|
407
|
+
...fakeDevice(),
|
|
408
|
+
createSendTransport: () => ({
|
|
409
|
+
...fakeTransport(),
|
|
410
|
+
async produce(input: { appData?: Record<string, unknown> }) {
|
|
411
|
+
produced.push(String(input.appData?.source));
|
|
412
|
+
return { id: `p-${produced.length}`, kind: "audio" as const, closed: false, pause: vi.fn(), resume: vi.fn(), close: vi.fn() };
|
|
413
|
+
},
|
|
414
|
+
}),
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
afterEach(() => {
|
|
418
|
+
delete (navigator as { mediaDevices?: unknown }).mediaDevices;
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("captures and produces ONCE when the control is pressed twice in a tick", async () => {
|
|
422
|
+
const { getUserMedia, release } = promptThatHangs();
|
|
423
|
+
const produced: string[] = [];
|
|
424
|
+
|
|
425
|
+
let controls: ReturnType<typeof useLocalMedia> | undefined;
|
|
426
|
+
function Probe() {
|
|
427
|
+
controls = useLocalMedia();
|
|
428
|
+
return <div data-testid="pending">{String(controls.pending.microphone)}</div>;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const { getByTestId } = render(
|
|
432
|
+
<MediaRoomProvider
|
|
433
|
+
getCredentials={() => ({ mediaUrl: "wss://media.example", token: "t" })}
|
|
434
|
+
device={() => countingDevice(produced)}
|
|
435
|
+
webSocket={nodeWith().factory}
|
|
436
|
+
autoSubscribe={false}
|
|
437
|
+
>
|
|
438
|
+
<Probe />
|
|
439
|
+
</MediaRoomProvider>,
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
await waitFor(() => expect(controls).toBeDefined());
|
|
443
|
+
|
|
444
|
+
// Both in ONE tick, before React has re-rendered anything. That is why the
|
|
445
|
+
// guard has to be a ref: a state flag reads `false` in both handlers.
|
|
446
|
+
let both: Promise<unknown> | undefined;
|
|
447
|
+
await act(async () => {
|
|
448
|
+
both = Promise.all([controls?.publishMicrophone(), controls?.publishMicrophone()]);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// And the control says it is working, rather than looking untouched.
|
|
452
|
+
expect(getByTestId("pending").textContent).toBe("true");
|
|
453
|
+
|
|
454
|
+
await act(async () => {
|
|
455
|
+
release();
|
|
456
|
+
await both;
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
expect(getUserMedia).toHaveBeenCalledTimes(1);
|
|
460
|
+
expect(produced).toEqual(["microphone"]);
|
|
461
|
+
expect(controls?.isMicrophoneEnabled).toBe(true);
|
|
462
|
+
expect(getByTestId("pending").textContent).toBe("false");
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The subscribe barrier, applied where it is rendered.
|
|
468
|
+
*
|
|
469
|
+
* `useRoomState().producers` is every producer the node has ever announced,
|
|
470
|
+
* kept deliberately so that a rule which narrows mid-call can later widen -
|
|
471
|
+
* there is no `producerAppeared` replay to bring back what a prune deleted. The
|
|
472
|
+
* price is that the raw list holds people this seat has been barred from, and
|
|
473
|
+
* the node does not clean up for us: `revokeNewlyForbidden` closes only
|
|
474
|
+
* producers this session had a LIVE consumer for, so one that was announced and
|
|
475
|
+
* never consumed stays in the list for the rest of the call. On a sealed room
|
|
476
|
+
* the other party's presence is most of what the barrier was hiding.
|
|
477
|
+
*/
|
|
478
|
+
describe("useVisibleProducers applies the barrier the raw list deliberately does not", () => {
|
|
479
|
+
it("drops a producer the node has barred, without pruning the room's own record", async () => {
|
|
480
|
+
const seen: string[][] = [];
|
|
481
|
+
function Probe() {
|
|
482
|
+
const visible = useVisibleProducers();
|
|
483
|
+
const state = useRoomState();
|
|
484
|
+
seen.push(visible.map((p) => p.producerId));
|
|
485
|
+
return <div data-testid="visible">{`${visible.length}/${state.producers.length}`}</div>;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const server = nodeWith([
|
|
489
|
+
{ producerId: "p-client", identity: "client", kind: "audio" },
|
|
490
|
+
{ producerId: "p-third-party", identity: "third-party", kind: "audio" },
|
|
491
|
+
]);
|
|
492
|
+
const { getByTestId } = render(wrap(server, <Probe />));
|
|
493
|
+
|
|
494
|
+
await waitFor(() => expect(getByTestId("visible").textContent).toBe("2/2"));
|
|
495
|
+
|
|
496
|
+
server.event({ event: "subscribeRuleChanged", subscribe: { mode: "allow", identities: ["client"] } });
|
|
497
|
+
|
|
498
|
+
// One rendered, two remembered. Pruning the record instead would make a
|
|
499
|
+
// barrier that later widens unrecoverable.
|
|
500
|
+
await waitFor(() => expect(getByTestId("visible").textContent).toBe("1/2"));
|
|
501
|
+
expect(seen.at(-1)).toEqual(["p-client"]);
|
|
502
|
+
|
|
503
|
+
// And it settles: the filter builds a new array per call, so a version of
|
|
504
|
+
// this read through `useSyncExternalStore` would re-render for ever.
|
|
505
|
+
const settled = seen.length;
|
|
506
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
507
|
+
expect(seen.length).toBe(settled);
|
|
508
|
+
});
|
|
509
|
+
});
|