diff --git a/.eslintrc.js b/.eslintrc.js index 96ca83a..565a7de 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -8,6 +8,7 @@ module.exports = { browser: true, }, rules: { + "no-duplicate-imports": ["error"], "no-var": ["warn"], "prefer-rest-params": ["warn"], "prefer-spread": ["warn"], @@ -24,6 +25,8 @@ module.exports = { asyncArrow: "always", }, ], + "@typescript-eslint/await-thenable": ["error"], + "@typescript-eslint/prefer-readonly": ["error"], "arrow-parens": "off", "prefer-promise-reject-errors": "off", "quotes": "off", diff --git a/examples/widget/utils.js b/examples/widget/utils.js index 705a6f0..46eb78b 100644 --- a/examples/widget/utils.js +++ b/examples/widget/utils.js @@ -15,7 +15,7 @@ */ function parseFragment() { - const fragmentString = window.location.hash || "?"; + const fragmentString = globalThis.location.hash || "?"; return new URLSearchParams(fragmentString.substring(Math.max(fragmentString.indexOf("?"), 0))); } diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 6404904..87f4cff 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -62,7 +62,6 @@ import { } from "./interfaces/SendToDeviceAction"; import { EventDirection, EventKind, WidgetEventCapability } from "./models/WidgetEventCapability"; import { IRoomEvent } from "./interfaces/IRoomEvent"; -import { IRoomAccountData } from "./interfaces/IRoomAccountData"; import { IGetOpenIDActionRequest, IGetOpenIDActionResponseData, @@ -141,15 +140,15 @@ export class ClientWidgetApi extends EventEmitter { private cachedWidgetVersions: ApiVersion[] | null = null; // contentLoadedActionSent is used to check that only one ContentLoaded request is send. private contentLoadedActionSent = false; - private allowedCapabilities = new Set(); - private allowedEvents: WidgetEventCapability[] = []; + private readonly allowedCapabilities = new Set(); + private readonly allowedEvents: WidgetEventCapability[] = []; private isStopped = false; private turnServers: AsyncGenerator | null = null; private contentLoadedWaitTimer?: ReturnType; // Stores pending requests to push a room's state to the widget - private pushRoomStateTasks = new Set>(); + private readonly pushRoomStateTasks = new Set>(); // Room ID → event type → state key → events to be pushed - private pushRoomStateResult = new Map>>(); + private readonly pushRoomStateResult = new Map>>(); private flushRoomStateTask: Promise | null = null; /** @@ -162,8 +161,8 @@ export class ClientWidgetApi extends EventEmitter { */ public constructor( public readonly widget: Widget, - private iframe: HTMLIFrameElement, - private driver: WidgetDriver, + iframe: HTMLIFrameElement, + private readonly driver: WidgetDriver, ) { super(); if (!iframe?.contentWindow) { @@ -175,7 +174,12 @@ export class ClientWidgetApi extends EventEmitter { if (!driver) { throw new Error("Invalid driver"); } - this.transport = new PostmessageTransport(WidgetApiDirection.ToWidget, widget.id, iframe.contentWindow, window); + this.transport = new PostmessageTransport( + WidgetApiDirection.ToWidget, + widget.id, + iframe.contentWindow, + globalThis, + ); this.transport.targetOrigin = widget.origin; this.transport.on("message", this.handleMessage.bind(this)); @@ -230,7 +234,7 @@ export class ClientWidgetApi extends EventEmitter { public async getWidgetVersions(): Promise { if (Array.isArray(this.cachedWidgetVersions)) { - return Promise.resolve(this.cachedWidgetVersions); + return this.cachedWidgetVersions; } try { @@ -381,7 +385,7 @@ export class ClientWidgetApi extends EventEmitter { }); } - if (!request.data?.uri || !request.data?.uri.toString().startsWith("https://matrix.to/#")) { + if (!request.data?.uri.startsWith("https://matrix.to/#")) { return this.transport.reply(request, { error: { message: "Invalid matrix.to URI" }, }); @@ -467,9 +471,9 @@ export class ClientWidgetApi extends EventEmitter { this.driver.askOpenID(observer); } + private handleReadRoomAccountData(request: IReadRoomAccountDataFromWidgetActionRequest): void | Promise { - let events: Promise = Promise.resolve([]); - events = this.driver.readRoomAccountData(request.data.type); + const events = this.driver.readRoomAccountData(request.data.type); if (!this.canReceiveRoomAccountData(request.data.type)) { return this.transport.reply(request, { @@ -609,17 +613,17 @@ export class ClientWidgetApi extends EventEmitter { }); } - if (!isDelayedEvent) { - sendEventPromise = this.driver.sendEvent( + if (isDelayedEvent) { + sendEventPromise = this.driver.sendDelayedEvent( + request.data.delay ?? null, + request.data.parent_delay_id ?? null, request.data.type, request.data.content || {}, request.data.state_key, request.data.room_id, ); } else { - sendEventPromise = this.driver.sendDelayedEvent( - request.data.delay ?? null, - request.data.parent_delay_id ?? null, + sendEventPromise = this.driver.sendEvent( request.data.type, request.data.content || {}, request.data.state_key, @@ -635,17 +639,17 @@ export class ClientWidgetApi extends EventEmitter { }); } - if (!isDelayedEvent) { - sendEventPromise = this.driver.sendEvent( + if (isDelayedEvent) { + sendEventPromise = this.driver.sendDelayedEvent( + request.data.delay ?? null, + request.data.parent_delay_id ?? null, request.data.type, content, null, // not sending a state event request.data.room_id, ); } else { - sendEventPromise = this.driver.sendDelayedEvent( - request.data.delay ?? null, - request.data.parent_delay_id ?? null, + sendEventPromise = this.driver.sendEvent( request.data.type, content, null, // not sending a state event @@ -715,25 +719,25 @@ export class ClientWidgetApi extends EventEmitter { private async handleSendToDevice(request: ISendToDeviceFromWidgetActionRequest): Promise { if (!request.data.type) { - await this.transport.reply(request, { + this.transport.reply(request, { error: { message: "Invalid request - missing event type" }, }); } else if (!request.data.messages) { - await this.transport.reply(request, { + this.transport.reply(request, { error: { message: "Invalid request - missing event contents" }, }); } else if (typeof request.data.encrypted !== "boolean") { - await this.transport.reply(request, { + this.transport.reply(request, { error: { message: "Invalid request - missing encryption flag" }, }); } else if (!this.canSendToDeviceEvent(request.data.type)) { - await this.transport.reply(request, { + this.transport.reply(request, { error: { message: "Cannot send to-device events of this type" }, }); } else { try { await this.driver.sendToDevice(request.data.type, request.data.encrypted, request.data.messages); - await this.transport.reply(request, {}); + this.transport.reply(request, {}); } catch (e) { console.error("error sending to-device event", e); this.handleDriverError(e, request, "Error sending event"); @@ -762,12 +766,12 @@ export class ClientWidgetApi extends EventEmitter { private async handleWatchTurnServers(request: IWatchTurnServersRequest): Promise { if (!this.hasCapability(MatrixCapabilities.MSC3846TurnServers)) { - await this.transport.reply(request, { + this.transport.reply(request, { error: { message: "Missing capability" }, }); } else if (this.turnServers) { // We're already polling, so this is a no-op - await this.transport.reply(request, {}); + this.transport.reply(request, {}); } else { try { const turnServers = this.driver.getTurnServers(); @@ -776,14 +780,14 @@ export class ClientWidgetApi extends EventEmitter { // client isn't banned from getting TURN servers entirely const { done, value } = await turnServers.next(); if (done) throw new Error("Client refuses to provide any TURN servers"); - await this.transport.reply(request, {}); + this.transport.reply(request, {}); // Start the poll loop, sending the widget the initial result this.pollTurnServers(turnServers, value); this.turnServers = turnServers; } catch (e) { console.error("error getting first TURN server results", e); - await this.transport.reply(request, { + this.transport.reply(request, { error: { message: "TURN servers not available" }, }); } @@ -792,17 +796,17 @@ export class ClientWidgetApi extends EventEmitter { private async handleUnwatchTurnServers(request: IUnwatchTurnServersRequest): Promise { if (!this.hasCapability(MatrixCapabilities.MSC3846TurnServers)) { - await this.transport.reply(request, { + this.transport.reply(request, { error: { message: "Missing capability" }, }); } else if (!this.turnServers) { // We weren't polling anyways, so this is a no-op - await this.transport.reply(request, {}); + this.transport.reply(request, {}); } else { // Stop the generator, allowing it to clean up await this.turnServers.return(undefined); this.turnServers = null; - await this.transport.reply(request, {}); + this.transport.reply(request, {}); } } @@ -1147,7 +1151,7 @@ export class ClientWidgetApi extends EventEmitter { private async flushRoomState(): Promise { try { // Only send a single action once all concurrent tasks have completed - do await Promise.all([...this.pushRoomStateTasks]); + do await Promise.all(this.pushRoomStateTasks); while (this.pushRoomStateTasks.size > 0); const events: IRoomEvent[] = []; @@ -1257,7 +1261,7 @@ export class ClientWidgetApi extends EventEmitter { eventTypeMap.set(rawEvent.type, stateKeyMap); } if (!stateKeyMap.has(rawEvent.type)) stateKeyMap.set(rawEvent.state_key, rawEvent); - do await Promise.all([...this.pushRoomStateTasks]); + do await Promise.all(this.pushRoomStateTasks); while (this.pushRoomStateTasks.size > 0); await this.flushRoomStateTask; } diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index b35f826..73ee411 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -131,7 +131,7 @@ export class WidgetApi extends EventEmitter { private capabilitiesFinished = false; private supportsMSC2974Renegotiate = false; - private requestedCapabilities: Capability[] = []; + private readonly requestedCapabilities: Capability[] = []; private approvedCapabilities?: Capability[]; private cachedClientVersions?: ApiVersion[]; private turnServerWatchers = 0; @@ -142,15 +142,17 @@ export class WidgetApi extends EventEmitter { * the API will use the widget ID from the first valid request it receives. * @param {string} clientOrigin The origin of the client, or null if not known. */ - public constructor( - widgetId: string | null = null, - private clientOrigin: string | null = null, - ) { + public constructor(widgetId: string | null = null, clientOrigin: string | null = null) { super(); - if (!window.parent) { + if (!globalThis.parent) { throw new Error("No parent window. This widget doesn't appear to be embedded properly."); } - this.transport = new PostmessageTransport(WidgetApiDirection.FromWidget, widgetId, window.parent, window); + this.transport = new PostmessageTransport( + WidgetApiDirection.FromWidget, + widgetId, + globalThis.parent, + globalThis, + ); this.transport.targetOrigin = clientOrigin; this.transport.on("message", this.handleMessage.bind(this)); } @@ -191,7 +193,9 @@ export class WidgetApi extends EventEmitter { * @throws Throws if the capabilities negotiation has already started. */ public requestCapabilities(capabilities: Capability[]): void { - capabilities.forEach((cap) => this.requestCapability(cap)); + for (const cap of capabilities) { + this.requestCapability(cap); + } } /** @@ -474,7 +478,7 @@ export class WidgetApi extends EventEmitter { } /** - * @deprecated This currently relies on an unstable MSC (MSC4157). + * @experimental This currently relies on an unstable MSC (MSC4157). */ public cancelScheduledDelayedEvent(delayId: string): Promise { return this.transport.send( @@ -487,7 +491,7 @@ export class WidgetApi extends EventEmitter { } /** - * @deprecated This currently relies on an unstable MSC (MSC4157). + * @experimental This currently relies on an unstable MSC (MSC4157). */ public restartScheduledDelayedEvent(delayId: string): Promise { return this.transport.send( @@ -500,7 +504,7 @@ export class WidgetApi extends EventEmitter { } /** - * @deprecated This currently relies on an unstable MSC (MSC4157). + * @experimental This currently relies on an unstable MSC (MSC4157). */ public sendScheduledDelayedEvent(delayId: string): Promise { return this.transport.send( @@ -681,7 +685,7 @@ export class WidgetApi extends EventEmitter { * @param {string} uri The URI to navigate to. * @returns {Promise} Resolves when complete. * @throws Throws if the URI is invalid or cannot be processed. - * @deprecated This currently relies on an unstable MSC (MSC2931). + * @experimental This currently relies on an unstable MSC (MSC2931). */ public navigateTo(uri: string): Promise { if (!uri || !uri.startsWith("https://matrix.to/#")) { @@ -704,7 +708,7 @@ export class WidgetApi extends EventEmitter { const onUpdateTurnServers = async (ev: CustomEvent): Promise => { ev.preventDefault(); setTurnServer(ev.detail.data); - await this.transport.reply(ev.detail, {}); + this.transport.reply(ev.detail, {}); }; // Start listening for updates before we even start watching, to catch diff --git a/src/interfaces/Capabilities.ts b/src/interfaces/Capabilities.ts index f541ac5..9b1ec15 100644 --- a/src/interfaces/Capabilities.ts +++ b/src/interfaces/Capabilities.ts @@ -26,28 +26,31 @@ export enum MatrixCapabilities { */ RequiresClient = "io.element.requires_client", /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + * @experimental It is not recommended to rely on this existing - it can be removed without notice. */ MSC2931Navigate = "org.matrix.msc2931.navigate", + /** + * @experimental It is not recommended to rely on this existing - it can be removed without notice. + */ MSC3846TurnServers = "town.robin.msc3846.turn_servers", /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + * @experimental It is not recommended to rely on this existing - it can be removed without notice. */ MSC3973UserDirectorySearch = "org.matrix.msc3973.user_directory_search", /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + * @experimental It is not recommended to rely on this existing - it can be removed without notice. */ MSC4039UploadFile = "org.matrix.msc4039.upload_file", /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + * @experimental It is not recommended to rely on this existing - it can be removed without notice. */ MSC4039DownloadFile = "org.matrix.msc4039.download_file", /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + * @experimental It is not recommended to rely on this existing - it can be removed without notice. */ MSC4157SendDelayedEvent = "org.matrix.msc4157.send.delayed_event", /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + * @experimental It is not recommended to rely on this existing - it can be removed without notice. */ MSC4157UpdateDelayedEvent = "org.matrix.msc4157.update_delayed_event", } diff --git a/src/interfaces/WidgetApiAction.ts b/src/interfaces/WidgetApiAction.ts index 2f0bcf5..b6ac75d 100644 --- a/src/interfaces/WidgetApiAction.ts +++ b/src/interfaces/WidgetApiAction.ts @@ -49,47 +49,47 @@ export enum WidgetApiFromWidgetAction { BeeperReadRoomAccountData = "com.beeper.read_room_account_data", /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + * @experimental It is not recommended to rely on this existing - it can be removed without notice. */ MSC2876ReadEvents = "org.matrix.msc2876.read_events", /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + * @experimental It is not recommended to rely on this existing - it can be removed without notice. */ MSC2931Navigate = "org.matrix.msc2931.navigate", /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + * @experimental It is not recommended to rely on this existing - it can be removed without notice. */ MSC2974RenegotiateCapabilities = "org.matrix.msc2974.request_capabilities", /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + * @experimental It is not recommended to rely on this existing - it can be removed without notice. */ MSC3869ReadRelations = "org.matrix.msc3869.read_relations", /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + * @experimental It is not recommended to rely on this existing - it can be removed without notice. */ MSC3973UserDirectorySearch = "org.matrix.msc3973.user_directory_search", /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + * @experimental It is not recommended to rely on this existing - it can be removed without notice. */ MSC4039GetMediaConfigAction = "org.matrix.msc4039.get_media_config", /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + * @experimental It is not recommended to rely on this existing - it can be removed without notice. */ MSC4039UploadFileAction = "org.matrix.msc4039.upload_file", /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + * @experimental It is not recommended to rely on this existing - it can be removed without notice. */ MSC4039DownloadFileAction = "org.matrix.msc4039.download_file", /** - * @deprecated It is not recommended to rely on this existing - it can be removed without notice. + * @experimental It is not recommended to rely on this existing - it can be removed without notice. */ MSC4157UpdateDelayedEvent = "org.matrix.msc4157.update_delayed_event", } diff --git a/src/models/Widget.ts b/src/models/Widget.ts index 0b66452..0207b87 100644 --- a/src/models/Widget.ts +++ b/src/models/Widget.ts @@ -14,15 +14,14 @@ * limitations under the License. */ -import { IWidget, IWidgetData, WidgetType } from ".."; +import { ITemplateParams, IWidget, IWidgetData, runTemplate, WidgetType } from ".."; import { assertPresent } from "./validation/utils"; -import { ITemplateParams, runTemplate } from ".."; /** * Represents the barest form of widget. */ export class Widget { - public constructor(private definition: IWidget) { + public constructor(private readonly definition: IWidget) { if (!this.definition) throw new Error("Definition is required"); assertPresent(definition, "id"); diff --git a/src/transport/PostmessageTransport.ts b/src/transport/PostmessageTransport.ts index 4589735..b1c7125 100644 --- a/src/transport/PostmessageTransport.ts +++ b/src/transport/PostmessageTransport.ts @@ -45,9 +45,9 @@ export class PostmessageTransport extends EventEmitter implements ITransport { public timeoutSeconds = 10; private _ready = false; - private _widgetId: string | null = null; - private outboundRequests = new Map(); - private stopController = new AbortController(); + private _widgetId: string | null; + private readonly outboundRequests = new Map(); + private readonly stopController = new AbortController(); public get ready(): boolean { return this._ready; @@ -58,10 +58,10 @@ export class PostmessageTransport extends EventEmitter implements ITransport { } public constructor( - private sendDirection: WidgetApiDirection, - private initialWidgetId: string | null, - private transportWindow: Window, - private inboundWindow: Window, + private readonly sendDirection: WidgetApiDirection, + initialWidgetId: string | null, + private readonly transportWindow: Window | typeof globalThis, + private readonly inboundWindow: Window | typeof globalThis, ) { super(); this._widgetId = initialWidgetId; @@ -159,21 +159,19 @@ export class PostmessageTransport extends EventEmitter implements ITransport { if (this.stopController.signal.aborted) return; if (!ev.data) return; // invalid event - if (this.strictOriginCheck && ev.origin !== window.origin) return; // bad origin + if (this.strictOriginCheck && ev.origin !== globalThis.origin) return; // bad origin // treat the message as a response first, then downgrade to a request const response = ev.data; if (!response.action || !response.requestId || !response.widgetId) return; // invalid request/response - if (!response.response) { - // it's a request + if (response.response) { + if (response.api !== this.sendDirection) return; // wrong direction + this.handleResponse(response); + } else { const request = response; if (request.api !== invertedDirection(this.sendDirection)) return; // wrong direction this.handleRequest(request); - } else { - // it's a response - if (response.api !== this.sendDirection) return; // wrong direction - this.handleResponse(response); } } diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index 1a49390..22c879a 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -214,12 +214,12 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Missing capability" }, }); }); - expect(driver.navigate).not.toBeCalled(); + expect(driver.navigate).not.toHaveBeenCalled(); }); it("fails to navigate to an unsupported URI", async () => { @@ -238,12 +238,12 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Invalid matrix.to URI" }, }); }); - expect(driver.navigate).not.toBeCalled(); + expect(driver.navigate).not.toHaveBeenCalled(); }); it("should reject requests when the driver throws an exception", async () => { @@ -264,7 +264,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Error handling navigation" }, }); }); @@ -294,7 +294,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Error handling navigation", matrix_api_error: { @@ -416,7 +416,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Error sending event" }, }); }); @@ -453,7 +453,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Error sending event", matrix_api_error: { @@ -498,112 +498,124 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: expect.any(String) }, }); }); - expect(driver.sendDelayedEvent).not.toBeCalled(); + expect(driver.sendDelayedEvent).not.toHaveBeenCalled(); }); - it("sends delayed message events", async () => { - const roomId = "!room:example.org"; - const parentDelayId = "fp"; - const timeoutDelayId = "ft"; + it.each([ + { hasDelay: true, hasParent: false }, + { hasDelay: false, hasParent: true }, + { hasDelay: true, hasParent: true }, + ])( + "sends delayed message events (hasDelay = $hasDelay, hasParent = $hasParent)", + async ({ hasDelay, hasParent }) => { + const roomId = "!room:example.org"; + const timeoutDelayId = "ft"; - driver.sendDelayedEvent.mockResolvedValue({ - roomId, - delayId: timeoutDelayId, - }); + driver.sendDelayedEvent.mockResolvedValue({ + roomId, + delayId: timeoutDelayId, + }); - const event: ISendEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendEvent, - data: { - type: "m.room.message", - content: {}, - room_id: roomId, - delay: 5000, - parent_delay_id: parentDelayId, - }, - }; + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: "m.room.message", + content: {}, + room_id: roomId, + ...(hasDelay && { delay: 5000 }), + ...(hasParent && { parent_delay_id: "fp" }), + }, + }; - await loadIframe([ - `org.matrix.msc2762.timeline:${event.data.room_id}`, - `org.matrix.msc2762.send.event:${event.data.type}`, - "org.matrix.msc4157.send.delayed_event", - ]); + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.event:${event.data.type}`, + "org.matrix.msc4157.send.delayed_event", + ]); - emitEvent(new CustomEvent("", { detail: event })); + emitEvent(new CustomEvent("", { detail: event })); - await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(event, { - room_id: roomId, - delay_id: timeoutDelayId, + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, { + room_id: roomId, + delay_id: timeoutDelayId, + }); }); - }); - expect(driver.sendDelayedEvent).toHaveBeenCalledWith( - event.data.delay, - event.data.parent_delay_id, - event.data.type, - event.data.content, - null, - roomId, - ); - }); - - it("sends delayed state events", async () => { - const roomId = "!room:example.org"; - const parentDelayId = "fp"; - const timeoutDelayId = "ft"; + expect(driver.sendDelayedEvent).toHaveBeenCalledWith( + event.data.delay ?? null, + event.data.parent_delay_id ?? null, + event.data.type, + event.data.content, + null, + roomId, + ); + }, + ); - driver.sendDelayedEvent.mockResolvedValue({ - roomId, - delayId: timeoutDelayId, - }); + it.each([ + { hasDelay: true, hasParent: false }, + { hasDelay: false, hasParent: true }, + { hasDelay: true, hasParent: true }, + ])( + "sends delayed state events (hasDelay = $hasDelay, hasParent = $hasParent)", + async ({ hasDelay, hasParent }) => { + const roomId = "!room:example.org"; + const timeoutDelayId = "ft"; + + driver.sendDelayedEvent.mockResolvedValue({ + roomId, + delayId: timeoutDelayId, + }); - const event: ISendEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: "test", - requestId: "0", - action: WidgetApiFromWidgetAction.SendEvent, - data: { - type: "m.room.topic", - content: {}, - state_key: "", - room_id: roomId, - delay: 5000, - parent_delay_id: parentDelayId, - }, - }; + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: "test", + requestId: "0", + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: "m.room.topic", + content: {}, + state_key: "", + room_id: roomId, + ...(hasDelay && { delay: 5000 }), + ...(hasParent && { parent_delay_id: "fp" }), + }, + }; - await loadIframe([ - `org.matrix.msc2762.timeline:${event.data.room_id}`, - `org.matrix.msc2762.send.state_event:${event.data.type}`, - "org.matrix.msc4157.send.delayed_event", - ]); + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.state_event:${event.data.type}`, + "org.matrix.msc4157.send.delayed_event", + ]); - emitEvent(new CustomEvent("", { detail: event })); + emitEvent(new CustomEvent("", { detail: event })); - await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(event, { - room_id: roomId, - delay_id: timeoutDelayId, + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, { + room_id: roomId, + delay_id: timeoutDelayId, + }); }); - }); - expect(driver.sendDelayedEvent).toHaveBeenCalledWith( - event.data.delay, - event.data.parent_delay_id, - event.data.type, - event.data.content, - "", - roomId, - ); - }); + expect(driver.sendDelayedEvent).toHaveBeenCalledWith( + event.data.delay ?? null, + event.data.parent_delay_id ?? null, + event.data.type, + event.data.content, + "", + roomId, + ); + }, + ); it("should reject requests when the driver throws an exception", async () => { const roomId = "!room:example.org"; @@ -633,7 +645,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Error sending event" }, }); }); @@ -673,7 +685,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Error sending event", matrix_api_error: { @@ -922,12 +934,12 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: expect.any(String) }, }); }); - expect(driver.cancelScheduledDelayedEvent).not.toBeCalled(); + expect(driver.cancelScheduledDelayedEvent).not.toHaveBeenCalled(); }); it("fails to restart delayed events", async () => { @@ -947,12 +959,12 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: expect.any(String) }, }); }); - expect(driver.restartScheduledDelayedEvent).not.toBeCalled(); + expect(driver.restartScheduledDelayedEvent).not.toHaveBeenCalled(); }); it("fails to send delayed events", async () => { @@ -972,12 +984,12 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: expect.any(String) }, }); }); - expect(driver.sendScheduledDelayedEvent).not.toBeCalled(); + expect(driver.sendScheduledDelayedEvent).not.toHaveBeenCalled(); }); it("fails to update delayed events with unsupported action", async () => { @@ -997,14 +1009,14 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: expect.any(String) }, }); }); - expect(driver.cancelScheduledDelayedEvent).not.toBeCalled(); - expect(driver.restartScheduledDelayedEvent).not.toBeCalled(); - expect(driver.sendScheduledDelayedEvent).not.toBeCalled(); + expect(driver.cancelScheduledDelayedEvent).not.toHaveBeenCalled(); + expect(driver.restartScheduledDelayedEvent).not.toHaveBeenCalled(); + expect(driver.sendScheduledDelayedEvent).not.toHaveBeenCalled(); }); it("can cancel delayed events", async () => { @@ -1101,7 +1113,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Error updating delayed event" }, }); }); @@ -1132,7 +1144,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Error updating delayed event", matrix_api_error: { @@ -1209,12 +1221,12 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Invalid request - missing event type" }, }); }); - expect(driver.sendToDevice).not.toBeCalled(); + expect(driver.sendToDevice).not.toHaveBeenCalled(); }); it("fails to send to-device events without event contents", async () => { @@ -1234,12 +1246,12 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Invalid request - missing event contents" }, }); }); - expect(driver.sendToDevice).not.toBeCalled(); + expect(driver.sendToDevice).not.toHaveBeenCalled(); }); it("fails to send to-device events without encryption flag", async () => { @@ -1265,12 +1277,12 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Invalid request - missing encryption flag" }, }); }); - expect(driver.sendToDevice).not.toBeCalled(); + expect(driver.sendToDevice).not.toHaveBeenCalled(); }); it("fails to send to-device events with any event type", async () => { @@ -1297,12 +1309,12 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Cannot send to-device events of this type" }, }); }); - expect(driver.sendToDevice).not.toBeCalled(); + expect(driver.sendToDevice).not.toHaveBeenCalled(); }); it("should reject requests when the driver throws an exception", async () => { @@ -1333,7 +1345,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Error sending event" }, }); }); @@ -1371,7 +1383,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Error sending event", matrix_api_error: { @@ -1685,12 +1697,12 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: expect.any(String) }, }); }); - expect(driver.readRoomTimeline).not.toBeCalled(); + expect(driver.readRoomTimeline).not.toHaveBeenCalled(); }); it("reads state events with a specific state key", async () => { @@ -1785,12 +1797,12 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: expect.any(String) }, }); }); - expect(driver.readRoomTimeline).not.toBeCalled(); + expect(driver.readRoomTimeline).not.toHaveBeenCalled(); }); }); @@ -1806,7 +1818,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { supported_versions: expect.arrayContaining([UnstableApiVersion.MSC3869]), }); }); @@ -1829,12 +1841,12 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { chunk: [createRoomEvent()], }); }); - expect(driver.readEventRelations).toBeCalledWith( + expect(driver.readEventRelations).toHaveBeenCalledWith( "$event", undefined, undefined, @@ -1872,12 +1884,12 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { chunk: [createRoomEvent(), createRoomEvent({ type: "net.example.test", state_key: "A" })], }); }); - expect(driver.readEventRelations).toBeCalledWith( + expect(driver.readEventRelations).toHaveBeenCalledWith( "$event", undefined, undefined, @@ -1916,12 +1928,12 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { chunk: [], }); }); - expect(driver.readEventRelations).toBeCalledWith( + expect(driver.readEventRelations).toHaveBeenCalledWith( "$event", "!room-id", "m.reference", @@ -1944,7 +1956,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Invalid request - missing event ID" }, }); }); @@ -1963,7 +1975,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Invalid request - limit out of range" }, }); }); @@ -1982,7 +1994,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Unable to access room timeline: !another-room-id" }, }); }); @@ -2005,7 +2017,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Unexpected error while reading relations" }, }); }); @@ -2033,7 +2045,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Unexpected error while reading relations", matrix_api_error: { @@ -2064,7 +2076,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { supported_versions: expect.arrayContaining([UnstableApiVersion.MSC3973]), }); }); @@ -2092,7 +2104,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { limited: true, results: [ { @@ -2104,7 +2116,7 @@ describe("ClientWidgetApi", () => { }); }); - expect(driver.searchUserDirectory).toBeCalledWith("foo", undefined); + expect(driver.searchUserDirectory).toHaveBeenCalledWith("foo", undefined); }); it("should accept all options and pass it to the driver", async () => { @@ -2138,7 +2150,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { limited: false, results: [ { @@ -2155,7 +2167,7 @@ describe("ClientWidgetApi", () => { }); }); - expect(driver.searchUserDirectory).toBeCalledWith("foo", 5); + expect(driver.searchUserDirectory).toHaveBeenCalledWith("foo", 5); }); it("should accept empty search_term", async () => { @@ -2177,13 +2189,13 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { limited: false, results: [], }); }); - expect(driver.searchUserDirectory).toBeCalledWith("", undefined); + expect(driver.searchUserDirectory).toHaveBeenCalledWith("", undefined); }); it("should reject requests when the capability was not requested", async () => { @@ -2197,11 +2209,11 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Missing capability" }, }); - expect(driver.searchUserDirectory).not.toBeCalled(); + expect(driver.searchUserDirectory).not.toHaveBeenCalled(); }); it("should reject requests without search_term", async () => { @@ -2217,11 +2229,11 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Invalid request - missing search term" }, }); - expect(driver.searchUserDirectory).not.toBeCalled(); + expect(driver.searchUserDirectory).not.toHaveBeenCalled(); }); it("should reject requests with a negative limit", async () => { @@ -2240,11 +2252,11 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Invalid request - limit out of range" }, }); - expect(driver.searchUserDirectory).not.toBeCalled(); + expect(driver.searchUserDirectory).not.toHaveBeenCalled(); }); it("should reject requests when the driver throws an exception", async () => { @@ -2263,7 +2275,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Unexpected error while searching in the user directory" }, }); }); @@ -2292,7 +2304,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Unexpected error while searching in the user directory", matrix_api_error: { @@ -2324,7 +2336,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { supported_versions: expect.arrayContaining([UnstableApiVersion.MSC4039]), }); }); @@ -2347,12 +2359,12 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { "m.upload.size": 1000, }); }); - expect(driver.getMediaConfig).toBeCalled(); + expect(driver.getMediaConfig).toHaveBeenCalled(); }); it("should reject requests when the capability was not requested", async () => { @@ -2366,11 +2378,11 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Missing capability" }, }); - expect(driver.getMediaConfig).not.toBeCalled(); + expect(driver.getMediaConfig).not.toHaveBeenCalled(); }); it("should reject requests when the driver throws an exception", async () => { @@ -2389,7 +2401,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Unexpected error while getting the media configuration" }, }); }); @@ -2418,7 +2430,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Unexpected error while getting the media configuration", matrix_api_error: { @@ -2450,7 +2462,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { supported_versions: expect.arrayContaining([UnstableApiVersion.MSC4039]), }); }); @@ -2477,12 +2489,12 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { content_uri: "mxc://...", }); }); - expect(driver.uploadFile).toBeCalled(); + expect(driver.uploadFile).toHaveBeenCalled(); }); it("should reject requests when the capability was not requested", async () => { @@ -2498,11 +2510,11 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Missing capability" }, }); - expect(driver.uploadFile).not.toBeCalled(); + expect(driver.uploadFile).not.toHaveBeenCalled(); }); it("should reject requests when the driver throws an exception", async () => { @@ -2523,7 +2535,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Unexpected error while uploading a file" }, }); }); @@ -2554,7 +2566,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Unexpected error while uploading a file", matrix_api_error: { @@ -2616,11 +2628,11 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Missing capability" }, }); - expect(driver.uploadFile).not.toBeCalled(); + expect(driver.uploadFile).not.toHaveBeenCalled(); }); it("should reject requests when the driver throws an exception", async () => { @@ -2641,7 +2653,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Unexpected error while downloading a file" }, }); }); @@ -2672,7 +2684,7 @@ describe("ClientWidgetApi", () => { emitEvent(new CustomEvent("", { detail: event })); await waitFor(() => { - expect(transport.reply).toBeCalledWith(event, { + expect(transport.reply).toHaveBeenCalledWith(event, { error: { message: "Unexpected error while downloading a file", matrix_api_error: { diff --git a/test/WidgetApi-test.ts b/test/WidgetApi-test.ts index 89273a9..94b6bdc 100644 --- a/test/WidgetApi-test.ts +++ b/test/WidgetApi-test.ts @@ -53,7 +53,7 @@ class WidgetTransportHelper { /** For ignoring the request sent by {@link WidgetApi.start} */ private skippedFirstRequest = false; - public constructor(private channels: TransportChannels) {} + public constructor(private readonly channels: TransportChannels) {} public nextTrackedRequest(): SendRequestArgs | undefined { if (!this.skippedFirstRequest) { @@ -69,7 +69,7 @@ class WidgetTransportHelper { } class ClientTransportHelper { - public constructor(private channels: TransportChannels) {} + public constructor(private readonly channels: TransportChannels) {} public trackRequest(action: WidgetApiFromWidgetAction, data: IWidgetApiRequestData): void { this.channels.requestQueue.push({ action, data }); @@ -99,7 +99,7 @@ describe("WidgetApi", () => { const response = clientTrafficHelper.nextQueuedResponse(); if (response) { - window.postMessage( + globalThis.postMessage( { ...request, response: response, @@ -108,14 +108,14 @@ describe("WidgetApi", () => { ); } }; - window.addEventListener("message", clientListener); + globalThis.addEventListener("message", clientListener); widgetApi = new WidgetApi("WidgetApi-test", "*"); widgetApi.start(); }); afterEach(() => { - window.removeEventListener("message", clientListener); + globalThis.removeEventListener("message", clientListener); }); describe("readEventRelations", () => { @@ -335,10 +335,12 @@ describe("WidgetApi", () => { delay_id: "id", } as ISendEventFromWidgetResponseData); - await expect(widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", 1000, undefined)).resolves.toEqual({ - room_id: "!room-id", - delay_id: "id", - }); + await expect(widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", 1000, "parent-id")).resolves.toEqual( + { + room_id: "!room-id", + delay_id: "id", + }, + ); }); it("sends delayed child action state events", async () => { @@ -348,7 +350,7 @@ describe("WidgetApi", () => { } as ISendEventFromWidgetResponseData); await expect( - widgetApi.sendStateEvent("m.room.topic", "", {}, "!room-id", 1000, undefined), + widgetApi.sendStateEvent("m.room.topic", "", {}, "!room-id", 1000, "parent-id"), ).resolves.toEqual({ room_id: "!room-id", delay_id: "id", @@ -360,7 +362,7 @@ describe("WidgetApi", () => { error: { message: "An error occurred" }, } as IWidgetApiErrorResponseData); - await expect(widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", 1000, undefined)).rejects.toThrow( + await expect(widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", 1000)).rejects.toThrow( "An error occurred", ); }); @@ -385,7 +387,7 @@ describe("WidgetApi", () => { }, } as IWidgetApiErrorResponseData); - await expect(widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", 1000, undefined)).rejects.toThrow( + await expect(widgetApi.sendRoomEvent("m.room.message", {}, "!room-id", 1000)).rejects.toThrow( new WidgetApiResponseError("An error occurred", errorDetails), ); }); @@ -769,3 +771,153 @@ describe("WidgetApi", () => { }); }); }); + +describe("capabilities", () => { + let widgetApi: WidgetApi; + + beforeEach(() => { + widgetApi = new WidgetApi(); + }); + + it("should request single capability", () => { + const capability = "org.example.capability"; + expect(widgetApi.hasCapability(capability)).toBe(false); + widgetApi.requestCapability(capability); + expect(widgetApi.hasCapability(capability)).toBe(true); + }); + + it("should request multiple capabilities", () => { + const capabilities: string[] = []; + for (let i = 1; i <= 3; i++) { + capabilities.push(`org.example.capability${i}`); + } + for (const capability of capabilities) { + expect(widgetApi.hasCapability(capability)).toBe(false); + } + widgetApi.requestCapabilities(capabilities); + for (const capability of capabilities) { + expect(widgetApi.hasCapability(capability)).toBe(true); + } + }); +}); + +describe("postMessage", () => { + let widgetApi: WidgetApi; + const onHandledRequest = jest.fn(); + + let afterMessage: Promise; + let afterMessageListener: () => void; + + beforeEach(() => { + widgetApi = new WidgetApi(); + widgetApi.start(); + + onHandledRequest.mockClear(); + widgetApi.transport.on("message", onHandledRequest); + + ({ promise: afterMessage, resolve: afterMessageListener } = Promise.withResolvers()); + afterMessage = new Promise((resolve) => { + afterMessageListener = resolve; + globalThis.addEventListener("message", afterMessageListener); + }); + }); + + afterEach(() => { + globalThis.removeEventListener("message", afterMessageListener); + widgetApi.transport.removeAllListeners(); + }); + + async function postMessage(message: unknown): Promise { + globalThis.postMessage(message, "*"); + await afterMessage; + } + + it("should handle a valid request", async () => { + const action = "a"; + widgetApi.on(`action:${action}`, (ev: Event) => { + ev.preventDefault(); + }); + + await postMessage({ + api: WidgetApiDirection.ToWidget, + requestId: "rid", + action, + widgetId: "w", + data: {}, + } satisfies IWidgetApiRequest); + expect(onHandledRequest).toHaveBeenCalled(); + }); + + it("should drop a request with the wrong direction", async () => { + await postMessage({ + api: WidgetApiDirection.FromWidget, + requestId: "rid", + action: "a", + widgetId: "w", + data: {}, + } satisfies IWidgetApiRequest); + expect(onHandledRequest).not.toHaveBeenCalled(); + }); + + it("should drop a request without an action", async () => { + await postMessage({ + api: WidgetApiDirection.ToWidget, + requestId: "rid", + widgetId: "w", + data: {}, + } satisfies Omit); + expect(onHandledRequest).not.toHaveBeenCalled(); + }); + + it("should drop a request without an request ID", async () => { + await postMessage({ + api: WidgetApiDirection.ToWidget, + action: "a", + widgetId: "w", + data: {}, + } satisfies Omit); + expect(onHandledRequest).not.toHaveBeenCalled(); + }); + + it("should drop a request without an widget ID", async () => { + await postMessage({ + api: WidgetApiDirection.ToWidget, + action: "a", + requestId: "rid", + data: {}, + } satisfies Omit); + expect(onHandledRequest).not.toHaveBeenCalled(); + }); + + it("should enforce strictOriginCheck", async () => { + // the generated MessageEvent will have an empty origin + widgetApi.transport.strictOriginCheck = true; + + await postMessage({ + api: WidgetApiDirection.ToWidget, + requestId: "rid", + action: "a", + widgetId: "w", + data: {}, + } satisfies IWidgetApiRequest); + expect(onHandledRequest).not.toHaveBeenCalled(); + }); + + it("should drop a null message", async () => { + await postMessage(null); + expect(onHandledRequest).not.toHaveBeenCalled(); + }); + + it("should drop a request after having aborted", async () => { + widgetApi.transport.stop(); + + await postMessage({ + api: WidgetApiDirection.ToWidget, + requestId: "rid", + action: "a", + widgetId: "w", + data: {}, + } satisfies IWidgetApiRequest); + expect(onHandledRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index e5261eb..075bb62 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,8 @@ "declaration": true, "types": ["jest"], "lib": ["es2020", "dom"], - "strict": true + "strict": true, + "noUnusedLocals": true }, "include": ["./src/**/*.ts"] }