diff --git a/.eslintrc.js b/.eslintrc.js index 65a34bd..5d451d2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -31,6 +31,9 @@ module.exports = { "files": ["src/**/*.ts", "test/**/*.ts"], "extends": ["matrix-org/ts"], "rules": { + // TypeScript has its own version of this + "babel/no-invalid-this": "off", + "quotes": "off", }, }], diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index de88957..8fcc662 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -330,15 +330,13 @@ export class ClientWidgetApi extends EventEmitter { }); } - const onErr = (e: any) => { + const onErr = (e: unknown) => { console.error("[ClientWidgetApi] Failed to handle navigation: ", e); - return this.transport.reply(request, { - error: {message: "Error handling navigation"}, - }); + this.handleDriverError(e, request, "Error handling navigation"); }; try { - this.driver.navigate(request.data.uri.toString()).catch(e => onErr(e)).then(() => { + this.driver.navigate(request.data.uri.toString()).catch((e: unknown) => onErr(e)).then(() => { return this.transport.reply(request, {}); }); } catch (e) { @@ -554,11 +552,9 @@ export class ClientWidgetApi extends EventEmitter { delay_id: sentEvent.delayId, }), }); - }).catch(e => { + }).catch((e: unknown) => { console.error("error sending event: ", e); - return this.transport.reply(request, { - error: {message: "Error sending event"}, - }); + this.handleDriverError(e, request, "Error sending event"); }); } @@ -581,11 +577,9 @@ export class ClientWidgetApi extends EventEmitter { case UpdateDelayedEventAction.Send: this.driver.updateDelayedEvent(request.data.delay_id, request.data.action).then(() => { return this.transport.reply(request, {}); - }).catch(e => { + }).catch((e: unknown) => { console.error("error updating delayed event: ", e); - return this.transport.reply(request, { - error: {message: "Error updating delayed event"}, - }); + this.handleDriverError(e, request, "Error updating delayed event"); }); break; default: @@ -618,9 +612,7 @@ export class ClientWidgetApi extends EventEmitter { await this.transport.reply(request, {}); } catch (e) { console.error("error sending to-device event", e); - await this.transport.reply(request, { - error: {message: "Error sending event"}, - }); + this.handleDriverError(e, request, "Error sending event"); } } } @@ -735,9 +727,7 @@ export class ClientWidgetApi extends EventEmitter { ); } catch (e) { console.error("error getting the relations", e); - await this.transport.reply(request, { - error: { message: "Unexpected error while reading relations" }, - }); + this.handleDriverError(e, request, "Unexpected error while reading relations"); } } @@ -778,9 +768,7 @@ export class ClientWidgetApi extends EventEmitter { ); } catch (e) { console.error("error searching in the user directory", e); - await this.transport.reply(request, { - error: { message: "Unexpected error while searching in the user directory" }, - }); + this.handleDriverError(e, request, "Unexpected error while searching in the user directory"); } } @@ -800,9 +788,7 @@ export class ClientWidgetApi extends EventEmitter { ); } catch (e) { console.error("error while getting the media configuration", e); - await this.transport.reply(request, { - error: { message: "Unexpected error while getting the media configuration" }, - }); + this.handleDriverError(e, request, "Unexpected error while getting the media configuration"); } } @@ -822,9 +808,7 @@ export class ClientWidgetApi extends EventEmitter { ); } catch (e) { console.error("error while uploading a file", e); - await this.transport.reply(request, { - error: { message: "Unexpected error while uploading a file" }, - }); + this.handleDriverError(e, request, "Unexpected error while uploading a file"); } } @@ -844,12 +828,20 @@ export class ClientWidgetApi extends EventEmitter { ); } catch (e) { console.error("error while downloading a file", e); - this.transport.reply(request, { - error: { message: "Unexpected error while downloading a file" }, - }); + this.handleDriverError(e, request, "Unexpected error while downloading a file"); } } + private handleDriverError(e: unknown, request: IWidgetApiRequest, message: string) { + const data = this.driver.processError(e); + this.transport.reply(request, { + error: { + message, + ...data, + }, + }); + } + private handleMessage(ev: CustomEvent) { if (this.isStopped) return; const actionEv = new CustomEvent(`action:${ev.detail.action}`, { diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index eb567d0..e02176f 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -33,7 +33,7 @@ import { import { ITransport } from "./transport/ITransport"; import { PostmessageTransport } from "./transport/PostmessageTransport"; import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./interfaces/WidgetApiAction"; -import { IWidgetApiErrorResponseData } from "./interfaces/IWidgetApiErrorResponse"; +import { IWidgetApiErrorResponseData, IWidgetApiErrorResponseDataDetails } from "./interfaces/IWidgetApiErrorResponse"; import { IStickerActionRequestData } from "./interfaces/StickerAction"; import { IStickyActionRequestData, IStickyActionResponseData } from "./interfaces/StickyAction"; import { @@ -95,6 +95,19 @@ import { UpdateDelayedEventAction, } from "./interfaces/UpdateDelayedEventAction"; +export class WidgetApiResponseError extends Error { + static { + this.prototype.name = this.name; + } + + public constructor( + message: string, + public readonly data: IWidgetApiErrorResponseDataDetails, + ) { + super(message); + } +} + /** * API handler for widgets. This raises events for each action * received as `action:${action}` (eg: "action:screenshot"). diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index 590d677..4edc933 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -22,6 +22,7 @@ import { IRoomEvent, IRoomAccountData, ITurnServer, + IWidgetApiErrorResponseDataDetails, UpdateDelayedEventAction, } from ".."; @@ -358,4 +359,14 @@ export abstract class WidgetDriver { ): Promise<{ file: XMLHttpRequestBodyInit }> { throw new Error("Download file is not implemented"); } + + /** + * Expresses an error thrown by this driver in a format compatible with the Widget API. + * @param error The error to handle. + * @returns The error expressed as a {@link IWidgetApiErrorResponseDataDetails}, + * or undefined if it cannot be expressed as one. + */ + public processError(error: unknown): IWidgetApiErrorResponseDataDetails | undefined { + return undefined; + } } diff --git a/src/interfaces/IWidgetApiErrorResponse.ts b/src/interfaces/IWidgetApiErrorResponse.ts index f9e123f..89a29de 100644 --- a/src/interfaces/IWidgetApiErrorResponse.ts +++ b/src/interfaces/IWidgetApiErrorResponse.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2024 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,20 +16,42 @@ import { IWidgetApiResponse, IWidgetApiResponseData } from "./IWidgetApiResponse"; +/** + * The format of errors returned by Matrix API requests + * made by a WidgetDriver. + */ +export interface IMatrixApiError { + /** The HTTP status code of the associated request. */ + http_status: number; // eslint-disable-line camelcase + /** Any HTTP response headers that are relevant to the error. */ + http_headers: {[name: string]: string}; // eslint-disable-line camelcase + /** The URL of the failed request. */ + url: string; + /** @see {@link https://spec.matrix.org/latest/client-server-api/#standard-error-response} */ + response: { + errcode: string; + error: string; + } & IWidgetApiResponseData; // extensible +} + +export interface IWidgetApiErrorResponseDataDetails { + /** Set if the error came from a Matrix API request made by a widget driver */ + matrix_api_error?: IMatrixApiError; // eslint-disable-line camelcase +} + export interface IWidgetApiErrorResponseData extends IWidgetApiResponseData { error: { + /** A user-friendly string describing the error */ message: string; - }; + } & IWidgetApiErrorResponseDataDetails; } export interface IWidgetApiErrorResponse extends IWidgetApiResponse { response: IWidgetApiErrorResponseData; } -export function isErrorResponse(responseData: IWidgetApiResponseData): boolean { - if ("error" in responseData) { - const err = responseData; - return !!err.error.message; - } - return false; +export function isErrorResponse(responseData: IWidgetApiResponseData): responseData is IWidgetApiErrorResponseData { + const error = responseData.error; + return typeof error === "object" && error !== null && + "message" in error && typeof error.message === "string"; } diff --git a/src/transport/ITransport.ts b/src/transport/ITransport.ts index b3b2e9a..776c5f5 100644 --- a/src/transport/ITransport.ts +++ b/src/transport/ITransport.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2024 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -71,11 +71,12 @@ export interface ITransport extends EventEmitter { /** * Sends a request to the remote end. - * @param {WidgetApiAction} action The action to send. - * @param {IWidgetApiRequestData} data The request data. - * @returns {Promise} A promise which resolves - * to the remote end's response, or throws with an Error if the request - * failed. + * @param action The action to send. + * @param data The request data. + * @returns A promise which resolves to the remote end's response. + * @throws {Error} if the request failed with a generic error. + * @throws {WidgetApiResponseError} if the request failed with error details + * that can be communicated to the Widget API. */ send( action: WidgetApiAction, @@ -88,9 +89,10 @@ export interface ITransport extends EventEmitter { * data. * @param {WidgetApiAction} action The action to send. * @param {IWidgetApiRequestData} data The request data. - * @returns {Promise} A promise which resolves - * to the remote end's response, or throws with an Error if the request - * failed. + * @returns {Promise} A promise which resolves to the remote end's response + * @throws {Error} if the request failed with a generic error. + * @throws {WidgetApiResponseError} if the request failed with error details + * that can be communicated to the Widget API. */ sendComplete(action: WidgetApiAction, data: T) : Promise; diff --git a/src/transport/PostmessageTransport.ts b/src/transport/PostmessageTransport.ts index 9f86aa5..b836c6b 100644 --- a/src/transport/PostmessageTransport.ts +++ b/src/transport/PostmessageTransport.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2024 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,11 +19,11 @@ import { ITransport } from "./ITransport"; import { invertedDirection, isErrorResponse, - IWidgetApiErrorResponseData, IWidgetApiRequest, IWidgetApiRequestData, IWidgetApiResponse, IWidgetApiResponseData, + WidgetApiResponseError, WidgetApiAction, WidgetApiDirection, WidgetApiToWidgetAction, @@ -194,8 +194,8 @@ export class PostmessageTransport extends EventEmitter implements ITransport { if (!req) return; // response to an unknown request if (isErrorResponse(response.response)) { - const err = response.response; - req.reject(new Error(err.error.message)); + const {message, ...data} = response.response.error; + req.reject(new WidgetApiResponseError(message, data)); } else { req.resolve(response); } diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index 40b5b64..c8c9b11 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -1,5 +1,6 @@ /* * Copyright 2022 Nordeck IT + Consulting GmbH. + * Copyright 2024 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,10 +31,14 @@ import { Widget } from '../src/models/Widget'; import { PostmessageTransport } from '../src/transport/PostmessageTransport'; import { IDownloadFileActionFromWidgetActionRequest, + IMatrixApiError, + INavigateActionRequest, IReadEventFromWidgetActionRequest, ISendEventFromWidgetActionRequest, + ISendToDeviceFromWidgetActionRequest, IUpdateDelayedEventFromWidgetActionRequest, IUploadFileActionFromWidgetActionRequest, + IWidgetApiErrorResponseDataDetails, UpdateDelayedEventAction, } from '../src'; import { IGetMediaConfigActionFromWidgetActionRequest } from '../src/interfaces/GetMediaConfigAction'; @@ -57,6 +62,32 @@ function createRoomEvent(event: Partial = {}): IRoomEvent { }; } +class CustomMatrixError extends Error { + constructor( + message: string, + public readonly httpStatus: number, + public readonly name: string, + public readonly data: Record, + ) { + super(message); + } +} + +function processCustomMatrixError(e: unknown): IWidgetApiErrorResponseDataDetails | undefined { + return e instanceof CustomMatrixError ? { + matrix_api_error: { + http_status: e.httpStatus, + http_headers: {}, + url: '', + response: { + errcode: e.name, + error: e.message, + ...e.data, + }, + }, + } : undefined; +} + describe('ClientWidgetApi', () => { let capabilities: Capability[]; let iframe: HTMLIFrameElement; @@ -83,16 +114,19 @@ describe('ClientWidgetApi', () => { document.body.appendChild(iframe); driver = { + navigate: jest.fn(), readStateEvents: jest.fn(), readEventRelations: jest.fn(), sendEvent: jest.fn(), sendDelayedEvent: jest.fn(), updateDelayedEvent: jest.fn(), + sendToDevice: jest.fn(), validateCapabilities: jest.fn(), searchUserDirectory: jest.fn(), getMediaConfig: jest.fn(), uploadFile: jest.fn(), downloadFile: jest.fn(), + processError: jest.fn(), } as Partial as jest.Mocked; clientWidgetApi = new ClientWidgetApi( @@ -127,6 +161,155 @@ describe('ClientWidgetApi', () => { expect(clientWidgetApi.hasCapability('m.sticker')).toBe(false); }); + describe('navigate action', () => { + it('navigates', async () => { + driver.navigate.mockResolvedValue(Promise.resolve()); + + const event: INavigateActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC2931Navigate, + data: { + uri: 'https://matrix.to/#/#room:example.net', + }, + }; + + await loadIframe(['org.matrix.msc2931.navigate']); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, {}); + }); + + expect(driver.navigate).toHaveBeenCalledWith( + event.data.uri, + ); + }); + + it('fails to navigate', async () => { + const event: INavigateActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC2931Navigate, + data: { + uri: 'https://matrix.to/#/#room:example.net', + }, + }; + + await loadIframe([]); // Without the required capability + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Missing capability' }, + }); + }); + + expect(driver.navigate).not.toBeCalled(); + }); + + it('fails to navigate to an unsupported URI', async () => { + const event: INavigateActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC2931Navigate, + data: { + uri: 'https://example.net', + }, + }; + + await loadIframe(['org.matrix.msc2931.navigate']); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Invalid matrix.to URI' }, + }); + }); + + expect(driver.navigate).not.toBeCalled(); + }); + + it('should reject requests when the driver throws an exception', async () => { + driver.navigate.mockRejectedValue( + new Error("M_UNKNOWN: Unknown error"), + ); + + const event: INavigateActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC2931Navigate, + data: { + uri: 'https://matrix.to/#/#room:example.net', + }, + }; + + await loadIframe(['org.matrix.msc2931.navigate']); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Error handling navigation' }, + }); + }); + }); + + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.navigate.mockRejectedValue( + new CustomMatrixError( + 'failed to navigate', + 400, + 'M_UNKNOWN', + { + reason: 'Unknown error', + }, + ), + ); + + const event: INavigateActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC2931Navigate, + data: { + uri: 'https://matrix.to/#/#room:example.net', + }, + }; + + await loadIframe(['org.matrix.msc2931.navigate']); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Error handling navigation', + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: '', + response: { + errcode: 'M_UNKNOWN', + error: 'failed to navigate', + reason: 'Unknown error', + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); + }); + describe('send_event action', () => { it('sends message events', async () => { const roomId = '!room:example.org'; @@ -214,10 +397,99 @@ describe('ClientWidgetApi', () => { roomId, ); }); + + it('should reject requests when the driver throws an exception', async () => { + const roomId = '!room:example.org'; + + driver.sendEvent.mockRejectedValue( + new Error("M_BAD_JSON: Content must be a JSON object"), + ); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: 'm.room.message', + content: 'hello', + room_id: roomId, + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.event:${event.data.type}`, + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Error sending event' }, + }); + }); + }); + + it('should reject with Matrix API error response thrown by driver', async () => { + const roomId = '!room:example.org'; + + driver.processError.mockImplementation(processCustomMatrixError); + + driver.sendEvent.mockRejectedValue( + new CustomMatrixError( + 'failed to send event', + 400, + 'M_NOT_JSON', + { + reason: 'Content must be a JSON object.', + }, + ), + ); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: 'm.room.message', + content: 'hello', + room_id: roomId, + }, + }; + + await loadIframe([ + `org.matrix.msc2762.timeline:${event.data.room_id}`, + `org.matrix.msc2762.send.event:${event.data.type}`, + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Error sending event', + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: '', + response: { + errcode: 'M_NOT_JSON', + error: 'failed to send event', + reason: 'Content must be a JSON object.', + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); }); describe('send_event action for delayed events', () => { it('fails to send delayed events', async () => { + const roomId = '!room:example.org'; + const event: ISendEventFromWidgetActionRequest = { api: WidgetApiDirection.FromWidget, widgetId: 'test', @@ -227,6 +499,7 @@ describe('ClientWidgetApi', () => { type: 'm.room.message', content: {}, delay: 5000, + room_id: roomId, }, }; @@ -286,150 +559,562 @@ describe('ClientWidgetApi', () => { }); }); - expect(driver.sendDelayedEvent).toHaveBeenCalledWith( - event.data.delay, - event.data.parent_delay_id, - event.data.type, - event.data.content, - null, - roomId, - ); + 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'; + + 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, + }, + }; + + 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 })); + + 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, + ); + }); + + it('should reject requests when the driver throws an exception', async () => { + const roomId = '!room:example.org'; + + driver.sendDelayedEvent.mockRejectedValue( + new Error("M_BAD_JSON: Content must be a JSON object"), + ); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: 'm.room.message', + content: 'hello', + room_id: roomId, + delay: 5000, + 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', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Error sending event' }, + }); + }); + }); + + it('should reject with Matrix API error response thrown by driver', async () => { + const roomId = '!room:example.org'; + + driver.processError.mockImplementation(processCustomMatrixError); + + driver.sendDelayedEvent.mockRejectedValue( + new CustomMatrixError( + 'failed to send event', + 400, + 'M_NOT_JSON', + { + reason: 'Content must be a JSON object.', + }, + ), + ); + + const event: ISendEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendEvent, + data: { + type: 'm.room.message', + content: 'hello', + room_id: roomId, + delay: 5000, + 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', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Error sending event', + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: '', + response: { + errcode: 'M_NOT_JSON', + error: 'failed to send event', + reason: 'Content must be a JSON object.', + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); + }); + + describe('update_delayed_event action', () => { + it('fails to update delayed events', async () => { + const event: IUpdateDelayedEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, + data: { + delay_id: 'f', + action: UpdateDelayedEventAction.Send, + }, + }; + + await loadIframe([]); // Without the required capability + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: expect.any(String) }, + }); + }); + + expect(driver.updateDelayedEvent).not.toBeCalled() + }); + + it('fails to update delayed events with unsupported action', async () => { + const event: IUpdateDelayedEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, + data: { + delay_id: 'f', + action: 'unknown' as UpdateDelayedEventAction, + }, + }; + + await loadIframe(['org.matrix.msc4157.update_delayed_event']); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: expect.any(String) }, + }); + }); + + expect(driver.updateDelayedEvent).not.toBeCalled() + }); + + it('updates delayed events', async () => { + driver.updateDelayedEvent.mockResolvedValue(undefined); + + for (const action of [ + UpdateDelayedEventAction.Cancel, + UpdateDelayedEventAction.Restart, + UpdateDelayedEventAction.Send, + ]) { + const event: IUpdateDelayedEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, + data: { + delay_id: 'f', + action, + }, + }; + + await loadIframe(['org.matrix.msc4157.update_delayed_event']); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, {}); + }); + + expect(driver.updateDelayedEvent).toHaveBeenCalledWith( + event.data.delay_id, + event.data.action, + ); + } + }); + + it('should reject requests when the driver throws an exception', async () => { + driver.updateDelayedEvent.mockRejectedValue( + new Error("M_BAD_JSON: Content must be a JSON object"), + ); + + const event: IUpdateDelayedEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, + data: { + delay_id: 'f', + action: UpdateDelayedEventAction.Send, + }, + }; + + await loadIframe(['org.matrix.msc4157.update_delayed_event']); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Error updating delayed event' }, + }); + }); + }); + + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.updateDelayedEvent.mockRejectedValue( + new CustomMatrixError( + 'failed to update delayed event', + 400, + 'M_NOT_JSON', + { + reason: 'Content must be a JSON object.', + }, + ), + ); + + const event: IUpdateDelayedEventFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, + data: { + delay_id: 'f', + action: UpdateDelayedEventAction.Send, + }, + }; + + await loadIframe(['org.matrix.msc4157.update_delayed_event']); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Error updating delayed event', + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: '', + response: { + errcode: 'M_NOT_JSON', + error: 'failed to update delayed event', + reason: 'Content must be a JSON object.', + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); + }); + + describe('send_to_device action', () => { + it('sends unencrypted to-device events', async () => { + const event: ISendToDeviceFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + type: 'net.example.test', + encrypted: false, + messages: { + '@foo:bar.com': { + 'DEVICEID': { + 'example_content_key': 'value', + }, + }, + }, + }, + }; + + await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}`]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toHaveBeenCalledWith(event, {}); + }); + + expect(driver.sendToDevice).toHaveBeenCalledWith( + event.data.type, + event.data.encrypted, + event.data.messages, + ); + }); + + it('fails to send to-device events without event type', async () => { + const event: IWidgetApiRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + encrypted: false, + messages: { + '@foo:bar.com': { + 'DEVICEID': { + 'example_content_key': 'value', + }, + }, + }, + }, + }; + + await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}`]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Invalid request - missing event type' }, + }); + }); + + expect(driver.sendToDevice).not.toBeCalled(); + }); + + it('fails to send to-device events without event contents', async () => { + const event: IWidgetApiRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + type: 'net.example.test', + encrypted: false, + }, + }; + + await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}`]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Invalid request - missing event contents' }, + }); + }); + + expect(driver.sendToDevice).not.toBeCalled(); }); - it('sends delayed state events', async () => { - const roomId = '!room:example.org'; - const parentDelayId = 'fp'; - const timeoutDelayId = 'ft'; - - driver.sendDelayedEvent.mockResolvedValue({ - roomId, - delayId: timeoutDelayId, - }); - - const event: ISendEventFromWidgetActionRequest = { + it('fails to send to-device events without encryption flag', async () => { + const event: IWidgetApiRequest = { api: WidgetApiDirection.FromWidget, widgetId: 'test', requestId: '0', - action: WidgetApiFromWidgetAction.SendEvent, + action: WidgetApiFromWidgetAction.SendToDevice, data: { - type: 'm.room.topic', - content: {}, - state_key: '', - room_id: roomId, - delay: 5000, - parent_delay_id: parentDelayId, + type: 'net.example.test', + messages: { + '@foo:bar.com': { + 'DEVICEID': { + 'example_content_key': 'value', + }, + }, + }, }, }; - 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.msc3819.send.to_device:${event.data.type}`]); emitEvent(new CustomEvent('', { detail: event })); await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(event, { - room_id: roomId, - delay_id: timeoutDelayId, + expect(transport.reply).toBeCalledWith(event, { + error: { message: 'Invalid request - missing encryption flag' }, }); }); - expect(driver.sendDelayedEvent).toHaveBeenCalledWith( - event.data.delay, - event.data.parent_delay_id, - event.data.type, - event.data.content, - '', - roomId, - ); + expect(driver.sendToDevice).not.toBeCalled(); }); - }); - describe('update_delayed_event action', () => { - it('fails to update delayed events', async () => { - const event: IUpdateDelayedEventFromWidgetActionRequest = { + it('fails to send to-device events with any event type', async () => { + const event: ISendToDeviceFromWidgetActionRequest = { api: WidgetApiDirection.FromWidget, widgetId: 'test', requestId: '0', - action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, + action: WidgetApiFromWidgetAction.SendToDevice, data: { - delay_id: 'f', - action: UpdateDelayedEventAction.Send, + type: 'net.example.test', + encrypted: false, + messages: { + '@foo:bar.com': { + 'DEVICEID': { + 'example_content_key': 'value', + }, + }, + }, }, }; - await loadIframe([]); // Without the required capability + await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}_different`]); emitEvent(new CustomEvent('', { detail: event })); await waitFor(() => { expect(transport.reply).toBeCalledWith(event, { - error: { message: expect.any(String) }, + error: { message: 'Cannot send to-device events of this type' }, }); }); - expect(driver.updateDelayedEvent).not.toBeCalled() + expect(driver.sendToDevice).not.toBeCalled(); }); - it('fails to update delayed events with unsupported action', async () => { - const event: IUpdateDelayedEventFromWidgetActionRequest = { + it('should reject requests when the driver throws an exception', async () => { + driver.sendToDevice.mockRejectedValue( + new Error("M_FORBIDDEN: You don't have permission to send to-device events"), + ); + + const event: ISendToDeviceFromWidgetActionRequest = { api: WidgetApiDirection.FromWidget, widgetId: 'test', requestId: '0', - action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, + action: WidgetApiFromWidgetAction.SendToDevice, data: { - delay_id: 'f', - action: 'unknown' as UpdateDelayedEventAction, + type: 'net.example.test', + encrypted: false, + messages: { + '@foo:bar.com': { + 'DEVICEID': { + 'example_content_key': 'value', + }, + }, + }, }, }; - await loadIframe(['org.matrix.msc4157.update_delayed_event']); + await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}`]); emitEvent(new CustomEvent('', { detail: event })); await waitFor(() => { expect(transport.reply).toBeCalledWith(event, { - error: { message: expect.any(String) }, + error: { message: 'Error sending event' }, }); }); - - expect(driver.updateDelayedEvent).not.toBeCalled() }); - it('updates delayed events', async () => { - driver.updateDelayedEvent.mockResolvedValue(undefined); + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); - for (const action of [ - UpdateDelayedEventAction.Cancel, - UpdateDelayedEventAction.Restart, - UpdateDelayedEventAction.Send, - ]) { - const event: IUpdateDelayedEventFromWidgetActionRequest = { - api: WidgetApiDirection.FromWidget, - widgetId: 'test', - requestId: '0', - action: WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent, - data: { - delay_id: 'f', - action, + driver.sendToDevice.mockRejectedValue( + new CustomMatrixError( + 'failed to send event', + 400, + 'M_FORBIDDEN', + { + reason: "You don't have permission to send to-device events", }, - }; + ), + ); - await loadIframe(['org.matrix.msc4157.update_delayed_event']); + const event: ISendToDeviceFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.SendToDevice, + data: { + type: 'net.example.test', + encrypted: false, + messages: { + '@foo:bar.com': { + 'DEVICEID': { + 'example_content_key': 'value', + }, + }, + }, + }, + }; - emitEvent(new CustomEvent('', { detail: event })); + await loadIframe([`org.matrix.msc3819.send.to_device:${event.data.type}`]); - await waitFor(() => { - expect(transport.reply).toHaveBeenCalledWith(event, {}); - }); + emitEvent(new CustomEvent('', { detail: event })); - expect(driver.updateDelayedEvent).toHaveBeenCalledWith( - event.data.delay_id, - event.data.action, - ); - } + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Error sending event', + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: '', + response: { + errcode: 'M_FORBIDDEN', + error: 'failed to send event', + reason: "You don't have permission to send to-device events", + }, + } satisfies IMatrixApiError, + }, + }); + }); }); }); @@ -761,6 +1446,51 @@ describe('ClientWidgetApi', () => { }); }); }); + + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.readEventRelations.mockRejectedValue( + new CustomMatrixError( + 'failed to read relations', + 403, + 'M_FORBIDDEN', + { + reason: "You don't have permission to access that event", + }, + ), + ); + + const event: IReadRelationsFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC3869ReadRelations, + data: { event_id: '$event' }, + }; + + await loadIframe(); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Unexpected error while reading relations', + matrix_api_error: { + http_status: 403, + http_headers: {}, + url: '', + response: { + errcode: 'M_FORBIDDEN', + error: 'failed to read relations', + reason: "You don't have permission to access that event", + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); }); describe('org.matrix.msc3973.user_directory_search action', () => { @@ -991,6 +1721,55 @@ describe('ClientWidgetApi', () => { }); }); }); + + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.searchUserDirectory.mockRejectedValue( + new CustomMatrixError( + 'failed to search the user directory', + 429, + 'M_LIMIT_EXCEEDED', + { + reason: 'Too many requests', + retry_after_ms: 2000, + }, + ), + ); + + const event: IUserDirectorySearchFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { search_term: 'foo' }, + }; + + await loadIframe([ + 'org.matrix.msc3973.user_directory_search', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Unexpected error while searching in the user directory', + matrix_api_error: { + http_status: 429, + http_headers: {}, + url: '', + response: { + errcode: 'M_LIMIT_EXCEEDED', + error: 'failed to search the user directory', + reason: 'Too many requests', + retry_after_ms: 2000, + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); }); describe('org.matrix.msc4039.get_media_config action', () => { @@ -1083,6 +1862,55 @@ describe('ClientWidgetApi', () => { }); }); }); + + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.getMediaConfig.mockRejectedValue( + new CustomMatrixError( + 'failed to get the media configuration', + 429, + 'M_LIMIT_EXCEEDED', + { + reason: 'Too many requests', + retry_after_ms: 2000, + }, + ), + ); + + const event: IGetMediaConfigActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: {}, + }; + + await loadIframe([ + 'org.matrix.msc4039.upload_file', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Unexpected error while getting the media configuration', + matrix_api_error: { + http_status: 429, + http_headers: {}, + url: '', + response: { + errcode: 'M_LIMIT_EXCEEDED', + error: 'failed to get the media configuration', + reason: 'Too many requests', + retry_after_ms: 2000, + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); }); describe('MSC4039', () => { @@ -1157,7 +1985,7 @@ describe('ClientWidgetApi', () => { }); it('should reject requests when the driver throws an exception', async () => { - driver.getMediaConfig.mockRejectedValue( + driver.uploadFile.mockRejectedValue( new Error("M_LIMIT_EXCEEDED: Too many requests"), ); @@ -1183,6 +2011,57 @@ describe('ClientWidgetApi', () => { }); }); }); + + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.uploadFile.mockRejectedValue( + new CustomMatrixError( + 'failed to upload a file', + 429, + 'M_LIMIT_EXCEEDED', + { + reason: 'Too many requests', + retry_after_ms: 2000, + }, + ), + ); + + const event: IUploadFileActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC4039UploadFileAction, + data: { + file: 'data', + }, + }; + + await loadIframe([ + 'org.matrix.msc4039.upload_file', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Unexpected error while uploading a file', + matrix_api_error: { + http_status: 429, + http_headers: {}, + url: '', + response: { + errcode: 'M_LIMIT_EXCEEDED', + error: 'failed to upload a file', + reason: 'Too many requests', + retry_after_ms: 2000, + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); }); describe('org.matrix.msc4039.download_file action', () => { @@ -1237,7 +2116,7 @@ describe('ClientWidgetApi', () => { }); it('should reject requests when the driver throws an exception', async () => { - driver.getMediaConfig.mockRejectedValue( + driver.downloadFile.mockRejectedValue( new Error("M_LIMIT_EXCEEDED: Too many requests"), ); @@ -1263,5 +2142,56 @@ describe('ClientWidgetApi', () => { }); }); }); + + it('should reject with Matrix API error response thrown by driver', async () => { + driver.processError.mockImplementation(processCustomMatrixError); + + driver.downloadFile.mockRejectedValue( + new CustomMatrixError( + 'failed to download a file', + 429, + 'M_LIMIT_EXCEEDED', + { + reason: 'Too many requests', + retry_after_ms: 2000, + }, + ), + ); + + const event: IDownloadFileActionFromWidgetActionRequest = { + api: WidgetApiDirection.FromWidget, + widgetId: 'test', + requestId: '0', + action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction, + data: { + content_uri: 'mxc://example.com/test_file', + }, + }; + + await loadIframe([ + 'org.matrix.msc4039.download_file', + ]); + + emitEvent(new CustomEvent('', { detail: event })); + + await waitFor(() => { + expect(transport.reply).toBeCalledWith(event, { + error: { + message: 'Unexpected error while downloading a file', + matrix_api_error: { + http_status: 429, + http_headers: {}, + url: '', + response: { + errcode: 'M_LIMIT_EXCEEDED', + error: 'failed to download a file', + reason: 'Too many requests', + retry_after_ms: 2000, + }, + } satisfies IMatrixApiError, + }, + }); + }); + }); }); }); diff --git a/test/WidgetApi-test.ts b/test/WidgetApi-test.ts index 4d17d6f..9bf7e02 100644 --- a/test/WidgetApi-test.ts +++ b/test/WidgetApi-test.ts @@ -1,5 +1,6 @@ /* * Copyright 2022 Nordeck IT + Consulting GmbH. + * Copyright 2024 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,28 +24,110 @@ import { IUploadFileActionFromWidgetResponseData } from '../src/interfaces/Uploa import { IDownloadFileActionFromWidgetResponseData } from '../src/interfaces/DownloadFileAction'; import { IUserDirectorySearchFromWidgetResponseData } from '../src/interfaces/UserDirectorySearchAction'; import { WidgetApiFromWidgetAction } from '../src/interfaces/WidgetApiAction'; -import { PostmessageTransport } from '../src/transport/PostmessageTransport'; -import { WidgetApi } from '../src/WidgetApi'; - -jest.mock('../src/transport/PostmessageTransport') +import { WidgetApi, WidgetApiResponseError } from '../src/WidgetApi'; +import { + IWidgetApiErrorResponseData, + IWidgetApiErrorResponseDataDetails, + IWidgetApiRequest, + IWidgetApiRequestData, + IWidgetApiResponse, + IWidgetApiResponseData, + UpdateDelayedEventAction, + WidgetApiDirection, +} from '../src'; + +type SendRequestArgs = { + action: WidgetApiFromWidgetAction, + data: IWidgetApiRequestData, +}; + +class TransportChannels { + /** Data sent by widget requests */ + public readonly requestQueue: Array = []; + /** Responses to send as if from a client. Initialized with the response to {@link WidgetApi.start}*/ + public readonly responseQueue: IWidgetApiResponseData[] = [ + { supported_versions: [] } satisfies ISupportedVersionsActionResponseData, + ]; +} + +class WidgetTransportHelper { + /** For ignoring the request sent by {@link WidgetApi.start} */ + private skippedFirstRequest = false; + + constructor(private channels: TransportChannels) {} + + public nextTrackedRequest(): SendRequestArgs | undefined { + if (!this.skippedFirstRequest) { + this.skippedFirstRequest = true; + this.channels.requestQueue.shift(); + } + return this.channels.requestQueue.shift(); + } + + public queueResponse(data: IWidgetApiResponseData): void { + this.channels.responseQueue.push(data); + } +} + +class ClientTransportHelper { + constructor(private channels: TransportChannels) {} + + public trackRequest(action: WidgetApiFromWidgetAction, data: IWidgetApiRequestData): void { + this.channels.requestQueue.push({action, data}); + } + + public nextQueuedResponse(): IWidgetApiRequestData | undefined { + return this.channels.responseQueue.shift(); + } +} describe('WidgetApi', () => { let widgetApi: WidgetApi; + let widgetTransportHelper: WidgetTransportHelper; + let clientListener: (e: MessageEvent) => void; beforeEach(() => { - widgetApi = new WidgetApi() + const channels = new TransportChannels(); + widgetTransportHelper = new WidgetTransportHelper(channels); + const clientTrafficHelper = new ClientTransportHelper(channels); + + clientListener = (e: MessageEvent) => { + if (!e.data.action || !e.data.requestId || !e.data.widgetId) return; // invalid request/response + if ("response" in e.data || e.data.api !== WidgetApiDirection.FromWidget) return; // not a request + const request = e.data; + + clientTrafficHelper.trackRequest( + request.action as WidgetApiFromWidgetAction, + request.data, + ); + + const response = clientTrafficHelper.nextQueuedResponse(); + if (response) { + window.postMessage( + { + ...request, + response: response, + } satisfies IWidgetApiResponse, + "*", + ); + } + } + window.addEventListener("message", clientListener); + + widgetApi = new WidgetApi("WidgetApi-test", "*"); + widgetApi.start(); }); afterEach(() => { - jest.resetAllMocks(); + window.removeEventListener("message", clientListener); }); describe('readEventRelations', () => { it('should forward the request to the ClientWidgetApi', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC3869] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValue( + widgetTransportHelper.queueResponse( { chunk: [], } as IReadRelationsFromWidgetResponseData, @@ -57,20 +140,24 @@ describe('WidgetApi', () => { chunk: [], }); - expect(PostmessageTransport.prototype.send).toBeCalledWith(WidgetApiFromWidgetAction.MSC3869ReadRelations, { - event_id: '$event', - room_id: '!room-id', - rel_type: 'm.reference', - event_type: 'm.room.message', - limit: 25, - from: 'from-token', - to: 'to-token', - direction: 'f', - }); + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ + action: WidgetApiFromWidgetAction.MSC3869ReadRelations, + data: { + event_id: '$event', + room_id: '!room-id', + rel_type: 'm.reference', + event_type: 'm.room.message', + limit: 25, + from: 'from-token', + to: 'to-token', + direction: 'f', + }, + } satisfies SendRequestArgs); }); it('should reject the request if the api is not supported', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [] } as ISupportedVersionsActionResponseData, ); @@ -79,16 +166,20 @@ describe('WidgetApi', () => { 'from-token', 'to-token', 'f', )).rejects.toThrow("The read_relations action is not supported by the client."); - expect(PostmessageTransport.prototype.send) - .not.toBeCalledWith(WidgetApiFromWidgetAction.MSC3869ReadRelations, expect.anything()); + const request = widgetTransportHelper.nextTrackedRequest(); + expect(request).not.toBeUndefined(); + expect(request).not.toEqual({ + action: WidgetApiFromWidgetAction.MSC3869ReadRelations, + data: expect.anything(), + } satisfies SendRequestArgs) }); it('should handle an error', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC3869] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockRejectedValue( - new Error('An error occurred'), + widgetTransportHelper.queueResponse( + { error: { message: 'An error occurred' } } as IWidgetApiErrorResponseData, ); await expect(widgetApi.readEventRelations( @@ -96,19 +187,49 @@ describe('WidgetApi', () => { 'from-token', 'to-token', 'f', )).rejects.toThrow('An error occurred'); }); + + it('should handle an error with details', async () => { + widgetTransportHelper.queueResponse( + { supported_versions: [UnstableApiVersion.MSC3869] } as ISupportedVersionsActionResponseData, + ); + + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: 'Unknown error', + }, + }, + }; + + widgetTransportHelper.queueResponse( + { + error: { + message: 'An error occurred', + ...errorDetails, + }, + } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.readEventRelations( + '$event', '!room-id', 'm.reference', 'm.room.message', 25, + 'from-token', 'to-token', 'f', + )).rejects.toThrow(new WidgetApiResponseError('An error occurred', errorDetails)); + }); }); describe('sendEvent', () => { - beforeEach(() => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + it('sends message events', async () => { + widgetTransportHelper.queueResponse( { room_id: '!room-id', event_id: '$event', } as ISendEventFromWidgetResponseData, ); - }); - it('sends message events', async () => { await expect(widgetApi.sendRoomEvent( 'm.room.message', {}, @@ -120,6 +241,13 @@ describe('WidgetApi', () => { }); it('sends state events', async () => { + widgetTransportHelper.queueResponse( + { + room_id: '!room-id', + event_id: '$event', + } as ISendEventFromWidgetResponseData, + ); + await expect(widgetApi.sendStateEvent( 'm.room.topic', "", @@ -130,19 +258,58 @@ describe('WidgetApi', () => { event_id: '$event', }); }); + + it('should handle an error', async () => { + widgetTransportHelper.queueResponse( + { error: { message: 'An error occurred' } } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.sendRoomEvent( + 'm.room.message', + {}, + '!room-id', + )).rejects.toThrow('An error occurred'); + }); + + it('should handle an error with details', async () => { + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: 'Unknown error', + }, + }, + }; + + widgetTransportHelper.queueResponse( + { + error: { + message: 'An error occurred', + ...errorDetails, + }, + } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.sendRoomEvent( + 'm.room.message', + {}, + '!room-id', + )).rejects.toThrow(new WidgetApiResponseError('An error occurred', errorDetails)); + }); }); describe('delayed sendEvent', () => { - beforeEach(() => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + it('sends delayed message events', async () => { + widgetTransportHelper.queueResponse( { room_id: '!room-id', delay_id: 'id', } as ISendEventFromWidgetResponseData, ); - }); - it('sends delayed message events', async () => { await expect(widgetApi.sendRoomEvent( 'm.room.message', {}, @@ -155,6 +322,13 @@ describe('WidgetApi', () => { }); it('sends delayed state events', async () => { + widgetTransportHelper.queueResponse( + { + room_id: '!room-id', + delay_id: 'id', + } as ISendEventFromWidgetResponseData, + ); + await expect(widgetApi.sendStateEvent( 'm.room.topic', "", @@ -168,12 +342,19 @@ describe('WidgetApi', () => { }); it('sends delayed child action message events', async () => { + widgetTransportHelper.queueResponse( + { + room_id: '!room-id', + delay_id: 'id', + } as ISendEventFromWidgetResponseData, + ); + await expect(widgetApi.sendRoomEvent( 'm.room.message', {}, '!room-id', - null, - 'id-parent', + 1000, + undefined, )).resolves.toEqual({ room_id: '!room-id', delay_id: 'id', @@ -181,33 +362,124 @@ describe('WidgetApi', () => { }); it('sends delayed child action state events', async () => { + widgetTransportHelper.queueResponse( + { + room_id: '!room-id', + delay_id: 'id', + } as ISendEventFromWidgetResponseData, + ); + await expect(widgetApi.sendStateEvent( 'm.room.topic', "", {}, '!room-id', - null, - 'id-parent', + 1000, + undefined, )).resolves.toEqual({ room_id: '!room-id', delay_id: 'id', }); }); + + it('should handle an error', async () => { + widgetTransportHelper.queueResponse( + { error: { message: 'An error occurred' } } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.sendRoomEvent( + 'm.room.message', + {}, + '!room-id', + 1000, + undefined, + )).rejects.toThrow('An error occurred'); + }); + + it('should handle an error with details', async () => { + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: 'Unknown error', + }, + }, + }; + + widgetTransportHelper.queueResponse( + { + error: { + message: 'An error occurred', + ...errorDetails, + }, + } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.sendRoomEvent( + 'm.room.message', + {}, + '!room-id', + 1000, + undefined, + )).rejects.toThrow(new WidgetApiResponseError('An error occurred', errorDetails)); + }); }); describe('updateDelayedEvent', () => { - beforeEach(() => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce({}); + it('updates delayed events', async () => { + widgetTransportHelper.queueResponse({}); + await expect(widgetApi.updateDelayedEvent( + 'id', + UpdateDelayedEventAction.Send, + )).resolves.toEqual({}); }); - it('updates delayed events', async () => { - await expect(widgetApi.updateDelayedEvent('id', 'send')).resolves.toEqual({}); + it('should handle an error', async () => { + widgetTransportHelper.queueResponse( + { error: { message: 'An error occurred' } } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.updateDelayedEvent( + 'id', + UpdateDelayedEventAction.Send, + )).rejects.toThrow('An error occurred'); + }); + + it('should handle an error with details', async () => { + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: 'Unknown error', + }, + }, + }; + + widgetTransportHelper.queueResponse( + { + error: { + message: 'An error occurred', + ...errorDetails, + }, + } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.updateDelayedEvent( + 'id', + UpdateDelayedEventAction.Send, + )).rejects.toThrow(new WidgetApiResponseError('An error occurred', errorDetails)); }); }); describe('getClientVersions', () => { beforeEach(() => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [ UnstableApiVersion.MSC3869, UnstableApiVersion.MSC2762, @@ -231,16 +503,17 @@ describe('WidgetApi', () => { 'org.matrix.msc3869', 'org.matrix.msc2762', ]); - expect(PostmessageTransport.prototype.send).toBeCalledTimes(1); + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toBeUndefined(); }) }); describe('searchUserDirectory', () => { it('should forward the request to the ClientWidgetApi', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC3973] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValue( + widgetTransportHelper.queueResponse( { limited: false, results: [], @@ -254,17 +527,18 @@ describe('WidgetApi', () => { results: [], }); - expect(PostmessageTransport.prototype.send).toBeCalledWith( - WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, - { + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: { search_term: 'foo', limit: 10, }, - ); + } satisfies SendRequestArgs); }); it('should reject the request if the api is not supported', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [] } as ISupportedVersionsActionResponseData, ); @@ -272,30 +546,65 @@ describe('WidgetApi', () => { 'foo', 10, )).rejects.toThrow("The user_directory_search action is not supported by the client."); - expect(PostmessageTransport.prototype.send) - .not.toBeCalledWith(WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, expect.anything()); + const request = widgetTransportHelper.nextTrackedRequest(); + expect(request).not.toBeUndefined(); + expect(request).not.toEqual({ + action: WidgetApiFromWidgetAction.MSC3973UserDirectorySearch, + data: expect.anything(), + } satisfies SendRequestArgs); }); it('should handle an error', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC3973] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockRejectedValue( - new Error('An error occurred'), + widgetTransportHelper.queueResponse( + { error: { message: 'An error occurred' } }, ); await expect(widgetApi.searchUserDirectory( 'foo', 10, )).rejects.toThrow('An error occurred'); }); + + it('should handle an error with details', async () => { + widgetTransportHelper.queueResponse( + { supported_versions: [UnstableApiVersion.MSC3973] } as ISupportedVersionsActionResponseData, + ); + + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: 'Unknown error', + }, + }, + }; + + widgetTransportHelper.queueResponse( + { + error: { + message: 'An error occurred', + ...errorDetails, + }, + } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.searchUserDirectory( + 'foo', 10, + )).rejects.toThrow(new WidgetApiResponseError('An error occurred', errorDetails)); + }); }); describe('getMediaConfig', () => { it('should forward the request to the ClientWidgetApi', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValue( + widgetTransportHelper.queueResponse( { 'm.upload.size': 1000 } as IGetMediaConfigActionFromWidgetResponseData, ); @@ -303,14 +612,15 @@ describe('WidgetApi', () => { 'm.upload.size': 1000, }); - expect(PostmessageTransport.prototype.send).toBeCalledWith( - WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, - {}, - ); + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: {}, + } satisfies SendRequestArgs); }); it('should reject the request if the api is not supported', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [] } as ISupportedVersionsActionResponseData, ); @@ -318,30 +628,65 @@ describe('WidgetApi', () => { "The get_media_config action is not supported by the client.", ); - expect(PostmessageTransport.prototype.send) - .not.toBeCalledWith(WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, expect.anything()); + const request = widgetTransportHelper.nextTrackedRequest(); + expect(request).not.toBeUndefined(); + expect(request).not.toEqual({ + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: expect.anything(), + } satisfies SendRequestArgs); }); it('should handle an error', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockRejectedValue( - new Error('An error occurred'), + widgetTransportHelper.queueResponse( + { error: { message: 'An error occurred' } }, ); await expect(widgetApi.getMediaConfig()).rejects.toThrow( 'An error occurred', ); }); + + it('should handle an error with details', async () => { + widgetTransportHelper.queueResponse( + { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, + ); + + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: 'Unknown error', + }, + }, + }; + + widgetTransportHelper.queueResponse( + { + error: { + message: 'An error occurred', + ...errorDetails, + }, + } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.getMediaConfig()).rejects.toThrow( + new WidgetApiResponseError('An error occurred', errorDetails), + ); + }); }); describe('uploadFile', () => { it('should forward the request to the ClientWidgetApi', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValue( + widgetTransportHelper.queueResponse( { content_uri: 'mxc://...' } as IUploadFileActionFromWidgetResponseData, ); @@ -349,14 +694,15 @@ describe('WidgetApi', () => { content_uri: 'mxc://...', }); - expect(PostmessageTransport.prototype.send).toBeCalledWith( - WidgetApiFromWidgetAction.MSC4039UploadFileAction, - { file: "data" }, - ); + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ + action: WidgetApiFromWidgetAction.MSC4039UploadFileAction, + data: { file: "data" }, + } satisfies SendRequestArgs); }); it('should reject the request if the api is not supported', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [] } as ISupportedVersionsActionResponseData, ); @@ -364,30 +710,65 @@ describe('WidgetApi', () => { "The upload_file action is not supported by the client.", ); - expect(PostmessageTransport.prototype.send) - .not.toBeCalledWith(WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, expect.anything()); + const request = widgetTransportHelper.nextTrackedRequest(); + expect(request).not.toBeUndefined(); + expect(request).not.toEqual({ + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: expect.anything(), + } satisfies SendRequestArgs); }); it('should handle an error', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockRejectedValue( - new Error('An error occurred'), + widgetTransportHelper.queueResponse( + { error: { message: 'An error occurred' } }, ); await expect(widgetApi.uploadFile("data")).rejects.toThrow( 'An error occurred', ); }); + + it('should handle an error with details', async () => { + widgetTransportHelper.queueResponse( + { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, + ); + + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: 'Unknown error', + }, + }, + }; + + widgetTransportHelper.queueResponse( + { + error: { + message: 'An error occurred', + ...errorDetails, + }, + } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.uploadFile("data")).rejects.toThrow( + new WidgetApiResponseError('An error occurred', errorDetails), + ); + }); }); describe('downloadFile', () => { it('should forward the request to the ClientWidgetApi', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValue( + widgetTransportHelper.queueResponse( { file: 'test contents' } as IDownloadFileActionFromWidgetResponseData, ); @@ -395,14 +776,15 @@ describe('WidgetApi', () => { file: 'test contents', }); - expect(PostmessageTransport.prototype.send).toHaveBeenCalledWith( - WidgetApiFromWidgetAction.MSC4039DownloadFileAction, - { content_uri: "mxc://example.com/test_file" }, - ); + expect(widgetTransportHelper.nextTrackedRequest()).not.toBeUndefined(); + expect(widgetTransportHelper.nextTrackedRequest()).toEqual({ + action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction, + data: { content_uri: "mxc://example.com/test_file" }, + } satisfies SendRequestArgs); }); it('should reject the request if the api is not supported', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [] } as ISupportedVersionsActionResponseData, ); @@ -410,21 +792,56 @@ describe('WidgetApi', () => { "The download_file action is not supported by the client.", ); - expect(PostmessageTransport.prototype.send) - .not.toHaveBeenCalledWith(WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, expect.anything()); + const request = widgetTransportHelper.nextTrackedRequest(); + expect(request).not.toBeUndefined(); + expect(request).not.toEqual({ + action: WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, + data: expect.anything(), + } satisfies SendRequestArgs); }); it('should handle an error', async () => { - jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce( + widgetTransportHelper.queueResponse( { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, ); - jest.mocked(PostmessageTransport.prototype.send).mockRejectedValue( - new Error('An error occurred'), + widgetTransportHelper.queueResponse( + { error: { message: 'An error occurred' } }, ); await expect(widgetApi.downloadFile("mxc://example.com/test_file")).rejects.toThrow( 'An error occurred', ); }); + + it('should handle an error with details', async () => { + widgetTransportHelper.queueResponse( + { supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData, + ); + + const errorDetails: IWidgetApiErrorResponseDataDetails = { + matrix_api_error: { + http_status: 400, + http_headers: {}, + url: "", + response: { + errcode: "M_UNKNOWN", + error: 'Unknown error', + }, + }, + }; + + widgetTransportHelper.queueResponse( + { + error: { + message: 'An error occurred', + ...errorDetails, + }, + } as IWidgetApiErrorResponseData, + ); + + await expect(widgetApi.downloadFile("mxc://example.com/test_file")).rejects.toThrow( + new WidgetApiResponseError('An error occurred', errorDetails), + ); + }); }); });