e4425570c7
* cleanup: Remove deprecated rtc room key transport * fix: rtc statistics are managed by transport directly * mark as readonly * cleanup do not use deprecated `room` * doc: Add missing param doc * fixup: add back test wrongly removed
934 lines
42 KiB
TypeScript
934 lines
42 KiB
TypeScript
/*
|
|
Copyright 2023-2026 The Matrix.org Foundation C.I.C.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
import { type Mock } from "vitest";
|
|
|
|
import { type EventTimeline, EventType, KnownMembership, MatrixClient, type Room } from "../../../src";
|
|
import { MatrixRTCSession, MatrixRTCSessionEvent, MembershipManagerEvent, Status } from "../../../src/matrixrtc";
|
|
import {
|
|
makeMockRoom,
|
|
type MembershipData,
|
|
mockRoomState,
|
|
mockRTCEvent,
|
|
owmMemberIdentity,
|
|
rtcMembershipTemplate,
|
|
sessionMembershipTemplate,
|
|
} from "./mocks";
|
|
import { RoomStickyEventsEvent, type StickyMatrixEvent } from "../../../src/models/room-sticky-events.ts";
|
|
import { StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
|
|
import { flushPromises } from "../../test-utils/flushPromises.ts";
|
|
import {
|
|
computeRtcIdentityRaw,
|
|
type RtcMembershipData,
|
|
type SessionMembershipData,
|
|
} from "../../../src/matrixrtc/membershipData/index.ts";
|
|
|
|
const mockFocus = { type: "mock" };
|
|
|
|
const callSession = { id: "ROOM", application: "m.call" };
|
|
|
|
describe("MatrixRTCSession", () => {
|
|
let client: MatrixClient;
|
|
let sess: MatrixRTCSession | undefined;
|
|
|
|
beforeEach(() => {
|
|
client = new MatrixClient({ baseUrl: "base_url" });
|
|
client.getUserId = vi.fn().mockReturnValue("@alice:example.org");
|
|
client.getDeviceId = vi.fn().mockReturnValue("AAAAAAA");
|
|
client.sendEvent = vi.fn().mockResolvedValue({ event_id: "success" });
|
|
client.decryptEventIfNeeded = vi.fn();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
client.stopClient();
|
|
client.matrixRTC.stop();
|
|
if (sess) await sess.stop();
|
|
sess = undefined;
|
|
});
|
|
|
|
describe.each([
|
|
{
|
|
listenForStickyEvents: true,
|
|
listenForMemberStateEvents: true,
|
|
testCreateSticky: false,
|
|
createWithDefaults: true, // Create MatrixRTCSession with defaults
|
|
},
|
|
{
|
|
listenForStickyEvents: true,
|
|
listenForMemberStateEvents: true,
|
|
testCreateSticky: false,
|
|
},
|
|
{
|
|
listenForStickyEvents: false,
|
|
listenForMemberStateEvents: true,
|
|
testCreateSticky: false,
|
|
},
|
|
{
|
|
listenForStickyEvents: true,
|
|
listenForMemberStateEvents: true,
|
|
testCreateSticky: true,
|
|
createWithDefaults: false,
|
|
},
|
|
{
|
|
listenForStickyEvents: true,
|
|
listenForMemberStateEvents: false,
|
|
testCreateSticky: true,
|
|
createWithDefaults: false,
|
|
},
|
|
])(
|
|
"roomsessionForSlot listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky",
|
|
(testConfig) => {
|
|
function generateMembership(
|
|
opts: { type: string; callId?: string; createdTs?: number; expires?: number; deviceId?: string } = {
|
|
type: "m.call",
|
|
},
|
|
): MembershipData {
|
|
if (testConfig.testCreateSticky) {
|
|
// Ignoring createdTs, expires which are legacy
|
|
return {
|
|
...rtcMembershipTemplate,
|
|
member: {
|
|
...rtcMembershipTemplate.member,
|
|
device_id: opts.deviceId ?? rtcMembershipTemplate.member.device_id,
|
|
},
|
|
slot_id: opts.callId ? `${opts.type}#${opts.callId}` : rtcMembershipTemplate.slot_id,
|
|
application: {
|
|
...rtcMembershipTemplate.application,
|
|
type: opts.type,
|
|
},
|
|
} satisfies RtcMembershipData & { user_id: string };
|
|
}
|
|
|
|
return {
|
|
...sessionMembershipTemplate,
|
|
application: opts.type,
|
|
device_id: opts.deviceId ?? sessionMembershipTemplate.device_id,
|
|
call_id: opts.callId ?? sessionMembershipTemplate.call_id,
|
|
created_ts: opts.createdTs,
|
|
expires: opts.expires,
|
|
} satisfies SessionMembershipData & { user_id: string };
|
|
}
|
|
|
|
it(`will ${testConfig.listenForMemberStateEvents ? "" : "NOT"} throw if the room does not have any state stored`, async () => {
|
|
const mockRoom = makeMockRoom([generateMembership()], testConfig.testCreateSticky);
|
|
mockRoom.getLiveTimeline.mockReturnValue({
|
|
getState: vi.fn().mockReturnValue(undefined),
|
|
} as unknown as EventTimeline);
|
|
|
|
const warnLogSpy = vi.spyOn(console, "warn");
|
|
warnLogSpy.mockClear();
|
|
const stateWarningWasLogged = () =>
|
|
warnLogSpy.mock.calls.find((call) => (call[1] as string).includes("Couldn't get state for room"));
|
|
|
|
const sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig);
|
|
await sess.initialMembershipCalculated;
|
|
|
|
if (testConfig.listenForMemberStateEvents) {
|
|
// eslint-disable-next-line @vitest/no-conditional-expect
|
|
expect(stateWarningWasLogged()).toBeTruthy();
|
|
} else {
|
|
// eslint-disable-next-line @vitest/no-conditional-expect
|
|
expect(stateWarningWasLogged()).toBeFalsy();
|
|
}
|
|
});
|
|
|
|
it("creates a room-scoped session from room state", async () => {
|
|
const mockRoom = makeMockRoom([generateMembership()], testConfig.testCreateSticky);
|
|
|
|
sess = MatrixRTCSession.sessionForSlot(
|
|
client,
|
|
mockRoom,
|
|
callSession,
|
|
testConfig.createWithDefaults ? undefined : testConfig,
|
|
);
|
|
await sess.initialMembershipCalculated;
|
|
expect(sess?.memberships.length).toEqual(1);
|
|
expect(sess?.memberships[0].slotDescription.id).toEqual("ROOM");
|
|
expect(sess?.memberships[0].scope).toEqual(testConfig.testCreateSticky ? undefined : "m.room");
|
|
expect(sess?.memberships[0].applicationData).toEqual({ type: "m.call" });
|
|
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
|
expect(sess?.memberships[0].isExpired()).toEqual(false);
|
|
expect(sess?.slotDescription.id).toEqual("ROOM");
|
|
});
|
|
|
|
it("ignores memberships where application is not m.call", () => {
|
|
const testMembership = Object.assign({}, sessionMembershipTemplate, {
|
|
application: "not-m.call",
|
|
});
|
|
const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky);
|
|
const sess = MatrixRTCSession.sessionForSlot(
|
|
client,
|
|
mockRoom,
|
|
callSession,
|
|
testConfig.createWithDefaults ? undefined : testConfig,
|
|
);
|
|
expect(sess?.memberships).toHaveLength(0);
|
|
});
|
|
|
|
it("ignores memberships where callId is not empty", () => {
|
|
const testMembership = Object.assign({}, sessionMembershipTemplate, {
|
|
call_id: "not-empty",
|
|
scope: "m.room",
|
|
});
|
|
const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky);
|
|
const sess = MatrixRTCSession.sessionForSlot(
|
|
client,
|
|
mockRoom,
|
|
callSession,
|
|
testConfig.createWithDefaults ? undefined : testConfig,
|
|
);
|
|
expect(sess?.memberships).toHaveLength(0);
|
|
});
|
|
|
|
it("ignores expired memberships events if legacy session", async () => {
|
|
vi.useFakeTimers();
|
|
const expiredMembership = generateMembership({ type: "m.call", expires: 1000, deviceId: "EXPIRED" });
|
|
const mockRoom = makeMockRoom([generateMembership(), expiredMembership], testConfig.testCreateSticky);
|
|
|
|
vi.advanceTimersByTime(2000);
|
|
sess = MatrixRTCSession.sessionForSlot(
|
|
client,
|
|
mockRoom,
|
|
callSession,
|
|
testConfig.createWithDefaults ? undefined : testConfig,
|
|
)!;
|
|
await sess.initialMembershipCalculated;
|
|
expect(sess?.memberships.length).toEqual(testConfig.testCreateSticky ? 2 : 1);
|
|
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
|
});
|
|
|
|
it("ignores memberships events of members not in the room", async () => {
|
|
const mockRoom = makeMockRoom([generateMembership()], testConfig.testCreateSticky);
|
|
mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join);
|
|
sess = MatrixRTCSession.sessionForSlot(
|
|
client,
|
|
mockRoom,
|
|
callSession,
|
|
testConfig.createWithDefaults ? undefined : testConfig,
|
|
);
|
|
await sess.initialMembershipCalculated;
|
|
expect(sess?.memberships.length).toEqual(0);
|
|
});
|
|
|
|
it("ignores memberships events with no sender", async () => {
|
|
// Force the sender to be undefined.
|
|
const mockRoom = makeMockRoom(
|
|
[{ ...sessionMembershipTemplate, user_id: "" }],
|
|
testConfig.testCreateSticky,
|
|
);
|
|
mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join);
|
|
sess = MatrixRTCSession.sessionForSlot(
|
|
client,
|
|
mockRoom,
|
|
callSession,
|
|
testConfig.createWithDefaults ? undefined : testConfig,
|
|
);
|
|
await sess.initialMembershipCalculated;
|
|
expect(sess?.memberships.length).toEqual(0);
|
|
});
|
|
|
|
it("honours created_ts", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(500);
|
|
const expiredMembership = generateMembership({ type: "m.call", createdTs: 500, expires: 1000 });
|
|
const mockRoom = makeMockRoom([expiredMembership], testConfig.testCreateSticky);
|
|
sess = MatrixRTCSession.sessionForSlot(
|
|
client,
|
|
mockRoom,
|
|
callSession,
|
|
testConfig.createWithDefaults ? undefined : testConfig,
|
|
);
|
|
await sess.initialMembershipCalculated;
|
|
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(
|
|
testConfig.testCreateSticky ? undefined : 1500,
|
|
);
|
|
});
|
|
|
|
it("returns empty session if no membership events are present", async () => {
|
|
const mockRoom = makeMockRoom([], testConfig.testCreateSticky);
|
|
sess = MatrixRTCSession.sessionForSlot(
|
|
client,
|
|
mockRoom,
|
|
callSession,
|
|
testConfig.createWithDefaults ? undefined : testConfig,
|
|
);
|
|
await sess.initialMembershipCalculated;
|
|
expect(sess?.memberships).toHaveLength(0);
|
|
});
|
|
|
|
it("safely ignores events with no memberships section", () => {
|
|
const event = {
|
|
getType: vi.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
|
getContent: vi.fn().mockReturnValue({}),
|
|
getSender: vi.fn().mockReturnValue("@mock:user.example"),
|
|
getTs: vi.fn().mockReturnValue(1000),
|
|
getLocalAge: vi.fn().mockReturnValue(0),
|
|
};
|
|
const mockRoom = makeMockRoom([]);
|
|
mockRoom.getLiveTimeline.mockReturnValue({
|
|
getState: vi.fn().mockReturnValue({
|
|
on: vi.fn(),
|
|
off: vi.fn(),
|
|
getStateEvents: (_type: string, _stateKey: string) => [event],
|
|
events: new Map([
|
|
[
|
|
EventType.GroupCallMemberPrefix,
|
|
{
|
|
size: () => true,
|
|
has: (_stateKey: string) => true,
|
|
get: (_stateKey: string) => event,
|
|
values: () => [event],
|
|
},
|
|
],
|
|
]),
|
|
}),
|
|
} as unknown as EventTimeline);
|
|
sess = MatrixRTCSession.sessionForSlot(
|
|
client,
|
|
mockRoom,
|
|
callSession,
|
|
testConfig.createWithDefaults ? undefined : testConfig,
|
|
);
|
|
expect(sess.memberships).toHaveLength(0);
|
|
});
|
|
|
|
it("safely ignores events with junk memberships section", () => {
|
|
const event = {
|
|
getType: vi.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
|
getContent: vi.fn().mockReturnValue({ memberships: ["i am a fish"] }),
|
|
getSender: vi.fn().mockReturnValue("@mock:user.example"),
|
|
getTs: vi.fn().mockReturnValue(1000),
|
|
getLocalAge: vi.fn().mockReturnValue(0),
|
|
};
|
|
const mockRoom = makeMockRoom([]);
|
|
mockRoom.getLiveTimeline.mockReturnValue({
|
|
getState: vi.fn().mockReturnValue({
|
|
on: vi.fn(),
|
|
off: vi.fn(),
|
|
getStateEvents: (_type: string, _stateKey: string) => [event],
|
|
events: new Map([
|
|
[
|
|
EventType.GroupCallMemberPrefix,
|
|
{
|
|
size: () => true,
|
|
has: (_stateKey: string) => true,
|
|
get: (_stateKey: string) => event,
|
|
values: () => [event],
|
|
},
|
|
],
|
|
]),
|
|
}),
|
|
} as unknown as EventTimeline);
|
|
sess = MatrixRTCSession.sessionForSlot(
|
|
client,
|
|
mockRoom,
|
|
callSession,
|
|
testConfig.createWithDefaults ? undefined : testConfig,
|
|
);
|
|
expect(sess.memberships).toHaveLength(0);
|
|
});
|
|
|
|
it("ignores memberships with no device_id", async () => {
|
|
const testMembership = Object.assign({}, sessionMembershipTemplate);
|
|
(testMembership.device_id as string | undefined) = undefined;
|
|
const mockRoom = makeMockRoom([testMembership]);
|
|
const sess = MatrixRTCSession.sessionForSlot(
|
|
client,
|
|
mockRoom,
|
|
callSession,
|
|
testConfig.createWithDefaults ? undefined : testConfig,
|
|
);
|
|
await sess.initialMembershipCalculated;
|
|
expect(sess.memberships).toHaveLength(0);
|
|
});
|
|
|
|
it("ignores memberships with no call_id", async () => {
|
|
const testMembership = Object.assign({}, sessionMembershipTemplate);
|
|
(testMembership.call_id as string | undefined) = undefined;
|
|
const mockRoom = makeMockRoom([testMembership]);
|
|
sess = MatrixRTCSession.sessionForSlot(
|
|
client,
|
|
mockRoom,
|
|
callSession,
|
|
testConfig.createWithDefaults ? undefined : testConfig,
|
|
);
|
|
await sess.initialMembershipCalculated;
|
|
expect(sess.memberships).toHaveLength(0);
|
|
});
|
|
|
|
it("assigns RTC backend identities to memberships", async () => {
|
|
const mockRoom = makeMockRoom([generateMembership()], testConfig.testCreateSticky);
|
|
sess = MatrixRTCSession.sessionForSlot(
|
|
client,
|
|
mockRoom,
|
|
callSession,
|
|
testConfig.createWithDefaults ? undefined : testConfig,
|
|
);
|
|
await sess.initialMembershipCalculated;
|
|
expect(sess?.memberships.length).toEqual(1);
|
|
// Backend identity is expected to not be hashed with a legacy (session) membership
|
|
expect(sess?.memberships[0].rtcBackendIdentity).toEqual(
|
|
testConfig.testCreateSticky
|
|
? await computeRtcIdentityRaw(
|
|
rtcMembershipTemplate.member.user_id,
|
|
rtcMembershipTemplate.member.device_id,
|
|
rtcMembershipTemplate.member.id,
|
|
)
|
|
: "@mock:user.example:AAAAAAA",
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
describe("roomsessionForSlot combined state", () => {
|
|
it("perfers sticky events when both membership and sticky events appear for the same user", async () => {
|
|
// Create a room with identical member state and sticky state for the same user.
|
|
const mockRoom = makeMockRoom([rtcMembershipTemplate]);
|
|
mockRoom._unstable_getStickyEvents.mockImplementation(() => {
|
|
const ev = mockRTCEvent(
|
|
{
|
|
...rtcMembershipTemplate,
|
|
msc4354_sticky_key: `_${rtcMembershipTemplate.user_id}_${rtcMembershipTemplate.member.device_id}`,
|
|
},
|
|
mockRoom.roomId,
|
|
5000,
|
|
);
|
|
return [ev as StickyMatrixEvent];
|
|
});
|
|
|
|
// Expect for there to be one membership as the state has been merged down.
|
|
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, {
|
|
listenForStickyEvents: true,
|
|
listenForMemberStateEvents: true,
|
|
});
|
|
await sess.initialMembershipCalculated;
|
|
expect(sess?.memberships.length).toEqual(1);
|
|
expect(sess?.memberships[0].slotDescription.id).toEqual("ROOM");
|
|
expect(sess?.memberships[0].scope).toEqual(undefined);
|
|
expect(sess?.memberships[0].application).toEqual("m.call");
|
|
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
|
|
expect(sess?.memberships[0].isExpired()).toEqual(false);
|
|
expect(sess?.slotDescription.id).toEqual("ROOM");
|
|
});
|
|
it("combines sticky and membership events when both exist", async () => {
|
|
// Create a room with identical member state and sticky state for the same user.
|
|
const mockRoom = makeMockRoom([sessionMembershipTemplate]);
|
|
const stickyUserId = "@stickyev:user.example";
|
|
mockRoom._unstable_getStickyEvents.mockImplementation(() => {
|
|
const ev = mockRTCEvent(
|
|
{
|
|
...rtcMembershipTemplate,
|
|
member: {
|
|
...rtcMembershipTemplate.member,
|
|
user_id: stickyUserId,
|
|
},
|
|
user_id: stickyUserId,
|
|
msc4354_sticky_key: `_${stickyUserId}_${rtcMembershipTemplate.member.device_id}`,
|
|
},
|
|
mockRoom.roomId,
|
|
15000,
|
|
Date.now() - 1000, // Sticky event comes first.
|
|
);
|
|
return [ev as StickyMatrixEvent];
|
|
});
|
|
|
|
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, {
|
|
listenForStickyEvents: true,
|
|
listenForMemberStateEvents: true,
|
|
});
|
|
await sess.initialMembershipCalculated;
|
|
|
|
const memberships = sess.memberships;
|
|
expect(memberships.length).toEqual(2);
|
|
expect(memberships[0].sender).toEqual(stickyUserId);
|
|
expect(memberships[0].slotDescription.id).toEqual("ROOM");
|
|
expect(memberships[0].scope).toEqual(undefined);
|
|
expect(memberships[0].applicationData).toEqual({ type: "m.call" });
|
|
expect(memberships[0].deviceId).toEqual("AAAAAAA");
|
|
expect(memberships[0].isExpired()).toEqual(false);
|
|
|
|
// Then state
|
|
expect(memberships[1].sender).toEqual(sessionMembershipTemplate.user_id);
|
|
|
|
expect(sess?.slotDescription.id).toEqual("ROOM");
|
|
});
|
|
it("handles an incoming sticky event to an existing session", async () => {
|
|
const mockRoom = makeMockRoom([sessionMembershipTemplate], false);
|
|
const stickyUserId = "@stickyev:user.example";
|
|
|
|
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, {
|
|
listenForStickyEvents: true,
|
|
listenForMemberStateEvents: true,
|
|
});
|
|
await sess.initialMembershipCalculated;
|
|
expect(sess.memberships.length).toEqual(1);
|
|
const membershipRecalculated = new Promise((r) => sess?.once(MatrixRTCSessionEvent.MembershipsChanged, r));
|
|
const stickyEv = mockRTCEvent(
|
|
{
|
|
...rtcMembershipTemplate,
|
|
member: {
|
|
...rtcMembershipTemplate.member,
|
|
user_id: stickyUserId,
|
|
},
|
|
user_id: stickyUserId,
|
|
msc4354_sticky_key: `_${stickyUserId}_${rtcMembershipTemplate.member.device_id}`,
|
|
},
|
|
mockRoom.roomId,
|
|
15000,
|
|
Date.now() - 1000, // Sticky event comes first.
|
|
) as StickyMatrixEvent;
|
|
mockRoom._unstable_getStickyEvents.mockImplementation(() => {
|
|
return [stickyEv];
|
|
});
|
|
mockRoom.emit(RoomStickyEventsEvent.Update, [stickyEv], [], []);
|
|
await membershipRecalculated;
|
|
expect(sess.memberships.length).toEqual(2);
|
|
});
|
|
});
|
|
|
|
describe("getOldestMembership", () => {
|
|
it("returns the oldest membership event", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(4000);
|
|
const mockRoom = makeMockRoom([
|
|
Object.assign({}, sessionMembershipTemplate, { device_id: "foo", created_ts: 3000 }),
|
|
Object.assign({}, sessionMembershipTemplate, { device_id: "old", created_ts: 1000 }),
|
|
Object.assign({}, sessionMembershipTemplate, { device_id: "bar", created_ts: 2000 }),
|
|
]);
|
|
|
|
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
|
await sess.initialMembershipCalculated;
|
|
expect(sess.getOldestMembership()!.deviceId).toEqual("old");
|
|
vi.useRealTimers();
|
|
});
|
|
});
|
|
|
|
describe("getConsensusCallIntent", () => {
|
|
it.each([
|
|
[undefined, undefined, undefined],
|
|
["audio", undefined, "audio"],
|
|
[undefined, "audio", "audio"],
|
|
["audio", "audio", "audio"],
|
|
["audio", "video", undefined],
|
|
])("gets correct consensus for %s + %s = %s", async (intentA, intentB, result) => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(4000);
|
|
const mockRoom = makeMockRoom([
|
|
Object.assign({}, sessionMembershipTemplate, { "m.call.intent": intentA }),
|
|
Object.assign({}, sessionMembershipTemplate, { "m.call.intent": intentB }),
|
|
]);
|
|
|
|
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
|
await sess.initialMembershipCalculated;
|
|
expect(sess.getConsensusCallIntent()).toEqual(result);
|
|
vi.useRealTimers();
|
|
});
|
|
});
|
|
|
|
describe("getsActiveFocus", () => {
|
|
const firstPreferredFocus = {
|
|
type: "livekit",
|
|
livekit_service_url: "https://active.url",
|
|
livekit_alias: "!active:active.url",
|
|
};
|
|
it("gets the correct active focus with oldest_membership", async () => {
|
|
client.sendStateEvent = vi.fn();
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(3000);
|
|
const mockRoom = makeMockRoom([
|
|
Object.assign({}, sessionMembershipTemplate, {
|
|
device_id: "foo",
|
|
created_ts: 500,
|
|
foci_preferred: [firstPreferredFocus],
|
|
}),
|
|
Object.assign({}, sessionMembershipTemplate, { device_id: "old", created_ts: 1000 }),
|
|
Object.assign({}, sessionMembershipTemplate, { device_id: "bar", created_ts: 2000 }),
|
|
]);
|
|
|
|
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
|
|
|
sess.joinRTCSession(
|
|
owmMemberIdentity,
|
|
[{ type: "livekit", livekit_service_url: "htts://test.org" }],
|
|
undefined,
|
|
);
|
|
await flushPromises();
|
|
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
"org.matrix.msc3401.call.member",
|
|
{
|
|
"application": "m.call",
|
|
"call_id": "",
|
|
"device_id": "AAAAAAA",
|
|
"expires": 14400000,
|
|
"foci_preferred": [
|
|
{
|
|
livekit_service_url: "htts://test.org",
|
|
type: "livekit",
|
|
},
|
|
],
|
|
"focus_active": {
|
|
focus_selection: "oldest_membership",
|
|
type: "livekit",
|
|
},
|
|
"m.call.intent": undefined,
|
|
"membershipID": "@alice:example.org:AAAAAAA",
|
|
"scope": "m.room",
|
|
},
|
|
"_@alice:example.org_AAAAAAA_m.call",
|
|
);
|
|
vi.useRealTimers();
|
|
});
|
|
it("does not provide focus if the selection method is unknown", () => {
|
|
const mockRoom = makeMockRoom([
|
|
Object.assign({}, sessionMembershipTemplate, {
|
|
device_id: "foo",
|
|
created_ts: 500,
|
|
foci_preferred: [firstPreferredFocus],
|
|
}),
|
|
Object.assign({}, sessionMembershipTemplate, { device_id: "old", created_ts: 1000 }),
|
|
Object.assign({}, sessionMembershipTemplate, { device_id: "bar", created_ts: 2000 }),
|
|
]);
|
|
|
|
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
|
|
|
sess.joinRTCSession(owmMemberIdentity, [{ type: "livekit", livekit_service_url: "htts://test.org" }], {
|
|
type: "livekit",
|
|
focus_selection: "unknown",
|
|
});
|
|
expect(sess.memberships.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("joining", () => {
|
|
let mockRoom: Room;
|
|
let sendEventMock: Mock;
|
|
let sendStateEventMock: Mock;
|
|
|
|
let sentStateEvent: Promise<void>;
|
|
beforeEach(async () => {
|
|
sentStateEvent = new Promise((resolve) => {
|
|
sendStateEventMock = vi.fn(resolve);
|
|
});
|
|
sendEventMock = vi.fn().mockResolvedValue(undefined);
|
|
client.sendStateEvent = sendStateEventMock;
|
|
client.sendEvent = sendEventMock;
|
|
|
|
client._unstable_updateDelayedEvent = vi.fn();
|
|
client._unstable_cancelScheduledDelayedEvent = vi.fn();
|
|
client._unstable_restartScheduledDelayedEvent = vi.fn();
|
|
client._unstable_sendScheduledDelayedEvent = vi.fn();
|
|
|
|
mockRoom = makeMockRoom([]);
|
|
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
|
await sess.initialMembershipCalculated;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
const wasJoined = sess!.isJoined();
|
|
// stop the timers
|
|
const left = await sess!.leaveRoomSession();
|
|
if (left !== wasJoined) {
|
|
throw new Error(`Unexpected leave result: wanted ${wasJoined}, got ${left}`);
|
|
}
|
|
});
|
|
|
|
it("starts un-joined", () => {
|
|
expect(sess!.isJoined()).toEqual(false);
|
|
});
|
|
|
|
it("shows joined once join is called", () => {
|
|
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus);
|
|
expect(sess!.isJoined()).toEqual(true);
|
|
});
|
|
|
|
it("uses the sticky events membership manager implementation", () => {
|
|
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { unstableSendStickyEvents: true });
|
|
expect(sess!.isJoined()).toEqual(true);
|
|
expect(sess!["membershipManager"] instanceof StickyEventMembershipManager).toEqual(true);
|
|
});
|
|
|
|
it("sends a notification when starting a call and emit DidSendCallNotification", async () => {
|
|
// Simulate a join, including the update to the room state
|
|
// Ensure sendEvent returns event IDs so the DidSendCallNotification payload includes them
|
|
sendEventMock.mockResolvedValueOnce({ event_id: "new-evt" });
|
|
const didSendEventFn = vi.fn();
|
|
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, didSendEventFn);
|
|
// Create an additional listener to create a promise that resolves after the emission.
|
|
const didSendNotification = new Promise((resolve) => {
|
|
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, resolve);
|
|
});
|
|
|
|
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { notificationType: "ring" });
|
|
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
|
|
mockRoomState(mockRoom, [{ ...sessionMembershipTemplate, user_id: client.getUserId()! }]);
|
|
await sess!._onRTCSessionMemberUpdate();
|
|
const ownMembershipId = sess?.memberships[0].eventId;
|
|
|
|
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.RTCNotification, {
|
|
"m.mentions": { user_ids: [], room: true },
|
|
"notification_type": "ring",
|
|
"m.relates_to": {
|
|
event_id: ownMembershipId,
|
|
rel_type: "m.reference",
|
|
},
|
|
"lifetime": 30000,
|
|
"sender_ts": expect.any(Number),
|
|
});
|
|
|
|
await didSendNotification;
|
|
// And ensure we emitted the DidSendCallNotification event with both payloads
|
|
expect(didSendEventFn).toHaveBeenCalledWith({
|
|
"event_id": "new-evt",
|
|
"lifetime": 30000,
|
|
"m.mentions": { room: true, user_ids: [] },
|
|
"m.relates_to": {
|
|
event_id: expect.any(String),
|
|
rel_type: "m.reference",
|
|
},
|
|
"notification_type": "ring",
|
|
"sender_ts": expect.any(Number),
|
|
});
|
|
});
|
|
|
|
it("sends a notification with a intent when starting a call and emits DidSendCallNotification", async () => {
|
|
// Simulate a join, including the update to the room state
|
|
// Ensure sendEvent returns event IDs so the DidSendCallNotification payload includes them
|
|
sendEventMock.mockResolvedValueOnce({ event_id: "new-evt" });
|
|
const didSendEventFn = vi.fn();
|
|
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, didSendEventFn);
|
|
// Create an additional listener to create a promise that resolves after the emission.
|
|
const didSendNotification = new Promise((resolve) => {
|
|
sess!.once(MatrixRTCSessionEvent.DidSendCallNotification, resolve);
|
|
});
|
|
|
|
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, {
|
|
notificationType: "ring",
|
|
callIntent: "audio",
|
|
});
|
|
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
|
|
|
|
mockRoomState(mockRoom, [
|
|
{
|
|
...sessionMembershipTemplate,
|
|
"user_id": client.getUserId()!,
|
|
// This is what triggers the intent type on the notification event.
|
|
"m.call.intent": "audio",
|
|
},
|
|
]);
|
|
|
|
await sess!._onRTCSessionMemberUpdate();
|
|
const ownMembershipEventId = sess?.memberships[0].eventId;
|
|
expect(sess!.getConsensusCallIntent()).toEqual("audio");
|
|
|
|
expect(client.sendEvent).toHaveBeenCalledWith(mockRoom!.roomId, EventType.RTCNotification, {
|
|
"m.mentions": { user_ids: [], room: true },
|
|
"notification_type": "ring",
|
|
"m.call.intent": "audio",
|
|
"m.relates_to": {
|
|
event_id: ownMembershipEventId,
|
|
rel_type: "m.reference",
|
|
},
|
|
"lifetime": 30000,
|
|
"sender_ts": expect.any(Number),
|
|
});
|
|
|
|
await didSendNotification;
|
|
// And ensure we emitted the DidSendCallNotification event with both payloads
|
|
expect(didSendEventFn).toHaveBeenCalledWith({
|
|
"event_id": "new-evt",
|
|
"lifetime": 30000,
|
|
"m.mentions": { room: true, user_ids: [] },
|
|
"m.relates_to": {
|
|
event_id: expect.any(String),
|
|
rel_type: "m.reference",
|
|
},
|
|
"notification_type": "ring",
|
|
"m.call.intent": "audio",
|
|
"sender_ts": expect.any(Number),
|
|
});
|
|
});
|
|
|
|
it("doesn't send a notification when joining an existing call", async () => {
|
|
// Add another member to the call so that it is considered an existing call
|
|
mockRoomState(mockRoom, [sessionMembershipTemplate]);
|
|
await sess!._onRTCSessionMemberUpdate();
|
|
|
|
// Simulate a join, including the update to the room state
|
|
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { notificationType: "ring" });
|
|
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
|
|
mockRoomState(mockRoom, [
|
|
sessionMembershipTemplate,
|
|
{ ...sessionMembershipTemplate, user_id: client.getUserId()! },
|
|
]);
|
|
await sess!._onRTCSessionMemberUpdate();
|
|
|
|
// check we send out join event
|
|
expect(client.sendStateEvent).toHaveBeenCalled();
|
|
// but no notification event
|
|
expect(client.sendEvent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("doesn't send a notification when someone else starts the call faster than us", async () => {
|
|
// Simulate a join, including the update to the room state
|
|
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { notificationType: "ring" });
|
|
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
|
|
// But this time we want to simulate a race condition in which we receive a state event
|
|
// from someone else, starting the call before our own state event has been sent
|
|
mockRoomState(mockRoom, [sessionMembershipTemplate]);
|
|
await sess!._onRTCSessionMemberUpdate();
|
|
mockRoomState(mockRoom, [
|
|
sessionMembershipTemplate,
|
|
{ ...sessionMembershipTemplate, user_id: client.getUserId()! },
|
|
]);
|
|
await sess!._onRTCSessionMemberUpdate();
|
|
|
|
// check we send out join event
|
|
expect(client.sendStateEvent).toHaveBeenCalled();
|
|
// but no notification event
|
|
//
|
|
// We assume that the responsibility to send a notification, if any, lies with the other
|
|
// participant that won the race
|
|
expect(client.sendEvent).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("onMembershipsChanged", () => {
|
|
it("only emit if membership changes", async () => {
|
|
const mockRoom = makeMockRoom([sessionMembershipTemplate]);
|
|
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
|
await sess.initialMembershipCalculated;
|
|
const onMembershipsChanged = vi.fn();
|
|
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
|
|
|
|
// no change -> no emission
|
|
await sess._onRTCSessionMemberUpdate();
|
|
expect(onMembershipsChanged).not.toHaveBeenCalled();
|
|
|
|
// no change -> emission
|
|
mockRoomState(mockRoom, []);
|
|
await sess._onRTCSessionMemberUpdate();
|
|
expect(onMembershipsChanged).toHaveBeenCalled();
|
|
});
|
|
|
|
// TODO: re-enable this test when expiry is implemented
|
|
// eslint-disable-next-line @vitest/no-commented-out-tests
|
|
// it("emits an event at the time a membership event expires", () => {
|
|
// vi.useFakeTimers();
|
|
// try {
|
|
// const membership = Object.assign({}, membershipTemplate);
|
|
// const mockRoom = makeMockRoom([membership]);
|
|
|
|
// sess = MatrixRTCSession.roomsessionForSlot(client, mockRoom);
|
|
// const membershipObject = sess.memberships[0];
|
|
|
|
// const onMembershipsChanged = vi.fn();
|
|
// sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
|
|
|
|
// vi.advanceTimersByTime(61 * 1000 * 1000);
|
|
|
|
// expect(onMembershipsChanged).toHaveBeenCalledWith([membershipObject], []);
|
|
// expect(sess?.memberships.length).toEqual(0);
|
|
// } finally {
|
|
// vi.useRealTimers();
|
|
// }
|
|
// });
|
|
});
|
|
|
|
describe("key management", () => {
|
|
// Then encryption manager is tested separately, here we just test the integration
|
|
it("provides encryption keys for memberships", async () => {
|
|
client.encryptAndSendToDevice = vi.fn().mockResolvedValue(undefined);
|
|
const mockRoom = makeMockRoom([
|
|
{
|
|
...sessionMembershipTemplate,
|
|
user_id: "@bob:user.example",
|
|
device_id: "BBBBBB",
|
|
},
|
|
{
|
|
...sessionMembershipTemplate,
|
|
user_id: client.getUserId()!,
|
|
device_id: client.getDeviceId()!,
|
|
},
|
|
]);
|
|
const sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
|
sess.joinRTCSession(owmMemberIdentity, [{ type: "livekit", livekit_service_url: "https://test.org" }], {
|
|
type: "livekit",
|
|
focus_selection: "oldest_membership",
|
|
});
|
|
await flushPromises();
|
|
|
|
expect(client.encryptAndSendToDevice).toHaveBeenCalledTimes(1);
|
|
expect(client.encryptAndSendToDevice).toHaveBeenCalledWith(
|
|
"io.element.call.encryption_keys",
|
|
[{ userId: "@bob:user.example", deviceId: "BBBBBB" }],
|
|
expect.anything(),
|
|
);
|
|
expect(sess.statistics.counters.roomEventEncryptionKeysSent).toEqual(1);
|
|
|
|
await sess.leaveRoomSession();
|
|
});
|
|
});
|
|
|
|
describe("read status", () => {
|
|
it("returns the correct probablyLeft status", () => {
|
|
const mockRoom = makeMockRoom([sessionMembershipTemplate]);
|
|
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
|
expect(sess!.probablyLeft).toBe(undefined);
|
|
|
|
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
|
expect(sess!.probablyLeft).toBe(false);
|
|
|
|
// Simulate the membership manager believing the user has left
|
|
const accessPrivateFieldsSession = sess as unknown as {
|
|
membershipManager: { state: { probablyLeft: boolean } };
|
|
};
|
|
accessPrivateFieldsSession.membershipManager.state.probablyLeft = true;
|
|
expect(sess!.probablyLeft).toBe(true);
|
|
});
|
|
|
|
it("returns membershipStatus once joinRTCSession got called", () => {
|
|
const mockRoom = makeMockRoom([rtcMembershipTemplate]);
|
|
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
|
|
expect(sess!.membershipStatus).toBe(undefined);
|
|
|
|
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
|
|
expect(sess!.membershipStatus).toBe(Status.Connecting);
|
|
});
|
|
});
|
|
it("reemits membershipManager events", () => {
|
|
sess = MatrixRTCSession.sessionForSlot(client, makeMockRoom([rtcMembershipTemplate]), callSession);
|
|
const delayIdChanged = vi.fn();
|
|
sess.on(MembershipManagerEvent.DelayIdChanged, delayIdChanged);
|
|
const statusChanged = vi.fn();
|
|
sess.on(MembershipManagerEvent.StatusChanged, statusChanged);
|
|
const probablyLeftChanged = vi.fn();
|
|
sess.on(MembershipManagerEvent.ProbablyLeft, probablyLeftChanged);
|
|
|
|
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus);
|
|
|
|
const membershipManager = sess["membershipManager"]!;
|
|
membershipManager.emit(MembershipManagerEvent.DelayIdChanged, "newDelayId");
|
|
membershipManager.emit(MembershipManagerEvent.StatusChanged, Status.Connected, Status.Disconnected);
|
|
membershipManager.emit(MembershipManagerEvent.ProbablyLeft, false);
|
|
expect(delayIdChanged).toHaveBeenCalledWith("newDelayId", membershipManager);
|
|
expect(statusChanged).toHaveBeenCalledWith(Status.Connected, Status.Disconnected, membershipManager);
|
|
expect(probablyLeftChanged).toHaveBeenCalledWith(false, membershipManager);
|
|
});
|
|
});
|