add more test

This commit is contained in:
Valere
2026-09-03 11:47:35 +02:00
parent 873dfdbfbd
commit ebb7fe07b7
6 changed files with 423 additions and 3 deletions
+22 -1
View File
@@ -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(
<BrowserRouter>
<ClientProvider>
<ShowClientState />
</ClientProvider>
</BrowserRouter>,
);
// 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");
});
+159 -2
View File
@@ -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<WidgetApi>): WidgetHelpers {
@@ -20,7 +26,158 @@ function mockWidget(api: Partial<WidgetApi>): WidgetHelpers {
} as unknown as WidgetHelpers;
}
/** A widget whose transport records what Element Call sends it. */
function mockTransport(): {
send: ReturnType<typeof vi.fn>;
reply: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
} {
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";
+85
View File
@@ -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<MatrixClient> 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" }),
),
);
});
});
+36
View File
@@ -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();
});
});
+59
View File
@@ -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);
});
});
+62
View File
@@ -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.");
});
});