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.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-04-22 21:46:25 +02:00
|
|
|
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.
|
2026-04-22 15:18:50 +02:00
|
|
|
// 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", () => {
|
2026-04-22 15:18:50 +02:00
|
|
|
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", () => {
|
2026-04-22 15:18:50 +02:00
|
|
|
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", () => {
|
2026-04-22 15:18:50 +02:00
|
|
|
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", () => {
|
2026-04-22 15:18:50 +02:00
|
|
|
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", () => {
|
2026-04-22 15:18:50 +02:00
|
|
|
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", () => {
|
2026-04-22 15:18:50 +02:00
|
|
|
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", () => {
|
2026-04-22 15:18:50 +02:00
|
|
|
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", () => {
|
2026-04-22 15:18:50 +02:00
|
|
|
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", () => {
|
2026-04-22 15:18:50 +02:00
|
|
|
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
|
|
|
});
|
|
|
|
|
});
|
2026-04-22 21:46:25 +02: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,
|
|
|
|
|
);
|
2026-04-22 21:46:25 +02:00
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
);
|
2026-04-22 21:46:25 +02:00
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
);
|
2026-04-22 21:46:25 +02:00
|
|
|
|
|
|
|
|
// 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
|
|
|
});
|