mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
add more test
This commit is contained in:
@@ -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
@@ -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";
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user