1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-26 12:29:06 +01:00
metamask-extension/test/e2e/tests/errors.spec.js
Mark Stacey 702ee233e7
Remove snapshot update from release process (#20546)
The Sentry e2e state snapshots now mask the application version and
migration version, ensuring that the snapshots don't need a extra
update in each release candidate branch and post-release sync branch.

The values are masked rather than removed so that the test still shows
they are present in error reports, where they can be quite useful for
diagnostic purposes.
2023-08-21 12:53:04 -02:30

740 lines
25 KiB
JavaScript

const { resolve } = require('path');
const { promises: fs } = require('fs');
const { strict: assert } = require('assert');
const { get, has, set, unset } = require('lodash');
const { Browser } = require('selenium-webdriver');
const { format } = require('prettier');
const { convertToHexValue, withFixtures } = require('../helpers');
const FixtureBuilder = require('../fixture-builder');
const maskedBackgroundFields = [
'CurrencyController.conversionDate', // This is a timestamp that changes each run
// App metadata is masked so that we don't have to update the snapshot as
// part of the release process
'AppMetadataController.currentAppVersion',
'AppMetadataController.currentMigrationVersion',
];
const maskedUiFields = [
'metamask.conversionDate', // This is a timestamp that changes each run
// App metadata is masked so that we don't have to update the snapshot as
// part of the release process
'metamask.currentAppVersion',
'metamask.currentMigrationVersion',
];
const removedBackgroundFields = [
// This property is timing-dependent
'AccountTracker.currentBlockGasLimit',
// These properties are set to undefined, causing inconsistencies between Chrome and Firefox
'AppStateController.currentPopupId',
'AppStateController.timeoutMinutes',
];
const removedUiFields = [
// This property is timing-dependent
'metamask.currentBlockGasLimit',
// These properties are set to undefined, causing inconsistencies between Chrome and Firefox
'metamask.currentPopupId',
'metamask.timeoutMinutes',
];
/**
* Transform background state to make it consistent between test runs.
*
* @param {unknown} data - The data to transform
*/
function transformBackgroundState(data) {
for (const field of maskedBackgroundFields) {
if (has(data, field)) {
set(data, field, typeof get(data, field));
}
}
for (const field of removedBackgroundFields) {
if (has(data, field)) {
unset(data, field);
}
}
return data;
}
/**
* Transform UI state to make it consistent between test runs.
*
* @param {unknown} data - The data to transform
*/
function transformUiState(data) {
for (const field of maskedUiFields) {
if (has(data, field)) {
set(data, field, typeof get(data, field));
}
}
for (const field of removedUiFields) {
if (has(data, field)) {
unset(data, field);
}
}
return data;
}
/**
* Check that the data provided matches the snapshot.
*
* @param {object }args - Function arguments.
* @param {any} args.data - The data to compare with the snapshot.
* @param {string} args.snapshot - The name of the snapshot.
* @param {boolean} [args.update] - Whether to update the snapshot if it doesn't match.
*/
async function matchesSnapshot({
data,
snapshot,
update = process.env.UPDATE_SNAPSHOTS === 'true',
}) {
const snapshotPath = resolve(__dirname, `./state-snapshots/${snapshot}.json`);
const rawSnapshotData = await fs.readFile(snapshotPath, {
encoding: 'utf-8',
});
const snapshotData = JSON.parse(rawSnapshotData);
try {
assert.deepStrictEqual(data, snapshotData);
} catch (error) {
if (update && error instanceof assert.AssertionError) {
const stringifiedData = JSON.stringify(data);
// filepath specified so that Prettier can infer which parser to use
// from the file extension
const formattedData = format(stringifiedData, {
filepath: 'something.json',
});
await fs.writeFile(snapshotPath, formattedData, {
encoding: 'utf-8',
});
console.log(`Snapshot '${snapshot}' updated`);
return;
}
throw error;
}
}
describe('Sentry errors', function () {
const migrationError =
process.env.SELENIUM_BROWSER === Browser.CHROME
? `Cannot read properties of undefined (reading 'version')`
: 'meta is undefined';
async function mockSentryMigratorError(mockServer) {
return await mockServer
.forPost('https://sentry.io/api/0000000/envelope/')
.withBodyIncluding(migrationError)
.thenCallback(() => {
return {
statusCode: 200,
json: {},
};
});
}
async function mockSentryInvariantMigrationError(mockServer) {
return await mockServer
.forPost('https://sentry.io/api/0000000/envelope/')
.withBodyIncluding('typeof state.PreferencesController is number')
.thenCallback(() => {
return {
statusCode: 200,
json: {},
};
});
}
async function mockSentryTestError(mockServer) {
return await mockServer
.forPost('https://sentry.io/api/0000000/envelope/')
.withBodyIncluding('Test Error')
.thenCallback(() => {
return {
statusCode: 200,
json: {},
};
});
}
const ganacheOptions = {
accounts: [
{
secretKey:
'0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC',
balance: convertToHexValue(25000000000000000000),
},
],
};
describe('before initialization, after opting out of metrics', function () {
it('should NOT send error events in the background', async function () {
await withFixtures(
{
fixtures: {
...new FixtureBuilder()
.withMetaMetricsController({
metaMetricsId: null,
participateInMetaMetrics: false,
})
.build(),
// Intentionally corrupt state to trigger migration error during initialization
meta: undefined,
},
ganacheOptions,
title: this.test.title,
failOnConsoleError: false,
testSpecificMock: mockSentryMigratorError,
},
async ({ driver, mockedEndpoint }) => {
await driver.navigate();
// Wait for Sentry request
await driver.delay(3000);
const isPending = await mockedEndpoint.isPending();
assert.ok(
isPending,
'A request to sentry was sent when it should not have been',
);
},
);
});
it('should NOT send error events in the UI', async function () {
await withFixtures(
{
fixtures: new FixtureBuilder()
.withMetaMetricsController({
metaMetricsId: null,
participateInMetaMetrics: false,
})
.build(),
ganacheOptions,
title: this.test.title,
failOnConsoleError: false,
testSpecificMock: mockSentryTestError,
},
async ({ driver, mockedEndpoint }) => {
await driver.navigate();
await driver.findElement('#password');
// Erase `getSentryAppState` hook, simulating a "before initialization" state
await driver.executeScript(
'window.stateHooks.getSentryAppState = undefined',
);
// Wait for Sentry request
await driver.delay(3000);
const isPending = await mockedEndpoint.isPending();
assert.ok(
isPending,
'A request to sentry was sent when it should not have been',
);
},
);
});
});
describe('before initialization, after opting into metrics', function () {
it('should send error events in background', async function () {
await withFixtures(
{
fixtures: {
...new FixtureBuilder()
.withMetaMetricsController({
metaMetricsId: 'fake-metrics-id',
participateInMetaMetrics: true,
})
.build(),
// Intentionally corrupt state to trigger migration error during initialization
meta: undefined,
},
ganacheOptions,
title: this.test.title,
failOnConsoleError: false,
testSpecificMock: mockSentryMigratorError,
},
async ({ driver, mockedEndpoint }) => {
await driver.navigate();
// Wait for Sentry request
await driver.wait(async () => {
const isPending = await mockedEndpoint.isPending();
return isPending === false;
}, 3000);
const [mockedRequest] = await mockedEndpoint.getSeenRequests();
const mockTextBody = mockedRequest.body.text.split('\n');
const mockJsonBody = JSON.parse(mockTextBody[2]);
const { level } = mockJsonBody;
const [{ type, value }] = mockJsonBody.exception.values;
// Verify request
assert.equal(type, 'TypeError');
assert(value.includes(migrationError));
assert.equal(level, 'error');
},
);
});
it('should capture background application state', async function () {
await withFixtures(
{
fixtures: {
...new FixtureBuilder()
.withMetaMetricsController({
metaMetricsId: 'fake-metrics-id',
participateInMetaMetrics: true,
})
.build(),
// Intentionally corrupt state to trigger migration error during initialization
meta: undefined,
},
ganacheOptions,
title: this.test.title,
failOnConsoleError: false,
testSpecificMock: mockSentryMigratorError,
},
async ({ driver, mockedEndpoint }) => {
await driver.navigate();
// Wait for Sentry request
await driver.wait(async () => {
const isPending = await mockedEndpoint.isPending();
return isPending === false;
}, 3000);
const [mockedRequest] = await mockedEndpoint.getSeenRequests();
const mockTextBody = mockedRequest.body.text.split('\n');
const mockJsonBody = JSON.parse(mockTextBody[2]);
const appState = mockJsonBody?.extra?.appState;
assert.deepStrictEqual(Object.keys(appState), [
'browser',
'version',
'persistedState',
]);
assert.ok(
typeof appState?.browser === 'string' &&
appState?.browser.length > 0,
'Invalid browser state',
);
assert.ok(
typeof appState?.version === 'string' &&
appState?.version.length > 0,
'Invalid version state',
);
await matchesSnapshot({
data: transformBackgroundState(appState.persistedState),
snapshot: 'errors-before-init-opt-in-background-state',
});
},
);
});
it('should capture migration log breadcrumbs when there is an invariant state error in a migration', async function () {
await withFixtures(
{
fixtures: {
...new FixtureBuilder()
.withMetaMetricsController({
metaMetricsId: 'fake-metrics-id',
participateInMetaMetrics: true,
})
.withBadPreferencesControllerState()
.build(),
},
ganacheOptions,
title: this.test.title,
failOnConsoleError: false,
testSpecificMock: mockSentryInvariantMigrationError,
},
async ({ driver, mockedEndpoint }) => {
await driver.navigate();
// Wait for Sentry request
await driver.wait(async () => {
const isPending = await mockedEndpoint.isPending();
return isPending === false;
}, 3000);
const [mockedRequest] = await mockedEndpoint.getSeenRequests();
const mockTextBody = mockedRequest.body.text.split('\n');
const mockJsonBody = JSON.parse(mockTextBody[2]);
const breadcrumbs = mockJsonBody?.breadcrumbs ?? [];
const migrationLogBreadcrumbs = breadcrumbs.filter((breadcrumb) => {
return breadcrumb.message?.match(/Running migration \d+/u);
});
const migrationLogMessages = migrationLogBreadcrumbs.map(
(breadcrumb) =>
breadcrumb.message.match(/(Running migration \d+)/u)[1],
);
const firstMigrationLog = migrationLogMessages[0];
const lastMigrationLog =
migrationLogMessages[migrationLogMessages.length - 1];
assert.equal(migrationLogMessages.length, 8);
assert.equal(firstMigrationLog, 'Running migration 75');
assert.equal(lastMigrationLog, 'Running migration 82');
},
);
});
it('should send error events in UI', async function () {
await withFixtures(
{
fixtures: new FixtureBuilder()
.withMetaMetricsController({
metaMetricsId: 'fake-metrics-id',
participateInMetaMetrics: true,
})
.build(),
ganacheOptions,
title: this.test.title,
failOnConsoleError: false,
testSpecificMock: mockSentryTestError,
},
async ({ driver, mockedEndpoint }) => {
await driver.navigate();
await driver.findElement('#password');
// Erase `getSentryAppState` hook, simulating a "before initialization" state
await driver.executeScript(
'window.stateHooks.getSentryAppState = undefined',
);
// Trigger error
await driver.executeScript('window.stateHooks.throwTestError()');
// Wait for Sentry request
await driver.wait(async () => {
const isPending = await mockedEndpoint.isPending();
return isPending === false;
}, 3000);
const [mockedRequest] = await mockedEndpoint.getSeenRequests();
const mockTextBody = mockedRequest.body.text.split('\n');
const mockJsonBody = JSON.parse(mockTextBody[2]);
const { level } = mockJsonBody;
const [{ type, value }] = mockJsonBody.exception.values;
// Verify request
assert.equal(type, 'TestError');
assert.equal(value, 'Test Error');
assert.equal(level, 'error');
},
);
});
it('should capture UI application state', async function () {
await withFixtures(
{
fixtures: new FixtureBuilder()
.withMetaMetricsController({
metaMetricsId: 'fake-metrics-id',
participateInMetaMetrics: true,
})
.build(),
ganacheOptions,
title: this.test.title,
failOnConsoleError: false,
testSpecificMock: mockSentryTestError,
},
async ({ driver, mockedEndpoint }) => {
await driver.navigate();
await driver.findElement('#password');
// Erase `getSentryAppState` hook, simulating a "before initialization" state
await driver.executeScript(
'window.stateHooks.getSentryAppState = undefined',
);
// Trigger error
await driver.executeScript('window.stateHooks.throwTestError()');
// Wait for Sentry request
await driver.wait(async () => {
const isPending = await mockedEndpoint.isPending();
return isPending === false;
}, 3000);
const [mockedRequest] = await mockedEndpoint.getSeenRequests();
const mockTextBody = mockedRequest.body.text.split('\n');
const mockJsonBody = JSON.parse(mockTextBody[2]);
const appState = mockJsonBody?.extra?.appState;
assert.deepStrictEqual(Object.keys(appState), [
'browser',
'version',
'persistedState',
]);
assert.ok(
typeof appState?.browser === 'string' &&
appState?.browser.length > 0,
'Invalid browser state',
);
assert.ok(
typeof appState?.version === 'string' &&
appState?.version.length > 0,
'Invalid version state',
);
await matchesSnapshot({
data: transformBackgroundState(appState.persistedState),
snapshot: 'errors-before-init-opt-in-ui-state',
});
},
);
});
});
describe('after initialization, after opting out of metrics', function () {
it('should NOT send error events in the background', async function () {
await withFixtures(
{
fixtures: new FixtureBuilder()
.withMetaMetricsController({
metaMetricsId: null,
participateInMetaMetrics: false,
})
.build(),
ganacheOptions,
title: this.test.title,
failOnConsoleError: false,
testSpecificMock: mockSentryTestError,
},
async ({ driver, mockedEndpoint }) => {
await driver.navigate();
await driver.findElement('#password');
// Trigger error
await driver.executeScript(
'window.stateHooks.throwTestBackgroundError()',
);
// Wait for Sentry request
const isPending = await mockedEndpoint.isPending();
assert.ok(
isPending,
'A request to sentry was sent when it should not have been',
);
},
);
});
it('should NOT send error events in the UI', async function () {
await withFixtures(
{
fixtures: new FixtureBuilder()
.withMetaMetricsController({
metaMetricsId: null,
participateInMetaMetrics: false,
})
.build(),
ganacheOptions,
title: this.test.title,
failOnConsoleError: false,
testSpecificMock: mockSentryTestError,
},
async ({ driver, mockedEndpoint }) => {
await driver.navigate();
await driver.findElement('#password');
// Trigger error
await driver.executeScript('window.stateHooks.throwTestError()');
// Wait for Sentry request
const isPending = await mockedEndpoint.isPending();
assert.ok(
isPending,
'A request to sentry was sent when it should not have been',
);
},
);
});
});
describe('after initialization, after opting into metrics', function () {
it('should send error events in background', async function () {
await withFixtures(
{
fixtures: new FixtureBuilder()
.withMetaMetricsController({
metaMetricsId: 'fake-metrics-id',
participateInMetaMetrics: true,
})
.build(),
ganacheOptions,
title: this.test.title,
failOnConsoleError: false,
testSpecificMock: mockSentryTestError,
},
async ({ driver, mockedEndpoint }) => {
await driver.navigate();
await driver.findElement('#password');
// Trigger error
await driver.executeScript(
'window.stateHooks.throwTestBackgroundError()',
);
// Wait for Sentry request
await driver.wait(async () => {
const isPending = await mockedEndpoint.isPending();
return isPending === false;
}, 3000);
const [mockedRequest] = await mockedEndpoint.getSeenRequests();
const mockTextBody = mockedRequest.body.text.split('\n');
const mockJsonBody = JSON.parse(mockTextBody[2]);
const { level, extra } = mockJsonBody;
const [{ type, value }] = mockJsonBody.exception.values;
const { participateInMetaMetrics } =
extra.appState.state.MetaMetricsController;
// Verify request
assert.equal(type, 'TestError');
assert.equal(value, 'Test Error');
assert.equal(level, 'error');
assert.equal(participateInMetaMetrics, true);
},
);
});
it('should capture background application state', async function () {
await withFixtures(
{
fixtures: new FixtureBuilder()
.withMetaMetricsController({
metaMetricsId: 'fake-metrics-id',
participateInMetaMetrics: true,
})
.build(),
ganacheOptions,
title: this.test.title,
failOnConsoleError: false,
testSpecificMock: mockSentryTestError,
},
async ({ driver, mockedEndpoint }) => {
await driver.navigate();
await driver.findElement('#password');
// Trigger error
await driver.executeScript(
'window.stateHooks.throwTestBackgroundError()',
);
// Wait for Sentry request
await driver.wait(async () => {
const isPending = await mockedEndpoint.isPending();
return isPending === false;
}, 3000);
const [mockedRequest] = await mockedEndpoint.getSeenRequests();
const mockTextBody = mockedRequest.body.text.split('\n');
const mockJsonBody = JSON.parse(mockTextBody[2]);
const appState = mockJsonBody?.extra?.appState;
assert.deepStrictEqual(Object.keys(appState), [
'browser',
'version',
'state',
]);
assert.ok(
typeof appState?.browser === 'string' &&
appState?.browser.length > 0,
'Invalid browser state',
);
assert.ok(
typeof appState?.version === 'string' &&
appState?.version.length > 0,
'Invalid version state',
);
await matchesSnapshot({
data: transformBackgroundState(appState.state),
snapshot: 'errors-after-init-opt-in-background-state',
});
},
);
});
it('should send error events in UI', async function () {
await withFixtures(
{
fixtures: new FixtureBuilder()
.withMetaMetricsController({
metaMetricsId: 'fake-metrics-id',
participateInMetaMetrics: true,
})
.build(),
ganacheOptions,
title: this.test.title,
failOnConsoleError: false,
testSpecificMock: mockSentryTestError,
},
async ({ driver, mockedEndpoint }) => {
await driver.navigate();
await driver.findElement('#password');
// Trigger error
await driver.executeScript('window.stateHooks.throwTestError()');
// Wait for Sentry request
await driver.wait(async () => {
const isPending = await mockedEndpoint.isPending();
return isPending === false;
}, 3000);
const [mockedRequest] = await mockedEndpoint.getSeenRequests();
const mockTextBody = mockedRequest.body.text.split('\n');
const mockJsonBody = JSON.parse(mockTextBody[2]);
const { level, extra } = mockJsonBody;
const [{ type, value }] = mockJsonBody.exception.values;
const { participateInMetaMetrics } = extra.appState.state.metamask;
// Verify request
assert.equal(type, 'TestError');
assert.equal(value, 'Test Error');
assert.equal(level, 'error');
assert.equal(participateInMetaMetrics, true);
},
);
});
it('should capture UI application state', async function () {
await withFixtures(
{
fixtures: new FixtureBuilder()
.withMetaMetricsController({
metaMetricsId: 'fake-metrics-id',
participateInMetaMetrics: true,
})
.build(),
ganacheOptions,
title: this.test.title,
failOnConsoleError: false,
testSpecificMock: mockSentryTestError,
},
async ({ driver, mockedEndpoint }) => {
await driver.navigate();
await driver.findElement('#password');
// Trigger error
await driver.executeScript('window.stateHooks.throwTestError()');
// Wait for Sentry request
await driver.wait(async () => {
const isPending = await mockedEndpoint.isPending();
return isPending === false;
}, 3000);
const [mockedRequest] = await mockedEndpoint.getSeenRequests();
const mockTextBody = mockedRequest.body.text.split('\n');
const mockJsonBody = JSON.parse(mockTextBody[2]);
const appState = mockJsonBody?.extra?.appState;
assert.deepStrictEqual(Object.keys(appState), [
'browser',
'version',
'state',
]);
assert.ok(
typeof appState?.browser === 'string' &&
appState?.browser.length > 0,
'Invalid browser state',
);
assert.ok(
typeof appState?.version === 'string' &&
appState?.version.length > 0,
'Invalid version state',
);
await matchesSnapshot({
data: transformUiState(appState.state),
snapshot: 'errors-after-init-opt-in-ui-state',
});
},
);
});
});
});