Merge pull request #3464 from robintown/reconnecting-test

Test sync loop status and membership status in reconnection test as well
This commit is contained in:
Timo
2025-08-25 14:03:32 +02:00
committed by GitHub
2 changed files with 270 additions and 226 deletions

View File

@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { test, vi, onTestFinished, it } from "vitest"; import { test, vi, onTestFinished, it } from "vitest";
import EventEmitter from "events";
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest, combineLatest,
@@ -17,7 +18,7 @@ import {
of, of,
switchMap, switchMap,
} from "rxjs"; } from "rxjs";
import { SyncState, type MatrixClient } from "matrix-js-sdk"; import { ClientEvent, SyncState, type MatrixClient } from "matrix-js-sdk";
import { import {
ConnectionState, ConnectionState,
type LocalParticipant, type LocalParticipant,
@@ -28,6 +29,7 @@ import {
} from "livekit-client"; } from "livekit-client";
import * as ComponentsCore from "@livekit/components-core"; import * as ComponentsCore from "@livekit/components-core";
import { import {
Status,
type CallMembership, type CallMembership,
type MatrixRTCSession, type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
@@ -48,7 +50,6 @@ import {
mockRtcMembership, mockRtcMembership,
MockRTCSession, MockRTCSession,
mockMediaDevices, mockMediaDevices,
mockEmitter,
} from "../utils/test"; } from "../utils/test";
import { import {
ECAddonConnectionState, ECAddonConnectionState,
@@ -233,29 +234,53 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
); );
} }
interface CallViewModelInputs {
remoteParticipants$: Behavior<RemoteParticipant[]>;
rtcMembers$: Behavior<Partial<CallMembership>[]>;
connectionState$: Observable<ECConnectionState>;
speaking: Map<Participant, Observable<boolean>>;
mediaDevices: MediaDevices;
initialSyncState: SyncState;
}
function withCallViewModel( function withCallViewModel(
remoteParticipants$: Behavior<RemoteParticipant[]>, {
rtcMembers$: Behavior<Partial<CallMembership>[]>, remoteParticipants$ = constant([]),
connectionState$: Observable<ECConnectionState>, rtcMembers$ = constant([localRtcMember]),
speaking: Map<Participant, Observable<boolean>>, connectionState$ = of(ConnectionState.Connected),
mediaDevices: MediaDevices, speaking = new Map(),
mediaDevices = mockMediaDevices({}),
initialSyncState = SyncState.Syncing,
}: Partial<CallViewModelInputs>,
continuation: ( continuation: (
vm: CallViewModel, vm: CallViewModel,
rtcSession: MockRTCSession, rtcSession: MockRTCSession,
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> }, subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
setSyncState: (value: SyncState) => void,
) => void, ) => void,
options: CallViewModelOptions = { options: CallViewModelOptions = {
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
autoLeaveWhenOthersLeft: false, autoLeaveWhenOthersLeft: false,
}, },
): void { ): void {
let syncState = initialSyncState;
const setSyncState = (value: SyncState): void => {
const prev = syncState;
syncState = value;
room.client.emit(ClientEvent.Sync, value, prev);
};
const room = mockMatrixRoom({ const room = mockMatrixRoom({
client: { client: new (class extends EventEmitter {
...mockEmitter(), public getUserId(): string | undefined {
getUserId: () => localRtcMember.sender, return localRtcMember.sender;
getDeviceId: () => localRtcMember.deviceId, }
getSyncState: () => SyncState.Syncing, public getDeviceId(): string {
} as Partial<MatrixClient> as MatrixClient, return localRtcMember.deviceId;
}
public getSyncState(): SyncState {
return syncState;
}
})() as Partial<MatrixClient> as MatrixClient,
getMember: (userId) => roomMembers.get(userId) ?? null, getMember: (userId) => roomMembers.get(userId) ?? null,
}); });
const rtcSession = new MockRTCSession(room, []).withMemberships(rtcMembers$); const rtcSession = new MockRTCSession(room, []).withMemberships(rtcMembers$);
@@ -311,7 +336,7 @@ function withCallViewModel(
roomEventSelectorSpy!.mockRestore(); roomEventSelectorSpy!.mockRestore();
}); });
continuation(vm, rtcSession, { raisedHands$: raisedHands$ }); continuation(vm, rtcSession, { raisedHands$: raisedHands$ }, setSyncState);
} }
test("participants are retained during a focus switch", () => { test("participants are retained during a focus switch", () => {
@@ -324,17 +349,17 @@ test("participants are retained during a focus switch", () => {
const expectedLayoutMarbles = " a"; const expectedLayoutMarbles = " a";
withCallViewModel( withCallViewModel(
behavior(participantInputMarbles, { {
a: [aliceParticipant, bobParticipant], remoteParticipants$: behavior(participantInputMarbles, {
b: [], a: [aliceParticipant, bobParticipant],
}), b: [],
constant([localRtcMember, aliceRtcMember, bobRtcMember]), }),
behavior(connectionInputMarbles, { rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]),
c: ConnectionState.Connected, connectionState$: behavior(connectionInputMarbles, {
s: ECAddonConnectionState.ECSwitchingFocus, c: ConnectionState.Connected,
}), s: ECAddonConnectionState.ECSwitchingFocus,
new Map(), }),
mockMediaDevices({}), },
(vm) => { (vm) => {
expectObservable(summarizeLayout$(vm.layout$)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles, expectedLayoutMarbles,
@@ -365,16 +390,15 @@ test("screen sharing activates spotlight layout", () => {
const expectedLayoutMarbles = " abcdaefeg"; const expectedLayoutMarbles = " abcdaefeg";
const expectedShowSpeakingMarbles = "y----nyny"; const expectedShowSpeakingMarbles = "y----nyny";
withCallViewModel( withCallViewModel(
behavior(participantInputMarbles, { {
a: [aliceParticipant, bobParticipant], remoteParticipants$: behavior(participantInputMarbles, {
b: [aliceSharingScreen, bobParticipant], a: [aliceParticipant, bobParticipant],
c: [aliceSharingScreen, bobSharingScreen], b: [aliceSharingScreen, bobParticipant],
d: [aliceParticipant, bobSharingScreen], c: [aliceSharingScreen, bobSharingScreen],
}), d: [aliceParticipant, bobSharingScreen],
constant([localRtcMember, aliceRtcMember, bobRtcMember]), }),
of(ConnectionState.Connected), rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]),
new Map(), },
mockMediaDevices({}),
(vm) => { (vm) => {
schedule(modeInputMarbles, { schedule(modeInputMarbles, {
s: () => vm.setGridMode("spotlight"), s: () => vm.setGridMode("spotlight"),
@@ -447,15 +471,24 @@ test("participants stay in the same order unless to appear/disappear", () => {
const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a"; const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a";
withCallViewModel( withCallViewModel(
constant([aliceParticipant, bobParticipant, daveParticipant]), {
constant([localRtcMember, aliceRtcMember, bobRtcMember, daveRtcMember]), remoteParticipants$: constant([
of(ConnectionState.Connected), aliceParticipant,
new Map([ bobParticipant,
[aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)], daveParticipant,
[bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], ]),
[daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], rtcMembers$: constant([
]), localRtcMember,
mockMediaDevices({}), aliceRtcMember,
bobRtcMember,
daveRtcMember,
]),
speaking: new Map([
[aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)],
[bobParticipant, behavior(bSpeakingInputMarbles, yesNo)],
[daveParticipant, behavior(dSpeakingInputMarbles, yesNo)],
]),
},
(vm) => { (vm) => {
schedule(visibilityInputMarbles, { schedule(visibilityInputMarbles, {
a: () => { a: () => {
@@ -505,14 +538,23 @@ test("participants adjust order when space becomes constrained", () => {
const expectedLayoutMarbles = " a-b"; const expectedLayoutMarbles = " a-b";
withCallViewModel( withCallViewModel(
constant([aliceParticipant, bobParticipant, daveParticipant]), {
constant([localRtcMember, aliceRtcMember, bobRtcMember, daveRtcMember]), remoteParticipants$: constant([
of(ConnectionState.Connected), aliceParticipant,
new Map([ bobParticipant,
[bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], daveParticipant,
[daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], ]),
]), rtcMembers$: constant([
mockMediaDevices({}), localRtcMember,
aliceRtcMember,
bobRtcMember,
daveRtcMember,
]),
speaking: new Map([
[bobParticipant, behavior(bSpeakingInputMarbles, yesNo)],
[daveParticipant, behavior(dSpeakingInputMarbles, yesNo)],
]),
},
(vm) => { (vm) => {
let setVisibleTiles: ((value: number) => void) | null = null; let setVisibleTiles: ((value: number) => void) | null = null;
vm.layout$.subscribe((layout) => { vm.layout$.subscribe((layout) => {
@@ -558,15 +600,24 @@ test("spotlight speakers swap places", () => {
const expectedLayoutMarbles = "abcd"; const expectedLayoutMarbles = "abcd";
withCallViewModel( withCallViewModel(
constant([aliceParticipant, bobParticipant, daveParticipant]), {
constant([localRtcMember, aliceRtcMember, bobRtcMember, daveRtcMember]), remoteParticipants$: constant([
of(ConnectionState.Connected), aliceParticipant,
new Map([ bobParticipant,
[aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)], daveParticipant,
[bobParticipant, behavior(bSpeakingInputMarbles, yesNo)], ]),
[daveParticipant, behavior(dSpeakingInputMarbles, yesNo)], rtcMembers$: constant([
]), localRtcMember,
mockMediaDevices({}), aliceRtcMember,
bobRtcMember,
daveRtcMember,
]),
speaking: new Map([
[aliceParticipant, behavior(aSpeakingInputMarbles, yesNo)],
[bobParticipant, behavior(bSpeakingInputMarbles, yesNo)],
[daveParticipant, behavior(dSpeakingInputMarbles, yesNo)],
]),
},
(vm) => { (vm) => {
schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") }); schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
@@ -608,11 +659,10 @@ test("layout enters picture-in-picture mode when requested", () => {
const expectedLayoutMarbles = " aba"; const expectedLayoutMarbles = " aba";
withCallViewModel( withCallViewModel(
constant([aliceParticipant, bobParticipant]), {
constant([localRtcMember, aliceRtcMember, bobRtcMember]), remoteParticipants$: constant([aliceParticipant, bobParticipant]),
of(ConnectionState.Connected), rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]),
new Map(), },
mockMediaDevices({}),
(vm) => { (vm) => {
schedule(pipControlInputMarbles, { schedule(pipControlInputMarbles, {
e: () => window.controls.enablePip(), e: () => window.controls.enablePip(),
@@ -650,11 +700,10 @@ test("spotlight remembers whether it's expanded", () => {
const expectedLayoutMarbles = "abcbada"; const expectedLayoutMarbles = "abcbada";
withCallViewModel( withCallViewModel(
constant([aliceParticipant, bobParticipant]), {
constant([localRtcMember, aliceRtcMember, bobRtcMember]), remoteParticipants$: constant([aliceParticipant, bobParticipant]),
of(ConnectionState.Connected), rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]),
new Map(), },
mockMediaDevices({}),
(vm) => { (vm) => {
schedule(modeInputMarbles, { schedule(modeInputMarbles, {
s: () => vm.setGridMode("spotlight"), s: () => vm.setGridMode("spotlight"),
@@ -707,23 +756,22 @@ test("participants must have a MatrixRTCSession to be visible", () => {
const expectedLayoutMarbles = "a-bc-b"; const expectedLayoutMarbles = "a-bc-b";
withCallViewModel( withCallViewModel(
behavior(scenarioInputMarbles, { {
a: [], remoteParticipants$: behavior(scenarioInputMarbles, {
b: [bobParticipant], a: [],
c: [aliceParticipant, bobParticipant], b: [bobParticipant],
d: [aliceParticipant, daveParticipant, bobParticipant], c: [aliceParticipant, bobParticipant],
e: [aliceParticipant, daveParticipant, bobSharingScreen], d: [aliceParticipant, daveParticipant, bobParticipant],
}), e: [aliceParticipant, daveParticipant, bobSharingScreen],
behavior(scenarioInputMarbles, { }),
a: [localRtcMember], rtcMembers$: behavior(scenarioInputMarbles, {
b: [localRtcMember], a: [localRtcMember],
c: [localRtcMember, aliceRtcMember], b: [localRtcMember],
d: [localRtcMember, aliceRtcMember, daveRtcMember], c: [localRtcMember, aliceRtcMember],
e: [localRtcMember, aliceRtcMember, daveRtcMember], d: [localRtcMember, aliceRtcMember, daveRtcMember],
}), e: [localRtcMember, aliceRtcMember, daveRtcMember],
of(ConnectionState.Connected), }),
new Map(), },
mockMediaDevices({}),
(vm) => { (vm) => {
vm.setGridMode("grid"); vm.setGridMode("grid");
expectObservable(summarizeLayout$(vm.layout$)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
@@ -760,15 +808,14 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
const expectedLayoutMarbles = "abc"; const expectedLayoutMarbles = "abc";
withCallViewModel( withCallViewModel(
behavior(scenarioInputMarbles, { {
a: [], remoteParticipants$: behavior(scenarioInputMarbles, {
b: [aliceParticipant], a: [],
c: [aliceParticipant, bobParticipant], b: [aliceParticipant],
}), c: [aliceParticipant, bobParticipant],
constant([localRtcMember]), // No one else joins the MatrixRTC session }),
of(ConnectionState.Connected), rtcMembers$: constant([localRtcMember]), // No one else joins the MatrixRTC session
new Map(), },
mockMediaDevices({}),
(vm) => { (vm) => {
vm.setGridMode("grid"); vm.setGridMode("grid");
expectObservable(summarizeLayout$(vm.layout$)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
@@ -807,16 +854,14 @@ it("should show at least one tile per MatrixRTCSession", () => {
const expectedLayoutMarbles = "abcd"; const expectedLayoutMarbles = "abcd";
withCallViewModel( withCallViewModel(
constant([]), {
behavior(scenarioInputMarbles, { rtcMembers$: behavior(scenarioInputMarbles, {
a: [localRtcMember], a: [localRtcMember],
b: [localRtcMember, aliceRtcMember], b: [localRtcMember, aliceRtcMember],
c: [localRtcMember, aliceRtcMember, daveRtcMember], c: [localRtcMember, aliceRtcMember, daveRtcMember],
d: [localRtcMember, daveRtcMember], d: [localRtcMember, daveRtcMember],
}), }),
of(ConnectionState.Connected), },
new Map(),
mockMediaDevices({}),
(vm) => { (vm) => {
vm.setGridMode("grid"); vm.setGridMode("grid");
expectObservable(summarizeLayout$(vm.layout$)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
@@ -855,22 +900,20 @@ test("should disambiguate users with the same displayname", () => {
const expectedLayoutMarbles = "abcde"; const expectedLayoutMarbles = "abcde";
withCallViewModel( withCallViewModel(
constant([]), {
behavior(scenarioInputMarbles, { rtcMembers$: behavior(scenarioInputMarbles, {
a: [localRtcMember], a: [localRtcMember],
b: [localRtcMember, aliceRtcMember], b: [localRtcMember, aliceRtcMember],
c: [localRtcMember, aliceRtcMember, aliceDoppelgangerRtcMember], c: [localRtcMember, aliceRtcMember, aliceDoppelgangerRtcMember],
d: [ d: [
localRtcMember, localRtcMember,
aliceRtcMember, aliceRtcMember,
aliceDoppelgangerRtcMember, aliceDoppelgangerRtcMember,
bobRtcMember, bobRtcMember,
], ],
e: [localRtcMember, aliceDoppelgangerRtcMember, bobRtcMember], e: [localRtcMember, aliceDoppelgangerRtcMember, bobRtcMember],
}), }),
of(ConnectionState.Connected), },
new Map(),
mockMediaDevices({}),
(vm) => { (vm) => {
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, { expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
// Carol has no displayname - So userId is used. // Carol has no displayname - So userId is used.
@@ -910,14 +953,12 @@ test("should disambiguate users with invisible characters", () => {
const expectedLayoutMarbles = "ab"; const expectedLayoutMarbles = "ab";
withCallViewModel( withCallViewModel(
constant([]), {
behavior(scenarioInputMarbles, { rtcMembers$: behavior(scenarioInputMarbles, {
a: [localRtcMember], a: [localRtcMember],
b: [localRtcMember, bobRtcMember, bobZeroWidthSpaceRtcMember], b: [localRtcMember, bobRtcMember, bobZeroWidthSpaceRtcMember],
}), }),
of(ConnectionState.Connected), },
new Map(),
mockMediaDevices({}),
(vm) => { (vm) => {
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, { expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
// Carol has no displayname - So userId is used. // Carol has no displayname - So userId is used.
@@ -943,14 +984,12 @@ test("should strip RTL characters from displayname", () => {
const expectedLayoutMarbles = "ab"; const expectedLayoutMarbles = "ab";
withCallViewModel( withCallViewModel(
constant([]), {
behavior(scenarioInputMarbles, { rtcMembers$: behavior(scenarioInputMarbles, {
a: [localRtcMember], a: [localRtcMember],
b: [localRtcMember, daveRtcMember, daveRTLRtcMember], b: [localRtcMember, daveRtcMember, daveRTLRtcMember],
}), }),
of(ConnectionState.Connected), },
new Map(),
mockMediaDevices({}),
(vm) => { (vm) => {
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, { expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
// Carol has no displayname - So userId is used. // Carol has no displayname - So userId is used.
@@ -970,16 +1009,15 @@ test("should strip RTL characters from displayname", () => {
}); });
it("should rank raised hands above video feeds and below speakers and presenters", () => { it("should rank raised hands above video feeds and below speakers and presenters", () => {
withTestScheduler(({ schedule, expectObservable, behavior }) => { withTestScheduler(({ schedule, expectObservable }) => {
// There should always be one tile for each MatrixRTCSession // There should always be one tile for each MatrixRTCSession
const expectedLayoutMarbles = "ab"; const expectedLayoutMarbles = "ab";
withCallViewModel( withCallViewModel(
constant([aliceParticipant, bobParticipant]), {
constant([localRtcMember, aliceRtcMember, bobRtcMember]), remoteParticipants$: constant([aliceParticipant, bobParticipant]),
of(ConnectionState.Connected), rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]),
new Map(), },
mockMediaDevices({}),
(vm, _rtcSession, { raisedHands$ }) => { (vm, _rtcSession, { raisedHands$ }) => {
schedule("ab", { schedule("ab", {
a: () => { a: () => {
@@ -1072,11 +1110,7 @@ test("allOthersLeft$ emits only when someone joined and then all others left", (
withTestScheduler(({ hot, expectObservable, scope }) => { withTestScheduler(({ hot, expectObservable, scope }) => {
// Test scenario 1: No one ever joins - should only emit initial false and never emit again // Test scenario 1: No one ever joins - should only emit initial false and never emit again
withCallViewModel( withCallViewModel(
scope.behavior(nooneEverThere$(hot), []), { remoteParticipants$: scope.behavior(nooneEverThere$(hot), []) },
constant([localRtcMember]),
of(ConnectionState.Connected),
new Map(),
mockMediaDevices({}),
(vm) => { (vm) => {
expectObservable(vm.allOthersLeft$).toBe("n------", { n: false }); expectObservable(vm.allOthersLeft$).toBe("n------", { n: false });
}, },
@@ -1087,11 +1121,10 @@ test("allOthersLeft$ emits only when someone joined and then all others left", (
test("allOthersLeft$ emits true when someone joined and then all others left", () => { test("allOthersLeft$ emits true when someone joined and then all others left", () => {
withTestScheduler(({ hot, expectObservable, scope }) => { withTestScheduler(({ hot, expectObservable, scope }) => {
withCallViewModel( withCallViewModel(
scope.behavior(participantJoinLeave$(hot), []), {
scope.behavior(rtcMemberJoinLeave$(hot), []), remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []),
of(ConnectionState.Connected), rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []),
new Map(), },
mockMediaDevices({}),
(vm) => { (vm) => {
expectObservable(vm.allOthersLeft$).toBe( expectObservable(vm.allOthersLeft$).toBe(
"n-----u", // false initially, then at frame 6: true then false emissions in same frame "n-----u", // false initially, then at frame 6: true then false emissions in same frame
@@ -1105,11 +1138,10 @@ test("allOthersLeft$ emits true when someone joined and then all others left", (
test("autoLeaveWhenOthersLeft$ emits only when autoLeaveWhenOthersLeft option is enabled", () => { test("autoLeaveWhenOthersLeft$ emits only when autoLeaveWhenOthersLeft option is enabled", () => {
withTestScheduler(({ hot, expectObservable, scope }) => { withTestScheduler(({ hot, expectObservable, scope }) => {
withCallViewModel( withCallViewModel(
scope.behavior(participantJoinLeave$(hot), []), {
scope.behavior(rtcMemberJoinLeave$(hot), []), remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []),
of(ConnectionState.Connected), rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []),
new Map(), },
mockMediaDevices({}),
(vm) => { (vm) => {
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe( expectObservable(vm.autoLeaveWhenOthersLeft$).toBe(
"------e", // false initially, then at frame 6: true then false emissions in same frame "------e", // false initially, then at frame 6: true then false emissions in same frame
@@ -1127,11 +1159,10 @@ test("autoLeaveWhenOthersLeft$ emits only when autoLeaveWhenOthersLeft option is
test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => { test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => {
withTestScheduler(({ hot, expectObservable, scope }) => { withTestScheduler(({ hot, expectObservable, scope }) => {
withCallViewModel( withCallViewModel(
scope.behavior(nooneEverThere$(hot), []), {
scope.behavior(nooneEverThere$(hot), []), remoteParticipants$: scope.behavior(nooneEverThere$(hot), []),
of(ConnectionState.Connected), rtcMembers$: scope.behavior(nooneEverThere$(hot), []),
new Map(), },
mockMediaDevices({}),
(vm) => { (vm) => {
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------"); expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------");
}, },
@@ -1146,11 +1177,10 @@ test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is ena
test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => { test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => {
withTestScheduler(({ hot, expectObservable, scope }) => { withTestScheduler(({ hot, expectObservable, scope }) => {
withCallViewModel( withCallViewModel(
scope.behavior(participantJoinLeave$(hot), []), {
scope.behavior(rtcMemberJoinLeave$(hot), []), remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []),
of(ConnectionState.Connected), rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []),
new Map(), },
mockMediaDevices({}),
(vm) => { (vm) => {
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------"); expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------");
}, },
@@ -1165,27 +1195,26 @@ test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option
test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => { test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => {
withTestScheduler(({ hot, expectObservable, scope }) => { withTestScheduler(({ hot, expectObservable, scope }) => {
withCallViewModel( withCallViewModel(
scope.behavior( {
hot("a-b-c-d", { remoteParticipants$: scope.behavior(
a: [], // Alone hot("a-b-c-d", {
b: [aliceParticipant], // Alice joins a: [], // Alone
c: [aliceParticipant], b: [aliceParticipant], // Alice joins
d: [], // Local joins with a second device c: [aliceParticipant],
}), d: [], // Local joins with a second device
[], //Alice leaves }),
), [], //Alice leaves
scope.behavior( ),
hot("a-b-c-d", { rtcMembers$: scope.behavior(
a: [localRtcMember], // Start empty hot("a-b-c-d", {
b: [localRtcMember, aliceRtcMember], // Alice joins a: [localRtcMember], // Start empty
c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there b: [localRtcMember, aliceRtcMember], // Alice joins
d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there
}), d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves
[], }),
), [],
of(ConnectionState.Connected), ),
new Map(), },
mockMediaDevices({}),
(vm) => { (vm) => {
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("------e", { expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("------e", {
e: undefined, e: undefined,
@@ -1219,27 +1248,18 @@ test("audio output changes when toggling earpiece mode", () => {
const expectedEarpieceModeMarbles = "n-yn"; const expectedEarpieceModeMarbles = "n-yn";
const expectedTargetStateMarbles = " sese"; const expectedTargetStateMarbles = " sese";
withCallViewModel( withCallViewModel({ mediaDevices: devices }, (vm) => {
constant([]), schedule(toggleInputMarbles, {
constant([localRtcMember]), a: () => getValue(vm.audioOutputSwitcher$)?.switch(),
of(ConnectionState.Connected), });
new Map(), expectObservable(vm.earpieceMode$).toBe(
devices, expectedEarpieceModeMarbles,
(vm) => { yesNo,
schedule(toggleInputMarbles, { );
a: () => getValue(vm.audioOutputSwitcher$)?.switch(), expectObservable(
}); vm.audioOutputSwitcher$.pipe(map((switcher) => switcher?.targetOutput)),
expectObservable(vm.earpieceMode$).toBe( ).toBe(expectedTargetStateMarbles, { s: "speaker", e: "earpiece" });
expectedEarpieceModeMarbles, });
yesNo,
);
expectObservable(
vm.audioOutputSwitcher$.pipe(
map((switcher) => switcher?.targetOutput),
),
).toBe(expectedTargetStateMarbles, { s: "speaker", e: "earpiece" });
},
);
}); });
}); });
@@ -1271,25 +1291,39 @@ test("media tracks are paused while reconnecting to MatrixRTC", () => {
localParticipant.trackPublications = originalPublications; localParticipant.trackPublications = originalPublications;
}); });
// TODO: Add marbles for sync state and membership status as well // There are three indicators that the client might be disconnected from
const connectedMarbles = " yny"; // MatrixRTC: whether the sync loop is connected, whether the membership is
const expectedReconnectingMarbles = "nyn"; // present in local room state, and whether the membership manager thinks
const expectedTrackRunningMarbles = "yny"; // we've hit the timeout for the delayed leave event. Let's test all
// combinations of these conditions.
const syncingMarbles = " nyny----n--y";
const membershipStatusMarbles = " y---ny-n-yn-y";
const probablyLeftMarbles = " n-----y-ny---n";
const expectedReconnectingMarbles = "n-ynyny------n";
const expectedTrackRunningMarbles = "nynynyn------y";
withCallViewModel( withCallViewModel(
constant([]), { initialSyncState: SyncState.Reconnecting },
constant([localRtcMember]), (vm, rtcSession, _subjects, setSyncState) => {
of(ConnectionState.Connected), schedule(syncingMarbles, {
new Map(), y: () => setSyncState(SyncState.Syncing),
mockMediaDevices({}), n: () => setSyncState(SyncState.Reconnecting),
(vm, rtcSession) => { });
schedule(connectedMarbles, { schedule(membershipStatusMarbles, {
y: () => { y: () => {
rtcSession.probablyLeft = false; rtcSession.membershipStatus = Status.Connected;
}, },
n: () => { n: () => {
rtcSession.membershipStatus = Status.Reconnecting;
},
});
schedule(probablyLeftMarbles, {
y: () => {
rtcSession.probablyLeft = true; rtcSession.probablyLeft = true;
}, },
n: () => {
rtcSession.probablyLeft = false;
},
}); });
expectObservable(vm.reconnecting$).toBe( expectObservable(vm.reconnecting$).toBe(
expectedReconnectingMarbles, expectedReconnectingMarbles,

View File

@@ -360,15 +360,25 @@ export class MockRTCSession extends TypedEventEmitter<
return this; return this;
} }
public readonly membershipStatus = Status.Connected; private _membershipStatus = Status.Connected;
public get membershipStatus(): Status {
return this._membershipStatus;
}
public set membershipStatus(value: Status) {
const prev = this._membershipStatus;
this._membershipStatus = value;
if (value !== prev)
this.emit(MembershipManagerEvent.StatusChanged, prev, value);
}
private _probablyLeft = false; private _probablyLeft = false;
public get probablyLeft(): boolean { public get probablyLeft(): boolean {
return this._probablyLeft; return this._probablyLeft;
} }
public set probablyLeft(value: boolean) { public set probablyLeft(value: boolean) {
const prev = this._probablyLeft;
this._probablyLeft = value; this._probablyLeft = value;
this.emit(MembershipManagerEvent.ProbablyLeft, value); if (value !== prev) this.emit(MembershipManagerEvent.ProbablyLeft, value);
} }
} }