diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index 428f6713f9..d3637a7234 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { "branches": 94.97, "functions": 98.78, - "lines": 98.63, - "statements": 98.32 + "lines": 98.45, + "statements": 98.18 } diff --git a/packages/snaps-controllers/package.json b/packages/snaps-controllers/package.json index 69578c10bd..5fce34ddff 100644 --- a/packages/snaps-controllers/package.json +++ b/packages/snaps-controllers/package.json @@ -81,6 +81,7 @@ "test:watch": "jest --watch" }, "dependencies": { + "@metamask/analytics-controller": "^1.1.1", "@metamask/approval-controller": "^9.0.2", "@metamask/base-controller": "^9.1.0", "@metamask/json-rpc-engine": "^10.5.0", diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx index db25e1fe9c..e64c72663f 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx +++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx @@ -519,7 +519,7 @@ describe('SnapController', () => { }); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ApprovalController:updateRequestState', { id: expect.any(String), @@ -574,7 +574,7 @@ describe('SnapController', () => { }); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ApprovalController:updateRequestState', { id: expect.any(String), @@ -665,7 +665,7 @@ describe('SnapController', () => { }); expect(options.messenger.call).toHaveBeenNthCalledWith( - 7, + 9, 'ApprovalController:updateRequestState', { id: expect.any(String), @@ -744,7 +744,7 @@ describe('SnapController', () => { }); expect(options.messenger.call).toHaveBeenNthCalledWith( - 7, + 9, 'ApprovalController:updateRequestState', { id: expect.any(String), @@ -794,7 +794,7 @@ describe('SnapController', () => { [MOCK_SNAP_ID]: expectedSnapObject, }); - expect(options.messenger.call).toHaveBeenCalledTimes(10); + expect(options.messenger.call).toHaveBeenCalledTimes(14); expect(options.messenger.call).toHaveBeenNthCalledWith( 1, @@ -816,7 +816,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 2, + 4, 'SnapRegistryController:get', { [MOCK_SNAP_ID]: { @@ -828,7 +828,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 3, + 5, 'StorageService:setItem', controllerName, MOCK_SNAP_ID, @@ -836,7 +836,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 4, + 6, 'SubjectMetadataController:addSubjectMetadata', { subjectType: SubjectType.Snap, @@ -848,7 +848,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ApprovalController:updateRequestState', { id: expect.any(String), @@ -861,13 +861,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 6, + 8, 'PermissionController:grantPermissions', expect.any(Object), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 7, + 9, 'ApprovalController:addRequest', expect.objectContaining({ requestData: { @@ -886,13 +886,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 8, + 10, 'ExecutionService:executeSnap', expect.any(Object), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 9, + 11, 'ApprovalController:updateRequestState', { id: expect.any(String), @@ -908,6 +908,7 @@ describe('SnapController', () => { MOCK_SNAP_ID, MOCK_ORIGIN, false, + false, ); expect(options.messenger.publish).toHaveBeenCalledWith( 'SnapController:snapInstalled', @@ -1272,7 +1273,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 3, + 5, 'StorageService:setItem', controllerName, MOCK_SNAP_ID, @@ -1280,7 +1281,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 6, + 8, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -1293,7 +1294,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 12, + 16, 'StorageService:removeItem', controllerName, MOCK_SNAP_ID, @@ -1334,10 +1335,10 @@ describe('SnapController', () => { }), ).rejects.toThrow('User rejected the request.'); - expect(messengerCallMock).toHaveBeenCalledTimes(12); + expect(messengerCallMock).toHaveBeenCalledTimes(16); expect(messengerCallMock).toHaveBeenNthCalledWith( - 3, + 5, 'StorageService:setItem', controllerName, MOCK_SNAP_ID, @@ -1345,7 +1346,7 @@ describe('SnapController', () => { ); expect(messengerCallMock).toHaveBeenNthCalledWith( - 6, + 8, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -1358,7 +1359,7 @@ describe('SnapController', () => { ); expect(messengerCallMock).toHaveBeenNthCalledWith( - 12, + 16, 'StorageService:removeItem', controllerName, MOCK_SNAP_ID, @@ -2042,7 +2043,7 @@ describe('SnapController', () => { expect(await promise).toBe('foo'); expect(options.messenger.call).toHaveBeenNthCalledWith( - 8, + 10, 'ExecutionService:executeSnap', expect.objectContaining({ snapId: MOCK_SNAP_ID, @@ -2050,7 +2051,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 19, + 24, 'ExecutionService:executeSnap', expect.objectContaining({ snapId: MOCK_SNAP_ID, @@ -5371,7 +5372,7 @@ describe('SnapController', () => { }, }); - expect(options.messenger.call).toHaveBeenCalledTimes(7); + expect(options.messenger.call).toHaveBeenCalledTimes(8); expect(options.messenger.call).toHaveBeenCalledWith( 'ExecutionService:handleRpcRequest', MOCK_SNAP_ID, @@ -5633,7 +5634,7 @@ describe('SnapController', () => { expect(result).toStrictEqual({ [MOCK_LOCAL_SNAP_ID]: truncatedSnap }); - expect(options.messenger.call).toHaveBeenCalledTimes(13); + expect(options.messenger.call).toHaveBeenCalledTimes(17); expect(options.messenger.call).toHaveBeenNthCalledWith( 2, @@ -5656,7 +5657,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 8, + 10, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -5669,7 +5670,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 9, + 11, 'PermissionController:grantPermissions', { approvedPermissions: permissions, @@ -5686,7 +5687,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 10, + 12, 'ApprovalController:addRequest', expect.objectContaining({ type: SNAP_APPROVAL_RESULT, @@ -5706,13 +5707,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 11, + 13, 'ExecutionService:executeSnap', expect.objectContaining({}), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 12, + 14, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -5782,7 +5783,7 @@ describe('SnapController', () => { [MOCK_LOCAL_SNAP_ID]: truncatedSnap, }); - expect(options.messenger.call).toHaveBeenCalledTimes(23); + expect(options.messenger.call).toHaveBeenCalledTimes(31); expect(options.messenger.call).toHaveBeenNthCalledWith( 1, @@ -5805,7 +5806,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -5818,7 +5819,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 6, + 8, 'PermissionController:grantPermissions', { approvedPermissions: permissions, @@ -5835,7 +5836,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 7, + 9, 'ApprovalController:addRequest', expect.objectContaining({ id: expect.any(String), @@ -5856,13 +5857,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 8, + 10, 'ExecutionService:executeSnap', expect.anything(), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 9, + 11, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -5874,7 +5875,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 11, + 15, 'ApprovalController:addRequest', expect.objectContaining({ type: SNAP_APPROVAL_INSTALL, @@ -5894,13 +5895,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 12, + 18, 'ExecutionService:terminateSnap', MOCK_LOCAL_SNAP_ID, ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 18, + 24, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -5913,7 +5914,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 19, + 25, 'PermissionController:grantPermissions', { approvedPermissions: permissions, @@ -5930,7 +5931,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 20, + 26, 'ApprovalController:addRequest', expect.objectContaining({ id: expect.any(String), @@ -5951,13 +5952,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 21, + 27, 'ExecutionService:executeSnap', expect.objectContaining({ snapId: MOCK_LOCAL_SNAP_ID }), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 22, + 28, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -6031,7 +6032,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 9, + 11, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -7391,7 +7392,7 @@ describe('SnapController', () => { expect(result).toStrictEqual({ [MOCK_SNAP_ID]: truncatedSnap, }); - expect(options.messenger.call).toHaveBeenCalledTimes(10); + expect(options.messenger.call).toHaveBeenCalledTimes(14); expect(options.messenger.call).toHaveBeenNthCalledWith( 1, @@ -7411,7 +7412,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -7424,7 +7425,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 6, + 8, 'PermissionController:grantPermissions', { approvedPermissions: permissions, @@ -7441,7 +7442,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 7, + 9, 'ApprovalController:addRequest', expect.objectContaining({ id: expect.any(String), @@ -7462,13 +7463,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 8, + 10, 'ExecutionService:executeSnap', expect.objectContaining({}), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 9, + 11, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -7681,7 +7682,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -7715,7 +7716,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 6, + 8, 'PermissionController:grantPermissions', { approvedPermissions: { @@ -7809,7 +7810,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -7826,7 +7827,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 6, + 8, 'PermissionController:grantPermissions', { approvedPermissions: { @@ -7893,7 +7894,7 @@ describe('SnapController', () => { }); expect(options.messenger.call).toHaveBeenNthCalledWith( - 10, + 12, 'PermissionController:grantPermissions', { approvedPermissions: { @@ -7968,7 +7969,7 @@ describe('SnapController', () => { }); expect(options.messenger.call).toHaveBeenNthCalledWith( - 10, + 12, 'PermissionController:revokePermissions', { [MOCK_SNAP_ID]: [SnapEndowments.Rpc, 'snap_dialog'], @@ -7976,7 +7977,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 11, + 13, 'PermissionController:grantPermissions', { approvedPermissions: { @@ -8068,10 +8069,10 @@ describe('SnapController', () => { [MOCK_SNAP_ID]: { version: newVersionRange }, }); - expect(options.messenger.call).toHaveBeenCalledTimes(22); + expect(options.messenger.call).toHaveBeenCalledTimes(30); expect(options.messenger.call).toHaveBeenNthCalledWith( - 3, + 5, 'StorageService:setItem', controllerName, MOCK_SNAP_ID, @@ -8081,7 +8082,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 4, + 6, 'SubjectMetadataController:addSubjectMetadata', { subjectType: SubjectType.Snap, @@ -8093,7 +8094,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 12, + 16, 'ApprovalController:addRequest', { origin: MOCK_ORIGIN, @@ -8115,13 +8116,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 14, + 20, 'PermissionController:getPermissions', MOCK_SNAP_ID, ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 15, + 21, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -8140,7 +8141,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 16, + 22, 'ApprovalController:addRequest', expect.objectContaining({ id: expect.any(String), @@ -8161,7 +8162,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 18, + 24, 'StorageService:setItem', controllerName, MOCK_SNAP_ID, @@ -8169,7 +8170,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 19, + 25, 'SubjectMetadataController:addSubjectMetadata', { subjectType: SubjectType.Snap, @@ -8181,13 +8182,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 20, + 26, 'ExecutionService:executeSnap', expect.objectContaining({}), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 21, + 27, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -8255,7 +8256,7 @@ describe('SnapController', () => { }), ).rejects.toThrow(errorMessage); - expect(options.messenger.call).toHaveBeenCalledTimes(3); + expect(options.messenger.call).toHaveBeenCalledTimes(7); expect(options.messenger.call).toHaveBeenCalledWith( 'ApprovalController:updateRequestState', @@ -8302,7 +8303,7 @@ describe('SnapController', () => { }), ).rejects.toThrow('foo'); - expect(options.messenger.call).toHaveBeenCalledTimes(3); + expect(options.messenger.call).toHaveBeenCalledTimes(7); expect(detect).toHaveBeenCalledTimes(1); expect(detect).toHaveBeenCalledWith( MOCK_SNAP_ID, @@ -8462,7 +8463,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 26, + 36, 'StorageService:setItem', controllerName, snapId3, @@ -8470,7 +8471,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 39, + 51, 'StorageService:setItem', controllerName, snapId1, @@ -8478,7 +8479,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 50, + 64, 'StorageService:setItem', controllerName, snapId2, @@ -8486,7 +8487,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 53, + 67, 'PermissionController:revokePermissions', { [MOCK_ORIGIN]: [WALLET_SNAP_PERMISSION_KEY], @@ -8494,14 +8495,14 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 62, + 78, 'StorageService:removeItem', controllerName, snapId3, ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 64, + 82, 'StorageService:setItem', controllerName, snapId1, @@ -8509,7 +8510,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 65, + 83, 'StorageService:setItem', controllerName, snapId2, @@ -8517,7 +8518,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 67, + 85, 'PermissionController:grantPermissions', { approvedPermissions: { @@ -8787,137 +8788,366 @@ describe('SnapController', () => { snapController.destroy(); }); - }); - - it('throws if the Snap source code is too large', async () => { - const { manifest, sourceCode, svgIcon, localizationFiles } = - await getMockSnapFilesWithUpdatedChecksum({ - sourceCode: 'a'.repeat(64_000_001), - }); - - const snapController = await getSnapController( - getSnapControllerOptions({ - detectSnapLocation: loopbackDetect({ - manifest, - files: [sourceCode, svgIcon as VirtualFile, ...localizationFiles], - }), - }), - ); - - await expect( - snapController.installSnaps(MOCK_ORIGIN, { - [MOCK_SNAP_ID]: {}, - }), - ).rejects.toThrow( - 'Failed to fetch snap "npm:@metamask/example-snap": Snap source code must be smaller than 64 MB..', - ); - - snapController.destroy(); - }); - - describe('Updating Snaps', () => { - it("throws an error if new version doesn't match version range", async () => { - const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ - manifest: getSnapManifest({ - version: '1.1.0' as SemVerVersion, - }), - }); - const detectSnapLocation = loopbackDetect({ - manifest: manifest.result, - }); + it('tracks `Snap Install Started` when a Snap installation starts', async () => { const options = getSnapControllerOptions({ state: { snaps: getPersistedSnapsState(), }, - detectSnapLocation, }); - const controller = await getSnapController(options); - const onSnapUpdated = jest.fn(); - - const snap = controller.getSnapExpect(MOCK_SNAP_ID); - - options.messenger.subscribe('SnapController:snapUpdated', onSnapUpdated); + const snapController = await getSnapController(options); - const newSnap = controller.getSnap(MOCK_SNAP_ID); + options.messenger.publish( + 'SnapController:snapInstallStarted', + MOCK_SNAP_ID, + MOCK_ORIGIN, + false, + false, + ); - await expect( - controller.installSnaps(MOCK_ORIGIN, { - [MOCK_SNAP_ID]: { version: '1.2.0' }, - }), - ).rejects.toThrow( - `Version mismatch. Manifest for "npm:@metamask/example-snap" specifies version "1.1.0" which doesn't satisfy requested version range "1.2.0".`, + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Install Started', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + origin: MOCK_ORIGIN, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, ); - expect(newSnap?.version).toStrictEqual(snap.version); - expect(onSnapUpdated).not.toHaveBeenCalled(); - controller.destroy(); + snapController.destroy(); }); - it('throws an error if the new version of the snap is blocked', async () => { - const rootMessenger = getRootMessenger(); - const registry = new MockSnapRegistryController(rootMessenger); - - const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ - manifest: getSnapManifest({ - version: '1.1.0' as SemVerVersion, - }), - }); - const detectSnapLocation = loopbackDetect({ - manifest: manifest.result, + it('does not track `Snap Install Started` for preinstalled Snaps', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState( + getPersistedSnapObject({ preinstalled: true }), + ), + }, }); - const controller = await getSnapController( - getSnapControllerOptions({ - rootMessenger, - state: { - snaps: getPersistedSnapsState(), - }, - detectSnapLocation, - }), - ); + const snapController = await getSnapController(options); - registry.get.mockResolvedValueOnce({ - [MOCK_SNAP_ID]: { status: SnapRegistryStatus.Blocked }, - }); + options.messenger.publish( + 'SnapController:snapInstallStarted', + MOCK_SNAP_ID, + MOCK_ORIGIN, + false, + true, + ); - await expect( - controller.installSnaps(MOCK_ORIGIN, { - [MOCK_SNAP_ID]: { version: '1.1.0' }, - }), - ).rejects.toThrow('Cannot install version "1.1.0" of snap'); + expect(options.messenger.call).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.objectContaining({ name: 'Snap Install Started' }), + ); - controller.destroy(); + snapController.destroy(); }); - it('does not update on older snap version downloaded', async () => { - const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ - manifest: getSnapManifest({ - version: '0.9.0' as SemVerVersion, - }), - }); - const detectSnapLocation = loopbackDetect({ - manifest: manifest.result, - }); - + it('tracks `Snap Install Failed` when a Snap installation fails', async () => { const options = getSnapControllerOptions({ state: { snaps: getPersistedSnapsState(), }, - detectSnapLocation, }); - const controller = await getSnapController(options); - const onSnapUpdated = jest.fn(); - - const snap = controller.getSnapExpect(MOCK_SNAP_ID); - - options.messenger.subscribe('SnapController:snapUpdated', onSnapUpdated); - - const publishSpy = jest.spyOn(options.messenger, 'publish'); + const snapController = await getSnapController(options); - const newSnap = controller.getSnap(MOCK_SNAP_ID); + options.messenger.publish( + 'SnapController:snapInstallFailed', + MOCK_SNAP_ID, + MOCK_ORIGIN, + false, + 'Installation failed.', + false, + ); + + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Install Failed', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + origin: MOCK_ORIGIN, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, + ); + + snapController.destroy(); + }); + + it('tracks `Snap Install Rejected` when a Snap installation is rejected', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstallFailed', + MOCK_SNAP_ID, + MOCK_ORIGIN, + false, + 'User rejected the request.', + false, + ); + + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Install Rejected', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + origin: MOCK_ORIGIN, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, + ); + + snapController.destroy(); + }); + + it('does not track `Snap Install Failed` for preinstalled Snaps', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState( + getPersistedSnapObject({ preinstalled: true }), + ), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstallFailed', + MOCK_SNAP_ID, + MOCK_ORIGIN, + false, + 'Installation failed.', + true, + ); + + expect(options.messenger.call).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.objectContaining({ name: 'Snap Install Failed' }), + ); + + snapController.destroy(); + }); + + it('tracks `Snap Installed` when a Snap is installed', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstalled', + getTruncatedSnap(), + MOCK_ORIGIN, + false, + ); + + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Installed', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + version: '1.0.0', + origin: MOCK_ORIGIN, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, + ); + + snapController.destroy(); + }); + + it('does not track `Snap Installed` for preinstalled Snaps', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstalled', + getTruncatedSnap(), + MOCK_ORIGIN, + true, + ); + + expect(options.messenger.call).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.objectContaining({ name: 'Snap Installed' }), + ); + + snapController.destroy(); + }); + }); + + it('throws if the Snap source code is too large', async () => { + const { manifest, sourceCode, svgIcon, localizationFiles } = + await getMockSnapFilesWithUpdatedChecksum({ + sourceCode: 'a'.repeat(64_000_001), + }); + + const snapController = await getSnapController( + getSnapControllerOptions({ + detectSnapLocation: loopbackDetect({ + manifest, + files: [sourceCode, svgIcon as VirtualFile, ...localizationFiles], + }), + }), + ); + + await expect( + snapController.installSnaps(MOCK_ORIGIN, { + [MOCK_SNAP_ID]: {}, + }), + ).rejects.toThrow( + 'Failed to fetch snap "npm:@metamask/example-snap": Snap source code must be smaller than 64 MB..', + ); + + snapController.destroy(); + }); + + describe('Updating Snaps', () => { + it("throws an error if new version doesn't match version range", async () => { + const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ + manifest: getSnapManifest({ + version: '1.1.0' as SemVerVersion, + }), + }); + const detectSnapLocation = loopbackDetect({ + manifest: manifest.result, + }); + + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + detectSnapLocation, + }); + + const controller = await getSnapController(options); + const onSnapUpdated = jest.fn(); + + const snap = controller.getSnapExpect(MOCK_SNAP_ID); + + options.messenger.subscribe('SnapController:snapUpdated', onSnapUpdated); + + const newSnap = controller.getSnap(MOCK_SNAP_ID); + + await expect( + controller.installSnaps(MOCK_ORIGIN, { + [MOCK_SNAP_ID]: { version: '1.2.0' }, + }), + ).rejects.toThrow( + `Version mismatch. Manifest for "npm:@metamask/example-snap" specifies version "1.1.0" which doesn't satisfy requested version range "1.2.0".`, + ); + expect(newSnap?.version).toStrictEqual(snap.version); + expect(onSnapUpdated).not.toHaveBeenCalled(); + + controller.destroy(); + }); + + it('throws an error if the new version of the snap is blocked', async () => { + const rootMessenger = getRootMessenger(); + const registry = new MockSnapRegistryController(rootMessenger); + + const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ + manifest: getSnapManifest({ + version: '1.1.0' as SemVerVersion, + }), + }); + const detectSnapLocation = loopbackDetect({ + manifest: manifest.result, + }); + + const controller = await getSnapController( + getSnapControllerOptions({ + rootMessenger, + state: { + snaps: getPersistedSnapsState(), + }, + detectSnapLocation, + }), + ); + + registry.get.mockResolvedValueOnce({ + [MOCK_SNAP_ID]: { status: SnapRegistryStatus.Blocked }, + }); + + await expect( + controller.installSnaps(MOCK_ORIGIN, { + [MOCK_SNAP_ID]: { version: '1.1.0' }, + }), + ).rejects.toThrow('Cannot install version "1.1.0" of snap'); + + controller.destroy(); + }); + + it('does not update on older snap version downloaded', async () => { + const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ + manifest: getSnapManifest({ + version: '0.9.0' as SemVerVersion, + }), + }); + const detectSnapLocation = loopbackDetect({ + manifest: manifest.result, + }); + + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + detectSnapLocation, + }); + + const controller = await getSnapController(options); + const onSnapUpdated = jest.fn(); + + const snap = controller.getSnapExpect(MOCK_SNAP_ID); + + options.messenger.subscribe('SnapController:snapUpdated', onSnapUpdated); + + const publishSpy = jest.spyOn(options.messenger, 'publish'); + + const newSnap = controller.getSnap(MOCK_SNAP_ID); const errorMessage = `Snap "${MOCK_SNAP_ID}@${snap.version}" is already installed. Couldn't update to a version inside requested "0.9.0" range.`; @@ -8932,6 +9162,7 @@ describe('SnapController', () => { MOCK_SNAP_ID, MOCK_ORIGIN, true, + false, ); expect(newSnap?.version).toStrictEqual(snap.version); expect(onSnapUpdated).not.toHaveBeenCalled(); @@ -8941,6 +9172,7 @@ describe('SnapController', () => { MOCK_ORIGIN, true, errorMessage, + false, ); controller.destroy(); @@ -8997,10 +9229,10 @@ describe('SnapController', () => { date: expect.any(Number), }, ]); - expect(callActionSpy).toHaveBeenCalledTimes(22); + expect(callActionSpy).toHaveBeenCalledTimes(30); expect(callActionSpy).toHaveBeenNthCalledWith( - 12, + 16, 'ApprovalController:addRequest', { origin: MOCK_ORIGIN, @@ -9022,13 +9254,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 14, + 20, 'PermissionController:getPermissions', MOCK_SNAP_ID, ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 15, + 21, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -9047,7 +9279,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 16, + 22, 'ApprovalController:addRequest', expect.objectContaining({ id: expect.any(String), @@ -9068,13 +9300,13 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 20, + 26, 'ExecutionService:executeSnap', expect.objectContaining({}), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 21, + 27, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -9087,6 +9319,7 @@ describe('SnapController', () => { MOCK_SNAP_ID, MOCK_ORIGIN, true, + false, ); expect(onSnapUpdated).toHaveBeenCalledTimes(1); expect(onSnapUpdated).toHaveBeenCalledWith( @@ -9161,9 +9394,9 @@ describe('SnapController', () => { [MOCK_SNAP_ID]: { version: '1.1.0' }, }); - expect(callActionSpy).toHaveBeenCalledTimes(22); + expect(callActionSpy).toHaveBeenCalledTimes(30); expect(callActionSpy).toHaveBeenNthCalledWith( - 12, + 16, 'ApprovalController:addRequest', { origin: MOCK_ORIGIN, @@ -9185,7 +9418,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 15, + 21, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -9276,9 +9509,9 @@ describe('SnapController', () => { [MOCK_SNAP_ID]: { version: '1.1.0' }, }); - expect(callActionSpy).toHaveBeenCalledTimes(23); + expect(callActionSpy).toHaveBeenCalledTimes(31); expect(callActionSpy).toHaveBeenNthCalledWith( - 12, + 16, 'ApprovalController:addRequest', { origin: MOCK_ORIGIN, @@ -9300,7 +9533,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 15, + 21, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -9324,7 +9557,7 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 20, + 26, 'PermissionController:revokePermissions', { [MOCK_SNAP_ID]: ['endowment:ethereum-provider', 'endowment:caip25'], @@ -9414,7 +9647,7 @@ describe('SnapController', () => { const isRunning = controller.isSnapRunning(MOCK_SNAP_ID); - expect(callActionSpy).toHaveBeenCalledTimes(15); + expect(callActionSpy).toHaveBeenCalledTimes(19); expect(callActionSpy).toHaveBeenNthCalledWith( 3, @@ -9445,13 +9678,13 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 6, + 8, 'PermissionController:getPermissions', MOCK_SNAP_ID, ); expect(callActionSpy).toHaveBeenNthCalledWith( - 7, + 9, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -9470,7 +9703,7 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 8, + 10, 'ApprovalController:addRequest', expect.objectContaining({ id: expect.any(String), @@ -9491,13 +9724,13 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 9, + 11, 'ExecutionService:terminateSnap', MOCK_SNAP_ID, ); expect(callActionSpy).toHaveBeenNthCalledWith( - 14, + 16, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -9577,7 +9810,7 @@ describe('SnapController', () => { const newSnap = controller.getSnap(MOCK_SNAP_ID); expect(newSnap?.version).toBe('1.0.0'); - expect(callActionSpy).toHaveBeenCalledTimes(6); + expect(callActionSpy).toHaveBeenCalledTimes(10); expect(callActionSpy).toHaveBeenNthCalledWith( 2, @@ -9602,13 +9835,13 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 4, + 6, 'PermissionController:getPermissions', MOCK_SNAP_ID, ); expect(callActionSpy).toHaveBeenNthCalledWith( - 5, + 7, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -9757,10 +9990,10 @@ describe('SnapController', () => { [MOCK_SNAP_ID]: { version: '1.1.0' }, }); - expect(callActionSpy).toHaveBeenCalledTimes(24); + expect(callActionSpy).toHaveBeenCalledTimes(32); expect(callActionSpy).toHaveBeenNthCalledWith( - 12, + 16, 'ApprovalController:addRequest', { origin: MOCK_ORIGIN, @@ -9782,7 +10015,7 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 15, + 21, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -9807,7 +10040,7 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 16, + 22, 'ApprovalController:addRequest', { origin: MOCK_ORIGIN, @@ -9829,13 +10062,13 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 20, + 26, 'PermissionController:revokePermissions', { [MOCK_SNAP_ID]: ['snap_manageState'] }, ); expect(callActionSpy).toHaveBeenNthCalledWith( - 21, + 27, 'PermissionController:grantPermissions', { approvedPermissions: { 'endowment:network-access': {} }, @@ -9852,13 +10085,13 @@ describe('SnapController', () => { ); expect(callActionSpy).toHaveBeenNthCalledWith( - 22, + 28, 'ExecutionService:executeSnap', expect.anything(), ); expect(callActionSpy).toHaveBeenNthCalledWith( - 23, + 29, 'ApprovalController:updateRequestState', expect.objectContaining({ id: expect.any(String), @@ -10072,81 +10305,315 @@ describe('SnapController', () => { ); /* eslint-enable @typescript-eslint/naming-convention */ - const snapController = await getSnapController( - getSnapControllerOptions({ - rootMessenger, - detectSnapLocation: detect, - }), + const snapController = await getSnapController( + getSnapControllerOptions({ + rootMessenger, + detectSnapLocation: detect, + }), + ); + + rootMessenger.registerActionHandler( + 'ApprovalController:addRequest', + async (request) => { + expect(request.id).toBe( + (request.requestData?.metadata as { id: string })?.id, + ); + return approvalControllerMock.addRequest.bind(approvalControllerMock)( + request, + ); + }, + ); + + rootMessenger.registerActionHandler( + 'ApprovalController:updateRequestState', + (request) => { + approvalControllerMock.updateRequestStateAndApprove.bind( + approvalControllerMock, + )(request); + }, + ); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + (origin) => { + if (origin === MOCK_ORIGIN) { + return MOCK_ORIGIN_PERMISSIONS; + } + return approvedPermissions; + }, + ); + + await snapController.installSnaps(MOCK_ORIGIN, { [MOCK_SNAP_ID]: {} }); + await snapController.installSnaps(MOCK_ORIGIN, { + [MOCK_SNAP_ID]: { version: '1.1.0' }, + }); + + snapController.destroy(); + }); + + it('handles unnormalized paths correctly', async () => { + const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ + manifest: getSnapManifest({ + version: '1.2.0' as SemVerVersion, + filePath: './dist/bundle.js', + iconPath: './images/icon.svg', + }), + }); + const detectSnapLocation = loopbackDetect({ + manifest: manifest.result, + }); + + const controller = await getSnapController( + getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + detectSnapLocation, + }), + ); + + await controller.installSnaps(MOCK_ORIGIN, { + [MOCK_SNAP_ID]: { version: '1.2.0' }, + }); + + const newSnap = controller.getSnap(MOCK_SNAP_ID); + expect(newSnap?.version).toBe('1.2.0'); + + controller.destroy(); + }); + + it('tracks `Snap Update Started` when a Snap update starts', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstallStarted', + MOCK_SNAP_ID, + MOCK_ORIGIN, + true, + false, + ); + + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Update Started', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + origin: MOCK_ORIGIN, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, + ); + + snapController.destroy(); + }); + + it('does not track `Snap Update Started` for preinstalled Snaps', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState( + getPersistedSnapObject({ preinstalled: true }), + ), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstallStarted', + MOCK_SNAP_ID, + MOCK_ORIGIN, + true, + true, + ); + + expect(options.messenger.call).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.objectContaining({ name: 'Snap Update Started' }), + ); + + snapController.destroy(); + }); + + it('tracks `Snap Update Failed` when a Snap update fails', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstallFailed', + MOCK_SNAP_ID, + MOCK_ORIGIN, + true, + 'Update failed.', + false, ); - rootMessenger.registerActionHandler( - 'ApprovalController:addRequest', - async (request) => { - expect(request.id).toBe( - (request.requestData?.metadata as { id: string })?.id, - ); - return approvalControllerMock.addRequest.bind(approvalControllerMock)( - request, - ); + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Update Failed', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + origin: MOCK_ORIGIN, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, }, ); - rootMessenger.registerActionHandler( - 'ApprovalController:updateRequestState', - (request) => { - approvalControllerMock.updateRequestStateAndApprove.bind( - approvalControllerMock, - )(request); + snapController.destroy(); + }); + + it('tracks `Snap Update Rejected` when a Snap update is rejected', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapInstallFailed', + MOCK_SNAP_ID, + MOCK_ORIGIN, + true, + 'User rejected the request.', + false, ); - rootMessenger.registerActionHandler( - 'PermissionController:getPermissions', - (origin) => { - if (origin === MOCK_ORIGIN) { - return MOCK_ORIGIN_PERMISSIONS; - } - return approvedPermissions; + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Update Rejected', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + origin: MOCK_ORIGIN, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, }, ); - await snapController.installSnaps(MOCK_ORIGIN, { [MOCK_SNAP_ID]: {} }); - await snapController.installSnaps(MOCK_ORIGIN, { - [MOCK_SNAP_ID]: { version: '1.1.0' }, + snapController.destroy(); + }); + + it('tracks `Snap Updated` when a Snap is updated', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, }); + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapUpdated', + getTruncatedSnap(), + '0.9.0', + MOCK_ORIGIN, + false, + ); + + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Updated', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + // eslint-disable-next-line @typescript-eslint/naming-convention + old_version: '0.9.0', + // eslint-disable-next-line @typescript-eslint/naming-convention + new_version: '1.0.0', + origin: MOCK_ORIGIN, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, + ); + snapController.destroy(); }); - it('handles unnormalized paths correctly', async () => { - const { manifest } = await getMockSnapFilesWithUpdatedChecksum({ - manifest: getSnapManifest({ - version: '1.2.0' as SemVerVersion, - filePath: './dist/bundle.js', - iconPath: './images/icon.svg', - }), - }); - const detectSnapLocation = loopbackDetect({ - manifest: manifest.result, + it('does not track `Snap Updated` for preinstalled Snaps', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, }); - const controller = await getSnapController( - getSnapControllerOptions({ - state: { - snaps: getPersistedSnapsState(), - }, - detectSnapLocation, - }), + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapUpdated', + getTruncatedSnap(), + '0.9.0', + MOCK_ORIGIN, + true, ); - await controller.installSnaps(MOCK_ORIGIN, { - [MOCK_SNAP_ID]: { version: '1.2.0' }, + expect(options.messenger.call).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.objectContaining({ name: 'Snap Updated' }), + ); + + snapController.destroy(); + }); + + it('does not track `Snap Update Failed` for preinstalled Snaps', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState( + getPersistedSnapObject({ preinstalled: true }), + ), + }, }); - const newSnap = controller.getSnap(MOCK_SNAP_ID); - expect(newSnap?.version).toBe('1.2.0'); + const snapController = await getSnapController(options); - controller.destroy(); + options.messenger.publish( + 'SnapController:snapInstallFailed', + MOCK_SNAP_ID, + MOCK_ORIGIN, + true, + 'Update failed.', + true, + ); + + expect(options.messenger.call).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.objectContaining({ name: 'Snap Update Failed' }), + ); + + snapController.destroy(); }); }); @@ -10185,7 +10652,7 @@ describe('SnapController', () => { }); await snapController.removeSnap(MOCK_SNAP_ID); - expect(callActionSpy).toHaveBeenCalledTimes(6); + expect(callActionSpy).toHaveBeenCalledTimes(8); expect(callActionSpy).toHaveBeenNthCalledWith( 5, 'PermissionController:revokePermissions', @@ -10237,7 +10704,7 @@ describe('SnapController', () => { }); await snapController.removeSnap(MOCK_SNAP_ID); - expect(callActionSpy).toHaveBeenCalledTimes(7); + expect(callActionSpy).toHaveBeenCalledTimes(9); expect(callActionSpy).toHaveBeenNthCalledWith( 6, 'PermissionController:updateCaveat', @@ -10288,7 +10755,7 @@ describe('SnapController', () => { }); await snapController.removeSnap(MOCK_SNAP_ID); - expect(callActionSpy).toHaveBeenCalledTimes(7); + expect(callActionSpy).toHaveBeenCalledTimes(9); expect(callActionSpy).toHaveBeenNthCalledWith( 5, 'PermissionController:revokePermissions', @@ -10348,6 +10815,40 @@ describe('SnapController', () => { snapController.destroy(); }); + + it('tracks `Snap Uninstalled` when a Snap is uninstalled', async () => { + const options = getSnapControllerOptions({ + state: { + snaps: getPersistedSnapsState(), + }, + }); + + const snapController = await getSnapController(options); + + options.messenger.publish( + 'SnapController:snapUninstalled', + getTruncatedSnap(), + ); + + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Uninstalled', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: MOCK_SNAP_ID, + version: '1.0.0', + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }, + ); + + snapController.destroy(); + }); }); describe('enableSnap', () => { @@ -11356,20 +11857,20 @@ describe('SnapController', () => { }); it('should track event for allowed handler', async () => { - const mockTrackEvent = jest.fn(); const rootMessenger = getRootMessenger(); const executionEnvironmentStub = new ExecutionEnvironmentStub( getNodeEESMessenger(rootMessenger), ) as unknown as NodeThreadExecutionService; + const options = getSnapControllerOptions({ + rootMessenger, + state: { + snaps: getPersistedSnapsState(), + }, + }); + const [snapController] = await getSnapControllerWithEES( - getSnapControllerOptions({ - rootMessenger, - trackEvent: mockTrackEvent, - state: { - snaps: getPersistedSnapsState(), - }, - }), + options, executionEnvironmentStub, ); @@ -11388,25 +11889,28 @@ describe('SnapController', () => { }, }); - expect(mockTrackEvent).toHaveBeenCalledTimes(1); - expect(mockTrackEvent).toHaveBeenCalledWith({ - event: 'Snap Export Used', - category: 'Snaps', - properties: { - export: 'onRpcRequest', - origin: 'https://example.com', - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_category: undefined, - // eslint-disable-next-line @typescript-eslint/naming-convention - snap_id: 'npm:@metamask/example-snap', - success: true, + expect(options.messenger.call).toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + { + name: 'Snap Export Used', + properties: { + export: 'onRpcRequest', + origin: 'https://example.com', + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: null, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: 'npm:@metamask/example-snap', + success: true, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, }, - }); + ); snapController.destroy(); }); it('should not track event for disallowed handler', async () => { - const mockTrackEvent = jest.fn(); const rootMessenger = getRootMessenger(); rootMessenger.registerActionHandler( @@ -11428,15 +11932,16 @@ describe('SnapController', () => { getNodeEESMessenger(rootMessenger), ) as unknown as NodeThreadExecutionService; + const options = getSnapControllerOptions({ + environmentEndowmentPermissions: ['endowment:cronjob'], + rootMessenger, + state: { + snaps: getPersistedSnapsState(), + }, + }); + const [snapController] = await getSnapControllerWithEES( - getSnapControllerOptions({ - environmentEndowmentPermissions: ['endowment:cronjob'], - rootMessenger, - trackEvent: mockTrackEvent, - state: { - snaps: getPersistedSnapsState(), - }, - }), + options, executionEnvironmentStub, ); @@ -11455,17 +11960,26 @@ describe('SnapController', () => { }, }); - expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(options.messenger.call).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.any(Object), + ); snapController.destroy(); }); - it('should properly handle error when MetaMetrics hook throws an error', async () => { + it('should properly handle error when AnalyticsController throws an error', async () => { const log = jest.spyOn(console, 'error').mockImplementation(); const error = new Error('MetaMetrics hook error'); - const mockTrackEvent = jest.fn().mockImplementation(() => { - throw error; - }); const rootMessenger = getRootMessenger(); + + rootMessenger.unregisterActionHandler('AnalyticsController:trackEvent'); + rootMessenger.registerActionHandler( + 'AnalyticsController:trackEvent', + () => { + throw error; + }, + ); + const executionEnvironmentStub = new ExecutionEnvironmentStub( getNodeEESMessenger(rootMessenger), ) as unknown as NodeThreadExecutionService; @@ -11473,7 +11987,6 @@ describe('SnapController', () => { const [snapController] = await getSnapControllerWithEES( getSnapControllerOptions({ rootMessenger, - trackEvent: mockTrackEvent, state: { snaps: getPersistedSnapsState(), }, @@ -11496,7 +12009,6 @@ describe('SnapController', () => { }, }); - expect(mockTrackEvent).toHaveBeenCalled(); expect(log).toHaveBeenCalledWith( expect.stringContaining( 'Error when calling MetaMetrics hook for snap', @@ -11506,22 +12018,22 @@ describe('SnapController', () => { }); it('should not track event for preinstalled snap', async () => { - const mockTrackEvent = jest.fn(); const rootMessenger = getRootMessenger(); const executionEnvironmentStub = new ExecutionEnvironmentStub( getNodeEESMessenger(rootMessenger), ) as unknown as NodeThreadExecutionService; + const options = getSnapControllerOptions({ + rootMessenger, + state: { + snaps: getPersistedSnapsState( + getPersistedSnapObject({ preinstalled: true }), + ), + }, + }); + const [snapController] = await getSnapControllerWithEES( - getSnapControllerOptions({ - rootMessenger, - trackEvent: mockTrackEvent, - state: { - snaps: getPersistedSnapsState( - getPersistedSnapObject({ preinstalled: true }), - ), - }, - }), + options, executionEnvironmentStub, ); @@ -11540,7 +12052,10 @@ describe('SnapController', () => { }, }); - expect(mockTrackEvent).not.toHaveBeenCalled(); + expect(options.messenger.call).not.toHaveBeenCalledWith( + 'AnalyticsController:trackEvent', + expect.any(Object), + ); snapController.destroy(); }); }); @@ -12781,13 +13296,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ExecutionService:executeSnap', expect.any(Object), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 6, + 8, 'ExecutionService:handleRpcRequest', MOCK_SNAP_ID, { @@ -12830,7 +13345,7 @@ describe('SnapController', () => { await new Promise((resolve) => setTimeout(resolve, 10)); - expect(options.messenger.call).toHaveBeenCalledTimes(2); + expect(options.messenger.call).toHaveBeenCalledTimes(4); expect(options.messenger.call).not.toHaveBeenCalledWith( 'ExecutionService:handleRpcRequest', MOCK_SNAP_ID, @@ -12934,13 +13449,13 @@ describe('SnapController', () => { ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 5, + 7, 'ExecutionService:executeSnap', expect.any(Object), ); expect(options.messenger.call).toHaveBeenNthCalledWith( - 6, + 8, 'ExecutionService:handleRpcRequest', MOCK_SNAP_ID, { @@ -12983,7 +13498,7 @@ describe('SnapController', () => { await new Promise((resolve) => setTimeout(resolve, 10)); - expect(options.messenger.call).toHaveBeenCalledTimes(2); + expect(options.messenger.call).toHaveBeenCalledTimes(4); expect(options.messenger.call).not.toHaveBeenCalledWith( 'ExecutionService:handleRpcRequest', MOCK_SNAP_ID, diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index 81afb51af7..31ebca1a9e 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -1,3 +1,4 @@ +import type { AnalyticsControllerTrackEventAction } from '@metamask/analytics-controller'; import { ORIGIN_METAMASK, type ApprovalControllerAddRequestAction, @@ -402,7 +403,12 @@ export type SnapControllerSnapBlockedEvent = { */ export type SnapControllerSnapInstallStartedEvent = { type: `${typeof controllerName}:snapInstallStarted`; - payload: [snapId: SnapId, origin: string, isUpdate: boolean]; + payload: [ + snapId: SnapId, + origin: string, + isUpdate: boolean, + preinstalled: boolean, + ]; }; /** @@ -410,7 +416,13 @@ export type SnapControllerSnapInstallStartedEvent = { */ export type SnapControllerSnapInstallFailedEvent = { type: `${typeof controllerName}:snapInstallFailed`; - payload: [snapId: SnapId, origin: string, isUpdate: boolean, error: string]; + payload: [ + snapId: SnapId, + origin: string, + isUpdate: boolean, + error: string, + preinstalled: boolean, + ]; }; /** @@ -513,6 +525,7 @@ export type SnapControllerEvents = | SnapControllerStateChangeEvent; type AllowedActions = + | AnalyticsControllerTrackEventAction | PermissionControllerGetEndowmentsAction | PermissionControllerGetPermissionsAction | PermissionControllerGetSubjectNamesAction @@ -544,7 +557,10 @@ type AllowedActions = type AllowedEvents = | ExecutionServiceEvents + | SnapControllerSnapInstallStartedEvent + | SnapControllerSnapInstallFailedEvent | SnapControllerSnapInstalledEvent + | SnapControllerSnapUninstalledEvent | SnapControllerSnapUpdatedEvent | KeyringControllerLockEvent | SnapRegistryControllerRegistryUpdatedEvent; @@ -677,11 +693,6 @@ export type SnapControllerArgs = { */ clientCryptography?: CryptographicFunctions; - /** - * MetaMetrics event tracking hook. - */ - trackEvent: TrackEventHook; - /** * A hook that returns a promise that resolves when the onboarding has completed. * @@ -708,14 +719,6 @@ type SetSnapArgs = Omit & { hideSnapBranding?: boolean; }; -type TrackingEventPayload = { - event: string; - category: string; - properties: Record; -}; - -type TrackEventHook = (event: TrackingEventPayload) => void; - const defaultState: SnapControllerState = { snaps: {}, snapStates: {}, @@ -800,8 +803,6 @@ export class SnapController extends BaseController< readonly #preinstalledSnaps: PreinstalledSnap[] | null; - readonly #trackEvent: TrackEventHook; - readonly #trackSnapExport: ReturnType; readonly #ensureOnboardingComplete: () => Promise; @@ -829,7 +830,6 @@ export class SnapController extends BaseController< getMnemonicSeed, getFeatureFlags = () => ({}), clientCryptography, - trackEvent, ensureOnboardingComplete, }: SnapControllerArgs) { super({ @@ -917,7 +917,6 @@ export class SnapController extends BaseController< this._onOutboundResponse = this._onOutboundResponse.bind(this); this.#rollbackSnapshots = new Map(); this.#snapsRuntimeData = new Map(); - this.#trackEvent = trackEvent; this.#ensureOnboardingComplete = ensureOnboardingComplete; this.#pollForLastRequestStatus(); @@ -941,34 +940,169 @@ export class SnapController extends BaseController< this.messenger.subscribe( 'SnapController:snapInstalled', - ({ id }, origin) => { - this.#callLifecycleHook(origin, id, HandlerType.OnInstall).catch( + (snap: TruncatedSnap, origin: string, preinstalled: boolean) => { + this.#callLifecycleHook(origin, snap.id, HandlerType.OnInstall).catch( (error) => { logError( - `Error when calling \`onInstall\` lifecycle hook for snap "${id}": ${getErrorMessage( + `Error when calling \`onInstall\` lifecycle hook for snap "${snap.id}": ${getErrorMessage( error, )}`, ); }, ); + + if (preinstalled) { + return; + } + + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snap.id, + ); + this.messenger.call('AnalyticsController:trackEvent', { + name: 'Snap Installed', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snap.id, + version: snap.version, + origin, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); }, ); this.messenger.subscribe( 'SnapController:snapUpdated', - ({ id }, _oldVersion, origin) => { - this.#callLifecycleHook(origin, id, HandlerType.OnUpdate).catch( + (snap, oldVersion, origin, preinstalled) => { + this.#callLifecycleHook(origin, snap.id, HandlerType.OnUpdate).catch( (error) => { logError( - `Error when calling \`onUpdate\` lifecycle hook for snap "${id}": ${getErrorMessage( + `Error when calling \`onUpdate\` lifecycle hook for snap "${snap.id}": ${getErrorMessage( error, )}`, ); }, ); + + if (preinstalled) { + return; + } + + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snap.id, + ); + this.messenger.call('AnalyticsController:trackEvent', { + name: 'Snap Updated', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snap.id, + // eslint-disable-next-line @typescript-eslint/naming-convention + old_version: oldVersion, + // eslint-disable-next-line @typescript-eslint/naming-convention + new_version: snap.version, + origin, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); }, ); + this.messenger.subscribe( + 'SnapController:snapInstallStarted', + (snapId, origin, isUpdate, preinstalled) => { + if (preinstalled) { + return; + } + + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snapId, + ); + + this.messenger.call('AnalyticsController:trackEvent', { + name: isUpdate ? 'Snap Update Started' : 'Snap Install Started', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snapId, + origin, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + }, + ); + + this.messenger.subscribe( + 'SnapController:snapInstallFailed', + (snapId, origin, isUpdate, error, preinstalled) => { + if (preinstalled) { + return; + } + + const isRejected = error.includes('User rejected the request.'); + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snapId, + ); + + // eslint-disable-next-line no-nested-ternary + const name = isUpdate + ? isRejected + ? 'Snap Update Rejected' + : 'Snap Update Failed' + : isRejected + ? 'Snap Install Rejected' + : 'Snap Install Failed'; + + this.messenger.call('AnalyticsController:trackEvent', { + name, + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snapId, + origin, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + }, + ); + + this.messenger.subscribe('SnapController:snapUninstalled', (snap) => { + const snapMetadata = this.messenger.call( + 'SnapRegistryController:getMetadata', + snap.id, + ); + this.messenger.call('AnalyticsController:trackEvent', { + name: 'Snap Uninstalled', + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_id: snap.id, + version: snap.version, + // eslint-disable-next-line @typescript-eslint/naming-convention + snap_category: snapMetadata?.category ?? null, + }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, + }); + }); + this.messenger.subscribe( 'KeyringController:lock', this.#handleLock.bind(this), @@ -997,18 +1131,21 @@ export class SnapController extends BaseController< 'SnapRegistryController:getMetadata', snapId, ); - this.#trackEvent({ - event: 'Snap Export Used', - category: 'Snaps', + + this.messenger.call('AnalyticsController:trackEvent', { + name: 'Snap Export Used', properties: { // eslint-disable-next-line @typescript-eslint/naming-convention snap_id: snapId, export: handler, // eslint-disable-next-line @typescript-eslint/naming-convention - snap_category: snapMetadata?.category, + snap_category: snapMetadata?.category ?? null, success, origin, }, + sensitiveProperties: {}, + saveDataRecording: false, + hasProperties: true, }); }, ); @@ -2765,6 +2902,7 @@ export class SnapController extends BaseController< snapId, origin, false, + false, ); // Existing snaps must be stopped before overwriting @@ -2824,6 +2962,7 @@ export class SnapController extends BaseController< origin, false, errorString, + false, ); throw error; @@ -2933,6 +3072,7 @@ export class SnapController extends BaseController< snapId, origin, true, + preinstalled ?? false, ); const oldManifest = snap.manifest; @@ -3095,6 +3235,7 @@ export class SnapController extends BaseController< origin, true, errorString, + preinstalled ?? false, ); throw error; diff --git a/packages/snaps-controllers/src/test-utils/controller.tsx b/packages/snaps-controllers/src/test-utils/controller.tsx index 33b7942d63..8fbf53c449 100644 --- a/packages/snaps-controllers/src/test-utils/controller.tsx +++ b/packages/snaps-controllers/src/test-utils/controller.tsx @@ -460,6 +460,11 @@ export const getRootMessenger = () => { () => undefined, ); + messenger.registerActionHandler( + 'AnalyticsController:trackEvent', + () => undefined, + ); + jest.spyOn(messenger, 'call'); return messenger; @@ -503,6 +508,7 @@ export const getSnapControllerMessenger = ( 'StorageService:getItem', 'StorageService:removeItem', 'StorageService:clear', + 'AnalyticsController:trackEvent', ], events: [ 'ExecutionService:unhandledError', @@ -600,7 +606,6 @@ export const getSnapControllerOptions = ({ Promise.resolve(TEST_SECRET_RECOVERY_PHRASE_SEED_BYTES), clientCryptography: {}, encryptor: getSnapControllerEncryptor(), - trackEvent: jest.fn(), ensureOnboardingComplete: jest.fn().mockResolvedValue(undefined), ...opts, } as SnapControllerConstructorParamsWithStorage & { diff --git a/yarn.lock b/yarn.lock index f37621133c..3b9018760c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2805,6 +2805,19 @@ __metadata: languageName: node linkType: hard +"@metamask/analytics-controller@npm:^1.1.1": + version: 1.1.1 + resolution: "@metamask/analytics-controller@npm:1.1.1" + dependencies: + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/utils": "npm:^11.9.0" + lodash: "npm:^4.17.21" + uuid: "npm:^8.3.2" + checksum: 10/2a64ac0e397a27ac029a324646ebd91c8f9afa6ae2d1b5786042eaf5ad9b4b946c85b6d311ad75319562f8952649c54e80a9e8efb37879bef82ee1b5afe3513b + languageName: node + linkType: hard + "@metamask/api-specs@npm:^0.14.0": version: 0.14.0 resolution: "@metamask/api-specs@npm:0.14.0" @@ -4217,6 +4230,7 @@ __metadata: resolution: "@metamask/snaps-controllers@workspace:packages/snaps-controllers" dependencies: "@lavamoat/allow-scripts": "npm:^4.0.0" + "@metamask/analytics-controller": "npm:^1.1.1" "@metamask/approval-controller": "npm:^9.0.2" "@metamask/auto-changelog": "npm:^6.1.1" "@metamask/base-controller": "npm:^9.1.0"