You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-08-06 12:02:40 +03:00
MatrixRTC: MembershipManager test cases and deprecation of MatrixRTCSession.room (#4713)
* WIP doodles on MembershipManager test cases * . * initial membership manager test setup. * Updates from discussion * revert renaming comments * remove unused import * fix leave delayed event resend test. It was missing a flush. * comment out and remove unused variables * es lint * use jsdom instead of node test environment * remove unused variables * remove unused export * temp * review * fixup tests * more review * remove wait for expect dependency * flatten tests and add comments * add more leave test cases * use defer * remove @jest/environment dependency * Cleanup awaits and Make mock types more correct. Make every mock return a Promise if the real implementation does return a pormise. * remove flush promise dependency * add linting to matrixrtc tests * Add fix async lints and use matrix rtc logger for test environment. * prettier * change to MatrixRTCSession logger * make accessing the full room deprecated * remove deprecated usage of full room * Clean up the deprecation --------- Co-authored-by: Hugh Nimmo-Smith <hughns@matrix.org>
This commit is contained in:
@@ -156,7 +156,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Enable stricter promise rules for the MatrixRTC codebase
|
// Enable stricter promise rules for the MatrixRTC codebase
|
||||||
files: ["src/matrixrtc/**/*.ts"],
|
files: ["src/matrixrtc/**/*.ts", "spec/unit/matrixrtc/*.ts"],
|
||||||
rules: {
|
rules: {
|
||||||
// Encourage proper usage of Promises:
|
// Encourage proper usage of Promises:
|
||||||
"@typescript-eslint/no-floating-promises": "error",
|
"@typescript-eslint/no-floating-promises": "error",
|
||||||
|
@@ -14,13 +14,12 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { encodeBase64, EventType, MatrixClient, MatrixError, type MatrixEvent, type Room } from "../../../src";
|
import { encodeBase64, EventType, MatrixClient, type MatrixError, type MatrixEvent, type Room } from "../../../src";
|
||||||
import { KnownMembership } from "../../../src/@types/membership";
|
import { KnownMembership } from "../../../src/@types/membership";
|
||||||
import { DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
import { DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||||
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
|
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
|
||||||
import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
|
import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
|
||||||
import { secureRandomString } from "../../../src/randomstring";
|
import { secureRandomString } from "../../../src/randomstring";
|
||||||
import { flushPromises } from "../../test-utils/flushPromises";
|
|
||||||
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
|
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
|
||||||
|
|
||||||
const mockFocus = { type: "mock" };
|
const mockFocus = { type: "mock" };
|
||||||
@@ -37,10 +36,10 @@ describe("MatrixRTCSession", () => {
|
|||||||
client.getDeviceId = jest.fn().mockReturnValue("AAAAAAA");
|
client.getDeviceId = jest.fn().mockReturnValue("AAAAAAA");
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(async () => {
|
||||||
client.stopClient();
|
client.stopClient();
|
||||||
client.matrixRTC.stop();
|
client.matrixRTC.stop();
|
||||||
if (sess) sess.stop();
|
if (sess) await sess.stop();
|
||||||
sess = undefined;
|
sess = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -322,11 +321,9 @@ describe("MatrixRTCSession", () => {
|
|||||||
let sendStateEventMock: jest.Mock;
|
let sendStateEventMock: jest.Mock;
|
||||||
let sendDelayedStateMock: jest.Mock;
|
let sendDelayedStateMock: jest.Mock;
|
||||||
let sendEventMock: jest.Mock;
|
let sendEventMock: jest.Mock;
|
||||||
let updateDelayedEventMock: jest.Mock;
|
|
||||||
|
|
||||||
let sentStateEvent: Promise<void>;
|
let sentStateEvent: Promise<void>;
|
||||||
let sentDelayedState: Promise<void>;
|
let sentDelayedState: Promise<void>;
|
||||||
let updatedDelayedEvent: Promise<void>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sentStateEvent = new Promise((resolve) => {
|
sentStateEvent = new Promise((resolve) => {
|
||||||
@@ -340,15 +337,12 @@ describe("MatrixRTCSession", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
updatedDelayedEvent = new Promise((r) => {
|
|
||||||
updateDelayedEventMock = jest.fn(r);
|
|
||||||
});
|
|
||||||
sendEventMock = jest.fn();
|
sendEventMock = jest.fn();
|
||||||
client.sendStateEvent = sendStateEventMock;
|
client.sendStateEvent = sendStateEventMock;
|
||||||
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
|
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
|
||||||
client.sendEvent = sendEventMock;
|
client.sendEvent = sendEventMock;
|
||||||
|
|
||||||
client._unstable_updateDelayedEvent = updateDelayedEventMock;
|
client._unstable_updateDelayedEvent = jest.fn();
|
||||||
|
|
||||||
mockRoom = makeMockRoom([]);
|
mockRoom = makeMockRoom([]);
|
||||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||||
@@ -432,120 +426,6 @@ describe("MatrixRTCSession", () => {
|
|||||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("calls", () => {
|
|
||||||
const activeFocusConfig = { type: "livekit", livekit_service_url: "https://active.url" };
|
|
||||||
const activeFocus = { type: "livekit", focus_selection: "oldest_membership" };
|
|
||||||
|
|
||||||
async function testJoin(useOwnedStateEvents: boolean): Promise<void> {
|
|
||||||
if (useOwnedStateEvents) {
|
|
||||||
mockRoom.getVersion = jest.fn().mockReturnValue("org.matrix.msc3757.default");
|
|
||||||
}
|
|
||||||
|
|
||||||
jest.useFakeTimers();
|
|
||||||
|
|
||||||
// preparing the delayed disconnect should handle the delay being too long
|
|
||||||
const sendDelayedStateExceedAttempt = new Promise<void>((resolve) => {
|
|
||||||
const error = new MatrixError({
|
|
||||||
"errcode": "M_UNKNOWN",
|
|
||||||
"org.matrix.msc4140.errcode": "M_MAX_DELAY_EXCEEDED",
|
|
||||||
"org.matrix.msc4140.max_delay": 7500,
|
|
||||||
});
|
|
||||||
sendDelayedStateMock.mockImplementationOnce(() => {
|
|
||||||
resolve();
|
|
||||||
return Promise.reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const userStateKey = `${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA`;
|
|
||||||
// preparing the delayed disconnect should handle ratelimiting
|
|
||||||
const sendDelayedStateAttempt = new Promise<void>((resolve) => {
|
|
||||||
const error = new MatrixError({ errcode: "M_LIMIT_EXCEEDED" });
|
|
||||||
sendDelayedStateMock.mockImplementationOnce(() => {
|
|
||||||
resolve();
|
|
||||||
return Promise.reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// setting the membership state should handle ratelimiting (also with a retry-after value)
|
|
||||||
const sendStateEventAttempt = new Promise<void>((resolve) => {
|
|
||||||
const error = new MatrixError(
|
|
||||||
{ errcode: "M_LIMIT_EXCEEDED" },
|
|
||||||
429,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
new Headers({ "Retry-After": "1" }),
|
|
||||||
);
|
|
||||||
sendStateEventMock.mockImplementationOnce(() => {
|
|
||||||
resolve();
|
|
||||||
return Promise.reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
sess!.joinRoomSession([activeFocusConfig], activeFocus, {
|
|
||||||
membershipServerSideExpiryTimeout: 9000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches
|
|
||||||
await sendDelayedStateAttempt;
|
|
||||||
const callProps = (d: number) => {
|
|
||||||
return [mockRoom!.roomId, { delay: d }, "org.matrix.msc3401.call.member", {}, userStateKey];
|
|
||||||
};
|
|
||||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenNthCalledWith(1, ...callProps(9000));
|
|
||||||
expect(client._unstable_sendDelayedStateEvent).toHaveBeenNthCalledWith(2, ...callProps(7500));
|
|
||||||
|
|
||||||
jest.advanceTimersByTime(5000);
|
|
||||||
|
|
||||||
await sendStateEventAttempt.then(); // needed to resolve after resendIfRateLimited catches
|
|
||||||
jest.advanceTimersByTime(1000);
|
|
||||||
|
|
||||||
await sentStateEvent;
|
|
||||||
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
|
||||||
mockRoom!.roomId,
|
|
||||||
EventType.GroupCallMemberPrefix,
|
|
||||||
{
|
|
||||||
application: "m.call",
|
|
||||||
scope: "m.room",
|
|
||||||
call_id: "",
|
|
||||||
expires: 14400000,
|
|
||||||
device_id: "AAAAAAA",
|
|
||||||
foci_preferred: [activeFocusConfig],
|
|
||||||
focus_active: activeFocus,
|
|
||||||
} satisfies SessionMembershipData,
|
|
||||||
userStateKey,
|
|
||||||
);
|
|
||||||
await sentDelayedState;
|
|
||||||
|
|
||||||
// should have prepared the heartbeat to keep delaying the leave event while still connected
|
|
||||||
await updatedDelayedEvent;
|
|
||||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// ensures that we reach the code that schedules the timeout for the next delay update before we advance the timers.
|
|
||||||
await flushPromises();
|
|
||||||
jest.advanceTimersByTime(5000);
|
|
||||||
// should update delayed disconnect
|
|
||||||
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
jest.useRealTimers();
|
|
||||||
}
|
|
||||||
|
|
||||||
it("sends a membership event with session payload when joining a call", async () => {
|
|
||||||
await testJoin(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not prefix the state key with _ for rooms that support user-owned state events", async () => {
|
|
||||||
await testJoin(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing if join called when already joined", async () => {
|
|
||||||
sess!.joinRoomSession([mockFocus], mockFocus);
|
|
||||||
await sentStateEvent;
|
|
||||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
sess!.joinRoomSession([mockFocus], mockFocus);
|
|
||||||
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("onMembershipsChanged", () => {
|
describe("onMembershipsChanged", () => {
|
||||||
@@ -616,9 +496,9 @@ describe("MatrixRTCSession", () => {
|
|||||||
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(async () => {
|
||||||
// stop the timers
|
// stop the timers
|
||||||
sess!.leaveRoomSession();
|
await sess!.leaveRoomSession();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a key when joining", () => {
|
it("creates a key when joining", () => {
|
||||||
@@ -715,7 +595,7 @@ describe("MatrixRTCSession", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cancels key send event that fail", async () => {
|
it("cancels key send event that fail", () => {
|
||||||
const eventSentinel = {} as unknown as MatrixEvent;
|
const eventSentinel = {} as unknown as MatrixEvent;
|
||||||
|
|
||||||
client.cancelPendingEvent = jest.fn();
|
client.cancelPendingEvent = jest.fn();
|
||||||
|
@@ -32,7 +32,7 @@ import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
|
|||||||
describe("MatrixRTCSessionManager", () => {
|
describe("MatrixRTCSessionManager", () => {
|
||||||
let client: MatrixClient;
|
let client: MatrixClient;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
client = new MatrixClient({ baseUrl: "base_url" });
|
client = new MatrixClient({ baseUrl: "base_url" });
|
||||||
client.matrixRTC.start();
|
client.matrixRTC.start();
|
||||||
});
|
});
|
||||||
|
606
spec/unit/matrixrtc/MembershipManager.spec.ts
Normal file
606
spec/unit/matrixrtc/MembershipManager.spec.ts
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
Copyright 2025 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 MockedFunction, type Mock } from "jest-mock";
|
||||||
|
|
||||||
|
import { EventType, HTTPError, MatrixError, type Room } from "../../../src";
|
||||||
|
import { type Focus, type LivekitFocusActive, type SessionMembershipData } from "../../../src/matrixrtc";
|
||||||
|
import { LegacyMembershipManager } from "../../../src/matrixrtc/MembershipManager";
|
||||||
|
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
|
||||||
|
import { defer } from "../../../src/utils";
|
||||||
|
|
||||||
|
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
method.mockImplementation(() => {
|
||||||
|
resolve();
|
||||||
|
return returnVal ?? Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAsyncHandle(method: MockedFunction<any>) {
|
||||||
|
const { reject, resolve, promise } = defer();
|
||||||
|
method.mockImplementation(() => promise);
|
||||||
|
return { reject, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests different MembershipManager implementations. Some tests don't apply to `LegacyMembershipManager`
|
||||||
|
* use !FailsForLegacy to skip those. See: testEnvironment for more details.
|
||||||
|
*/
|
||||||
|
describe.each([
|
||||||
|
{ TestMembershipManager: LegacyMembershipManager, description: "LegacyMembershipManager" },
|
||||||
|
// { TestMembershipManager: MembershipManager, description: "MembershipManager" },
|
||||||
|
])("$description", ({ TestMembershipManager }) => {
|
||||||
|
let client: MockClient;
|
||||||
|
let room: Room;
|
||||||
|
const focusActive: LivekitFocusActive = {
|
||||||
|
focus_selection: "oldest_membership",
|
||||||
|
type: "livekit",
|
||||||
|
};
|
||||||
|
const focus: Focus = {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: "https://active.url",
|
||||||
|
livekit_alias: "!active:active.url",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Default to fake timers.
|
||||||
|
jest.useFakeTimers();
|
||||||
|
client = makeMockClient("@alice:example.org", "AAAAAAA");
|
||||||
|
room = makeMockRoom(membershipTemplate);
|
||||||
|
// Provide a default mock that is like the default "non error" server behaviour.
|
||||||
|
(client._unstable_sendDelayedStateEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
|
||||||
|
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
|
||||||
|
(client.sendStateEvent as Mock<any>).mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
// There is no need to clean up mocks since we will recreate the client.
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isJoined()", () => {
|
||||||
|
it("defaults to false", () => {
|
||||||
|
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||||
|
expect(manager.isJoined()).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true after join()", () => {
|
||||||
|
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||||
|
manager.join([]);
|
||||||
|
expect(manager.isJoined()).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("join()", () => {
|
||||||
|
describe("sends a membership event", () => {
|
||||||
|
it("sends a membership event and schedules delayed leave when joining a call", async () => {
|
||||||
|
// Spys/Mocks
|
||||||
|
|
||||||
|
const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock);
|
||||||
|
|
||||||
|
// Test
|
||||||
|
const memberManager = new TestMembershipManager(undefined, room, client, () => undefined);
|
||||||
|
memberManager.join([focus], focusActive);
|
||||||
|
// expects
|
||||||
|
await waitForMockCall(client.sendStateEvent);
|
||||||
|
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||||
|
room.roomId,
|
||||||
|
"org.matrix.msc3401.call.member",
|
||||||
|
{
|
||||||
|
application: "m.call",
|
||||||
|
call_id: "",
|
||||||
|
device_id: "AAAAAAA",
|
||||||
|
expires: 14400000,
|
||||||
|
foci_preferred: [focus],
|
||||||
|
focus_active: focusActive,
|
||||||
|
scope: "m.room",
|
||||||
|
},
|
||||||
|
"_@alice:example.org_AAAAAAA",
|
||||||
|
);
|
||||||
|
updateDelayedEventHandle.resolve?.();
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
|
||||||
|
room.roomId,
|
||||||
|
{ delay: 8000 },
|
||||||
|
"org.matrix.msc3401.call.member",
|
||||||
|
{},
|
||||||
|
"_@alice:example.org_AAAAAAA",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("does not prefix the state key with _ for rooms that support user-owned state events", () => {
|
||||||
|
async function testJoin(useOwnedStateEvents: boolean): Promise<void> {
|
||||||
|
// TODO: this test does quiet a bit. Its more a like a test story summarizing to:
|
||||||
|
// - send delay with too long timeout and get server error (test delayedEventTimeout gets overwritten)
|
||||||
|
// - run into rate limit for sending delayed event
|
||||||
|
// - run into rate limit when setting membership state.
|
||||||
|
if (useOwnedStateEvents) {
|
||||||
|
room.getVersion = jest.fn().mockReturnValue("org.matrix.msc3757.default");
|
||||||
|
}
|
||||||
|
const updatedDelayedEvent = waitForMockCall(client._unstable_updateDelayedEvent);
|
||||||
|
const sentDelayedState = waitForMockCall(
|
||||||
|
client._unstable_sendDelayedStateEvent,
|
||||||
|
Promise.resolve({
|
||||||
|
delay_id: "id",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// preparing the delayed disconnect should handle the delay being too long
|
||||||
|
const sendDelayedStateExceedAttempt = new Promise<void>((resolve) => {
|
||||||
|
const error = new MatrixError({
|
||||||
|
"errcode": "M_UNKNOWN",
|
||||||
|
"org.matrix.msc4140.errcode": "M_MAX_DELAY_EXCEEDED",
|
||||||
|
"org.matrix.msc4140.max_delay": 7500,
|
||||||
|
});
|
||||||
|
(client._unstable_sendDelayedStateEvent as Mock).mockImplementationOnce(() => {
|
||||||
|
resolve();
|
||||||
|
return Promise.reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const userStateKey = `${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA`;
|
||||||
|
// preparing the delayed disconnect should handle ratelimiting
|
||||||
|
const sendDelayedStateAttempt = new Promise<void>((resolve) => {
|
||||||
|
const error = new MatrixError({ errcode: "M_LIMIT_EXCEEDED" });
|
||||||
|
(client._unstable_sendDelayedStateEvent as Mock).mockImplementationOnce(() => {
|
||||||
|
resolve();
|
||||||
|
return Promise.reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// setting the membership state should handle ratelimiting (also with a retry-after value)
|
||||||
|
const sendStateEventAttempt = new Promise<void>((resolve) => {
|
||||||
|
const error = new MatrixError(
|
||||||
|
{ errcode: "M_LIMIT_EXCEEDED" },
|
||||||
|
429,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
new Headers({ "Retry-After": "1" }),
|
||||||
|
);
|
||||||
|
(client.sendStateEvent as Mock).mockImplementationOnce(() => {
|
||||||
|
resolve();
|
||||||
|
return Promise.reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const manager = new TestMembershipManager(
|
||||||
|
{
|
||||||
|
membershipServerSideExpiryTimeout: 9000,
|
||||||
|
},
|
||||||
|
room,
|
||||||
|
client,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
|
||||||
|
await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches
|
||||||
|
await sendDelayedStateAttempt;
|
||||||
|
const callProps = (d: number) => {
|
||||||
|
return [room!.roomId, { delay: d }, "org.matrix.msc3401.call.member", {}, userStateKey];
|
||||||
|
};
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenNthCalledWith(1, ...callProps(9000));
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenNthCalledWith(2, ...callProps(7500));
|
||||||
|
|
||||||
|
await jest.advanceTimersByTimeAsync(5000);
|
||||||
|
|
||||||
|
await sendStateEventAttempt.then(); // needed to resolve after resendIfRateLimited catches
|
||||||
|
|
||||||
|
await jest.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
|
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||||
|
room!.roomId,
|
||||||
|
EventType.GroupCallMemberPrefix,
|
||||||
|
{
|
||||||
|
application: "m.call",
|
||||||
|
scope: "m.room",
|
||||||
|
call_id: "",
|
||||||
|
expires: 14400000,
|
||||||
|
device_id: "AAAAAAA",
|
||||||
|
foci_preferred: [focus],
|
||||||
|
focus_active: focusActive,
|
||||||
|
} satisfies SessionMembershipData,
|
||||||
|
userStateKey,
|
||||||
|
);
|
||||||
|
await sentDelayedState;
|
||||||
|
|
||||||
|
// should have prepared the heartbeat to keep delaying the leave event while still connected
|
||||||
|
await updatedDelayedEvent;
|
||||||
|
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// ensures that we reach the code that schedules the timeout for the next delay update before we advance the timers.
|
||||||
|
await jest.advanceTimersByTimeAsync(5000);
|
||||||
|
// should update delayed disconnect
|
||||||
|
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("sends a membership event after rate limits during delayed event setup when joining a call", async () => {
|
||||||
|
await testJoin(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not prefix the state key with _ for rooms that support user-owned state events", async () => {
|
||||||
|
await testJoin(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("delayed leave event", () => {
|
||||||
|
it("does not try again to schedule a delayed leave event if not supported", () => {
|
||||||
|
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||||
|
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
delayedHandle.reject?.(Error("Server does not support the delayed events API"));
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it("does try to schedule a delayed leave event again if rate limited", async () => {
|
||||||
|
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
|
||||||
|
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined));
|
||||||
|
await jest.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
it("uses membershipServerSideExpiryTimeout from config", () => {
|
||||||
|
const manager = new TestMembershipManager(
|
||||||
|
{ membershipServerSideExpiryTimeout: 123456 },
|
||||||
|
room,
|
||||||
|
client,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
|
||||||
|
room.roomId,
|
||||||
|
{ delay: 123456 },
|
||||||
|
"org.matrix.msc3401.call.member",
|
||||||
|
{},
|
||||||
|
"_@alice:example.org_AAAAAAA",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses membershipExpiryTimeout from config", async () => {
|
||||||
|
const manager = new TestMembershipManager(
|
||||||
|
{ membershipExpiryTimeout: 1234567 },
|
||||||
|
room,
|
||||||
|
client,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
await waitForMockCall(client.sendStateEvent);
|
||||||
|
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||||
|
room.roomId,
|
||||||
|
EventType.GroupCallMemberPrefix,
|
||||||
|
{
|
||||||
|
application: "m.call",
|
||||||
|
scope: "m.room",
|
||||||
|
call_id: "",
|
||||||
|
device_id: "AAAAAAA",
|
||||||
|
expires: 1234567,
|
||||||
|
foci_preferred: [focus],
|
||||||
|
focus_active: {
|
||||||
|
focus_selection: "oldest_membership",
|
||||||
|
type: "livekit",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"_@alice:example.org_AAAAAAA",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing if join called when already joined", async () => {
|
||||||
|
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
await waitForMockCall(client.sendStateEvent);
|
||||||
|
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("leave()", () => {
|
||||||
|
// TODO add rate limit cases.
|
||||||
|
it("resolves delayed leave event when leave is called", async () => {
|
||||||
|
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
|
await manager.leave();
|
||||||
|
expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send");
|
||||||
|
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("send leave event when leave is called and resolving delayed leave fails", async () => {
|
||||||
|
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
|
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
|
||||||
|
await manager.leave();
|
||||||
|
// We send a normal leave event since we failed using updateDelayedEvent with the "send" action.
|
||||||
|
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
|
||||||
|
room.roomId,
|
||||||
|
"org.matrix.msc3401.call.member",
|
||||||
|
{},
|
||||||
|
"_@alice:example.org_AAAAAAA",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// FailsForLegacy because legacy implementation always sends the empty state event even though it isn't needed
|
||||||
|
it("does nothing if not joined !FailsForLegacy", async () => {
|
||||||
|
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||||
|
await manager.leave();
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
||||||
|
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getsActiveFocus", () => {
|
||||||
|
it("gets the correct active focus with oldest_membership", () => {
|
||||||
|
const getOldestMembership = jest.fn();
|
||||||
|
const manager = new TestMembershipManager({}, room, client, getOldestMembership);
|
||||||
|
// Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession)
|
||||||
|
expect(manager.getActiveFocus()).toBe(undefined);
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
// After joining we want our own focus to be the one we select.
|
||||||
|
getOldestMembership.mockReturnValue(
|
||||||
|
mockCallMembership(
|
||||||
|
{
|
||||||
|
...membershipTemplate,
|
||||||
|
foci_preferred: [
|
||||||
|
{
|
||||||
|
livekit_alias: "!active:active.url",
|
||||||
|
livekit_service_url: "https://active.url",
|
||||||
|
type: "livekit",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
device_id: client.getDeviceId(),
|
||||||
|
created_ts: 1000,
|
||||||
|
},
|
||||||
|
room.roomId,
|
||||||
|
client.getUserId()!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(manager.getActiveFocus()).toStrictEqual(focus);
|
||||||
|
getOldestMembership.mockReturnValue(
|
||||||
|
mockCallMembership(
|
||||||
|
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
|
||||||
|
room.roomId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// If there is an older member we use its focus.
|
||||||
|
expect(manager.getActiveFocus()).toBe(membershipTemplate.foci_preferred[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not provide focus if the selection method is unknown", () => {
|
||||||
|
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||||
|
manager.join([focus], Object.assign(focusActive, { type: "unknown_type" }));
|
||||||
|
expect(manager.getActiveFocus()).toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onRTCSessionMemberUpdate()", () => {
|
||||||
|
it("does nothing if not joined", async () => {
|
||||||
|
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||||
|
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||||
|
await jest.advanceTimersToNextTimerAsync();
|
||||||
|
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
||||||
|
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("does nothing if own membership still present", async () => {
|
||||||
|
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
|
const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2];
|
||||||
|
// reset all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||||
|
(client.sendStateEvent as Mock).mockClear();
|
||||||
|
(client._unstable_updateDelayedEvent as Mock).mockClear();
|
||||||
|
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
|
||||||
|
|
||||||
|
await manager.onRTCSessionMemberUpdate([
|
||||||
|
mockCallMembership(membershipTemplate, room.roomId),
|
||||||
|
mockCallMembership(myMembership as SessionMembershipData, room.roomId, client.getUserId() ?? undefined),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
|
|
||||||
|
expect(client.sendStateEvent).not.toHaveBeenCalled();
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
|
||||||
|
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("recreates membership if it is missing", async () => {
|
||||||
|
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
|
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
|
||||||
|
(client.sendStateEvent as Mock).mockClear();
|
||||||
|
(client._unstable_updateDelayedEvent as Mock).mockClear();
|
||||||
|
(client._unstable_sendDelayedStateEvent as Mock).mockClear();
|
||||||
|
|
||||||
|
// Our own membership is removed:
|
||||||
|
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
|
||||||
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
|
expect(client.sendStateEvent).toHaveBeenCalled();
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(client._unstable_updateDelayedEvent).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Not sure about this name
|
||||||
|
describe("background timers", () => {
|
||||||
|
it("sends only one keep-alive for delayed leave event per `membershipKeepAlivePeriod`", async () => {
|
||||||
|
const manager = new TestMembershipManager(
|
||||||
|
{ membershipKeepAlivePeriod: 10_000, membershipServerSideExpiryTimeout: 30_000 },
|
||||||
|
room,
|
||||||
|
client,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The first call is from checking id the server deleted the delayed event
|
||||||
|
// so it does not need a `advanceTimersByTime`
|
||||||
|
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
|
||||||
|
// TODO: Check that update delayed event is called with the correct HTTP request timeout
|
||||||
|
// expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
|
||||||
|
|
||||||
|
for (let i = 2; i <= 12; i++) {
|
||||||
|
// flush promises before advancing the timers to make sure schedulers are setup
|
||||||
|
await jest.advanceTimersByTimeAsync(10_000);
|
||||||
|
|
||||||
|
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(i);
|
||||||
|
// TODO: Check that update delayed event is called with the correct HTTP request timeout
|
||||||
|
// expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// !FailsForLegacy because the expires logic was removed for the legacy call manager.
|
||||||
|
// Delayed events should replace it entirely but before they have wide adoption
|
||||||
|
// the expiration logic still makes sense.
|
||||||
|
// TODO: add git commit when we removed it.
|
||||||
|
it("extends `expires` when call still active !FailsForLegacy", async () => {
|
||||||
|
const manager = new TestMembershipManager(
|
||||||
|
{ membershipExpiryTimeout: 10_000 },
|
||||||
|
room,
|
||||||
|
client,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
await waitForMockCall(client.sendStateEvent);
|
||||||
|
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
const sentMembership = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData;
|
||||||
|
expect(sentMembership.expires).toBe(10_000);
|
||||||
|
for (let i = 2; i <= 12; i++) {
|
||||||
|
await jest.advanceTimersByTimeAsync(10_000);
|
||||||
|
expect(client.sendStateEvent).toHaveBeenCalledTimes(i);
|
||||||
|
const sentMembership = (client.sendStateEvent as Mock).mock.lastCall![2] as SessionMembershipData;
|
||||||
|
expect(sentMembership.expires).toBe(10_000 * i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("server error handling", () => {
|
||||||
|
// Types of server error: 429 rate limit with no retry-after header, 429 with retry-after, 50x server error (maybe retry every second), connection/socket timeout
|
||||||
|
describe("retries sending delayed leave event", () => {
|
||||||
|
it("sends retry if call membership event is still valid at time of retry", async () => {
|
||||||
|
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||||
|
|
||||||
|
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
handle.reject?.(
|
||||||
|
new MatrixError(
|
||||||
|
{ errcode: "M_LIMIT_EXCEEDED" },
|
||||||
|
429,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
new Headers({ "Retry-After": "1" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await jest.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
// FailsForLegacy as implementation does not re-check membership before retrying.
|
||||||
|
it("abandons retry loop and sends new own membership if not present anymore !FailsForLegacy", async () => {
|
||||||
|
(client._unstable_sendDelayedStateEvent as any).mockRejectedValue(
|
||||||
|
new MatrixError(
|
||||||
|
{ errcode: "M_LIMIT_EXCEEDED" },
|
||||||
|
429,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
new Headers({ "Retry-After": "1" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||||
|
// Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the
|
||||||
|
// RateLimit error.
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
|
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
(client._unstable_sendDelayedStateEvent as Mock<any>).mockResolvedValue({ delay_id: "id" });
|
||||||
|
// Remove our own membership so that there is no reason the send the delayed leave anymore.
|
||||||
|
// the membership is no longer present on the homeserver
|
||||||
|
await manager.onRTCSessionMemberUpdate([]);
|
||||||
|
// Wait for all timers to be setup
|
||||||
|
await jest.advanceTimersByTimeAsync(1000);
|
||||||
|
// We should send the first own membership and a new delayed event after the rate limit timeout.
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
|
||||||
|
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
// FailsForLegacy as implementation does not re-check membership before retrying.
|
||||||
|
it("abandons retry loop if leave() was called !FailsForLegacy", async () => {
|
||||||
|
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
|
||||||
|
|
||||||
|
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
handle.reject?.(
|
||||||
|
new MatrixError(
|
||||||
|
{ errcode: "M_LIMIT_EXCEEDED" },
|
||||||
|
429,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
new Headers({ "Retry-After": "1" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
// the user terminated the call locally
|
||||||
|
await manager.leave();
|
||||||
|
|
||||||
|
// Wait for all timers to be setup
|
||||||
|
// await flushPromises();
|
||||||
|
await jest.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
|
// No new events should have been sent:
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("retries sending update delayed leave event restart", () => {
|
||||||
|
it("resends the initial check delayed update event !FailsForLegacy", async () => {
|
||||||
|
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
|
||||||
|
new MatrixError(
|
||||||
|
{ errcode: "M_LIMIT_EXCEEDED" },
|
||||||
|
429,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
new Headers({ "Retry-After": "1" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const manager = new TestMembershipManager({}, room, client, () => undefined);
|
||||||
|
manager.join([focus], focusActive);
|
||||||
|
|
||||||
|
// Hit rate limit
|
||||||
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
|
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Hit second rate limit.
|
||||||
|
await jest.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Setup resolve
|
||||||
|
(client._unstable_updateDelayedEvent as Mock<any>).mockResolvedValue(undefined);
|
||||||
|
await jest.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
|
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3);
|
||||||
|
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
54
spec/unit/matrixrtc/memberManagerTestEnvironment.ts
Normal file
54
spec/unit/matrixrtc/memberManagerTestEnvironment.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
This file adds a custom test environment for the MembershipManager.spec.ts
|
||||||
|
It can be used with the comment at the top of the file:
|
||||||
|
|
||||||
|
@jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts
|
||||||
|
|
||||||
|
It is very specific to the MembershipManager.spec.ts file and introduces the following behaviour:
|
||||||
|
- The describe each block in the MembershipManager.spec.ts will go through describe block names `LegacyMembershipManager` and `MembershipManager`
|
||||||
|
- It will check all tests that are a child or indirect child of the `LegacyMembershipManager` block and skip the ones which include "!FailsForLegacy"
|
||||||
|
in their test name.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TestEnvironment } from "jest-environment-jsdom";
|
||||||
|
|
||||||
|
import { logger as rootLogger } from "../../../src/logger";
|
||||||
|
const logger = rootLogger.getChild("MatrixRTCSession");
|
||||||
|
|
||||||
|
class MemberManagerTestEnvironment extends TestEnvironment {
|
||||||
|
handleTestEvent(event: any) {
|
||||||
|
if (event.name === "test_start" && event.test.name.includes("!FailsForLegacy")) {
|
||||||
|
let parent = event.test.parent;
|
||||||
|
let isLegacy = false;
|
||||||
|
while (parent) {
|
||||||
|
if (parent.name === "LegacyMembershipManager") {
|
||||||
|
isLegacy = true;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
parent = parent.parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isLegacy) {
|
||||||
|
logger.info("skip test: ", event.test.name);
|
||||||
|
event.test.mode = "skip";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = MemberManagerTestEnvironment;
|
@@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventType, type MatrixEvent, type Room } from "../../../src";
|
import { EventType, type MatrixClient, type MatrixEvent, type Room } from "../../../src";
|
||||||
import { type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
|
||||||
import { secureRandomString } from "../../../src/randomstring";
|
import { secureRandomString } from "../../../src/randomstring";
|
||||||
|
|
||||||
type MembershipData = SessionMembershipData[] | SessionMembershipData | {};
|
type MembershipData = SessionMembershipData[] | SessionMembershipData | {};
|
||||||
@@ -40,6 +40,31 @@ export const membershipTemplate: SessionMembershipData = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MockClient = Pick<
|
||||||
|
MatrixClient,
|
||||||
|
| "getUserId"
|
||||||
|
| "getDeviceId"
|
||||||
|
| "sendEvent"
|
||||||
|
| "sendStateEvent"
|
||||||
|
| "_unstable_sendDelayedStateEvent"
|
||||||
|
| "_unstable_updateDelayedEvent"
|
||||||
|
| "cancelPendingEvent"
|
||||||
|
>;
|
||||||
|
/**
|
||||||
|
* Mocks a object that has all required methods for a MatrixRTC session client.
|
||||||
|
*/
|
||||||
|
export function makeMockClient(userId: string, deviceId: string): MockClient {
|
||||||
|
return {
|
||||||
|
getDeviceId: () => deviceId,
|
||||||
|
getUserId: () => userId,
|
||||||
|
sendEvent: jest.fn(),
|
||||||
|
sendStateEvent: jest.fn(),
|
||||||
|
cancelPendingEvent: jest.fn(),
|
||||||
|
_unstable_updateDelayedEvent: jest.fn(),
|
||||||
|
_unstable_sendDelayedStateEvent: jest.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function makeMockRoom(membershipData: MembershipData): Room {
|
export function makeMockRoom(membershipData: MembershipData): Room {
|
||||||
const roomId = secureRandomString(8);
|
const roomId = secureRandomString(8);
|
||||||
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
|
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
|
||||||
@@ -88,16 +113,17 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockRTCEvent(membershipData: MembershipData, roomId: string): MatrixEvent {
|
export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent {
|
||||||
|
const sender = customSender ?? "@mock:user.example";
|
||||||
return {
|
return {
|
||||||
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
|
||||||
getContent: jest.fn().mockReturnValue(membershipData),
|
getContent: jest.fn().mockReturnValue(membershipData),
|
||||||
getSender: jest.fn().mockReturnValue("@mock:user.example"),
|
getSender: jest.fn().mockReturnValue(sender),
|
||||||
getTs: jest.fn().mockReturnValue(Date.now()),
|
getTs: jest.fn().mockReturnValue(Date.now()),
|
||||||
getRoomId: jest.fn().mockReturnValue(roomId),
|
getRoomId: jest.fn().mockReturnValue(roomId),
|
||||||
sender: {
|
|
||||||
userId: "@mock:user.example",
|
|
||||||
},
|
|
||||||
isDecryptionFailure: jest.fn().mockReturnValue(false),
|
isDecryptionFailure: jest.fn().mockReturnValue(false),
|
||||||
} as unknown as MatrixEvent;
|
} as unknown as MatrixEvent;
|
||||||
}
|
}
|
||||||
|
export function mockCallMembership(membershipData: MembershipData, roomId: string, sender?: string): CallMembership {
|
||||||
|
return new CallMembership(mockRTCEvent(membershipData, roomId, sender), membershipData);
|
||||||
|
}
|
||||||
|
@@ -3414,7 +3414,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
|||||||
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
|
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
public async _unstable_updateDelayedEvent(delayId: string, action: UpdateDelayedEventAction): Promise<EmptyObject> {
|
public async _unstable_updateDelayedEvent(
|
||||||
|
delayId: string,
|
||||||
|
action: UpdateDelayedEventAction,
|
||||||
|
requestOptions: IRequestOpts = {},
|
||||||
|
): Promise<EmptyObject> {
|
||||||
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
|
||||||
throw Error("Server does not support the delayed events API");
|
throw Error("Server does not support the delayed events API");
|
||||||
}
|
}
|
||||||
@@ -3426,6 +3430,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
|||||||
action,
|
action,
|
||||||
};
|
};
|
||||||
return await this.http.authedRequest(Method.Post, path, undefined, data, {
|
return await this.http.authedRequest(Method.Post, path, undefined, data, {
|
||||||
|
...requestOptions,
|
||||||
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
|
prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -58,6 +58,8 @@ export interface MembershipConfig {
|
|||||||
/**
|
/**
|
||||||
* The timeout (in milliseconds) after we joined the call, that our membership should expire
|
* The timeout (in milliseconds) after we joined the call, that our membership should expire
|
||||||
* unless we have explicitly updated it.
|
* unless we have explicitly updated it.
|
||||||
|
*
|
||||||
|
* This is what goes into the m.rtc.member event expiry field and is typically set to a number of hours.
|
||||||
*/
|
*/
|
||||||
membershipExpiryTimeout?: number;
|
membershipExpiryTimeout?: number;
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@ export interface MembershipConfig {
|
|||||||
*/
|
*/
|
||||||
callMemberEventRetryJitter?: number;
|
callMemberEventRetryJitter?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EncryptionConfig {
|
export interface EncryptionConfig {
|
||||||
/**
|
/**
|
||||||
* If true, generate and share a media key for this participant,
|
* If true, generate and share a media key for this participant,
|
||||||
@@ -153,7 +156,9 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
|||||||
/**
|
/**
|
||||||
* Returns all the call memberships for a room, oldest first
|
* Returns all the call memberships for a room, oldest first
|
||||||
*/
|
*/
|
||||||
public static callMembershipsForRoom(room: Room): CallMembership[] {
|
public static callMembershipsForRoom(
|
||||||
|
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState">,
|
||||||
|
): CallMembership[] {
|
||||||
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||||
if (!roomState) {
|
if (!roomState) {
|
||||||
logger.warn("Couldn't get state for room " + room.roomId);
|
logger.warn("Couldn't get state for room " + room.roomId);
|
||||||
@@ -225,20 +230,51 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
|||||||
return new MatrixRTCSession(client, room, callMemberships);
|
return new MatrixRTCSession(client, room, callMemberships);
|
||||||
}
|
}
|
||||||
|
|
||||||
private constructor(
|
/**
|
||||||
private readonly client: MatrixClient,
|
* WARN: this can in theory only be a subset of the room with the properties required by
|
||||||
public readonly room: Room,
|
* this class.
|
||||||
|
* Outside of tests this most likely will be a full room, however.
|
||||||
|
* @deprecated Relying on a full Room object being available here is an anti-pattern. You should be tracking
|
||||||
|
* the room object in your own code and passing it in when needed.
|
||||||
|
*/
|
||||||
|
public get room(): Room {
|
||||||
|
return this.roomSubset as Room;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This constructs a room session. When using MatrixRTC inside the js-sdk this is expected
|
||||||
|
* to be used with the MatrixRTCSessionManager exclusively.
|
||||||
|
*
|
||||||
|
* In cases where you don't use the js-sdk but build on top of another Matrix stack this class can be used standalone
|
||||||
|
* to manage a joined MatrixRTC session.
|
||||||
|
*
|
||||||
|
* @param client A subset of the {@link MatrixClient} that lets the session interact with the Matrix room.
|
||||||
|
* @param roomSubset The room this session is attached to. A subset of a js-sdk Room that the session needs.
|
||||||
|
* @param memberships The list of memberships this session currently has.
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private readonly client: Pick<
|
||||||
|
MatrixClient,
|
||||||
|
| "getUserId"
|
||||||
|
| "getDeviceId"
|
||||||
|
| "sendStateEvent"
|
||||||
|
| "_unstable_sendDelayedStateEvent"
|
||||||
|
| "_unstable_updateDelayedEvent"
|
||||||
|
| "sendEvent"
|
||||||
|
| "cancelPendingEvent"
|
||||||
|
>,
|
||||||
|
private roomSubset: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState">,
|
||||||
public memberships: CallMembership[],
|
public memberships: CallMembership[],
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this._callId = memberships[0]?.callId;
|
this._callId = memberships[0]?.callId;
|
||||||
const roomState = this.room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||||
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
|
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
|
||||||
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||||
this.setExpiryTimer();
|
this.setExpiryTimer();
|
||||||
this.encryptionManager = new EncryptionManager(
|
this.encryptionManager = new EncryptionManager(
|
||||||
this.client,
|
this.client,
|
||||||
this.room,
|
this.roomSubset,
|
||||||
() => this.memberships,
|
() => this.memberships,
|
||||||
(keyBin: Uint8Array<ArrayBufferLike>, encryptionKeyIndex: number, participantId: string) => {
|
(keyBin: Uint8Array<ArrayBufferLike>, encryptionKeyIndex: number, participantId: string) => {
|
||||||
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId);
|
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId);
|
||||||
@@ -263,7 +299,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
|||||||
clearTimeout(this.expiryTimeout);
|
clearTimeout(this.expiryTimeout);
|
||||||
this.expiryTimeout = undefined;
|
this.expiryTimeout = undefined;
|
||||||
}
|
}
|
||||||
const roomState = this.room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||||
roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,10 +320,10 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
|||||||
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
|
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
|
||||||
// create MembershipManager
|
// create MembershipManager
|
||||||
if (this.isJoined()) {
|
if (this.isJoined()) {
|
||||||
logger.info(`Already joined to session in room ${this.room.roomId}: ignoring join call`);
|
logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
this.membershipManager = new LegacyMembershipManager(joinConfig, this.room, this.client, () =>
|
this.membershipManager = new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, () =>
|
||||||
this.getOldestMembership(),
|
this.getOldestMembership(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -311,11 +347,11 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
|||||||
*/
|
*/
|
||||||
public async leaveRoomSession(timeout: number | undefined = undefined): Promise<boolean> {
|
public async leaveRoomSession(timeout: number | undefined = undefined): Promise<boolean> {
|
||||||
if (!this.isJoined()) {
|
if (!this.isJoined()) {
|
||||||
logger.info(`Not joined to session in room ${this.room.roomId}: ignoring leave call`);
|
logger.info(`Not joined to session in room ${this.roomSubset.roomId}: ignoring leave call`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Leaving call session in room ${this.room.roomId}`);
|
logger.info(`Leaving call session in room ${this.roomSubset.roomId}`);
|
||||||
|
|
||||||
this.encryptionManager.leave();
|
this.encryptionManager.leave();
|
||||||
|
|
||||||
@@ -455,7 +491,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
|
|||||||
oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i]));
|
oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i]));
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
logger.info(`Memberships for call in room ${this.room.roomId} have changed: emitting`);
|
logger.info(`Memberships for call in room ${this.roomSubset.roomId} have changed: emitting`);
|
||||||
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
|
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
|
||||||
|
|
||||||
void this.membershipManager?.onRTCSessionMemberUpdate(this.memberships);
|
void this.membershipManager?.onRTCSessionMemberUpdate(this.memberships);
|
||||||
|
@@ -38,7 +38,7 @@ export interface IMembershipManager {
|
|||||||
* @returns It resolves with true in case the leave was sent successfully.
|
* @returns It resolves with true in case the leave was sent successfully.
|
||||||
* It resolves with false in case we hit the timeout before sending successfully.
|
* It resolves with false in case we hit the timeout before sending successfully.
|
||||||
*/
|
*/
|
||||||
leave(timeout: number | undefined): Promise<boolean>;
|
leave(timeout?: number): Promise<boolean>;
|
||||||
/**
|
/**
|
||||||
* Call this if the MatrixRTC session members have changed.
|
* Call this if the MatrixRTC session members have changed.
|
||||||
*/
|
*/
|
||||||
@@ -117,7 +117,6 @@ export class LegacyMembershipManager implements IMembershipManager {
|
|||||||
| "getUserId"
|
| "getUserId"
|
||||||
| "getDeviceId"
|
| "getDeviceId"
|
||||||
| "sendStateEvent"
|
| "sendStateEvent"
|
||||||
| "_unstable_sendDelayedEvent"
|
|
||||||
| "_unstable_sendDelayedStateEvent"
|
| "_unstable_sendDelayedStateEvent"
|
||||||
| "_unstable_updateDelayedEvent"
|
| "_unstable_updateDelayedEvent"
|
||||||
>,
|
>,
|
||||||
@@ -312,6 +311,7 @@ export class LegacyMembershipManager implements IMembershipManager {
|
|||||||
if (this.disconnectDelayId !== undefined) {
|
if (this.disconnectDelayId !== undefined) {
|
||||||
this.scheduleDelayDisconnection();
|
this.scheduleDelayDisconnection();
|
||||||
}
|
}
|
||||||
|
// TODO throw or log an error if this.disconnectDelayId === undefined
|
||||||
} else {
|
} else {
|
||||||
// Not joined
|
// Not joined
|
||||||
let sentDelayedDisconnect = false;
|
let sentDelayedDisconnect = false;
|
||||||
|
Reference in New Issue
Block a user