UNPKG

@metamask/snaps-jest

Version:

A Jest preset for end-to-end testing MetaMask Snaps, including a Jest environment, and a set of Jest matchers

282 lines 13.3 kB
// Note: Because this file imports from `@jest/globals`, it can only be used in // a Jest environment. This is why it's not exported from the index file. import $jestglobals from "@jest/globals"; const { expect } = $jestglobals; import { isJSXElementUnsafe, JSXElementStruct } from "@metamask/snaps-sdk/jsx"; import { InterfaceStruct, SnapResponseStruct } from "@metamask/snaps-simulation"; import { getJsxElementFromComponent, serialiseJsx } from "@metamask/snaps-utils"; import { is } from "@metamask/superstruct"; import { hasProperty } from "@metamask/utils"; import { EXPECTED_COLOR, diff, matcherErrorMessage, matcherHint, printReceived, printWithType, RECEIVED_COLOR } from "jest-matcher-utils"; /** * Ensure that the actual value is a response from the `request` function. * * @param actual - The actual value. * @param matcherName - The name of the matcher. * @param options - The matcher options. */ function assertActualIsSnapResponse(actual, matcherName, options) { if (!is(actual, SnapResponseStruct)) { throw new Error(matcherErrorMessage(matcherHint(matcherName, undefined, undefined, options), `${RECEIVED_COLOR('received')} value must be a response from the \`request\` function`, printWithType('Received', actual, printReceived))); } } /** * Ensure that the actual value is a response from the `request` function, and * that it has a `ui` property. * * @param actual - The actual value. * @param matcherName - The name of the matcher. * @param options - The matcher options. */ function assertHasInterface(actual, matcherName, options) { if (!is(actual, InterfaceStruct) || !actual.content) { throw new Error(matcherErrorMessage(matcherHint(matcherName, undefined, undefined, options), `${RECEIVED_COLOR('received')} value must have a \`content\` property`, printWithType('Received', actual, printReceived))); } } /** * Check if a JSON-RPC response matches the expected value. This matcher is * intended to be used with the `expect` global. * * @param actual - The actual response. * @param expected - The expected response. * @returns The status and message. */ export const toRespondWith = function (actual, expected) { assertActualIsSnapResponse(actual, 'toRespondWith'); const { response } = actual; if (hasProperty(response, 'error')) { const message = () => `${this.utils.matcherHint('.toRespondWith')}\n\n` + `Expected response: ${this.utils.printExpected(expected)}\n` + `Received error: ${this.utils.printReceived(response.error)}`; return { message, pass: false }; } const pass = this.equals(response.result, expected); const message = pass ? () => `${this.utils.matcherHint('.not.toRespondWith')}\n\n` + `Expected: ${this.utils.printExpected(expected)}\n` + `Received: ${this.utils.printReceived(response.result)}` : () => `${this.utils.matcherHint('.toRespondWith')}\n\n` + `Expected: ${this.utils.printExpected(expected)}\n` + `Received: ${this.utils.printReceived(response.result)}`; return { message, pass }; }; export const toRespondWithError = function (actual, expected) { assertActualIsSnapResponse(actual, 'toRespondWithError'); const { response } = actual; if (hasProperty(response, 'result')) { const message = () => `${this.utils.matcherHint('.toRespondWithError')}\n\n` + `Expected error: ${this.utils.printExpected(expected)}\n` + `Received result: ${this.utils.printReceived(response.result)}`; return { message, pass: false }; } const pass = this.equals(response.error, expected); const message = pass ? () => `${this.utils.matcherHint('.not.toRespondWithError')}\n\n` + `Expected: ${this.utils.printExpected(expected)}\n` + `Received: ${this.utils.printReceived(response.error)}` : () => `${this.utils.matcherHint('.toRespondWithError')}\n\n` + `Expected: ${this.utils.printExpected(expected)}\n` + `Received: ${this.utils.printReceived(response.error)}`; return { message, pass }; }; /** * Check if the snap sent a notification with the expected message. This matcher * is intended to be used with the `expect` global. * * @param actual - The actual response. * @param expectedMessage - The expected notification message. * @param expectedType - The expected notification type. * @param expectedTitle - The expected notification title. * @param expectedContent - The expected notification JSX content. * @param expectedFooterLink - The expected footer link object. * @returns The status and message. */ export const toSendNotification = function (actual, expectedMessage, expectedType, expectedTitle, expectedContent, expectedFooterLink) { assertActualIsSnapResponse(actual, 'toSendNotification'); const { notifications } = actual; let jsxContent; if ('getInterface' in actual) { jsxContent = actual.getInterface().content; } const notificationValidator = (notification) => { const { type, message, title, footerLink } = notification; if (!this.equals(message, expectedMessage)) { return false; } if (expectedType && type !== expectedType) { return false; } if (title && !this.equals(title, expectedTitle)) { return false; } if (jsxContent && !this.equals(jsxContent, expectedContent)) { return false; } if (footerLink && !this.equals(footerLink, expectedFooterLink)) { return false; } return true; }; const pass = notifications.some(notificationValidator); const transformedNotifications = notifications.map((notification) => { return { ...notification, // Ok to cast here as the function returns if the param is falsy content: serialiseJsx(jsxContent), }; }); const message = () => { let testMessage = pass ? `${this.utils.matcherHint('.not.toSendNotification')}\n\n` : `${this.utils.matcherHint('.toSendNotification')}\n\n`; const { title, type, message: notifMessage, footerLink, content, } = transformedNotifications[0]; testMessage += `Expected message: ${this.utils.printExpected(expectedMessage)}\n`; if (expectedType) { testMessage += `Expected type: ${this.utils.printExpected(expectedType)}\n`; } if (title) { testMessage += `Expected title: ${this.utils.printExpected(expectedTitle)}\n`; // We want to check if the expected content is actually JSX content, otherwise `serialiseJsx` won't return something useful. if (is(expectedContent, JSXElementStruct)) { testMessage += `Expected content: ${this.utils.printExpected(serialiseJsx(expectedContent))}\n`; } else { testMessage += `Expected content: ${this.utils.printExpected(expectedContent)}\n`; } } if (footerLink) { testMessage += `Expected footer link: ${this.utils.printExpected(expectedFooterLink)}\n`; } testMessage += `Received message: ${this.utils.printExpected(notifMessage)}\n`; if (expectedType) { testMessage += `Received type: ${this.utils.printReceived(type)}\n`; } if (title) { testMessage += `Received title: ${this.utils.printReceived(title)}\n`; testMessage += `Received content: ${this.utils.printReceived(serialiseJsx(content))}\n`; } if (footerLink) { testMessage += `Received footer link: ${this.utils.printReceived(footerLink)}\n`; } return testMessage; }; return { message, pass }; }; const toRenderLegacy = function (actual, expected) { assertHasInterface(actual, 'toRender'); const { content } = actual; const expectedElement = getJsxElementFromComponent(expected); const pass = this.equals(content, expectedElement); // This is typed as `string | null`, but in practice it's always a string. // The function only returns `null` if both the expected and actual values // are numbers, bigints, or booleans, which is never the case here. const difference = diff(expectedElement, content); const message = pass ? () => `${this.utils.matcherHint('.not.toRender')}\n\n` + `Expected:\n${this.utils.printExpected(expectedElement)}\n\n` + `Received:\n${this.utils.printReceived(content)}` + `\n\nDifference:\n\n${difference}` : () => `${this.utils.matcherHint('.toRender')}\n\n` + `Expected:\n${this.utils.printExpected(expectedElement)}\n\n` + `Received:\n${this.utils.printReceived(content)}` + `\n\nDifference:\n\n${difference}`; return { message, pass }; }; export const toRender = // This should not return a promise. // eslint-disable-next-line @typescript-eslint/promise-function-async function (actual, expected) { assertHasInterface(actual, 'toRender'); if (!isJSXElementUnsafe(expected)) { return toRenderLegacy.call(this, actual, expected); } const { content } = actual; const pass = this.equals(content, expected); // This is typed as `string | null`, but in practice it's always a string. // The function only returns `null` if both the expected and actual values // are numbers, bigints, or booleans, which is never the case here. const difference = diff(serialiseJsx(expected), serialiseJsx(content)); const message = pass ? () => `${this.utils.matcherHint('.not.toRender')}\n\n` + `Expected:\n${EXPECTED_COLOR(serialiseJsx(expected))}\n\n` + `Received:\n${RECEIVED_COLOR(serialiseJsx(content))}` + `\n\nDifference:\n\n${difference}` : () => `${this.utils.matcherHint('.toRender')}\n\n` + `Expected:\n${EXPECTED_COLOR(serialiseJsx(expected))}\n\n` + `Received:\n${RECEIVED_COLOR(serialiseJsx(content))}` + `\n\nDifference:\n\n${difference}`; return { message, pass }; }; export const toTrackError = function (actual, errorData) { assertActualIsSnapResponse(actual, 'toTrackError'); const errorValidator = (error) => { if (!errorData) { // If no error data is provided, we just check for the existence of an // error. return true; } return this.equals(error, errorData); }; const { errors } = actual.tracked; const pass = errors.some(errorValidator); const message = pass ? () => `${this.utils.matcherHint('.not.toTrackError')}\n\n` + `Expected not to track error with data: ${this.utils.printExpected(errorData)}\n` + `Received errors: ${this.utils.printReceived(errors)}` : () => `${this.utils.matcherHint('.toTrackError')}\n\n` + `Expected to track error with data: ${this.utils.printExpected(errorData)}\n` + `Received errors: ${this.utils.printReceived(errors)}`; return { message, pass }; }; export const toTrackEvent = function (actual, eventData) { assertActualIsSnapResponse(actual, 'toTrackEvent'); const eventValidator = (event) => { if (!eventData) { // If no event data is provided, we just check for the existence of an // event. return true; } return this.equals(event, eventData); }; const { events } = actual.tracked; const pass = events.some(eventValidator); const message = pass ? () => `${this.utils.matcherHint('.not.toTrackEvent')}\n\n` + `Expected not to track event with data: ${this.utils.printExpected(eventData)}\n` + `Received events: ${this.utils.printReceived(events)}` : () => `${this.utils.matcherHint('.toTrackEvent')}\n\n` + `Expected to track event with data: ${this.utils.printExpected(eventData)}\n` + `Received events: ${this.utils.printReceived(events)}`; return { message, pass }; }; export const toTrace = function (actual, traceData) { assertActualIsSnapResponse(actual, 'toTrace'); const traceValidator = (trace) => { if (!traceData) { // If no trace data is provided, we just check for the existence of a // trace. return true; } return this.equals(trace, traceData); }; const { traces } = actual.tracked; const pass = traces.some(traceValidator); const message = pass ? () => `${this.utils.matcherHint('.not.toTrace')}\n\n` + `Expected not to trace with data: ${this.utils.printExpected(traceData)}\n` + `Received traces: ${this.utils.printReceived(traces)}` : () => `${this.utils.matcherHint('.toTrace')}\n\n` + `Expected to trace with data: ${this.utils.printExpected(traceData)}\n` + `Received traces: ${this.utils.printReceived(traces)}`; return { message, pass }; }; expect.extend({ toRespondWith, toRespondWithError, toSendNotification, toRender, toTrackError, toTrackEvent, toTrace, }); //# sourceMappingURL=matchers.mjs.map