diff --git a/sdk/src/matrixrtc/LocalTransport.test.ts b/sdk/src/matrixrtc/LocalTransport.test.ts new file mode 100644 index 000000000..127f135a5 --- /dev/null +++ b/sdk/src/matrixrtc/LocalTransport.test.ts @@ -0,0 +1,78 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { logger } from "matrix-js-sdk/lib/logger"; +import { type Transport } from "matrix-js-sdk/lib/matrixrtc"; +import { describe, expect, it, vi } from "vitest"; + +import { MatrixRTCTransportMissingError } from "../errors"; +import { discoverLocalTransport } from "./LocalTransport"; + +const homeserverTransport: Transport = { + type: "livekit", + livekit_service_url: "https://homeserver.example.org", +}; +const hostTransport: Transport = { + type: "livekit", + livekit_service_url: "https://host.example.org", +}; + +function client( + advertised: Transport[], +): Parameters[0] { + return { + getDomain: () => "example.org", + _unstable_getRTCTransports: vi.fn().mockResolvedValue(advertised), + }; +} + +describe("discoverLocalTransport", () => { + it("takes the transport the host names without asking the homeserver", async () => { + const c = client([homeserverTransport]); + + await expect( + discoverLocalTransport( + c, + "livekit", + { transport: hostTransport }, + logger, + ), + ).resolves.toBe(hostTransport); + expect(c._unstable_getRTCTransports).not.toHaveBeenCalled(); + }); + + it("prefers the homeserver's transport over the host's fallback", async () => { + await expect( + discoverLocalTransport( + client([homeserverTransport]), + "livekit", + { fallbackTransport: hostTransport }, + logger, + ), + ).resolves.toEqual(homeserverTransport); + }); + + it("falls back to the host's transport where the homeserver advertises none of the type", async () => { + await expect( + discoverLocalTransport( + client([{ type: "not_livekit" }]), + "livekit", + { fallbackTransport: hostTransport }, + logger, + ), + ).resolves.toEqual(hostTransport); + }); + + it("fails naming the homeserver where there is no transport at all", async () => { + const promise = discoverLocalTransport(client([]), "livekit", {}, logger); + + await expect(promise).rejects.toBeInstanceOf( + MatrixRTCTransportMissingError, + ); + await expect(promise).rejects.toHaveProperty("domain", "example.org"); + }); +}); diff --git a/sdk/src/matrixrtc/joinJsSdkSession.test.ts b/sdk/src/matrixrtc/joinJsSdkSession.test.ts new file mode 100644 index 000000000..0c9c22330 --- /dev/null +++ b/sdk/src/matrixrtc/joinJsSdkSession.test.ts @@ -0,0 +1,113 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { + type MatrixRTCSession, + type Transport, +} from "matrix-js-sdk/lib/matrixrtc"; +import { describe, expect, it, type Mock, vi } from "vitest"; + +import { defaultSessionTimings, MatrixRTCMode } from "../config"; +import { ownMemberMock } from "../utils/test"; +import { joinJsSdkSession } from "./joinJsSdkSession"; + +const transport: Transport = { + type: "livekit", + livekit_service_url: "https://lk.example.org", +}; + +/** A session that only records the join; nothing else of it is touched. */ +function session(): MatrixRTCSession & { + join: Mock; +} { + return { join: vi.fn() } as unknown as MatrixRTCSession & { + join: Mock; + }; +} + +describe("joinJsSdkSession", () => { + it("joins with the identity, the transport and the delayed leave timings it was given", () => { + const s = session(); + const timings = { + ...defaultSessionTimings, + waitForKeyRotationMs: 500, + membershipEventExpiryMs: 60_000, + keyRotationParticipantLimit: 20, + }; + + joinJsSdkSession(s, ownMemberMock, transport, { + encryptMedia: true, + matrixRTCMode: MatrixRTCMode.Compatibility, + sendNotificationType: "ring", + timings, + delayedLeave: { + delay_ms: 3_600_000, + restart_ms: 300_000, + restart_timeout_ms: 5_000, + }, + }); + + expect(s.join).toHaveBeenCalledWith(ownMemberMock, [transport], { + notificationType: "ring", + callIntent: undefined, + applicationData: {}, + manageMediaKeys: true, + delayedLeaveEventRestartMs: 300_000, + delayedLeaveEventDelayMs: 3_600_000, + delayedLeaveEventRestartLocalTimeoutMs: 5_000, + networkErrorRetryMs: 1_000, + makeKeyDelay: 500, + membershipEventExpiryMs: 60_000, + keyRotationParticipantLimit: 20, + unstableSendStickyEvents: false, + // The sync grace period is the shorter wait: 10 s at 1 s per retry, plus one + maximumNetworkErrorRetryCount: 11, + }); + }); + + it("gives up on the network once the delayed leave has probably been sent", () => { + const s = session(); + + joinJsSdkSession(s, ownMemberMock, transport, { + encryptMedia: false, + matrixRTCMode: MatrixRTCMode.Compatibility, + timings: { ...defaultSessionTimings, networkErrorRetryMs: 4_000 }, + delayedLeave: { delay_ms: 18_000 }, + }); + + expect(s.join).toHaveBeenCalledWith( + ownMemberMock, + [transport], + expect.objectContaining({ + manageMediaKeys: false, + maximumNetworkErrorRetryCount: 4, + }), + ); + }); + + it("lifts the call intent out of the application data and sends sticky events in Matrix 2.0 mode", () => { + const s = session(); + + joinJsSdkSession(s, ownMemberMock, transport, { + encryptMedia: true, + matrixRTCMode: MatrixRTCMode.Matrix_2_0, + applicationData: { "m.call.intent": "audio", custom: 1 }, + timings: defaultSessionTimings, + delayedLeave: defaultSessionTimings.delayedLeave, + }); + + expect(s.join).toHaveBeenCalledWith( + ownMemberMock, + [transport], + expect.objectContaining({ + callIntent: "audio", + applicationData: { custom: 1 }, + unstableSendStickyEvents: true, + }), + ); + }); +}); diff --git a/sdk/src/media-backend/livekit/ConnectionFactory.test.ts b/sdk/src/media-backend/livekit/ConnectionFactory.test.ts new file mode 100644 index 000000000..182248a51 --- /dev/null +++ b/sdk/src/media-backend/livekit/ConnectionFactory.test.ts @@ -0,0 +1,173 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { + ConnectionState as LivekitConnectionState, + type RoomOptions, +} from "livekit-client"; +import { logger } from "matrix-js-sdk/lib/logger"; +import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; +import { describe, expect, it, vi } from "vitest"; + +import { type MediaQuality } from "../../config"; +import { type PublishRequest } from "../../media-api"; +import { constant } from "../../reactive/Behavior"; +import { ownMemberMock, testScope } from "../../utils/test"; +import { testJWTToken } from "../../utils/test-fixtures"; +import { type ClientGetTokenParts, type ClientOpenIDParts } from "./auth"; +import { + LivekitConnectionFactory, + type ResolvedConnection, +} from "./ConnectionFactory"; +import { exampleTransport } from "./test"; + +const urlTransport: UnstableLivekitTransport = { + type: "livekit", + url: "wss://sfu.example.org", +}; + +function client(): ClientGetTokenParts & ClientOpenIDParts { + return { + getOpenIdToken: vi.fn(), + _unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: testJWTToken }), + }; +} + +/** + * Builds a subscriber connection and hands back the options its LiveKit room + * was built with. The room is a real one: it never connects, and its options + * are what the factory gave it, filled in with LiveKit's defaults. + */ +function create( + publish: PublishRequest[], + mediaQuality?: MediaQuality, + audioOutputDeviceId?: string, + transport = exampleTransport, +): { connection: ResolvedConnection; options: RoomOptions } { + const factory = new LivekitConnectionFactory( + client(), + "!room:example.org", + "m.call#ROOM", + publish, + constant(audioOutputDeviceId), + undefined, + mediaQuality, + ); + const connection = factory.createConnection( + testScope(), + "subscriber", + transport, + "example.org", + ownMemberMock, + logger, + ) as ResolvedConnection; + return { connection, options: connection.livekitRoom.options }; +} + +describe("LivekitConnectionFactory", () => { + it("builds the room on the defaults where nothing is requested", () => { + const { options } = create([]); + + expect(options.publishDefaults).toMatchObject({ videoCodec: "vp8" }); + expect(options.videoCaptureDefaults).toMatchObject({ + resolution: expect.objectContaining({ height: 720 }), + }); + expect(options.audioOutput).toEqual({ deviceId: undefined }); + expect(options.e2ee).toBeUndefined(); + }); + + it("makes the initial microphone request the room's audio defaults", () => { + const { options } = create([ + { + source: "microphone", + deviceId: "mic-1", + capture: { + echoCancellation: false, + noiseSuppression: true, + autoGainControl: false, + }, + }, + ]); + + expect(options.audioCaptureDefaults).toMatchObject({ + deviceId: "mic-1", + echoCancellation: false, + noiseSuppression: true, + autoGainControl: false, + }); + }); + + it("makes the initial camera request the room's video and publish defaults", () => { + const { options } = create( + [ + { + source: "camera", + deviceId: "cam-1", + capture: { + resolution: { width: 1920, height: 1080, frameRate: 60 }, + maxBitrate: 4_000_000, + maxFramerate: 60, + codec: "vp9", + }, + }, + ], + { video_codec: "h264" }, + ); + + expect(options.videoCaptureDefaults).toMatchObject({ + deviceId: "cam-1", + resolution: { width: 1920, height: 1080, frameRate: 60 }, + }); + // The request wins over the media quality settings + expect(options.publishDefaults).toMatchObject({ + videoEncoding: { maxBitrate: 4_000_000, maxFramerate: 60 }, + videoCodec: "vp9", + }); + }); + + it("applies the media quality settings where the requests leave the defaults", () => { + const { options } = create([{ source: "camera" }], { + video_codec: "av1", + video: { max_bitrate: 900_000, max_framerate: 24 }, + }); + + expect(options.publishDefaults).toMatchObject({ + videoEncoding: { maxBitrate: 900_000, maxFramerate: 24 }, + videoCodec: "av1", + }); + }); + + it("plays on the output device selected when the room is built", () => { + const { options } = create([], undefined, "speaker"); + expect(options.audioOutput).toEqual({ deviceId: "speaker" }); + }); + + it("reports what it fetched while connected, and nothing before or after", async () => { + const { connection } = create([], undefined, undefined, urlTransport); + // The room must not reach for the network; its state is left as it is + vi.spyOn(connection.livekitRoom, "connect").mockResolvedValue(undefined); + vi.spyOn(connection.livekitRoom, "disconnect").mockResolvedValue(undefined); + expect(connection.resolved$.value).toBeUndefined(); + + await connection.start(); + + expect(connection.state$.value).toEqual({ + state: "authenticated", + livekitState: LivekitConnectionState.Disconnected, + }); + expect(connection.resolved$.value).toEqual({ + type: "livekit", + url: "wss://sfu.example.org", + token: testJWTToken, + roomAlias: "!example_room_id", + identity: "@me:example.org:ABCDEF", + }); + + await connection.stop(); + expect(connection.resolved$.value).toBeUndefined(); + }); +}); diff --git a/sdk/src/media-backend/livekit/LivekitBackend.test.ts b/sdk/src/media-backend/livekit/LivekitBackend.test.ts index ae8759b1a..ab0b44e53 100644 --- a/sdk/src/media-backend/livekit/LivekitBackend.test.ts +++ b/sdk/src/media-backend/livekit/LivekitBackend.test.ts @@ -390,6 +390,86 @@ describe("createLivekitBackend", () => { expect(media.tracks$.value).toBeNull(); }); + it("has no media for a member on another transport until it shows up there", async () => { + const b = await prepared(); + const media = b.mediaFor(testScope(), bob(otherTransport, [legacyId])); + await flushPromises(); + const remote = connections.find( + (c) => c.connection.role === "subscriber", + ); + expect(remote?.connection.transport).toEqual(otherTransport); + + // The same identity on our own transport is someone else's room + connections[0].participants$.next([ + mockRemoteParticipant({ + identity: legacyId, + trackPublications: new Map(), + }), + ]); + await flushPromises(); + expect(media.tracks$.value).toBeNull(); + + remote!.participants$.next([ + mockRemoteParticipant({ + identity: legacyId, + trackPublications: new Map(), + }), + ]); + await flushPromises(); + expect(media.tracks$.value).toEqual([]); + }); + + it("follows a member to a new transport, and drops the old connection with it", async () => { + const b = await prepared(); + const transport$ = new BehaviorSubject( + bob(bothTransport, [legacyId]).transport$.value, + ); + const media = b.mediaFor(testScope(), { + ...bob(bothTransport, [legacyId]), + transport$, + }); + connections[0].participants$.next([ + mockRemoteParticipant({ + identity: legacyId, + trackPublications: new Map(), + }), + ]); + await flushPromises(); + expect(media.tracks$.value).toEqual([]); + + transport$.next(bob(otherTransport, [legacyId]).transport$.value); + await flushPromises(); + + expect(media.tracks$.value).toBeNull(); + const remote = connections.find( + (c) => c.connection.role === "subscriber", + ); + expect(remote?.connection.transport).toEqual(otherTransport); + expect(b.connections$.value.map(({ transport }) => transport)).toEqual([ + bothTransport, + otherTransport, + ]); + + transport$.next(bob(bothTransport, [legacyId]).transport$.value); + await flushPromises(); + expect(media.tracks$.value).toEqual([]); + expect(b.connections$.value.map(({ transport }) => transport)).toEqual([ + bothTransport, + ]); + }); + + it("has no media for a member without a transport, and opens no connection", async () => { + const b = await prepared(); + const media = b.mediaFor(testScope(), { + ...bob(bothTransport, [legacyId]), + transport$: constant(undefined), + }); + await flushPromises(); + + expect(media.tracks$.value).toBeNull(); + expect(connections).toHaveLength(1); + }); + it("connects to a remote member's transport on the member's homeserver", async () => { const b = await prepared(); const carlsTransport: UnstableLivekitTransport = { diff --git a/sdk/src/media-backend/livekit/publishOptions.test.ts b/sdk/src/media-backend/livekit/publishOptions.test.ts new file mode 100644 index 000000000..d09d7bb5f --- /dev/null +++ b/sdk/src/media-backend/livekit/publishOptions.test.ts @@ -0,0 +1,145 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, it } from "vitest"; + +import { + audioCaptureOptions, + screenShareCaptureOptions, + screenSharePublishOptions, + videoCaptureOptions, + videoPublishOptions, +} from "./publishOptions"; + +describe("audioCaptureOptions", () => { + it("leaves a bare request to the room defaults", () => { + expect(audioCaptureOptions({ source: "microphone" })).toBeUndefined(); + }); + + it("carries the device and the processing flags", () => { + expect( + audioCaptureOptions({ + source: "microphone", + deviceId: "mic-1", + capture: { echoCancellation: false, noiseSuppression: true }, + }), + ).toEqual({ + deviceId: "mic-1", + echoCancellation: false, + noiseSuppression: true, + }); + }); + + it("drops an empty device id", () => { + expect( + audioCaptureOptions({ + source: "microphone", + deviceId: "", + capture: { autoGainControl: false }, + }), + ).toEqual({ autoGainControl: false }); + }); +}); + +describe("videoCaptureOptions", () => { + it("leaves a request without device, processor or resolution to the room defaults", () => { + expect( + videoCaptureOptions({ + source: "camera", + capture: { maxBitrate: 1_000_000 }, + }), + ).toBeUndefined(); + }); + + it("carries the device and the resolution, but not the encoding", () => { + expect( + videoCaptureOptions({ + source: "camera", + deviceId: "cam-1", + capture: { + resolution: { width: 1920, height: 1080, frameRate: 60 }, + maxBitrate: 4_000_000, + }, + }), + ).toEqual({ + deviceId: "cam-1", + resolution: { width: 1920, height: 1080, frameRate: 60 }, + }); + }); +}); + +describe("videoPublishOptions", () => { + it("is nothing without capture settings", () => { + expect(videoPublishOptions(undefined)).toBeUndefined(); + }); + + it("is empty for settings that only set the resolution", () => { + expect( + videoPublishOptions({ resolution: { width: 640, height: 360 } }), + ).toEqual({}); + }); + + it("maps the bitrate, framerate and codec to the encoding", () => { + expect( + videoPublishOptions({ + maxBitrate: 4_000_000, + maxFramerate: 60, + codec: "vp9", + }), + ).toEqual({ + videoEncoding: { maxBitrate: 4_000_000, maxFramerate: 60 }, + videoCodec: "vp9", + }); + }); +}); + +describe("screenShareCaptureOptions", () => { + it("captures the audio without processing by default", () => { + expect(screenShareCaptureOptions({ source: "screenShare" })).toEqual({ + audio: { + autoGainControl: false, + noiseSuppression: false, + voiceIsolation: false, + }, + selfBrowserSurface: "include", + surfaceSwitching: "include", + systemAudio: "include", + }); + }); + + it("leaves the audio out on request and carries the resolution", () => { + expect( + screenShareCaptureOptions({ + source: "screenShare", + audio: false, + capture: { resolution: { width: 1280, height: 720 } }, + }), + ).toMatchObject({ + audio: false, + resolution: { width: 1280, height: 720 }, + }); + }); +}); + +describe("screenSharePublishOptions", () => { + it("is nothing without capture settings", () => { + expect(screenSharePublishOptions(undefined)).toBeUndefined(); + }); + + it("maps the bitrate, framerate and codec to the screen share encoding", () => { + expect( + screenSharePublishOptions({ + maxBitrate: 5_000_000, + maxFramerate: 15, + codec: "av1", + }), + ).toEqual({ + screenShareEncoding: { maxBitrate: 5_000_000, maxFramerate: 15 }, + videoCodec: "av1", + }); + }); +});