diff --git a/src/ClientContext.test.tsx b/src/ClientContext.test.tsx index d279a87ee..65ff03d03 100644 --- a/src/ClientContext.test.tsx +++ b/src/ClientContext.test.tsx @@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details. */ import { expect, test, vi } from "vitest"; -import { render } from "@testing-library/react"; +import { render, waitFor } from "@testing-library/react"; import { BrowserRouter } from "react-router-dom"; import { type MatrixClient } from "matrix-js-sdk"; import { type FC } from "react"; @@ -92,3 +92,24 @@ test("follows the client when the host swaps it", () => { expect(container.textContent).toBe("@bob:example.org"); }); + +test("finds a client of its own when the host supplies none", async () => { + const client = mockClient(); + vi.doMock("./utils/spa", () => ({ + initSPA: vi.fn().mockResolvedValue({ client, passwordlessUser: true }), + })); + + const { container } = render( + + + + + , + ); + + // Nothing to show until a session has been restored or created + expect(container.textContent).toBe("loading"); + await waitFor(() => expect(container.textContent).toBe("@alice:example.org")); + + vi.doUnmock("./utils/spa"); +}); diff --git a/src/HostBridge.test.ts b/src/HostBridge.test.ts index e93fcfb1e..4ab42a135 100644 --- a/src/HostBridge.test.ts +++ b/src/HostBridge.test.ts @@ -6,10 +6,16 @@ Please see LICENSE in the repository root for full details. */ import { describe, expect, test, vi } from "vitest"; -import { type WidgetApi } from "matrix-widget-api"; +import { type WidgetApi, WidgetApiToWidgetAction } from "matrix-widget-api"; import EventEmitter from "events"; -import { createWidgetHostBridge, nullHostBridge } from "./HostBridge"; +import { type Observable } from "rxjs"; + +import { + createWidgetHostBridge, + type HostBridge, + nullHostBridge, +} from "./HostBridge"; import { ElementWidgetActions, type WidgetHelpers } from "./widget"; function mockWidget(api: Partial): WidgetHelpers { @@ -20,7 +26,158 @@ function mockWidget(api: Partial): WidgetHelpers { } as unknown as WidgetHelpers; } +/** A widget whose transport records what Element Call sends it. */ +function mockTransport(): { + send: ReturnType; + reply: ReturnType; + stop: ReturnType; +} { + return { + send: vi.fn().mockResolvedValue(undefined), + reply: vi.fn(), + stop: vi.fn(), + }; +} + describe("createWidgetHostBridge", () => { + describe("telling the host what Element Call is doing", () => { + test("asks to be kept on screen, and to stop being", async () => { + const setAlwaysOnScreen = vi.fn().mockResolvedValue(true); + const bridge = createWidgetHostBridge(mockWidget({ setAlwaysOnScreen })); + + await bridge.setAlwaysOnScreen(true); + await bridge.setAlwaysOnScreen(false); + + expect(setAlwaysOnScreen).toHaveBeenNthCalledWith(1, true); + expect(setAlwaysOnScreen).toHaveBeenNthCalledWith(2, false); + }); + + test("reports that it has loaded", async () => { + const sendContentLoaded = vi.fn().mockResolvedValue(undefined); + const bridge = createWidgetHostBridge(mockWidget({ sendContentLoaded })); + + await bridge.contentLoaded(); + + expect(sendContentLoaded).toHaveBeenCalledOnce(); + }); + + test.each([ + ["notifyJoined", ElementWidgetActions.JoinCall, {}], + ["notifyHungUp", ElementWidgetActions.HangupCall, {}], + ] as const)("sends %s as %s", async (method, action, payload) => { + const transport = mockTransport(); + const bridge = createWidgetHostBridge(mockWidget({ transport } as never)); + + await bridge[method](); + + expect(transport.send).toHaveBeenCalledWith(action, payload); + }); + + test("sends the mute state the host needs to mirror", async () => { + const transport = mockTransport(); + const bridge = createWidgetHostBridge(mockWidget({ transport } as never)); + + await bridge.notifyDeviceMute({ + audio_enabled: true, + video_enabled: false, + }); + + expect(transport.send).toHaveBeenCalledWith( + ElementWidgetActions.DeviceMute, + { audio_enabled: true, video_enabled: false }, + ); + }); + }); + + describe("relaying what the host asks for", () => { + /** Emits a widget action the way widget.ts does, and returns the event. */ + function askHost( + widget: WidgetHelpers, + action: string, + data: unknown, + ): CustomEvent { + const ev = new CustomEvent(action, { detail: { action, data } }); + widget.lazyActions.emit(action, ev); + return ev; + } + + // Selectors rather than keys, so each stream keeps its own request type + const inboundStreams: [ + name: string, + select: (bridge: HostBridge) => Observable<{ data: unknown }>, + action: string, + ][] = [ + [ + "themeChange$", + (bridge) => bridge.themeChange$, + WidgetApiToWidgetAction.ThemeChange, + ], + ["join$", (bridge) => bridge.join$, ElementWidgetActions.JoinCall], + ["hangUp$", (bridge) => bridge.hangUp$, ElementWidgetActions.HangupCall], + [ + "deviceMute$", + (bridge) => bridge.deviceMute$, + ElementWidgetActions.DeviceMute, + ], + ]; + + test.each(inboundStreams)( + "surfaces %s with the host's data", + (_name, select, action) => { + const widget = mockWidget({ transport: mockTransport() } as never); + const bridge = createWidgetHostBridge(widget); + const seen: unknown[] = []; + select(bridge).subscribe((request) => seen.push(request.data)); + + askHost(widget, action, { some: "payload" }); + + expect(seen).toEqual([{ some: "payload" }]); + }, + ); + + test("replies to the host against the request it made", () => { + const transport = mockTransport(); + const widget = mockWidget({ transport } as never); + const bridge = createWidgetHostBridge(widget); + bridge.deviceMute$.subscribe((request) => + request.reply({ audio_enabled: false, video_enabled: true }), + ); + + const ev = askHost(widget, ElementWidgetActions.DeviceMute, { + audio_enabled: false, + }); + + expect(transport.reply).toHaveBeenCalledWith(ev.detail, { + audio_enabled: false, + video_enabled: true, + }); + }); + + test("still replies when there is nothing to say", () => { + const transport = mockTransport(); + const widget = mockWidget({ transport } as never); + const bridge = createWidgetHostBridge(widget); + bridge.hangUp$.subscribe((request) => request.reply()); + + const ev = askHost(widget, ElementWidgetActions.HangupCall, {}); + + // The widget API requires an answer, so an empty reply becomes {} + expect(transport.reply).toHaveBeenCalledWith(ev.detail, {}); + }); + + test("stops listening once unsubscribed", () => { + const widget = mockWidget({ transport: mockTransport() } as never); + const bridge = createWidgetHostBridge(widget); + const seen: unknown[] = []; + const subscription = bridge.hangUp$.subscribe((r) => seen.push(r.data)); + + subscription.unsubscribe(); + askHost(widget, ElementWidgetActions.HangupCall, {}); + + expect(seen).toEqual([]); + }); + }); + describe("downloadMedia", () => { const mxcUri = "mxc://example.org/alice-avatar"; diff --git a/src/analytics/PosthogAnalytics.test.ts b/src/analytics/PosthogAnalytics.test.ts index ba51ba276..cd821c175 100644 --- a/src/analytics/PosthogAnalytics.test.ts +++ b/src/analytics/PosthogAnalytics.test.ts @@ -15,6 +15,7 @@ import { afterAll, } from "vitest"; import posthog, { type CaptureResult } from "posthog-js"; +import { type MatrixClient } from "matrix-js-sdk"; import { Anonymity, @@ -23,6 +24,7 @@ import { } from "./PosthogAnalytics"; import { mockConfig } from "../utils/test"; import { analyticsConfigFromEnvironment } from "../initializer"; +import { optInAnalytics } from "../settings/settings"; describe("PosthogAnalytics", () => { describe("enablement", () => { @@ -281,3 +283,86 @@ describe("PosthogAnalytics", () => { }); }); }); + +describe("identifying the user", () => { + const credentials = { + apiKey: "api_key", + apiHost: "https://api.example.com.localhost", + }; + + function mockClient(accountDataId: string | null): MatrixClient { + return { + isGuest: () => false, + getCrypto: () => undefined, + getAccountDataFromServer: vi + .fn() + .mockResolvedValue( + accountDataId === null ? null : { id: accountDataId }, + ), + setAccountData: vi.fn().mockResolvedValue({}), + } as Partial as MatrixClient; + } + + beforeEach(() => { + PosthogAnalytics.resetInstance(); + optInAnalytics.setValue(true); + }); + + it("reports under the ID its host assigned, and stores nothing", async () => { + const client = mockClient(null); + window.matrixclient = client; + PosthogAnalytics.configure({ + ...credentials, + matrixBackend: "embedded", + hostAnalyticsId: "assigned-by-host", + }); + const identify = vi.spyOn(posthog, "identify"); + + PosthogAnalytics.instance.startListeningToSettingsChanges(); + await vi.waitFor(() => + expect(identify).toHaveBeenCalledWith("assigned-by-host"), + ); + + // The host owns the user's account, so Element Call must not write to it + expect(client.setAccountData).not.toHaveBeenCalled(); + }); + + it("keeps its own ID in account data when it owns the session", async () => { + const client = mockClient(null); + window.matrixclient = client; + PosthogAnalytics.configure({ ...credentials, matrixBackend: "jssdk" }); + + PosthogAnalytics.instance.startListeningToSettingsChanges(); + + // No ID on the server yet, so one is minted and stored for other devices + await vi.waitFor(() => expect(client.setAccountData).toHaveBeenCalled()); + }); + + it("reuses the ID already in account data", async () => { + const client = mockClient("stored-earlier"); + window.matrixclient = client; + PosthogAnalytics.configure({ ...credentials, matrixBackend: "jssdk" }); + const identify = vi.spyOn(posthog, "identify"); + + PosthogAnalytics.instance.startListeningToSettingsChanges(); + await vi.waitFor(() => + expect(identify).toHaveBeenCalledWith("stored-earlier"), + ); + + expect(client.setAccountData).not.toHaveBeenCalled(); + }); + + it("records how it reaches Matrix as a super property", async () => { + window.matrixclient = mockClient("stored-earlier"); + PosthogAnalytics.configure({ ...credentials, matrixBackend: "embedded" }); + const register = vi.spyOn(posthog, "register"); + + PosthogAnalytics.instance.startListeningToSettingsChanges(); + + await vi.waitFor(() => + expect(register).toHaveBeenCalledWith( + expect.objectContaining({ matrixBackend: "embedded" }), + ), + ); + }); +}); diff --git a/src/e2ee/sharedKeyManagement.test.ts b/src/e2ee/sharedKeyManagement.test.ts new file mode 100644 index 000000000..9d0375309 --- /dev/null +++ b/src/e2ee/sharedKeyManagement.test.ts @@ -0,0 +1,36 @@ +/* +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 { afterEach, describe, expect, test } from "vitest"; + +import { getKeyForRoom, saveKeyForRoom } from "./sharedKeyManagement"; + +const roomId = "!room:example.org"; + +describe("getKeyForRoom", () => { + afterEach(() => { + window.location.hash = "#"; + localStorage.clear(); + }); + + test("prefers a key given in the parameters over the stored one", () => { + saveKeyForRoom(roomId, "stored"); + window.location.hash = `#?roomId=${encodeURIComponent(roomId)}&password=from-the-link`; + + expect(getKeyForRoom(roomId)).toBe("from-the-link"); + }); + + test("falls back to the stored key", () => { + saveKeyForRoom(roomId, "stored"); + + expect(getKeyForRoom(roomId)).toBe("stored"); + }); + + test("has no key to offer for a room it has never seen", () => { + expect(getKeyForRoom(roomId)).toBeNull(); + }); +}); diff --git a/src/state/MediaDevices.test.ts b/src/state/MediaDevices.test.ts new file mode 100644 index 000000000..f65b024e2 --- /dev/null +++ b/src/state/MediaDevices.test.ts @@ -0,0 +1,59 @@ +/* +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, test, vi } from "vitest"; +import { of } from "rxjs"; + +const getPlatform = vi.hoisted(() => vi.fn(() => "desktop")); +vi.mock("../Platform", () => ({ + get platform(): string { + return getPlatform(); + }, + isFirefox: (): boolean => false, +})); +vi.mock("@livekit/components-core", () => ({ + createMediaDeviceObserver: () => of([]), +})); + +import { AudioOutput, MediaDevices } from "./MediaDevices"; +import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput"; +import { IOSControlledAudioOutput } from "./IOSControlledAudioOutput"; +import { ObservableScope } from "./ObservableScope"; + +// Which audio output implementation is used is decided by what the app hosting +// Element Call told it, rather than being discovered from the environment. +describe("MediaDevices audio output", () => { + test("uses the browser's own output when nobody else is controlling it", () => { + const devices = new MediaDevices(new ObservableScope(), { + controlledAudioDevices: false, + }); + + expect(devices.audioOutput).toBeInstanceOf(AudioOutput); + }); + + test("hands control to the host on Android", () => { + getPlatform.mockReturnValue("android"); + + const devices = new MediaDevices(new ObservableScope(), { + controlledAudioDevices: true, + callIntent: "audio", + }); + + expect(devices.audioOutput).toBeInstanceOf(AndroidControlledAudioOutput); + }); + + test("hands control to the host elsewhere too", () => { + getPlatform.mockReturnValue("ios"); + + const devices = new MediaDevices(new ObservableScope(), { + controlledAudioDevices: true, + callIntent: "video", + }); + + expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput); + }); +}); diff --git a/src/utils/errors.test.ts b/src/utils/errors.test.ts new file mode 100644 index 000000000..306aa3cf8 --- /dev/null +++ b/src/utils/errors.test.ts @@ -0,0 +1,62 @@ +/* +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, test } from "vitest"; + +import { + ErrorCategory, + ErrorCode, + FailToStartLivekitConnection, + MembershipManagerError, + NoMatrix2AuthorizationService, + SFURoomCreationRestrictedError, +} from "./errors"; + +// These errors take their wording from Element Call's own i18next instance +// rather than the global one, so each needs to come out translated rather than +// as a raw key. +describe("localised errors", () => { + test("MembershipManagerError describes the failure and keeps its cause", () => { + const cause = new Error("the underlying problem"); + const error = new MembershipManagerError(cause); + + expect(error.code).toBe(ErrorCode.INTERNAL_MEMBERSHIP_MANAGER); + expect(error.category).toBe(ErrorCategory.SYSTEM_FAILURE); + expect(error.localisedTitle).not.toContain("error."); + expect(error.localisedMessage).not.toContain("error."); + expect(error.cause).toBe(cause); + }); + + test("NoMatrix2AuthorizationService is a configuration problem", () => { + const cause = new Error("404"); + const error = new NoMatrix2AuthorizationService(cause); + + expect(error.code).toBe(ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE); + expect(error.category).toBe(ErrorCategory.CONFIGURATION_ISSUE); + expect(error.localisedTitle).not.toContain("error."); + expect(error.localisedMessage).not.toContain("error."); + expect(error.cause).toBe(cause); + }); + + test("FailToStartLivekitConnection passes its detail through", () => { + const error = new FailToStartLivekitConnection("could not publish"); + + expect(error.code).toBe(ErrorCode.FAILED_TO_START_LIVEKIT); + expect(error.category).toBe(ErrorCategory.NETWORK_CONNECTIVITY); + expect(error.localisedTitle).not.toContain("error."); + expect(error.localisedMessage).toBe("could not publish"); + }); + + test("SFURoomCreationRestrictedError explains the restriction", () => { + const error = new SFURoomCreationRestrictedError(); + + expect(error.code).toBe(ErrorCode.SFU_ERROR); + expect(error.category).toBe(ErrorCategory.CONFIGURATION_ISSUE); + expect(error.localisedTitle).not.toContain("error."); + expect(error.localisedMessage).not.toContain("error."); + }); +});