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.");
+ });
+});