Files
element-call/src/state/CallViewModel/localMember/HomeserverConnected.test.ts
T

334 lines
11 KiB
TypeScript
Raw Normal View History

2025-11-21 13:04:28 +01:00
/*
Copyright 2025 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2025-11-21 13:04:28 +01:00
import { EventEmitter } from "events";
import { ClientEvent, SyncState } from "matrix-js-sdk";
import { MembershipManagerEvent, Status } from "matrix-js-sdk/lib/matrixrtc";
import { ObservableScope } from "../../ObservableScope";
import { createHomeserverConnected$ } from "./HomeserverConnected";
2026-05-04 13:24:19 +02:00
import { TestScheduler } from "rxjs/testing";
2025-11-21 13:04:28 +01:00
/**
* Minimal stub of a Matrix client sufficient for our tests:
```
createHomeserverConnected$(
scope: ObservableScope,
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
matrixRTCSession: NodeStyleEventEmitter &
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
)
```
*/
class MockMatrixClient extends EventEmitter {
private syncState: SyncState;
public constructor(initial: SyncState) {
super();
this.syncState = initial;
}
public setSyncState(state: SyncState): void {
this.syncState = state;
// Matrix's Sync event in createHomeserverConnected$ expects [SyncState]
this.emit(ClientEvent.Sync, [state]);
}
public getSyncState(): SyncState {
return this.syncState;
}
}
/**
* Minimal stub of MatrixRTCSession (membership manager):
```
createHomeserverConnected$(
scope: ObservableScope,
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
matrixRTCSession: NodeStyleEventEmitter &
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
)
```
*/
class MockMatrixRTCSession extends EventEmitter {
public membershipStatus: Status;
public probablyLeft: boolean;
public constructor(props: {
membershipStatus: Status;
probablyLeft: boolean;
}) {
super();
this.membershipStatus = props.membershipStatus;
this.probablyLeft = props.probablyLeft;
}
public setMembershipStatus(status: Status): void {
this.membershipStatus = status;
this.emit(MembershipManagerEvent.StatusChanged);
}
public setProbablyLeft(flag: boolean): void {
this.probablyLeft = flag;
this.emit(MembershipManagerEvent.ProbablyLeft);
}
}
describe("createHomeserverConnected$", () => {
let scope: ObservableScope;
let client: MockMatrixClient;
let session: MockMatrixRTCSession;
beforeEach(() => {
scope = new ObservableScope();
client = new MockMatrixClient(SyncState.Error); // start disconnected
session = new MockMatrixRTCSession({
membershipStatus: Status.Disconnected,
probablyLeft: false,
});
});
afterEach(() => {
scope.end();
});
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
// easy enough to read them so I think they can stay.
// Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests
2025-11-21 13:04:28 +01:00
it("is false when sync state is not Syncing", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(false);
2025-11-21 13:04:28 +01:00
});
it("remains false while membership status is not Connected even if sync is Syncing", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
2025-11-21 13:04:28 +01:00
client.setSyncState(SyncState.Syncing);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(false); // membership still disconnected
2025-11-21 13:04:28 +01:00
});
it("is false when membership status transitions to Connected but ProbablyLeft is true", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
2025-11-21 13:04:28 +01:00
// Make sync loop OK
client.setSyncState(SyncState.Syncing);
// Indicate probable leave before connection
session.setProbablyLeft(true);
session.setMembershipStatus(Status.Connected);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(false);
2025-11-21 13:04:28 +01:00
});
it("becomes true only when all three conditions are satisfied", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
2025-11-21 13:04:28 +01:00
// 1. Sync loop connected
client.setSyncState(SyncState.Syncing);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(false); // not yet membership connected
2025-11-21 13:04:28 +01:00
// 2. Membership connected
session.setMembershipStatus(Status.Connected);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(true); // probablyLeft is false
2025-11-21 13:04:28 +01:00
});
it("drops back to false when sync loop leaves Syncing", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
2025-11-21 13:04:28 +01:00
// Reach connected state
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(true);
2025-11-21 13:04:28 +01:00
// Sync loop error => should flip false
client.setSyncState(SyncState.Error);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(false);
2025-11-21 13:04:28 +01:00
});
it("drops back to false when membership status becomes disconnected", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
2025-11-21 13:04:28 +01:00
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(true);
2025-11-21 13:04:28 +01:00
session.setMembershipStatus(Status.Disconnected);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(false);
2025-11-21 13:04:28 +01:00
});
it("drops to false when ProbablyLeft is emitted after being true", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
2025-11-21 13:04:28 +01:00
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(true);
2025-11-21 13:04:28 +01:00
session.setProbablyLeft(true);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(false);
2025-11-21 13:04:28 +01:00
});
it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
2025-11-21 13:04:28 +01:00
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(true);
2025-11-21 13:04:28 +01:00
session.setProbablyLeft(true);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(false);
2025-11-21 13:04:28 +01:00
// Simulate clearing the flag (in realistic scenario membership manager would update)
session.setProbablyLeft(false);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(true);
2025-11-21 13:04:28 +01:00
});
it("composite sequence reflects each individual failure reason", () => {
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
2025-11-21 13:04:28 +01:00
// Initially false (sync error + disconnected + not probably left)
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(false);
2025-11-21 13:04:28 +01:00
// Fix sync only
client.setSyncState(SyncState.Syncing);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(false);
2025-11-21 13:04:28 +01:00
// Fix membership
session.setMembershipStatus(Status.Connected);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(true);
2025-11-21 13:04:28 +01:00
// Introduce probablyLeft -> false
session.setProbablyLeft(true);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(false);
2025-11-21 13:04:28 +01:00
// Restore notProbablyLeft -> true again
session.setProbablyLeft(false);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(true);
2025-11-21 13:04:28 +01:00
// Drop sync -> false
client.setSyncState(SyncState.Error);
2025-12-02 19:40:08 +01:00
expect(hsConnected.combined$.value).toBe(false);
2025-11-21 13:04:28 +01:00
});
});
describe("createHomeserverConnected$ - Grace Period", () => {
let scope: ObservableScope;
let client: MockMatrixClient;
let session: MockMatrixRTCSession;
const GRACE_PERIOD = 5000;
beforeEach(() => {
vi.useFakeTimers();
scope = new ObservableScope();
// Initialize with values that satisfy the "Connected" condition
client = new MockMatrixClient(SyncState.Syncing);
session = new MockMatrixRTCSession({
membershipStatus: Status.Connected,
probablyLeft: false,
});
});
afterEach(() => {
scope.end();
vi.useRealTimers();
});
it("respects gracePeriodMs: stays true during grace period and flips false after", () => {
2026-04-23 15:47:40 +02:00
const hsConnected = createHomeserverConnected$(
scope,
client,
session,
GRACE_PERIOD,
);
// Initial state: Everything is connected
expect(hsConnected.combined$.value).toBe(true);
// 1. Sync loses connection -> should remain TRUE due to grace period
client.setSyncState(SyncState.Error);
expect(hsConnected.combined$.value).toBe(true);
// 2. Fast forward time (just before expiration)
vi.advanceTimersByTime(GRACE_PERIOD - 1);
expect(hsConnected.combined$.value).toBe(true);
// 3. Fast forward time (expiration)
vi.advanceTimersByTime(1);
expect(hsConnected.combined$.value).toBe(false);
});
it("recovers immediately if sync returns during grace period", () => {
2026-04-23 15:47:40 +02:00
const hsConnected = createHomeserverConnected$(
scope,
client,
session,
GRACE_PERIOD,
);
// Initial state: Connected
expect(hsConnected.combined$.value).toBe(true);
// 1. Sync error occurs
client.setSyncState(SyncState.Error);
vi.advanceTimersByTime(GRACE_PERIOD / 2);
expect(hsConnected.combined$.value).toBe(true);
// 2. Sync recovers BEFORE the grace period expires
client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toBe(true);
// 3. Fast forward the remaining time -> should stay TRUE
vi.advanceTimersByTime(GRACE_PERIOD);
expect(hsConnected.combined$.value).toBe(true);
});
it("flips to true IMMEDIATELY even if a grace period was pending", () => {
2026-04-23 15:47:40 +02:00
const hsConnected = createHomeserverConnected$(
scope,
client,
session,
GRACE_PERIOD,
);
// 1. Initial error: wait until it flips to false
client.setSyncState(SyncState.Error);
expect(hsConnected.combined$.value).toBe(true);
vi.advanceTimersByTime(GRACE_PERIOD + 1);
expect(hsConnected.combined$.value).toBe(false);
// 2. Back to Syncing -> Must be TRUE immediately (synchronously)
client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toBe(true);
});
2026-05-04 13:24:19 +02:00
it('marble: sync "s----e" -> HomeserverConnected "t---------f"', () => {
const ts = new TestScheduler((a, b) => expect(a).toEqual(b));
2026-05-04 13:27:57 +02:00
2026-05-04 13:24:19 +02:00
ts.run(({ cold, expectObservable }) => {
const GRACE = 5;
const scope = new ObservableScope();
// Setup Mocks
const client = new MockMatrixClient(SyncState.Syncing);
const session = new MockMatrixRTCSession({
membershipStatus: Status.Connected,
probablyLeft: false,
});
const hs = createHomeserverConnected$(scope, client, session, GRACE);
// Marble-Input: s (Syncing) at 0ms, e (Error) at 5ms
const syncValues = { s: SyncState.Syncing, e: SyncState.Error };
const driver$ = cold("s----e", syncValues);
2026-05-04 13:27:57 +02:00
2026-05-04 13:24:19 +02:00
// Feed Mock-Client with marble values
driver$.subscribe((state) => {
client.setSyncState(state);
});
const values = { t: true, f: false };
2026-05-04 13:27:57 +02:00
2026-05-04 13:24:19 +02:00
// t (0ms: Syncing + Connected = true)
// (5ms: Error occurs, Grace period starts, still true)
// f (10ms: 5ms + 5ms Grace period ends, should flip to false)
expectObservable(hs.combined$).toBe("t---------f", values);
ts.flush();
scope.end();
});
});
2026-04-23 15:47:40 +02:00
});