diff --git a/.gitignore b/.gitignore index 914837a1..6e1aa0b3 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ src/background/utils/firebase.config.json dist.rar dist.7z .cursorrules +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/_raw/manifest/manifest.dev.json b/_raw/manifest/manifest.dev.json index 7351d005..6d61dbaf 100644 --- a/_raw/manifest/manifest.dev.json +++ b/_raw/manifest/manifest.dev.json @@ -40,6 +40,7 @@ "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self' 'wasm-unsafe-eval';" }, "permissions": ["storage", "activeTab", "tabs", "notifications", "identity", "camera"], + "host_permissions": ["https://api.mixpanel.com/*"], "web_accessible_resources": [ { "resources": [ diff --git a/_raw/manifest/manifest.pro.json b/_raw/manifest/manifest.pro.json index 548c3006..461ae561 100644 --- a/_raw/manifest/manifest.pro.json +++ b/_raw/manifest/manifest.pro.json @@ -40,6 +40,7 @@ "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self' 'wasm-unsafe-eval';" }, "permissions": ["storage", "activeTab", "tabs", "notifications", "identity", "camera", "*://*/*"], + "host_permissions": ["https://api.mixpanel.com/*"], "web_accessible_resources": [ { "resources": [ diff --git a/e2e/wallet.test.ts b/e2e/wallet.test.ts new file mode 100644 index 00000000..08617290 --- /dev/null +++ b/e2e/wallet.test.ts @@ -0,0 +1,33 @@ +import path from 'path'; + +import { test, expect, chromium } from '@playwright/test'; + +test('Load extension', async () => { + // Get path to extension + const pathToExtension = path.join(__dirname, '../dist'); + + // Launch browser with extension + const context = await chromium.launchPersistentContext('/tmp/test-user-data-dir', { + headless: false, + args: [`--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`], + }); + + // for manifest v3: + let [background] = context.serviceWorkers(); + if (!background) background = await context.waitForEvent('serviceworker'); + + // Get extension ID from service worker URL + const extensionId = background.url().split('/')[2]; + + // Create a new page and navigate to extension + const page = await context.newPage(); + + // Navigate and wait for network to be idle + await page.goto(`chrome-extension://${extensionId}/index.html#/welcome`); + + // Wait for the welcome page to be fully loaded + await page.waitForSelector('.welcomeBox', { state: 'visible' }); + + // Cleanup + await context.close(); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index e486f3f1..d776629a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -86,7 +86,12 @@ export default [ // Test files specific config { - files: ['**/*.test.{js,jsx,ts,tsx}', '**/*.spec.{js,jsx,ts,tsx}'], + files: ['e2e/**/*', 'playwright.config.ts', 'vitest.config.ts'], + languageOptions: { + parserOptions: { + project: './tsconfig.test.json', + }, + }, rules: { 'no-restricted-globals': 'off', '@typescript-eslint/no-explicit-any': 'off', diff --git a/package.json b/package.json index 58079bb2..86670118 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "format": "prettier .", "icon": "npx iconfont-h5", "test": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:unit": "vitest", "pub": "node build/release.js", "prepare": "husky", "preinstall": "npx only-allow pnpm", @@ -57,7 +60,6 @@ "@trustwallet/wallet-core": "^4.1.19", "@tsparticles/engine": "^3.6.0", "@tsparticles/react": "^3.0.0", - "@types/mixpanel-browser": "^2.50.2", "@walletconnect/core": "^2.17.2", "@walletconnect/jsonrpc-utils": "^1.0.8", "@walletconnect/modal": "^2.7.0", @@ -98,7 +100,6 @@ "lodash": "^4.17.21", "loglevel": "^1.9.2", "lru-cache": "^6.0.0", - "mixpanel-browser": "^2.56.0", "nanoid": "^3.3.7", "obs-store": "^4.0.3", "process": "^0.11.10", @@ -142,6 +143,7 @@ }, "devDependencies": { "@eslint/js": "^9.15.0", + "@playwright/test": "^1.49.0", "@svgr/webpack": "^5.5.0", "@types/chrome": "^0.0.281", "@types/events": "^3.0.3", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..d55da4df --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,74 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI ? 'github' : 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + video: process.env.CI ? 'on-first-retry' : 'off', + screenshot: process.env.CI ? 'only-on-failure' : 'off', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + // Chrome extension testing configuration + }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1595f42c..abac024c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,9 +104,6 @@ importers: '@tsparticles/react': specifier: ^3.0.0 version: 3.0.0(@tsparticles/engine@3.7.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@types/mixpanel-browser': - specifier: ^2.50.2 - version: 2.50.2 '@walletconnect/core': specifier: ^2.17.2 version: 2.17.2 @@ -227,9 +224,6 @@ importers: lru-cache: specifier: ^6.0.0 version: 6.0.0 - mixpanel-browser: - specifier: ^2.56.0 - version: 2.56.0 nanoid: specifier: ^3.3.7 version: 3.3.8 @@ -354,6 +348,9 @@ importers: '@eslint/js': specifier: ^9.15.0 version: 9.16.0 + '@playwright/test': + specifier: ^1.49.0 + version: 1.49.1 '@svgr/webpack': specifier: ^5.5.0 version: 5.5.0 @@ -2356,6 +2353,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.49.1': + resolution: {integrity: sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.28': resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} @@ -2507,9 +2509,6 @@ packages: cpu: [x64] os: [win32] - '@rrweb/types@2.0.0-alpha.17': - resolution: {integrity: sha512-AfDTVUuCyCaIG0lTSqYtrZqJX39ZEYzs4fYKnexhQ+id+kbZIpIJtaut5cto6dWZbB3SEe4fW0o90Po3LvTmfg==} - '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -2951,9 +2950,6 @@ packages: '@types/cookies@0.9.0': resolution: {integrity: sha512-40Zk8qR147RABiQ7NQnBzWzDcjKzNrntB5BAmeGCb2p/MIyOE+4BVvc17wumsUqUw00bJYqoXFHYygQnEFh4/Q==} - '@types/css-font-loading-module@0.0.7': - resolution: {integrity: sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==} - '@types/d3-array@3.2.1': resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} @@ -3089,9 +3085,6 @@ packages: '@types/minimatch@5.1.2': resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} - '@types/mixpanel-browser@2.50.2': - resolution: {integrity: sha512-Iw8cBzplUPfHoeYuasqeYwdbGTNXhN+5kFT9kU+C7zm0NtaiPpKoiuzITr2ZH9KgBsWi2MbG0FOzIg9sQepauQ==} - '@types/ms@0.7.34': resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} @@ -3529,9 +3522,6 @@ packages: '@xobotyi/scrollbar-width@1.9.5': resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==} - '@xstate/fsm@1.6.5': - resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==} - '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -3941,10 +3931,6 @@ packages: base-x@3.0.10: resolution: {integrity: sha512-7d0s06rR9rYaIWHkpfLIFICM/tkSVdoPC9qYAQRpxn9DdKNWNsKC0uk++akckyLq16Tx2WIinnZ6WRriAt6njQ==} - base64-arraybuffer@1.0.2: - resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} - engines: {node: '>= 0.6.0'} - base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -5484,9 +5470,6 @@ packages: fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fflate@0.4.8: - resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} - file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -5640,6 +5623,11 @@ packages: os: [darwin] deprecated: Upgrade to fsevents v2 to mitigate potential security issues + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -7236,16 +7224,10 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} - mitt@3.0.1: - resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} - mixin-deep@1.3.2: resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==} engines: {node: '>=0.10.0'} - mixpanel-browser@2.56.0: - resolution: {integrity: sha512-GYeEz58pV2M9MZtK8vSPL4oJmCwGS08FDDRZvZwr5VJpWdT4Lgyg6zXhmNfCmSTEIw2coaarm7HZ4FL9dAVvnA==} - mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true @@ -7771,6 +7753,16 @@ packages: pkg-types@1.2.1: resolution: {integrity: sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==} + playwright-core@1.49.1: + resolution: {integrity: sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.49.1: + resolution: {integrity: sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==} + engines: {node: '>=18'} + hasBin: true + pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -8514,15 +8506,6 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rrdom@2.0.0-alpha.17: - resolution: {integrity: sha512-b6caDiNcFO96Opp7TGdcVd4OLGSXu5dJe+A0IDiAu8mk7OmhqZCSDlgQdTKmdO5wMf4zPsUTgb8H/aNvR3kDHA==} - - rrweb-snapshot@2.0.0-alpha.18: - resolution: {integrity: sha512-hBHZL/NfgQX6wO1D9mpwqFu1NJPpim+moIcKhFEjVTZVRUfCln+LOugRc4teVTCISYHN8Cw5e2iNTWCSm+SkoA==} - - rrweb@2.0.0-alpha.13: - resolution: {integrity: sha512-a8GXOCnzWHNaVZPa7hsrLZtNZ3CGjiL+YrkpLo0TfmxGLhjNZbWY2r7pE06p+FcjFNlgUVTmFrSJbK3kO7yxvw==} - rtl-css-js@1.16.1: resolution: {integrity: sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==} @@ -12793,6 +12776,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.49.1': + dependencies: + playwright: 1.49.1 + '@polka/url@1.0.0-next.28': {} '@popperjs/core@2.11.8': {} @@ -12950,10 +12937,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.28.1': optional: true - '@rrweb/types@2.0.0-alpha.17': - dependencies: - rrweb-snapshot: 2.0.0-alpha.18 - '@rtsao/scc@1.1.0': {} '@safe-global/safe-apps-provider@0.18.5(typescript@5.7.2)(zod@3.23.8)': @@ -13584,8 +13567,6 @@ snapshots: '@types/keygrip': 1.0.6 '@types/node': 22.10.1 - '@types/css-font-loading-module@0.0.7': {} - '@types/d3-array@3.2.1': {} '@types/d3-color@3.1.3': {} @@ -13734,8 +13715,6 @@ snapshots: '@types/minimatch@5.1.2': {} - '@types/mixpanel-browser@2.50.2': {} - '@types/ms@0.7.34': {} '@types/node@16.18.121': {} @@ -14862,8 +14841,6 @@ snapshots: '@xobotyi/scrollbar-width@1.9.5': {} - '@xstate/fsm@1.6.5': {} - '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -15302,8 +15279,6 @@ snapshots: dependencies: safe-buffer: 5.2.1 - base64-arraybuffer@1.0.2: {} - base64-js@1.5.1: {} base@0.11.2: @@ -17310,8 +17285,6 @@ snapshots: dependencies: pend: 1.2.0 - fflate@0.4.8: {} - file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -17507,6 +17480,9 @@ snapshots: nan: 2.22.0 optional: true + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -19581,17 +19557,11 @@ snapshots: minipass@7.1.2: {} - mitt@3.0.1: {} - mixin-deep@1.3.2: dependencies: for-in: 1.0.2 is-extendable: 1.0.1 - mixpanel-browser@2.56.0: - dependencies: - rrweb: 2.0.0-alpha.13 - mkdirp@0.5.6: dependencies: minimist: 1.2.8 @@ -20116,6 +20086,14 @@ snapshots: mlly: 1.7.3 pathe: 1.1.2 + playwright-core@1.49.1: {} + + playwright@1.49.1: + dependencies: + playwright-core: 1.49.1 + optionalDependencies: + fsevents: 2.3.2 + pngjs@5.0.0: {} pony-cause@2.1.11: {} @@ -20993,25 +20971,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.28.1 fsevents: 2.3.3 - rrdom@2.0.0-alpha.17: - dependencies: - rrweb-snapshot: 2.0.0-alpha.18 - - rrweb-snapshot@2.0.0-alpha.18: - dependencies: - postcss: 8.4.49 - - rrweb@2.0.0-alpha.13: - dependencies: - '@rrweb/types': 2.0.0-alpha.17 - '@types/css-font-loading-module': 0.0.7 - '@xstate/fsm': 1.6.5 - base64-arraybuffer: 1.0.2 - fflate: 0.4.8 - mitt: 3.0.1 - rrdom: 2.0.0-alpha.17 - rrweb-snapshot: 2.0.0-alpha.18 - rtl-css-js@1.16.1: dependencies: '@babel/runtime': 7.26.0 diff --git a/src/background/controller/wallet.ts b/src/background/controller/wallet.ts index f2633672..afe306ed 100644 --- a/src/background/controller/wallet.ts +++ b/src/background/controller/wallet.ts @@ -12,6 +12,7 @@ import web3, { TransactionError } from 'web3'; import eventBus from '@/eventBus'; import { type FeatureFlags } from '@/shared/types/feature-types'; +import { type TrackingEvents } from '@/shared/types/tracking-types'; import { isValidEthereumAddress, withPrefix } from '@/shared/utils/address'; import { getHashAlgo, getSignAlgo } from '@/shared/utils/algo'; // eslint-disable-next-line no-restricted-imports @@ -4097,6 +4098,23 @@ export class WalletController extends BaseController { source: source, }); }; + + // This is called from the front end, we should find a better way to track this event + trackAccountRecovered = async () => { + mixpanelTrack.track('account_recovered', { + address: (await this.getCurrentAddress()) || '', + mechanism: 'multi-backup', + methods: [], + }); + }; + + trackPageView = async (pathname: string) => { + mixpanelTrack.trackPageView(pathname); + }; + + trackTime = async (eventName: keyof TrackingEvents) => { + mixpanelTrack.time(eventName); + }; } export default new WalletController(); diff --git a/src/background/service/mixpanel.ts b/src/background/service/mixpanel.ts index ed0e2c6e..e30840ee 100644 --- a/src/background/service/mixpanel.ts +++ b/src/background/service/mixpanel.ts @@ -1,15 +1,102 @@ -import eventBus from '@/eventBus'; import type { TrackingEvents } from '@/shared/types/tracking-types'; -// TODO: Look at using a server side proxy service to send events to Mixpanel -// Note: Mixpanel is initialized in the browser side. Yes... it is possible for events to be lost if there is no listener. -// At some point, we should migrate to a more reliable event bus. +import packageJson from '../../../package.json'; +const { version } = packageJson; + +const DISTINCT_ID_KEY = 't_distinct_id'; +const DEVICE_ID_PREFIX = '$device:'; + +interface IDInfo { + $user_id?: string; + $device_id: string; +} + +interface MixpanelEvent { + event: string; + properties: { + $duration?: number; + token?: string; + distinct_id: string; + time: number; + $app_version_string: string; + $browser: string; + $browser_version: string; + $os: string; + } & T; +} + +interface MixpanelIdentifyData { + $distinct_id: string; + $set_once: { + $name: string; + first_seen: string; + }; + $time: number; +} + +type MixpanelRequestData = + | MixpanelEvent + | MixpanelIdentifyData; + +// Super properties that will be sent with every event +type SuperProperties = { + app_version: string; + platform: 'extension'; + environment: 'development' | 'production'; + wallet_type: 'flow'; +}; + class MixpanelService { private static instance: MixpanelService; private initialized = false; + private eventTimers: Partial> = {}; + + private distinctId?: string; + private readonly API_URL = 'https://api.mixpanel.com'; + private readonly token: string; + private superProperties: SuperProperties = { + app_version: version, + platform: 'extension', + environment: process.env.NODE_ENV === 'production' ? 'production' : 'development', + wallet_type: 'flow', + }; + + async #getExtraProps() { + const extensionVersion = chrome.runtime.getManifest().version; + const browserInfo = getBrowserInfo(); + //const geoLocation = await this.getGeoLocation(); + + const extraProps = { + $app_version_string: extensionVersion, + $browser: browserInfo.browser, + $browser_version: browserInfo.version, + time: timestamp() / 1000, + $os: (await chrome.runtime.getPlatformInfo()).os, + }; + return extraProps; + } + + async getIdInfo(): Promise { + const res = await chrome.storage.local.get(DISTINCT_ID_KEY); + const idInfo = res?.[DISTINCT_ID_KEY] as IDInfo | undefined; + return idInfo; + } + + async setIdInfo(info: Partial) { + const _info = await this.getIdInfo(); + const newInfo = { + ...(_info ? _info : {}), + ...info, + }; + await chrome.storage.local.set({ [DISTINCT_ID_KEY]: newInfo }); + } private constructor() { // Private constructor for singleton + this.token = process.env.MIXPANEL_TOKEN!; + if (!this.token) { + console.error('MIXPANEL_TOKEN is not defined in environment variables'); + } } static getInstance(): MixpanelService { @@ -19,42 +106,206 @@ class MixpanelService { return MixpanelService.instance; } - init() { + async init() { if (this.initialized) return; + const ids = await this.getIdInfo(); + if (!ids?.$device_id) { + await this.setIdInfo({ $device_id: UUID() }); + } this.initialized = true; } - track(eventName: T, properties?: TrackingEvents[T]) { - chrome.runtime.sendMessage({ - msg: 'track_event', - eventName, - properties, - }); + + private async sendRequest(endpoint: string, data: MixpanelRequestData) { + await this.init(); + + const body = { + ...data, + }; + try { + const response = await fetch(`${this.API_URL}${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/plain', + }, + body: JSON.stringify([body]), + }); + + if (!response.ok) { + throw new Error(`Mixpanel API error: ${response.statusText}`); + } + + const responseText = await response.text(); + + if (responseText !== '1') { + throw new Error(`Mixpanel API returned unexpected response: ${responseText}`); + } + } catch (error) { + console.error('error sending event to Mixpanel - raw', error); + if (error instanceof Error) { + console.error('Error sending event to Mixpanel:', error.message); + } + } } - time(eventName: T) { - chrome.runtime.sendMessage({ - msg: 'track_time', - eventName, - }); + private removeTimer(eventName: keyof TrackingEvents) { + const startTimeStamp = this.eventTimers[eventName]; + this.eventTimers[eventName] = undefined; + + return startTimeStamp; } - identify(userId: string) { - if (!this.initialized) return; + async time(eventName: T) { + await this.init(); + + // Start the timer for the event + this.eventTimers[eventName] = Date.now(); + } + + async track(eventName: T, properties: TrackingEvents[T]) { + await this.init(); + + const ids = await this.getIdInfo(); + const deviceId = ids?.$device_id; + const userId = ids?.$user_id; + const distinct_id = userId || DEVICE_ID_PREFIX + deviceId; + + const baseProperties = { + token: this.token, + distinct_id, + ...(await this.#getExtraProps()), + ...this.superProperties, + }; + + const event: MixpanelEvent = { + event: eventName, + properties: { + ...baseProperties, + ...properties, + }, + }; + // Add duration if the timer was started + const startTimeStamp = this.removeTimer(eventName); + if (startTimeStamp !== undefined) { + event.properties.$duration = Date.now() - startTimeStamp; + } - chrome.runtime.sendMessage({ - msg: 'track_user', - userId, + //Set determine geo location from ip as ip=1 + await this.sendRequest('/track?ip=1', event); + } + async trackPageView(pathname: string) { + await this.init(); + + await this.track('$mp_web_page_view', { + current_page_title: 'Flow Wallet', + current_domain: 'flow-extension', + current_url_path: pathname, + current_url_protocol: 'chrome-extension:', + }); + } + async identify(userId: string, name?: string) { + await this.init(); + // get previous id. + const ids = await this.getIdInfo(); + const deviceId = ids?.$device_id; + if (!deviceId) return; + if (deviceId === userId) return; + await this.track('$identify', { + distinct_id: userId, + $anon_distinct_id: deviceId, + $name: name, }); + await this.setIdInfo({ $user_id: userId }); } - reset() { + async reset() { if (!this.initialized) return; + this.distinctId = undefined; - chrome.runtime.sendMessage({ - msg: 'track_reset', + return chrome.storage.local.remove(DISTINCT_ID_KEY).then(() => { + return this.setIdInfo({ $device_id: UUID() }); }); } } export const mixpanelTrack = MixpanelService.getInstance(); + +// https://github.com/mixpanel/mixpanel-js/blob/3623fe0132860386eeed31756e0d7eb4e61997ed/src/utils.js#L862C5-L889C7 +function UUID() { + const T = function () { + const time = +new Date(); // cross-browser version of Date.now() + let ticks = 0; + + while (time === +new Date()) { + ticks++; + } + return time.toString(16) + Math.floor(ticks).toString(16); + }; + const R = function () { + return Math.random().toString(16).replace('.', ''); + }; + const UA = function () { + const ua = navigator.userAgent; + let i, + ch, + buffer: number[] = [], + ret = 0; + + function xor(result: number, byte_array: number[]) { + let j, + tmp = 0; + for (j = 0; j < byte_array.length; j++) { + tmp |= buffer[j] << (j * 8); + } + return result ^ tmp; + } + + for (i = 0; i < ua.length; i++) { + ch = ua.charCodeAt(i); + buffer.unshift(ch & 0xff); + if (buffer.length >= 4) { + ret = xor(ret, buffer); + buffer = []; + } + } + + if (buffer.length > 0) { + ret = xor(ret, buffer); + } + + return ret.toString(16); + }; + const se = (Math.floor(Math.random() * 1000) * Math.floor(Math.random() * 10000)).toString(16); + return T() + '-' + R() + '-' + UA() + '-' + se + '-' + T(); +} + +function timestamp() { + Date.now = + Date.now || + function () { + return +new Date(); + }; + return Date.now(); +} + +function getBrowserInfo() { + const userAgent = navigator.userAgent; + const vendor = navigator.vendor; + let browser, version; + + if (userAgent.includes('Firefox')) { + browser = 'Firefox'; + version = userAgent.match(/Firefox\/([0-9]+)/)?.[1]; + } else if (userAgent.includes(' OPR/')) { + browser = 'Opera'; + version = userAgent.match(/Safari\/([0-9]+)/)?.[1]; // not tested + } else if (vendor && vendor.includes('Apple')) { + browser = 'Safari'; + version = userAgent.match(/Safari\/([0-9]+)/)?.[1]; + } else if (userAgent.includes('Chrome')) { + browser = 'Chrome'; + version = userAgent.match(/Chrome\/([0-9]+)/)?.[1]; + } + return { browser, version }; +} diff --git a/src/background/service/openapi.ts b/src/background/service/openapi.ts index a9298677..dff67f62 100644 --- a/src/background/service/openapi.ts +++ b/src/background/service/openapi.ts @@ -10,6 +10,7 @@ import { signInAnonymously, onAuthStateChanged, type Unsubscribe, + type User, } from 'firebase/auth'; import { getInstallations, getId } from 'firebase/installations'; import type { TokenInfo } from 'flow-native-token-registry'; @@ -90,7 +91,7 @@ const waitForAuthInit = async () => { (await unsubscribe!)(); }; -onAuthStateChanged(auth, (user) => { +onAuthStateChanged(auth, (user: User | null) => { if (user) { // User is signed in, see docs for a list of available properties // https://firebase.google.com/docs/reference/js/firebase.User @@ -98,6 +99,9 @@ onAuthStateChanged(auth, (user) => { console.log('User is signed in'); if (user.isAnonymous) { console.log('User is anonymous'); + } else { + mixpanelTrack.identify(user.uid, user.displayName ?? user.uid); + console.log('User is signed in'); } } else { // User is signed out @@ -662,6 +666,9 @@ class OpenApiService { }; register = async (account_key: AccountKey, username: string) => { + // Track the time until account_created is called + mixpanelTrack.time('account_created'); + const config = this.store.config.register; const data = await this.sendRequest( config.method, diff --git a/src/background/service/user.ts b/src/background/service/user.ts index 4739a452..302b7476 100644 --- a/src/background/service/user.ts +++ b/src/background/service/user.ts @@ -60,15 +60,18 @@ class UserInfo { this.store.avatar = data['avatar']; // identify the user - mixpanelTrack.identify(this.store.user_id); + if (this.store.user_id) { + mixpanelTrack.identify(this.store.user_id, this.store.username); + } // TODO: track the user info if not in private mode }; addUserId = (userId: string) => { this.store.user_id = userId; - // identify the user - mixpanelTrack.identify(this.store.user_id); + if (this.store.user_id) { + mixpanelTrack.identify(this.store.user_id); + } }; removeUserInfo = () => { diff --git a/src/shared/types/tracking-types.ts b/src/shared/types/tracking-types.ts index 4a8bbf2f..8f274f42 100644 --- a/src/shared/types/tracking-types.ts +++ b/src/shared/types/tracking-types.ts @@ -14,6 +14,18 @@ type RecoveryMechanismType = type AddressType = 'flow' | 'evm' | 'child' | 'coa'; export type TrackingEvents = { + // Mixpanel Events + $identify: { + distinct_id: string; // The distinct id of the user + $anon_distinct_id: string; // The anonymous distinct id of the user + $name?: string; // The name of the user + }; + $mp_web_page_view: { + current_page_title: string; // The title of the current page + current_domain: string; // The domain of the current page + current_url_path: string; // The path of the current page + current_url_protocol: string; + }; // General Events script_error: { error: string; // Error message of the script, e.g., Rate limit exceeded diff --git a/src/ui/FRWComponent/LandingPages/AllSet.tsx b/src/ui/FRWComponent/LandingPages/AllSet.tsx index 74d8abab..366514d4 100644 --- a/src/ui/FRWComponent/LandingPages/AllSet.tsx +++ b/src/ui/FRWComponent/LandingPages/AllSet.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useEffect } from 'react'; import { storage } from 'background/webapi'; import AllSetIcon from 'ui/FRWAssets/svg/allset.svg'; -import { useWallet, mixpanelBrowserService } from 'ui/utils'; +import { useWallet } from 'ui/utils'; interface AllSetProps { handleClick: () => void; @@ -12,23 +12,13 @@ interface AllSetProps { } const AllSet = ({ handleClick, variant = 'register' }: AllSetProps) => { - const wallet = useWallet(); + const usewallet = useWallet(); const loadScript = useCallback(async () => { if (variant === 'register') { - await wallet.getCadenceScripts(); + await usewallet.getCadenceScripts(); } - }, [wallet, variant]); - - const trackAccountRecovered = useCallback(async () => { - if (variant === 'register') { - mixpanelBrowserService.track('account_recovered', { - address: (await wallet.getMainAddress()) || '', - mechanism: 'multi-backup', - methods: [], - }); - } - }, [wallet, variant]); + }, [usewallet, variant]); useEffect(() => { const removeTempPass = () => { @@ -39,12 +29,12 @@ const AllSet = ({ handleClick, variant = 'register' }: AllSetProps) => { if (variant === 'register') { loadScript().then(() => { - trackAccountRecovered(); + usewallet.trackAccountRecovered(); }); } else if (variant === 'add') { removeTempPass(); } - }, [variant, loadScript, trackAccountRecovered]); + }, [variant, loadScript, usewallet]); const getMessage = () => { if (variant === 'recover') { diff --git a/src/ui/utils/index.ts b/src/ui/utils/index.ts index 3c0f1846..be33285d 100644 --- a/src/ui/utils/index.ts +++ b/src/ui/utils/index.ts @@ -15,8 +15,6 @@ export * from './number'; export * from './saveStorage'; -export * from './mixpanelBrowserService'; - const UI_TYPE = { Tab: 'index', Pop: 'popup', diff --git a/src/ui/utils/mixpanelBrowserService.ts b/src/ui/utils/mixpanelBrowserService.ts deleted file mode 100644 index 0a362d23..00000000 --- a/src/ui/utils/mixpanelBrowserService.ts +++ /dev/null @@ -1,147 +0,0 @@ -import mixpanel from 'mixpanel-browser'; - -import type { TrackingEvents, TrackMessage } from '@/shared/types/tracking-types'; - -import packageJson from '../../../package.json'; -const { version } = packageJson; - -// Super properties that will be sent with every event -type SuperProperties = { - app_version: string; - platform: 'extension'; - environment: 'development' | 'production'; - wallet_type: 'flow'; -}; - -class MixpanelBrowserService { - private static instance: MixpanelBrowserService; - private initialized = false; - - private mixpanelEventMessageHandler: (message: TrackMessage) => void; - - private constructor() { - this.initMixpanel(); - - this.mixpanelEventMessageHandler = (message: TrackMessage) => { - switch (message.msg) { - case 'track_event': - // TypeScript knows eventName and properties are available here - this.track(message.eventName, message.properties); - break; - case 'track_user': - // TypeScript knows userId is available here - this.identify(message.userId); - break; - case 'track_reset': - // TypeScript knows this is just a reset message - this.reset(); - break; - case 'track_time': - // TypeScript knows eventName is available here - this.time(message.eventName); - break; - } - }; - - this.setupEventListener(); - } - - private setupEventListener() { - // Listen for messages from the background script - // This feels blunt as we have to switch on the message type - // TODO: We should use a more elegant approach to filter messages based on the sender - chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - switch (message.msg) { - case 'track_event': - case 'track_user': - case 'track_reset': - case 'track_time': - this.mixpanelEventMessageHandler(message); - sendResponse({ success: true }); - break; - } - return true; // Keep the message channel open for asynchronous response - }); - } - - init() { - // Don't need to do anything here - // Mixpanel is initialized in the constructor - } - cleanup() { - // Remove the event listener - chrome.runtime.onMessage.removeListener(this.mixpanelEventMessageHandler); - } - - static getInstance(): MixpanelBrowserService { - if (!MixpanelBrowserService.instance) { - MixpanelBrowserService.instance = new MixpanelBrowserService(); - } - return MixpanelBrowserService.instance; - } - - private registerSuperProperties() { - const superProperties: SuperProperties = { - app_version: version, - platform: 'extension', - environment: process.env.NODE_ENV === 'production' ? 'production' : 'development', - wallet_type: 'flow', - }; - - mixpanel.register(superProperties); - } - - private initMixpanel() { - if (this.initialized) return; - - const token = process.env.MIXPANEL_TOKEN; - if (!token) { - console.warn('Mixpanel token not found'); - return; - } - - mixpanel.init(token, { - debug: process.env.NODE_ENV !== 'production', - track_pageview: 'full-url', // track the full url including the hash - persistence: 'localStorage', - batch_requests: true, - batch_size: 10, - batch_flush_interval_ms: 2000, - }); - - this.registerSuperProperties(); - this.initialized = true; - } - - track(eventName: T, properties?: TrackingEvents[T]) { - if (!this.initialized) { - console.warn('Mixpanel not initialized'); - return; - } - - const baseProps = { - timestamp: Date.now(), - }; - - mixpanel.track(eventName, { - ...baseProps, - ...properties, - }); - } - - time(eventName: T) { - mixpanel.time_event(eventName); - } - - identify(userId: string) { - if (!this.initialized) return; - mixpanel.identify(userId); - } - - reset() { - if (!this.initialized) return; - mixpanel.reset(); - } -} - -export const mixpanelBrowserService = MixpanelBrowserService.getInstance(); diff --git a/src/ui/utils/useMixpanel.ts b/src/ui/utils/useMixpanel.ts deleted file mode 100644 index 24809766..00000000 --- a/src/ui/utils/useMixpanel.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useCallback } from 'react'; - -import type { TrackingEvents } from '@/shared/types/tracking-types'; - -import { mixpanelBrowserService } from './mixpanelBrowserService'; - -export const useMixpanel = () => { - const track = useCallback( - (eventName: T, properties?: TrackingEvents[T]) => { - mixpanelBrowserService.track(eventName, properties); - }, - [] - ); - const time = useCallback((eventName: T) => { - mixpanelBrowserService.time(eventName); - }, []); - - const identify = useCallback((userId: string) => { - mixpanelBrowserService.identify(userId); - }, []); - - const reset = useCallback(() => { - mixpanelBrowserService.reset(); - }, []); - - return { - track, - time, - identify, - reset, - }; -}; diff --git a/src/ui/views/LandingPages/RecoverRegister/SetPassword.tsx b/src/ui/views/LandingPages/RecoverRegister/SetPassword.tsx index ca32a9e7..7c32dd50 100644 --- a/src/ui/views/LandingPages/RecoverRegister/SetPassword.tsx +++ b/src/ui/views/LandingPages/RecoverRegister/SetPassword.tsx @@ -24,7 +24,7 @@ import { storage } from '@/background/webapi'; import { LLSpinner } from '@/ui/FRWComponent'; import SlideRelative from '@/ui/FRWComponent/SlideRelative'; import { type AccountKey } from 'background/service/networkModel'; -import { useWallet, saveIndex, mixpanelBrowserService } from 'ui/utils'; +import { useWallet, saveIndex } from 'ui/utils'; import CheckCircleIcon from '../../../../components/iconfont/IconCheckmark'; import CancelIcon from '../../../../components/iconfont/IconClose'; @@ -191,8 +191,6 @@ const SetPassword = ({ handleClick, mnemonic, username }) => { await saveIndex(username); const accountKey = getAccountKey(mnemonic); - // track the time until account_created is called - mixpanelBrowserService.time('account_created'); wallet.openapi .register(accountKey, username) .then((response) => { diff --git a/src/ui/views/LandingPages/Welcome/Register/SetPassword.tsx b/src/ui/views/LandingPages/Welcome/Register/SetPassword.tsx index e2c4b6f8..975574be 100644 --- a/src/ui/views/LandingPages/Welcome/Register/SetPassword.tsx +++ b/src/ui/views/LandingPages/Welcome/Register/SetPassword.tsx @@ -13,7 +13,7 @@ import { } from '@/ui/FRWComponent/LandingPages/SetPassword'; import SlideRelative from '@/ui/FRWComponent/SlideRelative'; import { type AccountKey } from 'background/service/networkModel'; -import { useWallet, saveIndex, mixpanelBrowserService } from 'ui/utils'; +import { useWallet, saveIndex } from 'ui/utils'; import CheckCircleIcon from '../../../../../components/iconfont/IconCheckmark'; import CancelIcon from '../../../../../components/iconfont/IconClose'; @@ -126,8 +126,6 @@ const SetPassword = ({ handleClick, mnemonic, username, setExPassword, tempPassw await saveIndex(username); const accountKey = getAccountKey(mnemonic); - // track the time until account_created is called - mixpanelBrowserService.time('account_created'); wallet.openapi .register(accountKey, username) .then((response) => { diff --git a/src/ui/views/LandingPages/Welcome/Sync/SyncQr.tsx b/src/ui/views/LandingPages/Welcome/Sync/SyncQr.tsx index 0f753d58..d9629e7d 100644 --- a/src/ui/views/LandingPages/Welcome/Sync/SyncQr.tsx +++ b/src/ui/views/LandingPages/Welcome/Sync/SyncQr.tsx @@ -276,6 +276,7 @@ const SyncQr = ({ }; createWeb3Wallet(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( diff --git a/src/ui/views/Wallet/OnRampList.tsx b/src/ui/views/Wallet/OnRampList.tsx index 4e3aec85..7dbc2105 100644 --- a/src/ui/views/Wallet/OnRampList.tsx +++ b/src/ui/views/Wallet/OnRampList.tsx @@ -38,12 +38,11 @@ const OnRampList = ({ close }) => { const response = await wallet.openapi.getMoonpayURL(url); if (response?.data?.url) { + wallet.trackOnRampClicked('moonpay'); await chrome.tabs.create({ url: response?.data?.url, }); } - - wallet.trackOnRampClicked('moonpay'); }; const loadCoinbasePay = async () => { @@ -57,11 +56,13 @@ const OnRampList = ({ close }) => { }); if (onRampURL) { + // Track before opening the tab + wallet.trackOnRampClicked('coinbase'); + await chrome.tabs.create({ url: onRampURL, }); } - wallet.trackOnRampClicked('coinbase'); }; return ( diff --git a/src/ui/views/index.tsx b/src/ui/views/index.tsx index 1285de3e..5c10ffb7 100644 --- a/src/ui/views/index.tsx +++ b/src/ui/views/index.tsx @@ -2,12 +2,12 @@ import { CssBaseline } from '@mui/material'; import GlobalStyles from '@mui/material/GlobalStyles'; import { createTheme, ThemeProvider } from '@mui/material/styles'; import React from 'react'; -import { HashRouter as Router, Route } from 'react-router-dom'; +import { HashRouter as Router, Route, useLocation } from 'react-router-dom'; import themeOptions from '@/ui/style/LLTheme'; import { NewsProvider } from '@/ui/utils/NewsContext'; import { PrivateRoute } from 'ui/component'; -import { WalletProvider, mixpanelBrowserService } from 'ui/utils'; +import { WalletProvider, useWallet } from 'ui/utils'; import Approval from './Approval'; import InnerRoute from './InnerRoute'; @@ -19,15 +19,16 @@ import Unlock from './Unlock'; const theme = createTheme(themeOptions); -function Main() { +const Routes = () => { + const location = useLocation(); + const wallet = useWallet(); + React.useEffect(() => { - // Initialize mixpanel in the popup - // Note: Mixpanel is initialized in the constructor, just calling init here to make sure it is initialized - mixpanelBrowserService.init(); - }, []); + wallet.trackPageView(location.pathname); + }, [location, wallet]); return ( - + <> @@ -41,6 +42,14 @@ function Main() { + + ); +}; + +function Main() { + return ( + + ); } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..95573a21 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["e2e/**/*", "playwright.config.ts", "vitest.config.ts"], + "compilerOptions": { + "types": ["@playwright/test"] + } +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..72aebbe1 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/e2e/**', + '**/.{idea,git,cache,output,temp}/**', + ], + }, +});