Skip to content
Merged
10 changes: 6 additions & 4 deletions app/scripts/lib/migrator/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,13 @@ export default class Migrator extends EventEmitter {

log.info(`Migration ${migration.version} complete`);
} catch (err) {
// rewrite error message to add context without clobbering stack
const originalErrorMessage = err.message;
err.message = `MetaMask Migration Error #${migration.version}: ${originalErrorMessage}`;
// use an AggregateError to add context without clobbering stack
const aggregateError = new AggregateError(
[err],
`MetaMask Migration Error #${migration.version}`,
);
// emit error instead of throw so as to not break the run (gracefully fail)
this.emit('error', err);
this.emit('error', aggregateError);
// stop migrating and use state as is
break;
}
Expand Down
78 changes: 75 additions & 3 deletions app/scripts/lib/migrator/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,81 @@ describe('migrations', () => {
},
],
});
await expect(async () => {
await migrator.migrateData({ meta: { version: 0 } });
}).rejects.toThrow('Error: MetaMask Migration Error #1: test');
const onError = jest.fn();
migrator.on('error', onError);

const initialState = { meta: { version: 0 }, data: { hello: 'world' } };
const migratedData = await migrator.migrateData(initialState);

expect(onError).toHaveBeenCalledTimes(1);
const [error] = onError.mock.calls[0];
expect(error).toBeInstanceOf(AggregateError);
expect(error.message).toBe('MetaMask Migration Error #1');
expect(error.errors[0].message).toBe('test');
expect(migratedData.state).toBe(initialState);
Comment on lines +118 to +128
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shape of the error is different now.

This DOES effect migration error display sentry.

});

it('runs v2 migrations and reports changed controllers', async () => {
Copy link
Contributor Author

@davidmurdoch davidmurdoch Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

specifically testing the v2 migration flow here

const migrate = jest.fn(async (state, localChangedControllers) => {
state.meta.version = 187;
state.data.foo = 'bar';
localChangedControllers.add('TestController');
});

const migrator = new Migrator({
migrations: [
{
version: 187,
migrate,
},
],
});

const initialState = { meta: { version: 186 }, data: { hello: 'world' } };
const migratedData = await migrator.migrateData(initialState);

expect(migrate).toHaveBeenCalledTimes(1);
expect(migrate.mock.calls[0]).toHaveLength(2);
expect(migratedData.state).not.toBe(initialState);
expect(migratedData.state.data).toEqual({
hello: 'world',
foo: 'bar',
});
expect(migratedData.changedKeys.has('TestController')).toBe(true);
});

it('handles errors thrown when state is cloned for next migration', async () => {
const migrate = jest.fn();
const migrator = new Migrator({
migrations: [
{
version: 186,
migrate,
},
],
});
const onError = jest.fn();
migrator.on('error', onError);

const initialState = {
meta: { version: 0 },
// Regression test for https://github.com/MetaMask/metamask-extension/issues/39567
// `bad` is a function, and cannot be serialized by `structuredClone`
// this will throw a DOMException, which doesn't allow its `message`
// property to be mutated
data: { bad: () => {} },
};
const migratedData = await migrator.migrateData(initialState);

expect(migrate).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledTimes(1);
const [error] = onError.mock.calls[0];
expect(error).toBeInstanceOf(AggregateError);
expect(error.message).toBe('MetaMask Migration Error #186');
expect(error.errors[0]?.constructor?.name).toBe('DOMException');
expect(error.errors[0].name).toBe('DataCloneError');
expect(error.errors[0].message).toBe('() => {} could not be cloned.');
expect(migratedData.state).toBe(initialState);
});
});
});
Loading