Skip to content

Commit

Permalink
fix: properly handle invalid interfaces during test (#2433)
Browse files Browse the repository at this point in the history
When using `.getInterface()` with a Snap that produces an invalid
interface, the tests would previously time out. This PR aims to catch
the problem and fail sooner with a better error message.
  • Loading branch information
FrederikBolding authored May 28, 2024
1 parent 6bd631e commit 963bb5e
Show file tree
Hide file tree
Showing 2 changed files with 144 additions and 17 deletions.
76 changes: 76 additions & 0 deletions packages/snaps-jest/src/internals/request.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getRestrictedSnapInterfaceControllerMessenger,
getRootControllerMessenger,
} from '../test-utils';
import type { SnapResponseWithInterface } from '../types';
import {
getInterfaceApi,
getInterfaceFromResult,
Expand Down Expand Up @@ -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: `
Expand Down Expand Up @@ -125,6 +200,7 @@ describe('handleRequest', () => {
}),
},
notifications: [],
getInterface: expect.any(Function),
});

await closeServer();
Expand Down
85 changes: 68 additions & 17 deletions packages/snaps-jest/src/internals/request.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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;
Expand All @@ -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;
Expand Down

0 comments on commit 963bb5e

Please sign in to comment.