/* Copyright 2025 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, test, expect, beforeEach, afterEach, vi } from "vitest"; import { logger } from "matrix-js-sdk/lib/logger"; import { type CallMembership, type UnstableLivekitTransport, } from "matrix-js-sdk/lib/matrixrtc"; import { BehaviorSubject, combineLatest, map, type Observable } from "rxjs"; import { type RemoteMatrixLivekitMember, createRemoteMatrixLivekitMembers$, } from "./MatrixLivekitMembers.ts"; import { Epoch, ObservableScope, trackEpoch } from "../../ObservableScope.ts"; import { ConnectionMap } from "./ConnectionMap.ts"; import { flushPromises, mockRtcMembership, mockRemoteParticipant, } from "../../../utils/test.ts"; import { type Connection } from "./Connection.ts"; import { constant } from "../../Behavior.ts"; import { localRtcMember } from "../../../utils/test-fixtures.ts"; import { type TransportLocator } from "../../../livekit/auth"; import { membershipsAndTransports$ } from "../../SessionBehaviors.ts"; let testScope: ObservableScope; const fallbackMemberId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`; const transportA: UnstableLivekitTransport = { type: "livekit", livekit_service_url: "https://lk.example.org", }; const transportB: UnstableLivekitTransport = { type: "livekit", livekit_service_url: "https://lk.sample.com", }; const bobMembership = mockRtcMembership("@bob:example.org", "DEV000", { fociPreferred: [transportA], }); const carlMembership = mockRtcMembership("@carl:sample.com", "DEV111", { fociPreferred: [transportB], }); beforeEach(() => { testScope = new ObservableScope(); }); afterEach(() => { testScope.end(); }); function epochMeWith$( source$: Observable>, me$: Observable, ): Observable> { return combineLatest([source$, me$]).pipe( map(([ep, cd]) => { return new Epoch(cd, ep.epoch); }), ); } test("should signal participant not yet connected to livekit", async () => { const info = vi.spyOn(logger, "info"); const mockedMemberships$ = new BehaviorSubject([bobMembership]); const mockConnectionMap$ = new BehaviorSubject(new ConnectionMap()); const { memberships$, membershipsWithTransport$ } = createEpochedMemberships$(mockedMemberships$); const connectionMap$ = testScope.behavior( epochMeWith$(memberships$, mockConnectionMap$), ); const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), connectionMap$, localUser: localRtcMember, }); await flushPromises(); expect(remoteMatrixLivekitMembers$.value.value).toSatisfy( (data: RemoteMatrixLivekitMember[]) => { expect(data.length).toEqual(1); expect(data[0].membership$.value).toBe(bobMembership); expect(data[0].participant.value$.value).toBe(null); expect(data[0].connection$.value).toBe(null); return true; }, ); expect(info).toHaveBeenCalledWith( "[RemoteMatrixLivekitMembers] @bob:example.org:DEV000: LiveKit participant missing on no connection", ); }); // Helper to create epoch'ed memberships$ and membershipsWithTransport$ from memberships observable. function createEpochedMemberships$(m$: Observable): { memberships$: Observable>; membershipsWithTransport$: Observable< Epoch< { membership: CallMembership; transport: TransportLocator | undefined }[] > >; } { const memberships$ = testScope.behavior(m$.pipe(trackEpoch())); const { membershipsWithTransport$ } = membershipsAndTransports$( testScope, memberships$, ); return { memberships$, membershipsWithTransport$, }; } test("should signal participant on a connection that is publishing", async () => { const bobParticipantId = fallbackMemberId( bobMembership.userId, bobMembership.deviceId, ); const { memberships$, membershipsWithTransport$ } = createEpochedMemberships$( constant([bobMembership]), ); const connection = { transport: bobMembership.getTransport(), serverName: "example.org", } as unknown as Connection; const dataWithPublisher = new ConnectionMap(); dataWithPublisher.add(connection, [ mockRemoteParticipant({ identity: bobParticipantId }), ]); const connectionMap$ = testScope.behavior( epochMeWith$(memberships$, constant(dataWithPublisher)), ); const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), connectionMap$, localUser: localRtcMember, }); await flushPromises(); expect(remoteMatrixLivekitMembers$.value.value).toSatisfy( (data: RemoteMatrixLivekitMember[]) => { expect(data.length).toEqual(1); expect(data[0].membership$.value).toBe(bobMembership); expect(data[0].participant.value$.value).toSatisfy((participant) => { expect(participant).toBeDefined(); expect(participant!.identity).toEqual(bobParticipantId); return true; }); expect(data[0].connection$.value).toBe(connection); return true; }, ); }); // A member advertising both a `url` and a `livekit_service_url` may have // authenticated through either flow, so it has two candidate identities. const bothTransport: UnstableLivekitTransport = { type: "livekit", url: "wss://sfu.example.org", livekit_service_url: "https://lk.example.org", }; const bobLegacyId = fallbackMemberId("@bob:example.org", "DEV000"); const bobHashedId = "2qkXfWz1S7jQmU0bJ6yN8aVtR3pLcDgHe9oZxKwMiA4"; const bobWithBothIdentities = mockRtcMembership("@bob:example.org", "DEV000", { fociPreferred: [bothTransport], backendIdentities: [bobHashedId, bobLegacyId], }); test.each([ ["legacy", bobLegacyId], ["hashed", bobHashedId], ])( "matches a member with two candidate identities publishing under the %s one", async (_name, identity) => { const { memberships$, membershipsWithTransport$ } = createEpochedMemberships$(constant([bobWithBothIdentities])); const connection = { transport: bothTransport, serverName: "example.org", } as unknown as Connection; const dataWithPublisher = new ConnectionMap(); dataWithPublisher.add(connection, [mockRemoteParticipant({ identity })]); const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), connectionMap$: testScope.behavior( epochMeWith$(memberships$, constant(dataWithPublisher)), ), localUser: localRtcMember, }); await flushPromises(); expect(remoteMatrixLivekitMembers$.value.value).toSatisfy( (data: RemoteMatrixLivekitMember[]) => { expect(data.length).toEqual(1); expect(data[0].participant.value$.value?.identity).toEqual(identity); expect(data[0].connection$.value).toBe(connection); return true; }, ); }, ); test("does not match a member with two candidate identities to anyone else", async () => { const { memberships$, membershipsWithTransport$ } = createEpochedMemberships$( constant([bobWithBothIdentities]), ); const connection = { transport: bothTransport, serverName: "example.org", } as unknown as Connection; const dataWithStranger = new ConnectionMap(); dataWithStranger.add(connection, [ mockRemoteParticipant({ identity: "@stranger:example.org:DEV999" }), ]); const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), connectionMap$: testScope.behavior( epochMeWith$(memberships$, constant(dataWithStranger)), ), localUser: localRtcMember, }); await flushPromises(); expect(remoteMatrixLivekitMembers$.value.value).toSatisfy( (data: RemoteMatrixLivekitMember[]) => { expect(data.length).toEqual(1); expect(data[0].participant.value$.value).toBe(null); return true; }, ); }); test("should signal participant on a connection that is not publishing", async () => { const { memberships$, membershipsWithTransport$ } = createEpochedMemberships$( constant([bobMembership]), ); const connection = { transport: bobMembership.getTransport(), serverName: "example.org", } as unknown as Connection; const dataWithPublisher = new ConnectionMap(); dataWithPublisher.add(connection, []); const connectionMap$ = testScope.behavior( epochMeWith$(memberships$, constant(dataWithPublisher)), ); const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), connectionMap$, localUser: localRtcMember, }); await flushPromises(); expect(remoteMatrixLivekitMembers$.value.value).toSatisfy( (data: RemoteMatrixLivekitMember[]) => { expect(data.length).toEqual(1); expect(data[0].membership$.value).toBe(bobMembership); expect(data[0].participant.value$.value).toBe(null); expect(data[0].connection$.value).toBe(connection); return true; }, ); }); describe("Publication edge case", () => { test("bob is publishing in several connections", async () => { const { memberships$, membershipsWithTransport$ } = createEpochedMemberships$(constant([bobMembership, carlMembership])); const connectionWithPublisher = new ConnectionMap(); const bobParticipantId = fallbackMemberId( bobMembership.userId, bobMembership.deviceId, ); const connectionA = { transport: transportA, serverName: "example.org", } as unknown as Connection; const connectionB = { transport: transportB, serverName: "sample.com", } as unknown as Connection; connectionWithPublisher.add(connectionA, [ mockRemoteParticipant({ identity: bobParticipantId }), ]); connectionWithPublisher.add(connectionB, [ mockRemoteParticipant({ identity: bobParticipantId }), ]); const connectionMap$ = testScope.behavior( epochMeWith$(memberships$, constant(connectionWithPublisher)), ); const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), connectionMap$, localUser: localRtcMember, }); await flushPromises(); expect(remoteMatrixLivekitMembers$.value.value).toSatisfy( (data: RemoteMatrixLivekitMember[]) => { expect(data.length).toEqual(2); expect(data[0].membership$.value).toBe(bobMembership); expect(data[0].connection$.value).toBe(connectionA); expect(data[0].participant.value$.value).toSatisfy((participant) => { expect(participant).toBeDefined(); expect(participant!.identity).toEqual(bobParticipantId); return true; }); return true; }, ); }); }); test("bob is publishing in the wrong connection", async () => { const mockedMemberships$ = new BehaviorSubject([ bobMembership, carlMembership, ]); const { memberships$, membershipsWithTransport$ } = createEpochedMemberships$(mockedMemberships$); const connectionWithPublisher = new ConnectionMap(); const bobParticipantId = fallbackMemberId( bobMembership.userId, bobMembership.deviceId, ); const connectionA = { transport: transportA, serverName: "example.org", } as unknown as Connection; const connectionB = { transport: transportB, serverName: "sample.com", } as unknown as Connection; // Bob is not publishing on A connectionWithPublisher.add(connectionA, []); // Bob is publishing on B but his membership says A connectionWithPublisher.add(connectionB, [ mockRemoteParticipant({ identity: bobParticipantId }), ]); const connectionsWithPublisher$ = new BehaviorSubject( connectionWithPublisher, ); const connectionMap$ = testScope.behavior( epochMeWith$(memberships$, connectionsWithPublisher$), ); const remoteMatrixLivekitMembers$ = createRemoteMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), connectionMap$, localUser: localRtcMember, }); await flushPromises(); expect(remoteMatrixLivekitMembers$.value.value).toSatisfy( (data: RemoteMatrixLivekitMember[]) => { expect(data.length).toEqual(2); expect(data[0].membership$.value).toBe(bobMembership); expect(data[0].connection$.value).toBe(connectionA); expect(data[0].participant.value$.value).toBe(null); return true; }, ); });