Files
element-call-Github/src/HostBridge.test.ts
T
2026-09-03 11:47:35 +02:00

289 lines
9.2 KiB
TypeScript

/*
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 { type WidgetApi, WidgetApiToWidgetAction } from "matrix-widget-api";
import EventEmitter from "events";
import { type Observable } from "rxjs";
import {
createWidgetHostBridge,
type HostBridge,
nullHostBridge,
} from "./HostBridge";
import { ElementWidgetActions, type WidgetHelpers } from "./widget";
function mockWidget(api: Partial<WidgetApi>): WidgetHelpers {
return {
api: api as WidgetApi,
lazyActions: new EventEmitter(),
client: Promise.resolve(),
} 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";
test("passes a Blob through unchanged", async () => {
const file = new Blob([]);
const bridge = createWidgetHostBridge(
mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file }) }),
);
await expect(bridge.downloadMedia!(mxcUri)).resolves.toBe(file);
});
test("decodes a base64 string into a Blob", async () => {
const bridge = createWidgetHostBridge(
mockWidget({
// "hello" in base64
downloadFile: vi.fn().mockResolvedValue({ file: "aGVsbG8=" }),
}),
);
const blob = await bridge.downloadMedia!(mxcUri);
expect(blob).toBeInstanceOf(Blob);
// The five decoded bytes, rather than the eight characters of base64 —
// which is what we'd get if the string were stored verbatim.
expect(blob.size).toBe(5);
});
test("rejects a file format it does not understand", async () => {
const bridge = createWidgetHostBridge(
mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file: 42 }) }),
);
await expect(bridge.downloadMedia!(mxcUri)).rejects.toThrow(
"Downloaded file format is not supported",
);
});
});
describe("close", () => {
test("asks the host to close, then stops the transport", async () => {
const transport = {
send: vi.fn().mockResolvedValue(undefined),
stop: vi.fn(),
};
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
await bridge.close!();
expect(transport.send).toHaveBeenCalledWith(
ElementWidgetActions.Close,
{},
);
expect(transport.stop).toHaveBeenCalledOnce();
});
test("stops the transport even when the host refuses to close", async () => {
const transport = {
send: vi.fn().mockRejectedValue(new Error("no")),
stop: vi.fn(),
};
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
// Leaving the messaging live would leave the close affordance dead
await expect(bridge.close!()).rejects.toThrow("no");
expect(transport.stop).toHaveBeenCalledOnce();
});
});
describe("supportsReactions", () => {
const capabilities = [
"org.matrix.msc2762.send.event:m.reaction",
"org.matrix.msc2762.send.event:m.room.redaction",
"org.matrix.msc2762.receive.event:m.reaction",
"org.matrix.msc2762.receive.event:m.room.redaction",
];
test("is true when the host grants every reaction capability", () => {
const bridge = createWidgetHostBridge(
mockWidget({ hasCapability: () => true }),
);
expect(bridge.supportsReactions).toBe(true);
});
test.each(capabilities)("is false without %s", (missing) => {
const bridge = createWidgetHostBridge(
mockWidget({ hasCapability: (c) => c !== missing }),
);
expect(bridge.supportsReactions).toBe(false);
});
});
});
describe("nullHostBridge", () => {
test("offers no way to close, so the interface falls back to navigation", () => {
expect(nullHostBridge.close).toBeUndefined();
});
test("offers no media download, so Element Call uses its own client", () => {
expect(nullHostBridge.downloadMedia).toBeUndefined();
});
test("supports reactions, since nothing is mediating its homeserver access", () => {
expect(nullHostBridge.supportsReactions).toBe(true);
});
});