@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,595 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { FakeSignalServer, flush } from "../../core/_tests/fakeSignalServer";
|
|
4
|
+
import { MediaRoom } from "../room";
|
|
5
|
+
import type {
|
|
6
|
+
IceServer,
|
|
7
|
+
MediaConsumerHandle,
|
|
8
|
+
MediaDevice,
|
|
9
|
+
MediaProducerHandle,
|
|
10
|
+
MediaTransport,
|
|
11
|
+
TransportDescription,
|
|
12
|
+
TransportHandlers,
|
|
13
|
+
} from "../device";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The room's ordering rules, against a fake device.
|
|
17
|
+
*
|
|
18
|
+
* This is what the `MediaDevice` seam is FOR. Every rule below has a failure
|
|
19
|
+
* that only shows up on a live call - a consume refused for what looks like a
|
|
20
|
+
* codec problem, a black tile until the next keyframe, a second DTLS handshake
|
|
21
|
+
* nobody asked for - and none of them needs a browser to test.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type Recorded = { calls: string[]; transports: FakeTransport[] };
|
|
25
|
+
|
|
26
|
+
class FakeTransport implements MediaTransport {
|
|
27
|
+
readonly produced: { appData?: Record<string, unknown> }[] = [];
|
|
28
|
+
readonly consumed: string[] = [];
|
|
29
|
+
closed = false;
|
|
30
|
+
handlers: Partial<TransportHandlers> = {};
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
readonly id: string,
|
|
34
|
+
readonly direction: "send" | "recv",
|
|
35
|
+
private readonly recorded: Recorded,
|
|
36
|
+
) {}
|
|
37
|
+
|
|
38
|
+
async produce(input: { track: MediaStreamTrack; appData?: Record<string, unknown> }): Promise<MediaProducerHandle> {
|
|
39
|
+
this.produced.push({ ...(input.appData ? { appData: input.appData } : {}) });
|
|
40
|
+
// The real transport asks the application to tell the node, and the node's
|
|
41
|
+
// answer is the producer id. Modelled, because the round trip is the thing
|
|
42
|
+
// that can be got wrong.
|
|
43
|
+
const producerId = await this.handlers.onProduce!({
|
|
44
|
+
kind: input.track.kind === "audio" ? "audio" : "video",
|
|
45
|
+
rtpParameters: {} as never,
|
|
46
|
+
...(input.appData ? { appData: input.appData } : {}),
|
|
47
|
+
});
|
|
48
|
+
this.recorded.calls.push(`produce:${producerId}`);
|
|
49
|
+
return {
|
|
50
|
+
id: producerId,
|
|
51
|
+
kind: input.track.kind === "audio" ? "audio" : "video",
|
|
52
|
+
closed: false,
|
|
53
|
+
pause: vi.fn(),
|
|
54
|
+
resume: vi.fn(),
|
|
55
|
+
close: vi.fn(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async consume(input: { id: string; producerId: string; kind: "audio" | "video" }): Promise<MediaConsumerHandle> {
|
|
60
|
+
this.consumed.push(input.producerId);
|
|
61
|
+
this.recorded.calls.push(`consume:${input.producerId}`);
|
|
62
|
+
const resume = vi.fn(() => this.recorded.calls.push(`track-resume:${input.producerId}`));
|
|
63
|
+
return {
|
|
64
|
+
id: input.id,
|
|
65
|
+
producerId: input.producerId,
|
|
66
|
+
kind: input.kind,
|
|
67
|
+
track: { kind: input.kind, stop: vi.fn() } as unknown as MediaStreamTrack,
|
|
68
|
+
pause: vi.fn(),
|
|
69
|
+
resume,
|
|
70
|
+
close: vi.fn(),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
close() {
|
|
75
|
+
this.closed = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fakeDevice(recorded: Recorded) {
|
|
80
|
+
let loaded = false;
|
|
81
|
+
const device: MediaDevice = {
|
|
82
|
+
async load() {
|
|
83
|
+
recorded.calls.push("device-load");
|
|
84
|
+
loaded = true;
|
|
85
|
+
},
|
|
86
|
+
get loaded() {
|
|
87
|
+
return loaded;
|
|
88
|
+
},
|
|
89
|
+
get rtpCapabilities() {
|
|
90
|
+
// The rule: asking before `load` is a bug, so the fake makes it loud
|
|
91
|
+
// rather than returning something plausible.
|
|
92
|
+
if (!loaded) throw new Error("rtpCapabilities read before the device was loaded");
|
|
93
|
+
return { codecs: [] } as never;
|
|
94
|
+
},
|
|
95
|
+
canProduce: () => true,
|
|
96
|
+
createSendTransport({ description, handlers }: { description: TransportDescription; iceServers: IceServer[]; handlers: TransportHandlers }) {
|
|
97
|
+
const transport = new FakeTransport(description.transportId, "send", recorded);
|
|
98
|
+
transport.handlers = handlers;
|
|
99
|
+
recorded.transports.push(transport);
|
|
100
|
+
recorded.calls.push("create-send-transport");
|
|
101
|
+
return transport;
|
|
102
|
+
},
|
|
103
|
+
createRecvTransport({ description, handlers }) {
|
|
104
|
+
const transport = new FakeTransport(description.transportId, "recv", recorded);
|
|
105
|
+
transport.handlers = handlers as Partial<TransportHandlers>;
|
|
106
|
+
recorded.transports.push(transport);
|
|
107
|
+
recorded.calls.push("create-recv-transport");
|
|
108
|
+
return transport;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
return device;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A node that answers the frames the room sends.
|
|
116
|
+
*
|
|
117
|
+
* Built on the core suite's `FakeSignalServer` rather than a second fake, so
|
|
118
|
+
* the room is exercised against the same wire the signal client is.
|
|
119
|
+
*/
|
|
120
|
+
function nodeWith(input: {
|
|
121
|
+
producers?: { producerId: string; identity: string; kind: "audio" | "video" }[];
|
|
122
|
+
iceServers?: IceServer[];
|
|
123
|
+
}): FakeSignalServer {
|
|
124
|
+
let transportSeq = 0;
|
|
125
|
+
let producerSeq = 0;
|
|
126
|
+
let consumerSeq = 0;
|
|
127
|
+
|
|
128
|
+
const server: FakeSignalServer = new FakeSignalServer({
|
|
129
|
+
joined: {
|
|
130
|
+
identity: "me",
|
|
131
|
+
room: "matter-1",
|
|
132
|
+
routerRtpCapabilities: { codecs: [] },
|
|
133
|
+
producers: input.producers ?? [],
|
|
134
|
+
...(input.iceServers ? { iceServers: input.iceServers as never } : {}),
|
|
135
|
+
grants: { canPublish: true, canSubscribe: true, canPublishData: false },
|
|
136
|
+
},
|
|
137
|
+
onRequest: (frame, socket) => {
|
|
138
|
+
switch (frame.method) {
|
|
139
|
+
case "createTransport":
|
|
140
|
+
return server.reply(
|
|
141
|
+
frame.id,
|
|
142
|
+
{ transportId: `t-${transportSeq++}`, iceParameters: {}, iceCandidates: [], dtlsParameters: {} },
|
|
143
|
+
socket,
|
|
144
|
+
);
|
|
145
|
+
case "connectTransport":
|
|
146
|
+
return server.reply(frame.id, { connected: true }, socket);
|
|
147
|
+
case "produce":
|
|
148
|
+
return server.reply(frame.id, { producerId: `p-${producerSeq++}` }, socket);
|
|
149
|
+
case "consume":
|
|
150
|
+
return server.reply(
|
|
151
|
+
frame.id,
|
|
152
|
+
{ consumerId: `c-${consumerSeq++}`, producerId: frame.producerId, kind: "audio", rtpParameters: {} },
|
|
153
|
+
socket,
|
|
154
|
+
);
|
|
155
|
+
case "join":
|
|
156
|
+
return;
|
|
157
|
+
default:
|
|
158
|
+
return server.reply(frame.id, {}, socket);
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
return server;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Every request of one method, in arrival order. */
|
|
166
|
+
const requestsOf = (server: FakeSignalServer, method: string) =>
|
|
167
|
+
server.received.filter((frame) => frame.method === method);
|
|
168
|
+
|
|
169
|
+
const roomFor = (server: FakeSignalServer, recorded: Recorded, autoSubscribe = true) =>
|
|
170
|
+
new MediaRoom({
|
|
171
|
+
getCredentials: () => ({ mediaUrl: "wss://media.example", token: "t" }),
|
|
172
|
+
device: () => fakeDevice(recorded),
|
|
173
|
+
webSocket: server.factory,
|
|
174
|
+
autoSubscribe,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A room that retries almost instantly, and counts its tickets.
|
|
179
|
+
*
|
|
180
|
+
* The ladder itself is pinned exhaustively in `core/_tests/reconnect.spec.ts`
|
|
181
|
+
* against an injected `random`; what these specs are about is whether the room
|
|
182
|
+
* DRIVES it at all, so the delays are collapsed to something a real timer can
|
|
183
|
+
* clear inside a test rather than reproduced here.
|
|
184
|
+
*/
|
|
185
|
+
const recoveringRoom = (
|
|
186
|
+
server: FakeSignalServer,
|
|
187
|
+
recorded: Recorded,
|
|
188
|
+
overrides: Partial<ConstructorParameters<typeof MediaRoom>[0]> = {},
|
|
189
|
+
) => {
|
|
190
|
+
const tickets: string[] = [];
|
|
191
|
+
const room = new MediaRoom({
|
|
192
|
+
getCredentials: () => {
|
|
193
|
+
const token = `t-${tickets.length}`;
|
|
194
|
+
tickets.push(token);
|
|
195
|
+
return { mediaUrl: "wss://media.example", token };
|
|
196
|
+
},
|
|
197
|
+
device: () => fakeDevice(recorded),
|
|
198
|
+
webSocket: server.factory,
|
|
199
|
+
autoSubscribe: false,
|
|
200
|
+
reconnect: { baseMs: 1, maxMs: 4, factor: 1, jitter: "none" },
|
|
201
|
+
...overrides,
|
|
202
|
+
});
|
|
203
|
+
return { room, tickets };
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const track = () => ({ kind: "audio", stop: vi.fn() }) as unknown as MediaStreamTrack & { stop: ReturnType<typeof vi.fn> };
|
|
207
|
+
|
|
208
|
+
describe("rule 1: the device loads before anything needs its capabilities", () => {
|
|
209
|
+
it("loads on join, before any transport", async () => {
|
|
210
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
211
|
+
const server = nodeWith({});
|
|
212
|
+
const room = roomFor(server, recorded);
|
|
213
|
+
|
|
214
|
+
await room.connect();
|
|
215
|
+
|
|
216
|
+
expect(recorded.calls[0]).toBe("device-load");
|
|
217
|
+
await room.close();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("refuses a second connect on a live room rather than loading twice", async () => {
|
|
221
|
+
// One room, one connection. A second `connect()` on a live socket would
|
|
222
|
+
// mean two joins under one identity, and the node refuses the second with
|
|
223
|
+
// `duplicate_identity` - so the client should never get that far.
|
|
224
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
225
|
+
const room = roomFor(nodeWith({}), recorded);
|
|
226
|
+
|
|
227
|
+
await room.connect();
|
|
228
|
+
await expect(room.connect()).rejects.toThrow(/already connecting or connected/);
|
|
229
|
+
|
|
230
|
+
expect(recorded.calls.filter((c) => c === "device-load")).toHaveLength(1);
|
|
231
|
+
await room.close();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("rule 2: one transport per direction, created lazily", () => {
|
|
236
|
+
it("creates NO transport for a participant that neither publishes nor subscribes", async () => {
|
|
237
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
238
|
+
// Nothing to subscribe to, so a recv transport would be an ICE gather for
|
|
239
|
+
// a participant who may only ever watch an empty room.
|
|
240
|
+
const room = roomFor(nodeWith({}), recorded, false);
|
|
241
|
+
|
|
242
|
+
await room.connect();
|
|
243
|
+
|
|
244
|
+
expect(recorded.transports).toHaveLength(0);
|
|
245
|
+
await room.close();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("reuses the send transport across publishes", async () => {
|
|
249
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
250
|
+
const room = roomFor(nodeWith({}), recorded, false);
|
|
251
|
+
await room.connect();
|
|
252
|
+
|
|
253
|
+
await room.publish({ kind: "audio", stop: vi.fn() } as never, "microphone");
|
|
254
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "camera");
|
|
255
|
+
|
|
256
|
+
expect(recorded.calls.filter((c) => c === "create-send-transport")).toHaveLength(1);
|
|
257
|
+
await room.close();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("reuses the recv transport across subscriptions", async () => {
|
|
261
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
262
|
+
const server = nodeWith({
|
|
263
|
+
producers: [
|
|
264
|
+
{ producerId: "p-a", identity: "a", kind: "audio" },
|
|
265
|
+
{ producerId: "p-b", identity: "b", kind: "audio" },
|
|
266
|
+
],
|
|
267
|
+
});
|
|
268
|
+
const room = roomFor(server, recorded, false);
|
|
269
|
+
await room.connect();
|
|
270
|
+
|
|
271
|
+
await room.subscribe("p-a");
|
|
272
|
+
await room.subscribe("p-b");
|
|
273
|
+
|
|
274
|
+
expect(recorded.calls.filter((c) => c === "create-recv-transport")).toHaveLength(1);
|
|
275
|
+
await room.close();
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("rule 3: a consumer resumes only after its track exists", () => {
|
|
280
|
+
it("consumes, then resumes", async () => {
|
|
281
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
282
|
+
const server = nodeWith({ producers: [{ producerId: "p-a", identity: "a", kind: "audio" }] });
|
|
283
|
+
const room = roomFor(server, recorded, false);
|
|
284
|
+
await room.connect();
|
|
285
|
+
|
|
286
|
+
await room.subscribe("p-a");
|
|
287
|
+
|
|
288
|
+
// The node creates every consumer PAUSED. Resuming before the application
|
|
289
|
+
// has the track means media arrives with nowhere to go and is dropped, and
|
|
290
|
+
// for video that is a black tile until the next keyframe.
|
|
291
|
+
const consumeAt = recorded.calls.indexOf("consume:p-a");
|
|
292
|
+
const resumeAt = recorded.calls.indexOf("track-resume:p-a");
|
|
293
|
+
expect(consumeAt).toBeGreaterThanOrEqual(0);
|
|
294
|
+
expect(resumeAt).toBeGreaterThan(consumeAt);
|
|
295
|
+
expect(requestsOf(server, "resumeConsumer")).toHaveLength(1);
|
|
296
|
+
await room.close();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("exposes the track once it is attached", async () => {
|
|
300
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
301
|
+
const server = nodeWith({ producers: [{ producerId: "p-a", identity: "anwalt", kind: "audio" }] });
|
|
302
|
+
const room = roomFor(server, recorded, false);
|
|
303
|
+
await room.connect();
|
|
304
|
+
await room.subscribe("p-a");
|
|
305
|
+
|
|
306
|
+
expect(room.tracks).toHaveLength(1);
|
|
307
|
+
expect(room.tracks[0]).toMatchObject({ producerId: "p-a", identity: "anwalt", kind: "audio" });
|
|
308
|
+
await room.close();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("rule 4: autoSubscribe follows the ACTIVE SET", () => {
|
|
313
|
+
it("subscribes only to what the node put in the set", async () => {
|
|
314
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
315
|
+
const server = nodeWith({
|
|
316
|
+
producers: [
|
|
317
|
+
{ producerId: "p-1", identity: "a", kind: "audio" },
|
|
318
|
+
{ producerId: "p-2", identity: "b", kind: "audio" },
|
|
319
|
+
{ producerId: "p-3", identity: "c", kind: "audio" },
|
|
320
|
+
],
|
|
321
|
+
});
|
|
322
|
+
const room = roomFor(server, recorded);
|
|
323
|
+
await room.connect();
|
|
324
|
+
|
|
325
|
+
// Nothing yet: the node has not said what the active set is.
|
|
326
|
+
expect(requestsOf(server, "consume")).toHaveLength(0);
|
|
327
|
+
|
|
328
|
+
server.event({ event: "activeSpeakers", producerIds: ["p-1", "p-3"] });
|
|
329
|
+
await vi.waitFor(() => expect(requestsOf(server, "consume")).toHaveLength(2));
|
|
330
|
+
|
|
331
|
+
// Subscribing to everything would produce one `subscription_limit` refusal
|
|
332
|
+
// 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
|
+
]);
|
|
337
|
+
await room.close();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("drops a subscription the node moved OUT of the set, and tells the node", async () => {
|
|
341
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
342
|
+
const server = nodeWith({
|
|
343
|
+
producers: [
|
|
344
|
+
{ producerId: "p-1", identity: "a", kind: "audio" },
|
|
345
|
+
{ producerId: "p-2", identity: "b", kind: "audio" },
|
|
346
|
+
],
|
|
347
|
+
});
|
|
348
|
+
const room = roomFor(server, recorded);
|
|
349
|
+
await room.connect();
|
|
350
|
+
|
|
351
|
+
server.event({ event: "activeSpeakers", producerIds: ["p-1"] });
|
|
352
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(1));
|
|
353
|
+
|
|
354
|
+
server.event({ event: "activeSpeakers", producerIds: ["p-2"] });
|
|
355
|
+
await vi.waitFor(() => expect(room.tracks.map((t) => t.producerId)).toEqual(["p-2"]));
|
|
356
|
+
|
|
357
|
+
// Told, not just dropped locally: the node holds a Consumer per
|
|
358
|
+
// subscription, and in a busy room the set churns constantly.
|
|
359
|
+
expect(requestsOf(server, "closeConsumer")).toHaveLength(1);
|
|
360
|
+
await room.close();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("does not subscribe twice when two frames arrive in one tick", async () => {
|
|
364
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
365
|
+
const server = nodeWith({ producers: [{ producerId: "p-1", identity: "a", kind: "audio" }] });
|
|
366
|
+
const room = roomFor(server, recorded);
|
|
367
|
+
await room.connect();
|
|
368
|
+
|
|
369
|
+
server.event({ event: "activeSpeakers", producerIds: ["p-1"] });
|
|
370
|
+
server.event({ event: "activeSpeakers", producerIds: ["p-1"] });
|
|
371
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(1));
|
|
372
|
+
|
|
373
|
+
expect(requestsOf(server, "consume")).toHaveLength(1);
|
|
374
|
+
await room.close();
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("publishing", () => {
|
|
379
|
+
it("sends the SOURCE, which is what the node checks against publishKinds", async () => {
|
|
380
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
381
|
+
const server = nodeWith({});
|
|
382
|
+
const room = roomFor(server, recorded, false);
|
|
383
|
+
await room.connect();
|
|
384
|
+
|
|
385
|
+
await room.publish({ kind: "video", stop: vi.fn() } as never, "screen");
|
|
386
|
+
|
|
387
|
+
// A token granting audio only must not publish a screen share by
|
|
388
|
+
// relabelling it, and the node is where that is decided.
|
|
389
|
+
const produce = requestsOf(server, "produce")[0] as { appData?: { source?: string } };
|
|
390
|
+
expect(produce.appData?.source).toBe("screen");
|
|
391
|
+
await room.close();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("tells the node when a publication ends", async () => {
|
|
395
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
396
|
+
const server = nodeWith({});
|
|
397
|
+
const room = roomFor(server, recorded, false);
|
|
398
|
+
await room.connect();
|
|
399
|
+
|
|
400
|
+
const publication = await room.publish({ kind: "audio", stop: vi.fn() } as never, "microphone");
|
|
401
|
+
await room.unpublish(publication.producerId);
|
|
402
|
+
|
|
403
|
+
// Closing only the local handle leaves the node holding a producer nobody
|
|
404
|
+
// is feeding.
|
|
405
|
+
expect(requestsOf(server, "closeProducer")).toHaveLength(1);
|
|
406
|
+
expect(room.localPublications).toHaveLength(0);
|
|
407
|
+
await room.close();
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* A state called "reconnecting" has to mean something is reconnecting.
|
|
413
|
+
*
|
|
414
|
+
* Before this, nothing in the stack ever attempted a second connection:
|
|
415
|
+
* `superviseConnection` was written, tested and never called, so a drop set the
|
|
416
|
+
* connection to `reconnecting` and stopped there. Every screen above it drew a
|
|
417
|
+
* spinner promising a recovery that no code was performing, and the way back
|
|
418
|
+
* into a paid coaching hour was for the person to work out on their own that
|
|
419
|
+
* they should reload the page. A drained node - an ordinary deploy - did it to
|
|
420
|
+
* everybody on that node at once.
|
|
421
|
+
*/
|
|
422
|
+
describe("the room gets itself back in", () => {
|
|
423
|
+
it("opens a SECOND connection after a drop the client did not ask for", async () => {
|
|
424
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
425
|
+
const server = nodeWith({});
|
|
426
|
+
const { room, tickets } = recoveringRoom(server, recorded);
|
|
427
|
+
await room.connect();
|
|
428
|
+
|
|
429
|
+
server.socket.dropFromServer();
|
|
430
|
+
|
|
431
|
+
await vi.waitFor(() => expect(server.sockets.length).toBe(2));
|
|
432
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
433
|
+
// A join ticket lives minutes and a call lasts an hour, so the second
|
|
434
|
+
// attempt must carry a FRESH one rather than replaying the first.
|
|
435
|
+
expect(tickets).toEqual(["t-0", "t-1"]);
|
|
436
|
+
expect(room.state.phase).toBe("joined");
|
|
437
|
+
await room.close();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("says whether it is actually coming back, so a screen can tell wait from press-something", async () => {
|
|
441
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
442
|
+
const server = nodeWith({});
|
|
443
|
+
// `maxAttempts: 0` is the policy declining to retry at all, which is the
|
|
444
|
+
// same position the ladder reaches once it is exhausted.
|
|
445
|
+
const { room } = recoveringRoom(server, recorded, { reconnect: { maxAttempts: 0 } });
|
|
446
|
+
await room.connect();
|
|
447
|
+
expect(room.isRecovering).toBe(false);
|
|
448
|
+
|
|
449
|
+
server.socket.dropFromServer();
|
|
450
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("reconnecting"));
|
|
451
|
+
|
|
452
|
+
// The connection state says the same word in both cases. This is the half
|
|
453
|
+
// that separates them, and without it the UI can only spin.
|
|
454
|
+
expect(room.isRecovering).toBe(false);
|
|
455
|
+
expect(server.sockets.length).toBe(1);
|
|
456
|
+
await room.close();
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("moves off a node that is draining, which is the case a retry cannot fix by waiting", async () => {
|
|
460
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
461
|
+
const server = nodeWith({});
|
|
462
|
+
const { room, tickets } = recoveringRoom(server, recorded);
|
|
463
|
+
await room.connect();
|
|
464
|
+
|
|
465
|
+
// What a deploy or a scale-down sends: the node asks to be left, then goes.
|
|
466
|
+
server.event({ event: "draining", reconnectAfterMs: 1 });
|
|
467
|
+
await flush();
|
|
468
|
+
server.socket.dropFromServer();
|
|
469
|
+
|
|
470
|
+
await vi.waitFor(() => expect(server.sockets.length).toBe(2));
|
|
471
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
472
|
+
// Through `MEDIA_URL` with a new ticket: the node we just left is the one
|
|
473
|
+
// node that cannot serve us, and the client holds no node address anyway.
|
|
474
|
+
expect(tickets).toHaveLength(2);
|
|
475
|
+
await room.close();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("never rejoins a room the node closed", async () => {
|
|
479
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
480
|
+
const server = nodeWith({});
|
|
481
|
+
const { room } = recoveringRoom(server, recorded);
|
|
482
|
+
await room.connect();
|
|
483
|
+
|
|
484
|
+
server.event({ event: "roomClosed", reason: "the host ended it" });
|
|
485
|
+
await flush();
|
|
486
|
+
server.socket.dropFromServer();
|
|
487
|
+
|
|
488
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("reconnecting"));
|
|
489
|
+
expect(room.isRecovering).toBe(false);
|
|
490
|
+
// Rejoining a finished session would put somebody back into a call the
|
|
491
|
+
// other party has ended, over and over.
|
|
492
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
493
|
+
expect(server.sockets.length).toBe(1);
|
|
494
|
+
await room.close();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("does not come back after the person has left", async () => {
|
|
498
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
499
|
+
const server = nodeWith({});
|
|
500
|
+
const { room } = recoveringRoom(server, recorded);
|
|
501
|
+
await room.connect();
|
|
502
|
+
|
|
503
|
+
server.socket.dropFromServer();
|
|
504
|
+
await flush();
|
|
505
|
+
await room.close();
|
|
506
|
+
|
|
507
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
508
|
+
// A booked attempt that survives `close()` reopens a call somebody
|
|
509
|
+
// deliberately left, and the provider above has already unmounted.
|
|
510
|
+
expect(server.sockets.length).toBe(1);
|
|
511
|
+
expect(room.isRecovering).toBe(false);
|
|
512
|
+
expect(room.connectionState).toBe("closed");
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The camera light goes out whenever the connection does.
|
|
518
|
+
*
|
|
519
|
+
* Only `close()` stopped local tracks, so every disconnect the client did not
|
|
520
|
+
* ask for - the host ending the session, a refund at minute ten, a socket
|
|
521
|
+
* dying - left `getUserMedia` capturing with the OS recording indicator lit,
|
|
522
|
+
* while the controls above still read "Mute" about a microphone publishing into
|
|
523
|
+
* a room that no longer exists. That is retained personal capture after the
|
|
524
|
+
* session it was consented to has been terminated by the other party.
|
|
525
|
+
*/
|
|
526
|
+
describe("a disconnect releases the local capture", () => {
|
|
527
|
+
it("stops a published track when the node closes the room", async () => {
|
|
528
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
529
|
+
const server = nodeWith({});
|
|
530
|
+
const { room } = recoveringRoom(server, recorded);
|
|
531
|
+
await room.connect();
|
|
532
|
+
|
|
533
|
+
const microphone = track();
|
|
534
|
+
await room.publish(microphone, "microphone");
|
|
535
|
+
expect(room.localPublications).toHaveLength(1);
|
|
536
|
+
|
|
537
|
+
server.event({ event: "roomClosed", reason: "the host ended it" });
|
|
538
|
+
await flush();
|
|
539
|
+
server.socket.dropFromServer();
|
|
540
|
+
|
|
541
|
+
await vi.waitFor(() => expect(microphone.stop).toHaveBeenCalledTimes(1));
|
|
542
|
+
// And the publication is gone, so `isMicrophoneEnabled` above reads false
|
|
543
|
+
// and the button stops claiming to be sending anything.
|
|
544
|
+
expect(room.localPublications).toHaveLength(0);
|
|
545
|
+
await room.close();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("stops a published track when the socket simply dies", async () => {
|
|
549
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
550
|
+
const server = nodeWith({});
|
|
551
|
+
const { room } = recoveringRoom(server, recorded, { reconnect: { maxAttempts: 0 } });
|
|
552
|
+
await room.connect();
|
|
553
|
+
|
|
554
|
+
const microphone = track();
|
|
555
|
+
await room.publish(microphone, "microphone");
|
|
556
|
+
|
|
557
|
+
server.socket.dropFromServer();
|
|
558
|
+
|
|
559
|
+
await vi.waitFor(() => expect(microphone.stop).toHaveBeenCalledTimes(1));
|
|
560
|
+
expect(room.localPublications).toHaveLength(0);
|
|
561
|
+
await room.close();
|
|
562
|
+
// Once, not twice. `close()` runs the same teardown and a second `stop()`
|
|
563
|
+
// on a track that has gone is a symptom of two owners.
|
|
564
|
+
expect(microphone.stop).toHaveBeenCalledTimes(1);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
describe("what the room carries for rendering", () => {
|
|
569
|
+
it("exposes the grants the NODE reported, not a token it parsed", async () => {
|
|
570
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
571
|
+
const room = roomFor(nodeWith({}), recorded, false);
|
|
572
|
+
await room.connect();
|
|
573
|
+
|
|
574
|
+
// A signed blob a client reads is a signed blob a client eventually trusts
|
|
575
|
+
// its own reading of. This is the node's understanding, which is the one
|
|
576
|
+
// that will actually be enforced.
|
|
577
|
+
expect(room.grants).toMatchObject({ canPublish: true, canSubscribe: true });
|
|
578
|
+
await room.close();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("passes TURN credentials through to the transport", async () => {
|
|
582
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
583
|
+
const iceServers = [{ urls: ["turn:turn.example:3478"], username: "1:me", credential: "x" }];
|
|
584
|
+
const server = nodeWith({ iceServers });
|
|
585
|
+
const room = roomFor(server, recorded, false);
|
|
586
|
+
await room.connect();
|
|
587
|
+
|
|
588
|
+
await room.publish({ kind: "audio", stop: vi.fn() } as never, "microphone");
|
|
589
|
+
|
|
590
|
+
// Without a relay, a participant behind a symmetric NAT never connects and
|
|
591
|
+
// the symptom names nothing.
|
|
592
|
+
expect(room.state.phase).toBe("joined");
|
|
593
|
+
await room.close();
|
|
594
|
+
});
|
|
595
|
+
});
|