@tribe-nest/media-client 0.2.1 → 0.4.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/build/core/index.d.ts +1 -1
- package/build/core/index.d.ts.map +1 -1
- package/build/core/index.js.map +1 -1
- package/build/core/reconnect.d.ts +29 -0
- package/build/core/reconnect.d.ts.map +1 -1
- package/build/core/reconnect.js +49 -9
- package/build/core/reconnect.js.map +1 -1
- package/build/core/signal.d.ts +9 -0
- package/build/core/signal.d.ts.map +1 -1
- package/build/core/signal.js +18 -2
- package/build/core/signal.js.map +1 -1
- package/build/core/state.d.ts +28 -0
- package/build/core/state.d.ts.map +1 -1
- package/build/core/state.js +106 -7
- package/build/core/state.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js.map +1 -1
- package/build/react/index.d.ts +63 -11
- package/build/react/index.d.ts.map +1 -1
- package/build/react/index.js +229 -5
- package/build/react/index.js.map +1 -1
- package/build/room/device.d.ts +6 -0
- package/build/room/device.d.ts.map +1 -1
- package/build/room/room.d.ts +235 -1
- package/build/room/room.d.ts.map +1 -1
- package/build/room/room.js +627 -24
- package/build/room/room.js.map +1 -1
- package/package.json +2 -2
- package/src/core/_tests/reconnect.spec.ts +92 -0
- package/src/core/_tests/state.spec.ts +92 -0
- package/src/core/index.ts +2 -0
- package/src/core/reconnect.ts +76 -9
- package/src/core/signal.ts +16 -2
- package/src/core/state.ts +153 -10
- package/src/index.ts +2 -0
- package/src/react/index.tsx +261 -7
- package/src/room/_tests/room.spec.ts +720 -3
- package/src/room/device.ts +6 -0
- package/src/room/room.ts +733 -25
|
@@ -85,6 +85,7 @@ class FakeTransport implements MediaTransport {
|
|
|
85
85
|
this.consumed.push(input.producerId);
|
|
86
86
|
this.recorded.calls.push(`consume:${input.producerId}`);
|
|
87
87
|
const resume = vi.fn(() => this.recorded.calls.push(`track-resume:${input.producerId}`));
|
|
88
|
+
let statBytes = 0;
|
|
88
89
|
return {
|
|
89
90
|
id: input.id,
|
|
90
91
|
producerId: input.producerId,
|
|
@@ -93,6 +94,10 @@ class FakeTransport implements MediaTransport {
|
|
|
93
94
|
pause: vi.fn(),
|
|
94
95
|
resume,
|
|
95
96
|
close: vi.fn(),
|
|
97
|
+
// 50 kilobytes more per read: two reads a second apart show 400 kbps.
|
|
98
|
+
getStats: async () => [
|
|
99
|
+
{ type: "inbound-rtp", frameWidth: 320, frameHeight: 180, framesPerSecond: 24, bytesReceived: (statBytes += 50_000) },
|
|
100
|
+
],
|
|
96
101
|
};
|
|
97
102
|
}
|
|
98
103
|
|
|
@@ -1296,9 +1301,9 @@ describe("replaceTrack: a device switch keeps its producer", () => {
|
|
|
1296
1301
|
const produced = recorded.transports.find((t) => t.direction === "send")!.produced[0]!;
|
|
1297
1302
|
expect(produced.codec).toBe("vp8");
|
|
1298
1303
|
expect(produced.encodings).toEqual([
|
|
1299
|
-
{ rid: "q", scaleResolutionDownBy: 4, scalabilityMode: "
|
|
1300
|
-
{ rid: "h", scaleResolutionDownBy: 2, scalabilityMode: "
|
|
1301
|
-
{ rid: "f", scaleResolutionDownBy: 1, scalabilityMode: "
|
|
1304
|
+
{ rid: "q", scaleResolutionDownBy: 4, scalabilityMode: "L1T3", maxBitrate: 350_000 },
|
|
1305
|
+
{ rid: "h", scaleResolutionDownBy: 2, scalabilityMode: "L1T3", maxBitrate: 875_000 },
|
|
1306
|
+
{ rid: "f", scaleResolutionDownBy: 1, scalabilityMode: "L1T3", maxBitrate: 3_500_000 },
|
|
1302
1307
|
]);
|
|
1303
1308
|
});
|
|
1304
1309
|
|
|
@@ -1328,3 +1333,715 @@ describe("replaceTrack: a device switch keeps its producer", () => {
|
|
|
1328
1333
|
await expect(room.replaceTrack("nope", video)).rejects.toThrow(/no such publication/);
|
|
1329
1334
|
});
|
|
1330
1335
|
});
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* A node whose consume replies echo the producer's REAL kind, so viewport
|
|
1339
|
+
* tests get video consumers where the fixture above answers "audio" for
|
|
1340
|
+
* everything.
|
|
1341
|
+
*/
|
|
1342
|
+
function kindAwareNode(input: {
|
|
1343
|
+
producers: { producerId: string; identity: string; kind: "audio" | "video"; source?: string }[];
|
|
1344
|
+
refuse?: (method: string) => boolean;
|
|
1345
|
+
}): FakeSignalServer {
|
|
1346
|
+
let transportSeq = 0;
|
|
1347
|
+
let consumerSeq = 0;
|
|
1348
|
+
const kinds = new Map(input.producers.map((p) => [p.producerId, p.kind]));
|
|
1349
|
+
const server: FakeSignalServer = new FakeSignalServer({
|
|
1350
|
+
joined: {
|
|
1351
|
+
identity: "me",
|
|
1352
|
+
room: "matter-1",
|
|
1353
|
+
routerRtpCapabilities: { codecs: [] },
|
|
1354
|
+
producers: input.producers,
|
|
1355
|
+
grants: { canPublish: true, canSubscribe: true, canPublishData: true },
|
|
1356
|
+
},
|
|
1357
|
+
onRequest: (frame, socket) => {
|
|
1358
|
+
if (input.refuse?.(frame.method)) return server.fail(frame.id, "bad_request", "unknown method", socket);
|
|
1359
|
+
switch (frame.method) {
|
|
1360
|
+
case "createTransport":
|
|
1361
|
+
return server.reply(
|
|
1362
|
+
frame.id,
|
|
1363
|
+
{ transportId: `t-${transportSeq++}`, iceParameters: {}, iceCandidates: [], dtlsParameters: {} },
|
|
1364
|
+
socket,
|
|
1365
|
+
);
|
|
1366
|
+
case "connectTransport":
|
|
1367
|
+
return server.reply(frame.id, { connected: true }, socket);
|
|
1368
|
+
case "consume":
|
|
1369
|
+
return server.reply(
|
|
1370
|
+
frame.id,
|
|
1371
|
+
{
|
|
1372
|
+
consumerId: `c-${consumerSeq++}`,
|
|
1373
|
+
producerId: frame.producerId,
|
|
1374
|
+
kind: kinds.get(frame.producerId) ?? "audio",
|
|
1375
|
+
rtpParameters: {},
|
|
1376
|
+
},
|
|
1377
|
+
socket,
|
|
1378
|
+
);
|
|
1379
|
+
default:
|
|
1380
|
+
return server.reply(frame.id, {}, socket);
|
|
1381
|
+
}
|
|
1382
|
+
},
|
|
1383
|
+
});
|
|
1384
|
+
return server;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const GRID = [
|
|
1388
|
+
{ producerId: "a-1", identity: "a", kind: "audio" as const },
|
|
1389
|
+
{ producerId: "v-1", identity: "a", kind: "video" as const },
|
|
1390
|
+
{ producerId: "v-2", identity: "b", kind: "video" as const },
|
|
1391
|
+
{ producerId: "v-3", identity: "c", kind: "video" as const },
|
|
1392
|
+
];
|
|
1393
|
+
const GRID_SET: EventFrame = {
|
|
1394
|
+
event: "activeSpeakers",
|
|
1395
|
+
producerIds: ["a-1", "v-1", "v-2", "v-3"],
|
|
1396
|
+
audio: ["a-1"],
|
|
1397
|
+
video: ["v-1", "v-2", "v-3"],
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
const viewportRoom = (server: FakeSignalServer, recorded: Recorded, graceMs = 150) =>
|
|
1401
|
+
new MediaRoom({
|
|
1402
|
+
getCredentials: () => ({ mediaUrl: "wss://media.example", token: "t" }),
|
|
1403
|
+
device: () => fakeDevice(recorded),
|
|
1404
|
+
webSocket: server.factory,
|
|
1405
|
+
viewportCloseGraceMs: graceMs,
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
describe("viewport narrowing (capacity layer 2)", () => {
|
|
1409
|
+
it("subscribes video to the viewport's intersection with the set, and audio to ALL of it", async () => {
|
|
1410
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1411
|
+
const server = kindAwareNode({ producers: GRID });
|
|
1412
|
+
const room = viewportRoom(server, recorded);
|
|
1413
|
+
await room.connect();
|
|
1414
|
+
|
|
1415
|
+
room.setViewport([{ producerId: "v-1", widthPx: 320 }]);
|
|
1416
|
+
server.event(GRID_SET);
|
|
1417
|
+
await vi.waitFor(() => expect(requestsOf(server, "consume")).toHaveLength(2));
|
|
1418
|
+
|
|
1419
|
+
// v-2 and v-3 are in the set and NOT consumed; a-1 is consumed although
|
|
1420
|
+
// no viewport entry names it. Audio never narrows by viewport.
|
|
1421
|
+
expect(
|
|
1422
|
+
requestsOf(server, "consume")
|
|
1423
|
+
.map((r) => (r as { producerId: string }).producerId)
|
|
1424
|
+
.sort(),
|
|
1425
|
+
).toEqual(["a-1", "v-1"]);
|
|
1426
|
+
await room.close();
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
it("keeps every audio consumer on an EMPTY viewport (a hidden tab hears the call)", async () => {
|
|
1430
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1431
|
+
const server = kindAwareNode({ producers: GRID });
|
|
1432
|
+
const room = viewportRoom(server, recorded, 100);
|
|
1433
|
+
await room.connect();
|
|
1434
|
+
server.event(GRID_SET);
|
|
1435
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(4));
|
|
1436
|
+
|
|
1437
|
+
room.setViewport([]);
|
|
1438
|
+
// Paused at once, closed after the grace; the audio consumer stays.
|
|
1439
|
+
await vi.waitFor(() => expect(requestsOf(server, "closeConsumer")).toHaveLength(3));
|
|
1440
|
+
expect(room.tracks.map((t) => t.producerId)).toEqual(["a-1"]);
|
|
1441
|
+
expect(requestsOf(server, "pauseConsumer")).toHaveLength(3);
|
|
1442
|
+
await room.close();
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
it("never narrows without a viewport: the legacy full-set behavior stands", async () => {
|
|
1446
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1447
|
+
const server = kindAwareNode({ producers: GRID });
|
|
1448
|
+
const room = viewportRoom(server, recorded);
|
|
1449
|
+
await room.connect();
|
|
1450
|
+
server.event(GRID_SET);
|
|
1451
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(4));
|
|
1452
|
+
expect(requestsOf(server, "pauseConsumer")).toHaveLength(0);
|
|
1453
|
+
await room.close();
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
it("pauses immediately, and a flip back inside the grace resumes with a keyframe and NO new consume", async () => {
|
|
1457
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1458
|
+
const server = kindAwareNode({ producers: GRID });
|
|
1459
|
+
const room = viewportRoom(server, recorded, 60_000);
|
|
1460
|
+
await room.connect();
|
|
1461
|
+
room.setViewport([
|
|
1462
|
+
{ producerId: "v-1", widthPx: 320 },
|
|
1463
|
+
{ producerId: "v-2", widthPx: 320 },
|
|
1464
|
+
]);
|
|
1465
|
+
server.event(GRID_SET);
|
|
1466
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(3));
|
|
1467
|
+
const consumesBefore = requestsOf(server, "consume").length;
|
|
1468
|
+
|
|
1469
|
+
// Page flip: v-2 scrolls off...
|
|
1470
|
+
room.setViewport([{ producerId: "v-1", widthPx: 320 }]);
|
|
1471
|
+
await vi.waitFor(() => expect(requestsOf(server, "pauseConsumer")).toHaveLength(1));
|
|
1472
|
+
expect(requestsOf(server, "closeConsumer")).toHaveLength(0);
|
|
1473
|
+
|
|
1474
|
+
// ...and back, inside the generous grace.
|
|
1475
|
+
room.setViewport([
|
|
1476
|
+
{ producerId: "v-1", widthPx: 320 },
|
|
1477
|
+
{ producerId: "v-2", widthPx: 320 },
|
|
1478
|
+
]);
|
|
1479
|
+
await vi.waitFor(() => expect(requestsOf(server, "resumeConsumer").length).toBeGreaterThanOrEqual(4));
|
|
1480
|
+
// A resumed video consumer shows garbage until an I-frame, so one is asked for.
|
|
1481
|
+
expect(requestsOf(server, "requestKeyFrame")).toHaveLength(1);
|
|
1482
|
+
expect(requestsOf(server, "consume")).toHaveLength(consumesBefore);
|
|
1483
|
+
await room.close();
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
it("asks for the layer the tile width implies on consume, and switches on a bucket change without closing", async () => {
|
|
1487
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1488
|
+
const server = kindAwareNode({ producers: GRID });
|
|
1489
|
+
const room = viewportRoom(server, recorded);
|
|
1490
|
+
await room.connect();
|
|
1491
|
+
room.setViewport([{ producerId: "v-1", widthPx: 200 }]);
|
|
1492
|
+
server.event(GRID_SET);
|
|
1493
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(2));
|
|
1494
|
+
|
|
1495
|
+
const consume = requestsOf(server, "consume").find((r) => (r as { producerId: string }).producerId === "v-1");
|
|
1496
|
+
expect((consume as { preferredLayers?: unknown }).preferredLayers).toEqual({ spatialLayer: 0 });
|
|
1497
|
+
|
|
1498
|
+
// A resize inside the bucket says nothing; crossing a boundary speaks once.
|
|
1499
|
+
room.setViewport([{ producerId: "v-1", widthPx: 230 }]);
|
|
1500
|
+
room.setViewport([{ producerId: "v-1", widthPx: 700 }]);
|
|
1501
|
+
await vi.waitFor(() => expect(requestsOf(server, "setPreferredLayers")).toHaveLength(1));
|
|
1502
|
+
expect(requestsOf(server, "setPreferredLayers")[0]).toMatchObject({ consumerId: expect.any(String), spatialLayer: 2 });
|
|
1503
|
+
expect(requestsOf(server, "closeConsumer")).toHaveLength(0);
|
|
1504
|
+
await room.close();
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
it("disables a verb an OLD node refuses instead of retrying into the frame budget", async () => {
|
|
1508
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1509
|
+
const server = kindAwareNode({ producers: GRID, refuse: (method) => method === "pauseConsumer" });
|
|
1510
|
+
const room = viewportRoom(server, recorded, 60_000);
|
|
1511
|
+
await room.connect();
|
|
1512
|
+
room.setViewport([{ producerId: "v-1", widthPx: 320 }, { producerId: "v-2", widthPx: 320 }]);
|
|
1513
|
+
server.event(GRID_SET);
|
|
1514
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(3));
|
|
1515
|
+
|
|
1516
|
+
room.setViewport([{ producerId: "v-2", widthPx: 320 }]);
|
|
1517
|
+
await vi.waitFor(() => expect(requestsOf(server, "pauseConsumer")).toHaveLength(1));
|
|
1518
|
+
// A second narrow does not ask again: the verb is off for the session.
|
|
1519
|
+
room.setViewport([]);
|
|
1520
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
1521
|
+
expect(requestsOf(server, "pauseConsumer")).toHaveLength(1);
|
|
1522
|
+
await room.close();
|
|
1523
|
+
});
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
describe("ephemeral signals: reactions and hands", () => {
|
|
1527
|
+
it("sends a reaction and hears one back through onBroadcast", async () => {
|
|
1528
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1529
|
+
const server = kindAwareNode({ producers: [] });
|
|
1530
|
+
const room = viewportRoom(server, recorded);
|
|
1531
|
+
const heard: unknown[] = [];
|
|
1532
|
+
room.onBroadcast((event) => heard.push(event));
|
|
1533
|
+
await room.connect();
|
|
1534
|
+
|
|
1535
|
+
await room.sendReaction({ emoji: "clap" });
|
|
1536
|
+
expect(requestsOf(server, "broadcast")[0]).toMatchObject({ type: "reaction", data: { emoji: "clap" } });
|
|
1537
|
+
|
|
1538
|
+
server.event({ event: "broadcast", type: "reaction", identity: "peer-1", data: { emoji: "clap" }, at: 5 });
|
|
1539
|
+
await flush();
|
|
1540
|
+
expect(heard).toEqual([{ type: "reaction", identity: "peer-1", data: { emoji: "clap" }, at: 5 }]);
|
|
1541
|
+
// Ephemeral means ephemeral: nothing about it lands in RoomState.
|
|
1542
|
+
expect(room.state.raisedHands).toEqual([]);
|
|
1543
|
+
await room.close();
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
it("raises a hand over the wire and renders hands from room STATE", async () => {
|
|
1547
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1548
|
+
const server = kindAwareNode({ producers: [] });
|
|
1549
|
+
const room = viewportRoom(server, recorded);
|
|
1550
|
+
await room.connect();
|
|
1551
|
+
|
|
1552
|
+
await room.setHandRaised(true);
|
|
1553
|
+
expect(requestsOf(server, "setHand")[0]).toMatchObject({ raised: true });
|
|
1554
|
+
|
|
1555
|
+
server.event({ event: "handChanged", identity: "peer-1", raisedAt: 100 });
|
|
1556
|
+
server.event({ event: "handChanged", identity: "peer-0", raisedAt: 200 });
|
|
1557
|
+
await flush();
|
|
1558
|
+
// Oldest first: the order is the queue, not the alphabet.
|
|
1559
|
+
expect(room.state.raisedHands).toEqual([
|
|
1560
|
+
{ identity: "peer-1", raisedAt: 100 },
|
|
1561
|
+
{ identity: "peer-0", raisedAt: 200 },
|
|
1562
|
+
]);
|
|
1563
|
+
|
|
1564
|
+
server.event({ event: "handChanged", identity: "peer-1", raisedAt: null });
|
|
1565
|
+
await flush();
|
|
1566
|
+
expect(room.state.raisedHands).toEqual([{ identity: "peer-0", raisedAt: 200 }]);
|
|
1567
|
+
await room.close();
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
it("sends lowerHand as a courtesy broadcast, never as state", async () => {
|
|
1571
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1572
|
+
const server = kindAwareNode({ producers: [] });
|
|
1573
|
+
const room = viewportRoom(server, recorded);
|
|
1574
|
+
await room.connect();
|
|
1575
|
+
await room.sendLowerHand("peer-1");
|
|
1576
|
+
expect(requestsOf(server, "broadcast")[0]).toMatchObject({ type: "lowerHand", data: { target: "peer-1" } });
|
|
1577
|
+
await room.close();
|
|
1578
|
+
});
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
describe("the simulcast/h264 exclusivity is enforced", () => {
|
|
1582
|
+
it("drops simulcast for an h264 publish rather than sending layers that will not exist", async () => {
|
|
1583
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1584
|
+
const server = kindAwareNode({ producers: [] });
|
|
1585
|
+
const warnings: string[] = [];
|
|
1586
|
+
const room = new MediaRoom({
|
|
1587
|
+
getCredentials: () => ({ mediaUrl: "wss://media.example", token: "t" }),
|
|
1588
|
+
device: () => fakeDevice(recorded),
|
|
1589
|
+
webSocket: server.factory,
|
|
1590
|
+
onLog: (level, message) => {
|
|
1591
|
+
if (level === "warn") warnings.push(message);
|
|
1592
|
+
},
|
|
1593
|
+
});
|
|
1594
|
+
await room.connect();
|
|
1595
|
+
|
|
1596
|
+
await room.publish(track(), "program", { simulcast: true, codec: "h264", maxBitrateKbps: 2500 });
|
|
1597
|
+
const produced = recorded.transports.find((t) => t.direction === "send")!.produced[0]!;
|
|
1598
|
+
expect(produced.codec).toBe("h264");
|
|
1599
|
+
// ONE encoding, capped: the simulcast layers were dropped, loudly.
|
|
1600
|
+
expect(produced.encodings).toEqual([{ maxBitrate: 2_500_000 }]);
|
|
1601
|
+
expect(warnings.join(" ")).toMatch(/simulcast dropped/);
|
|
1602
|
+
await room.close();
|
|
1603
|
+
});
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
describe("the planned move: a drain is a migration, not a drop", () => {
|
|
1607
|
+
/** A capture whose liveness the stash can read; `track()` has no readyState. */
|
|
1608
|
+
const liveTrack = (kind: "audio" | "video" = "audio") =>
|
|
1609
|
+
({ kind, readyState: "live", stop: vi.fn() }) as unknown as MediaStreamTrack & { stop: ReturnType<typeof vi.fn> };
|
|
1610
|
+
|
|
1611
|
+
const movingRoom = (server: FakeSignalServer, recorded: Recorded) => {
|
|
1612
|
+
const tickets: string[] = [];
|
|
1613
|
+
const room = new MediaRoom({
|
|
1614
|
+
getCredentials: () => {
|
|
1615
|
+
tickets.push(`t-${tickets.length}`);
|
|
1616
|
+
return { mediaUrl: "wss://media.example", token: tickets[tickets.length - 1]! };
|
|
1617
|
+
},
|
|
1618
|
+
device: () => fakeDevice(recorded),
|
|
1619
|
+
webSocket: server.factory,
|
|
1620
|
+
autoSubscribe: false,
|
|
1621
|
+
reconnect: { baseMs: 1, maxMs: 4, factor: 1, jitter: "none", drainingRefusalFloorMs: 5 },
|
|
1622
|
+
});
|
|
1623
|
+
return { room, tickets };
|
|
1624
|
+
};
|
|
1625
|
+
|
|
1626
|
+
it("moves at the hinted delay: leaves the old node, rejoins with a fresh ticket, republishes the SAME captures", async () => {
|
|
1627
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1628
|
+
const server = nodeWith({});
|
|
1629
|
+
const { room, tickets } = movingRoom(server, recorded);
|
|
1630
|
+
await room.connect();
|
|
1631
|
+
|
|
1632
|
+
const mic = liveTrack("audio");
|
|
1633
|
+
await room.publish(mic, "microphone", { maxBitrateKbps: 64 });
|
|
1634
|
+
const published = room.localPublications[0]!;
|
|
1635
|
+
await room.setPaused(published.producerId, true);
|
|
1636
|
+
|
|
1637
|
+
server.event({ event: "draining", reconnectAfterMs: 30 });
|
|
1638
|
+
// The call continues untouched during the jittered wait.
|
|
1639
|
+
expect(room.connectionState).toBe("connected");
|
|
1640
|
+
expect(mic.stop).not.toHaveBeenCalled();
|
|
1641
|
+
|
|
1642
|
+
await vi.waitFor(() => expect(tickets.length).toBe(2));
|
|
1643
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1644
|
+
|
|
1645
|
+
// The old node was LEFT, not abandoned: the leave request is what tears
|
|
1646
|
+
// our record down before the rejoin can meet its own ghost.
|
|
1647
|
+
expect(requestsOf(server, "leave").length).toBe(1);
|
|
1648
|
+
// The capture never stopped, and the republish carried the same source,
|
|
1649
|
+
// the same options, and the same paused state.
|
|
1650
|
+
expect(mic.stop).not.toHaveBeenCalled();
|
|
1651
|
+
await vi.waitFor(() => expect(room.localPublications).toHaveLength(1));
|
|
1652
|
+
expect(room.localPublications[0]).toMatchObject({ source: "microphone", paused: true });
|
|
1653
|
+
const produces = requestsOf(server, "produce");
|
|
1654
|
+
expect(produces).toHaveLength(2);
|
|
1655
|
+
expect(requestsOf(server, "pauseProducer").length).toBeGreaterThanOrEqual(2);
|
|
1656
|
+
// Nothing was "lost": the person is never asked to re-enable anything.
|
|
1657
|
+
expect(room.lostPublicationSources).toEqual([]);
|
|
1658
|
+
await room.close();
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
it("ignores a second draining frame: one move, however many times the node asks", async () => {
|
|
1662
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1663
|
+
const server = nodeWith({});
|
|
1664
|
+
const { room, tickets } = movingRoom(server, recorded);
|
|
1665
|
+
await room.connect();
|
|
1666
|
+
|
|
1667
|
+
server.event({ event: "draining", reconnectAfterMs: 40 });
|
|
1668
|
+
server.event({ event: "draining", reconnectAfterMs: 1 });
|
|
1669
|
+
await vi.waitFor(() => expect(tickets.length).toBe(2));
|
|
1670
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1671
|
+
await settleMs(60);
|
|
1672
|
+
|
|
1673
|
+
// Had the second frame booked its own move, a third ticket would exist.
|
|
1674
|
+
expect(tickets.length).toBe(2);
|
|
1675
|
+
await room.close();
|
|
1676
|
+
});
|
|
1677
|
+
|
|
1678
|
+
it("keeps the smooth path when the server CUTS at the deadline, honoring the jittered hint", async () => {
|
|
1679
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1680
|
+
const server = nodeWith({});
|
|
1681
|
+
const { room, tickets } = movingRoom(server, recorded);
|
|
1682
|
+
await room.connect();
|
|
1683
|
+
const camera = liveTrack("video");
|
|
1684
|
+
await room.publish(camera, "camera");
|
|
1685
|
+
|
|
1686
|
+
// The deadline eviction: the frame and the close arrive together.
|
|
1687
|
+
const before = Date.now();
|
|
1688
|
+
server.event({ event: "draining", reconnectAfterMs: 60 });
|
|
1689
|
+
server.socket.dropFromServer();
|
|
1690
|
+
|
|
1691
|
+
await vi.waitFor(() => expect(tickets.length).toBe(2));
|
|
1692
|
+
// The first attempt waited the server's hint (not the backoff ladder).
|
|
1693
|
+
expect(Date.now() - before).toBeGreaterThanOrEqual(55);
|
|
1694
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1695
|
+
expect(camera.stop).not.toHaveBeenCalled();
|
|
1696
|
+
await vi.waitFor(() => expect(room.localPublications).toHaveLength(1));
|
|
1697
|
+
await room.close();
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
it("hands a capture that ENDED during the move to the lost-sources offer instead of publishing a dead track", async () => {
|
|
1701
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1702
|
+
const server = nodeWith({});
|
|
1703
|
+
const { room } = movingRoom(server, recorded);
|
|
1704
|
+
await room.connect();
|
|
1705
|
+
const mic = liveTrack("audio");
|
|
1706
|
+
await room.publish(mic, "microphone");
|
|
1707
|
+
|
|
1708
|
+
server.event({ event: "draining", reconnectAfterMs: 10 });
|
|
1709
|
+
(mic as unknown as { readyState: string }).readyState = "ended";
|
|
1710
|
+
|
|
1711
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1712
|
+
await vi.waitFor(() => expect(room.lostPublicationSources).toEqual(["microphone"]));
|
|
1713
|
+
expect(room.localPublications).toHaveLength(0);
|
|
1714
|
+
await room.close();
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
it("cancels a scheduled move when the room ENDS first: the ordinary path owns the close", async () => {
|
|
1718
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1719
|
+
const server = nodeWith({});
|
|
1720
|
+
const { room, tickets } = movingRoom(server, recorded);
|
|
1721
|
+
await room.connect();
|
|
1722
|
+
const mic = liveTrack("audio");
|
|
1723
|
+
await room.publish(mic, "microphone");
|
|
1724
|
+
|
|
1725
|
+
// A move is booked, and then the HOST ends the call before it fires.
|
|
1726
|
+
// (A close after a draining frame reports the draining cause and takes
|
|
1727
|
+
// the smooth path by design; roomClosed is what overrides it.)
|
|
1728
|
+
server.event({ event: "draining", reconnectAfterMs: 5_000 });
|
|
1729
|
+
server.event({ event: "roomClosed", reason: "the host ended it" });
|
|
1730
|
+
server.socket.dropFromServer();
|
|
1731
|
+
|
|
1732
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("reconnecting"));
|
|
1733
|
+
expect(room.isRecovering).toBe(false);
|
|
1734
|
+
// Ordinary end-of-call semantics exactly: the capture is stopped, and
|
|
1735
|
+
// the cancelled move never books a rejoin into a finished room.
|
|
1736
|
+
expect(mic.stop).toHaveBeenCalled();
|
|
1737
|
+
await settleMs(30);
|
|
1738
|
+
expect(tickets.length).toBe(1);
|
|
1739
|
+
expect(server.sockets.length).toBe(1);
|
|
1740
|
+
await room.close();
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
it("waits out a restarting single node: patient node_draining probes, then rejoin WITH the captures", async () => {
|
|
1744
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1745
|
+
// The standalone topology: MEDIA_URL is the one node, and during its
|
|
1746
|
+
// restart every join is refused node_draining. The ladder must not burn
|
|
1747
|
+
// out against a node that was always going to come back.
|
|
1748
|
+
let refusals = 0;
|
|
1749
|
+
let seq = 0;
|
|
1750
|
+
const server: FakeSignalServer = new FakeSignalServer({
|
|
1751
|
+
autoJoin: false,
|
|
1752
|
+
onRequest: (frame, socket) => {
|
|
1753
|
+
if (frame.method === "join") {
|
|
1754
|
+
if (refusals > 0) {
|
|
1755
|
+
refusals -= 1;
|
|
1756
|
+
server.fail(frame.id, "node_draining", "restarting", socket);
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
server.completeJoin(socket, frame.id);
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
if (frame.method === "createTransport") {
|
|
1763
|
+
server.reply(
|
|
1764
|
+
frame.id,
|
|
1765
|
+
{ transportId: `t-${seq++}`, iceParameters: {}, iceCandidates: [], dtlsParameters: {} },
|
|
1766
|
+
socket,
|
|
1767
|
+
);
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
if (frame.method === "produce") {
|
|
1771
|
+
server.reply(frame.id, { producerId: `p-${seq++}` }, socket);
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
server.reply(frame.id, {}, socket);
|
|
1775
|
+
},
|
|
1776
|
+
});
|
|
1777
|
+
const { room, tickets } = movingRoom(server, recorded);
|
|
1778
|
+
await room.connect();
|
|
1779
|
+
const mic = liveTrack("audio");
|
|
1780
|
+
await room.publish(mic, "microphone");
|
|
1781
|
+
|
|
1782
|
+
// The node announces the restart, then refuses the rejoins for a while.
|
|
1783
|
+
refusals = 3;
|
|
1784
|
+
server.event({ event: "draining", reconnectAfterMs: 5 });
|
|
1785
|
+
|
|
1786
|
+
// Refusal, refusal, refusal, success: four join tickets after the first.
|
|
1787
|
+
await vi.waitFor(() => expect(tickets.length).toBe(5), { timeout: 3_000 });
|
|
1788
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1789
|
+
expect(refusals).toBe(0);
|
|
1790
|
+
// The whole wait happened with the captures alive; the rejoin brought
|
|
1791
|
+
// them back without anybody pressing anything.
|
|
1792
|
+
expect(mic.stop).not.toHaveBeenCalled();
|
|
1793
|
+
await vi.waitFor(() => expect(room.localPublications).toHaveLength(1));
|
|
1794
|
+
expect(room.lostPublicationSources).toEqual([]);
|
|
1795
|
+
await room.close();
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
it("stashes at CLOSE time, so a mute during the leave round trip survives the move", async () => {
|
|
1799
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1800
|
+
let heldLeave: { id: number; socket: FakeSocket } | undefined;
|
|
1801
|
+
const server = nodeWith({
|
|
1802
|
+
intercept: (frame, socket) => {
|
|
1803
|
+
// Hold the leave reply open: this is the window in which the person
|
|
1804
|
+
// can still change what they are sending.
|
|
1805
|
+
if (frame.method === "leave" && !heldLeave) {
|
|
1806
|
+
heldLeave = { id: frame.id, socket };
|
|
1807
|
+
return true;
|
|
1808
|
+
}
|
|
1809
|
+
return false;
|
|
1810
|
+
},
|
|
1811
|
+
});
|
|
1812
|
+
const { room } = movingRoom(server, recorded);
|
|
1813
|
+
await room.connect();
|
|
1814
|
+
const mic = liveTrack("audio");
|
|
1815
|
+
const published = await room.publish(mic, "microphone");
|
|
1816
|
+
|
|
1817
|
+
server.event({ event: "draining", reconnectAfterMs: 5 });
|
|
1818
|
+
await vi.waitFor(() => expect(heldLeave).toBeDefined());
|
|
1819
|
+
|
|
1820
|
+
// Mid-window: the mic is muted and a SECOND capture starts.
|
|
1821
|
+
await room.setPaused(published.producerId, true);
|
|
1822
|
+
const camera = liveTrack("video");
|
|
1823
|
+
await room.publish(camera, "camera");
|
|
1824
|
+
server.reply(heldLeave!.id, {}, heldLeave!.socket);
|
|
1825
|
+
|
|
1826
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1827
|
+
await vi.waitFor(() => expect(room.localPublications).toHaveLength(2));
|
|
1828
|
+
// The muted state and the late capture both crossed: no hot mic on the
|
|
1829
|
+
// new node, no orphaned camera with its light on. (The republished pause
|
|
1830
|
+
// lands one round trip after the produce, hence the wait.)
|
|
1831
|
+
await vi.waitFor(() =>
|
|
1832
|
+
expect(room.localPublications.find((p) => p.source === "microphone")?.paused).toBe(true),
|
|
1833
|
+
);
|
|
1834
|
+
expect(room.localPublications.find((p) => p.source === "camera")).toBeDefined();
|
|
1835
|
+
expect(camera.stop).not.toHaveBeenCalled();
|
|
1836
|
+
expect(room.lostPublicationSources).toEqual([]);
|
|
1837
|
+
await room.close();
|
|
1838
|
+
});
|
|
1839
|
+
|
|
1840
|
+
it("never says 'connecting' during a move: the person is mid-call, the word is 'reconnecting'", async () => {
|
|
1841
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1842
|
+
const server = nodeWith({});
|
|
1843
|
+
const { room } = movingRoom(server, recorded);
|
|
1844
|
+
await room.connect();
|
|
1845
|
+
|
|
1846
|
+
const states: string[] = [];
|
|
1847
|
+
const stop = room.onChange(() => states.push(room.connectionState));
|
|
1848
|
+
server.event({ event: "draining", reconnectAfterMs: 5 });
|
|
1849
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1850
|
+
await vi.waitFor(() => expect(states).toContain("reconnecting"));
|
|
1851
|
+
|
|
1852
|
+
expect(states).not.toContain("connecting");
|
|
1853
|
+
stop();
|
|
1854
|
+
await room.close();
|
|
1855
|
+
});
|
|
1856
|
+
|
|
1857
|
+
it("still MOVES a room whose failure recovery is off: a drain is an order, not a failure", async () => {
|
|
1858
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1859
|
+
const server = nodeWith({});
|
|
1860
|
+
const tickets: string[] = [];
|
|
1861
|
+
const room = new MediaRoom({
|
|
1862
|
+
getCredentials: () => {
|
|
1863
|
+
tickets.push(`t-${tickets.length}`);
|
|
1864
|
+
return { mediaUrl: "wss://media.example", token: tickets[tickets.length - 1]! };
|
|
1865
|
+
},
|
|
1866
|
+
device: () => fakeDevice(recorded),
|
|
1867
|
+
webSocket: server.factory,
|
|
1868
|
+
autoSubscribe: false,
|
|
1869
|
+
reconnect: { maxAttempts: 0 },
|
|
1870
|
+
});
|
|
1871
|
+
await room.connect();
|
|
1872
|
+
|
|
1873
|
+
server.event({ event: "draining", reconnectAfterMs: 5 });
|
|
1874
|
+
await vi.waitFor(() => expect(tickets.length).toBe(2));
|
|
1875
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("connected"));
|
|
1876
|
+
|
|
1877
|
+
// The move happened; a later ORDINARY drop still gets no automatic retry.
|
|
1878
|
+
server.socket.dropFromServer(1006);
|
|
1879
|
+
await vi.waitFor(() => expect(room.connectionState).toBe("reconnecting"));
|
|
1880
|
+
expect(room.isRecovering).toBe(false);
|
|
1881
|
+
await settleMs(30);
|
|
1882
|
+
expect(tickets.length).toBe(2);
|
|
1883
|
+
await room.close();
|
|
1884
|
+
});
|
|
1885
|
+
|
|
1886
|
+
it("reports a move that dies for good AS a failed move, not as the refusal that ended it", async () => {
|
|
1887
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1888
|
+
let joins = 0;
|
|
1889
|
+
const server: FakeSignalServer = new FakeSignalServer({
|
|
1890
|
+
autoJoin: false,
|
|
1891
|
+
onRequest: (frame, socket) => {
|
|
1892
|
+
if (frame.method === "join") {
|
|
1893
|
+
joins += 1;
|
|
1894
|
+
if (joins > 1) {
|
|
1895
|
+
server.fail(frame.id, "capacity", "the fleet is full", socket);
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
server.completeJoin(socket, frame.id);
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
server.reply(frame.id, {}, socket);
|
|
1902
|
+
},
|
|
1903
|
+
});
|
|
1904
|
+
const room = new MediaRoom({
|
|
1905
|
+
getCredentials: () => ({ mediaUrl: "wss://media.example", token: "t" }),
|
|
1906
|
+
device: () => fakeDevice(recorded),
|
|
1907
|
+
webSocket: server.factory,
|
|
1908
|
+
autoSubscribe: false,
|
|
1909
|
+
// Failure recovery OFF: the one refused rejoin ends the move for good.
|
|
1910
|
+
reconnect: { maxAttempts: 0 },
|
|
1911
|
+
});
|
|
1912
|
+
await room.connect();
|
|
1913
|
+
|
|
1914
|
+
server.event({ event: "draining", reconnectAfterMs: 5 });
|
|
1915
|
+
await vi.waitFor(() => expect(joins).toBe(2));
|
|
1916
|
+
await vi.waitFor(() => expect(room.isRecovering).toBe(false));
|
|
1917
|
+
|
|
1918
|
+
// The screen's story is "could not move you", with a way back, because
|
|
1919
|
+
// that is what happened to the PERSON; the capacity refusal is a detail.
|
|
1920
|
+
expect(room.connectionState).toBe("reconnecting");
|
|
1921
|
+
expect(room.error).toMatchObject({ type: "draining" });
|
|
1922
|
+
await room.close();
|
|
1923
|
+
});
|
|
1924
|
+
|
|
1925
|
+
it("stops the stashed captures when the recovery gives up for good: a light never outlives its migration", async () => {
|
|
1926
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1927
|
+
const server = nodeWith({});
|
|
1928
|
+
const { room } = movingRoom(server, recorded);
|
|
1929
|
+
await room.connect();
|
|
1930
|
+
const mic = liveTrack("audio");
|
|
1931
|
+
await room.publish(mic, "microphone");
|
|
1932
|
+
|
|
1933
|
+
// Move, then make every rejoin fail terminally: the room is closed by the
|
|
1934
|
+
// person, which is the one unambiguous "stop everything".
|
|
1935
|
+
server.event({ event: "draining", reconnectAfterMs: 5 });
|
|
1936
|
+
await vi.waitFor(() => expect(requestsOf(server, "leave").length).toBe(1));
|
|
1937
|
+
await room.close();
|
|
1938
|
+
|
|
1939
|
+
expect(mic.stop).toHaveBeenCalled();
|
|
1940
|
+
expect(room.connectionState).toBe("closed");
|
|
1941
|
+
});
|
|
1942
|
+
});
|
|
1943
|
+
|
|
1944
|
+
/** Real-timer settle for paths whose timers are a few ms. */
|
|
1945
|
+
const settleMs = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1946
|
+
|
|
1947
|
+
describe("getDiagnostics: the call explained without webrtc-internals", () => {
|
|
1948
|
+
const nodeAnswer = {
|
|
1949
|
+
transports: [{ transportId: "t-1", availableOutgoingBitrate: 600_000, iceState: "connected", dtlsState: "connected" }],
|
|
1950
|
+
producers: [
|
|
1951
|
+
{
|
|
1952
|
+
producerId: "p-mine",
|
|
1953
|
+
kind: "video",
|
|
1954
|
+
paused: false,
|
|
1955
|
+
source: "camera",
|
|
1956
|
+
layers: [
|
|
1957
|
+
{ rid: "q", score: 10 },
|
|
1958
|
+
{ rid: "h", score: 7 },
|
|
1959
|
+
{ rid: "f", score: 0 },
|
|
1960
|
+
],
|
|
1961
|
+
},
|
|
1962
|
+
],
|
|
1963
|
+
consumers: [
|
|
1964
|
+
{
|
|
1965
|
+
consumerId: "c-1",
|
|
1966
|
+
producerId: "p-1",
|
|
1967
|
+
kind: "video",
|
|
1968
|
+
paused: false,
|
|
1969
|
+
preferredLayers: { spatialLayer: 2, temporalLayer: 2 },
|
|
1970
|
+
currentLayers: { spatialLayer: 0, temporalLayer: 0 },
|
|
1971
|
+
},
|
|
1972
|
+
],
|
|
1973
|
+
};
|
|
1974
|
+
|
|
1975
|
+
it("merges the node's answer into the tiles: asked, given, decoded, all in one place", async () => {
|
|
1976
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
1977
|
+
const server = nodeWith({
|
|
1978
|
+
producers: [{ producerId: "p-1", identity: "a", kind: "video", source: "camera" }],
|
|
1979
|
+
intercept: (frame, socket, srv) => {
|
|
1980
|
+
if (frame.method !== "diagnostics") return false;
|
|
1981
|
+
srv.reply(frame.id, nodeAnswer, socket);
|
|
1982
|
+
return true;
|
|
1983
|
+
},
|
|
1984
|
+
});
|
|
1985
|
+
const room = roomFor(server, recorded);
|
|
1986
|
+
await room.connect();
|
|
1987
|
+
room.setViewport([{ producerId: "p-1", widthPx: 900 }]);
|
|
1988
|
+
server.event({ event: "activeSpeakers", producerIds: ["p-1"] });
|
|
1989
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(1));
|
|
1990
|
+
|
|
1991
|
+
const first = await room.getDiagnostics();
|
|
1992
|
+
const tile = first.tiles[0]!;
|
|
1993
|
+
// The three-way answer on one line: this client asked full (900px tile),
|
|
1994
|
+
// the node is giving quarter, and the estimate names the culprit.
|
|
1995
|
+
expect(tile).toMatchObject({
|
|
1996
|
+
producerId: "p-1",
|
|
1997
|
+
identity: "a",
|
|
1998
|
+
source: "camera",
|
|
1999
|
+
requestedLayer: 2,
|
|
2000
|
+
currentLayer: 0,
|
|
2001
|
+
viewportWidthPx: 900,
|
|
2002
|
+
frameWidth: 320,
|
|
2003
|
+
frameHeight: 180,
|
|
2004
|
+
framesPerSecond: 24,
|
|
2005
|
+
});
|
|
2006
|
+
expect(first.availableOutgoingBitrate).toBe(600_000);
|
|
2007
|
+
// The publisher-side half: my own camera's f layer is not arriving.
|
|
2008
|
+
expect(first.localProducers?.[0]?.layers).toEqual([
|
|
2009
|
+
{ rid: "q", score: 10 },
|
|
2010
|
+
{ rid: "h", score: 7 },
|
|
2011
|
+
{ rid: "f", score: 0 },
|
|
2012
|
+
]);
|
|
2013
|
+
// Bitrate needs a delta, so the FIRST read has none and the second does.
|
|
2014
|
+
expect(tile.bitrateKbps).toBeUndefined();
|
|
2015
|
+
await settleMs(15);
|
|
2016
|
+
const second = await room.getDiagnostics();
|
|
2017
|
+
expect(second.tiles[0]?.bitrateKbps).toBeGreaterThan(0);
|
|
2018
|
+
await room.close();
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
it("still answers the LOCAL half against a node too old for the verb", async () => {
|
|
2022
|
+
const recorded: Recorded = { calls: [], transports: [] };
|
|
2023
|
+
const server = nodeWith({
|
|
2024
|
+
producers: [{ producerId: "p-1", identity: "a", kind: "video" }],
|
|
2025
|
+
intercept: (frame, socket, srv) => {
|
|
2026
|
+
if (frame.method !== "diagnostics") return false;
|
|
2027
|
+
srv.fail(frame.id, "bad_request", "unknown method", socket);
|
|
2028
|
+
return true;
|
|
2029
|
+
},
|
|
2030
|
+
});
|
|
2031
|
+
const room = roomFor(server, recorded);
|
|
2032
|
+
await room.connect();
|
|
2033
|
+
room.setViewport([{ producerId: "p-1", widthPx: 200 }]);
|
|
2034
|
+
server.event({ event: "activeSpeakers", producerIds: ["p-1"] });
|
|
2035
|
+
await vi.waitFor(() => expect(room.tracks).toHaveLength(1));
|
|
2036
|
+
|
|
2037
|
+
const diagnostics = await room.getDiagnostics();
|
|
2038
|
+
expect(diagnostics.tiles[0]).toMatchObject({ producerId: "p-1", requestedLayer: 0, frameWidth: 320 });
|
|
2039
|
+
expect(diagnostics.availableOutgoingBitrate).toBeUndefined();
|
|
2040
|
+
expect(diagnostics.node).toBeUndefined();
|
|
2041
|
+
|
|
2042
|
+
// And the verb is remembered as unsupported: no second ask.
|
|
2043
|
+
await room.getDiagnostics();
|
|
2044
|
+
expect(requestsOf(server, "diagnostics")).toHaveLength(1);
|
|
2045
|
+
await room.close();
|
|
2046
|
+
});
|
|
2047
|
+
});
|