diff --git a/packages/snaps-jest/src/internals/request.test.tsx b/packages/snaps-jest/src/internals/request.test.tsx index 49abb716ed..a9c5883073 100644 --- a/packages/snaps-jest/src/internals/request.test.tsx +++ b/packages/snaps-jest/src/internals/request.test.tsx @@ -10,6 +10,7 @@ import { getRestrictedSnapInterfaceControllerMessenger, getRootControllerMessenger, } from '../test-utils'; +import type { SnapResponseWithInterface } from '../types'; import { getInterfaceApi, getInterfaceFromResult, @@ -97,6 +98,80 @@ describe('handleRequest', () => { await snap.executionService.terminateAllSnaps(); }); + it('gracefully handles returned invalid UI', async () => { + const controllerMessenger = getRootControllerMessenger(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onHomePage = async (request) => { + return ({ content: 'foo' }); + }; + `, + port: 4242, + }); + + const snap = await handleInstallSnap(snapId); + const response = await handleRequest({ + ...snap, + controllerMessenger, + handler: HandlerType.OnHomePage, + request: { + method: '', + }, + }); + + expect(response).toStrictEqual({ + id: expect.any(String), + response: { + error: expect.objectContaining({ + code: -32603, + message: 'The Snap returned an invalid interface.', + }), + }, + notifications: [], + getInterface: expect.any(Function), + }); + + expect(() => + (response as SnapResponseWithInterface).getInterface(), + ).toThrow( + 'Unable to get the interface from the Snap: The request to the Snap failed.', + ); + + await closeServer(); + await snap.executionService.terminateAllSnaps(); + }); + + it('gracefully handles returned invalid UI when not awaiting the request', async () => { + const controllerMessenger = getRootControllerMessenger(); + + const { snapId, close: closeServer } = await getMockServer({ + sourceCode: ` + module.exports.onHomePage = async (request) => { + return ({ content: 'foo' }); + }; + `, + port: 4242, + }); + + const snap = await handleInstallSnap(snapId); + const promise = handleRequest({ + ...snap, + controllerMessenger, + handler: HandlerType.OnHomePage, + request: { + method: '', + }, + }); + + await expect(promise.getInterface()).rejects.toThrow( + 'Unable to get the interface from the Snap: The returned interface may be invalid. The error message received was: The Snap returned an invalid interface.', + ); + + await closeServer(); + await snap.executionService.terminateAllSnaps(); + }); + it('returns an error response', async () => { const { snapId, close: closeServer } = await getMockServer({ sourceCode: ` @@ -125,6 +200,7 @@ describe('handleRequest', () => { }), }, notifications: [], + getInterface: expect.any(Function), }); await closeServer(); diff --git a/packages/snaps-jest/src/internals/request.ts b/packages/snaps-jest/src/internals/request.ts index ee52b932e2..dae5083066 100644 --- a/packages/snaps-jest/src/internals/request.ts +++ b/packages/snaps-jest/src/internals/request.ts @@ -1,9 +1,20 @@ import type { AbstractExecutionService } from '@metamask/snaps-controllers'; -import type { SnapId, Component } from '@metamask/snaps-sdk'; +import { + type SnapId, + type JsonRpcError, + type ComponentOrElement, + ComponentOrElementStruct, +} from '@metamask/snaps-sdk'; import type { HandlerType } from '@metamask/snaps-utils'; import { unwrapError } from '@metamask/snaps-utils'; -import { getSafeJson, hasProperty, isPlainObject } from '@metamask/utils'; +import { + assert, + getSafeJson, + hasProperty, + isPlainObject, +} from '@metamask/utils'; import { nanoid } from '@reduxjs/toolkit'; +import { is } from 'superstruct'; import type { RequestOptions, @@ -20,6 +31,7 @@ import { } from './simulation'; import type { RunSagaFunction, Store } from './simulation'; import type { RootControllerMessenger } from './simulation/controllers'; +import { SnapResponseStruct } from './structs'; export type HandleRequestOptions = { snapId: SnapId; @@ -60,6 +72,12 @@ export function handleRequest({ runSaga, request: { id = nanoid(), origin = 'https://metamask.io', ...options }, }: HandleRequestOptions): SnapRequest { + const getInterfaceError = () => { + throw new Error( + 'Unable to get the interface from the Snap: The request to the Snap failed.', + ); + }; + const promise = executionService .handleRpcRequest(snapId, { origin, @@ -74,20 +92,32 @@ export function handleRequest({ const notifications = getNotifications(store.getState()); store.dispatch(clearNotifications()); - const getInterfaceFn = await getInterfaceApi( - result, - snapId, - controllerMessenger, - ); + try { + const getInterfaceFn = await getInterfaceApi( + result, + snapId, + controllerMessenger, + ); - return { - id: String(id), - response: { - result: getSafeJson(result), - }, - notifications, - ...(getInterfaceFn ? { getInterface: getInterfaceFn } : {}), - }; + return { + id: String(id), + response: { + result: getSafeJson(result), + }, + notifications, + ...(getInterfaceFn ? { getInterface: getInterfaceFn } : {}), + }; + } catch (error) { + const [unwrappedError] = unwrapError(error); + return { + id: String(id), + response: { + error: unwrappedError.serialize(), + }, + notifications: [], + getInterface: getInterfaceError, + }; + } }) .catch((error) => { const [unwrappedError] = unwrapError(error); @@ -98,16 +128,33 @@ export function handleRequest({ error: unwrappedError.serialize(), }, notifications: [], + getInterface: getInterfaceError, }; }) as unknown as SnapRequest; promise.getInterface = async () => { - return await runSaga( + const sagaPromise = runSaga( getInterface, runSaga, snapId, controllerMessenger, ).toPromise(); + const result = await Promise.race([promise, sagaPromise]); + + // If the request promise has resolved to an error, we should throw + // instead of waiting for an interface that likely will never be displayed + if ( + is(result, SnapResponseStruct) && + hasProperty(result.response, 'error') + ) { + throw new Error( + `Unable to get the interface from the Snap: The returned interface may be invalid. The error message received was: ${ + (result.response.error as JsonRpcError).message + }`, + ); + } + + return await sagaPromise; }; return promise; @@ -131,10 +178,14 @@ export async function getInterfaceFromResult( } if (isPlainObject(result) && hasProperty(result, 'content')) { + assert( + is(result.content, ComponentOrElementStruct), + 'The Snap returned an invalid interface.', + ); const id = await controllerMessenger.call( 'SnapInterfaceController:createInterface', snapId, - result.content as Component, + result.content as ComponentOrElement, ); return id;