@avalix/chroma 0.0.14 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,7 @@ Before running your tests, you need to download the wallet extensions:
22
22
  npx chroma download-extensions
23
23
  ```
24
24
 
25
- This will download the wallet extensions (Polkadot JS and Talisman) to `./.chroma` directory in your project root.
25
+ This will download the wallet extensions (e.g. MetaMask, Polkadot JS, Talisman) to `./.chroma` directory in your project root.
26
26
 
27
27
  **Tip**: Add this to your `package.json` scripts for convenience:
28
28
 
@@ -42,24 +42,22 @@ This will download the wallet extensions (Polkadot JS and Talisman) to `./.chrom
42
42
  import { createWalletTest, expect } from '@avalix/chroma'
43
43
 
44
44
  const test = createWalletTest({
45
- wallets: [{ type: 'polkadot-js' }]
45
+ wallets: [{ type: 'metamask' }]
46
46
  })
47
47
 
48
48
  test('connect wallet and sign transaction', async ({ page, wallets }) => {
49
- const polkadotJs = wallets['polkadot-js']
49
+ const metamask = wallets.metamask
50
50
 
51
- await polkadotJs.importMnemonic({
52
- seed: 'bottom drive obey lake curtain smoke basket hold race lonely fit walk',
53
- name: 'Test Account',
54
- password: 'securePassword123'
51
+ await metamask.importSeedPhrase({
52
+ seedPhrase: 'test test test test test test test test test test test junk'
55
53
  })
56
54
 
57
55
  await page.goto('http://localhost:3000')
58
56
  await page.click('button:has-text("Connect Wallet")')
59
- await polkadotJs.authorize()
57
+ await metamask.authorize()
60
58
 
61
59
  await page.click('button:has-text("Send Transaction")')
62
- await polkadotJs.approveTx({ password: 'securePassword123' })
60
+ await metamask.confirm()
63
61
 
64
62
  await expect(page.locator('.transaction-success')).toBeVisible()
65
63
  })
@@ -71,40 +69,21 @@ test('connect wallet and sign transaction', async ({ page, wallets }) => {
71
69
  import { createWalletTest } from '@avalix/chroma'
72
70
 
73
71
  const test = createWalletTest({
74
- wallets: [{ type: 'polkadot-js' }, { type: 'talisman' }]
72
+ wallets: [{ type: 'metamask' }, { type: 'talisman' }]
75
73
  })
76
74
 
77
75
  test('multi-wallet test', async ({ page, wallets }) => {
78
- const polkadotJs = wallets['polkadot-js']
76
+ const metamask = wallets.metamask
79
77
  const talisman = wallets.talisman
80
78
 
81
- await polkadotJs.importMnemonic({ seed: '...', name: 'Alice' })
79
+ await metamask.importSeedPhrase({ seedPhrase: 'test test test test test test test test test test test junk' })
82
80
  await talisman.importEthPrivateKey({ privateKey: '0x...', name: 'Bob' })
83
81
 
84
82
  await page.goto('http://localhost:3000')
85
- await polkadotJs.authorize()
83
+ await metamask.authorize()
86
84
  })
87
85
  ```
88
86
 
89
- ## Supported Wallets & Chains
90
-
91
- ### Supported Chains
92
-
93
- | Chain | Status |
94
- |-------|--------|
95
- | Polkadot | ✅ Supported |
96
- | Ethereum | ✅ Supported |
97
- | Solana | ⏳ Planned |
98
-
99
- ### Supported Wallets
100
-
101
- | Wallet | Status | Version |
102
- |--------|--------|---------|
103
- | Polkadot JS Extension | ✅ Supported | v0.62.6 |
104
- | Talisman | ✅ Supported | v3.1.13 |
105
- | SubWallet | ⏳ Planned | - |
106
- | MetaMask | ⏳ Planned | - |
107
-
108
87
  ## Features
109
88
 
110
89
  - **Easy Extension Setup** - Download wallet extensions with a single command
package/dist/index.d.mts CHANGED
@@ -9949,11 +9949,23 @@ declare function createTalismanWallet(extensionId: string, context: BrowserConte
9949
9949
  approveTx: () => Promise<void>;
9950
9950
  rejectTx: () => Promise<void>;
9951
9951
  };
9952
+ declare function createMetaMaskWallet(extensionId: string, context: BrowserContext): {
9953
+ extensionId: string;
9954
+ type: "metamask";
9955
+ importSeedPhrase: (options: {
9956
+ seedPhrase: string;
9957
+ }) => Promise<void>;
9958
+ unlock: () => Promise<void>;
9959
+ authorize: () => Promise<void>;
9960
+ reject: () => Promise<void>;
9961
+ confirm: () => Promise<void>;
9962
+ };
9952
9963
  type PolkadotJsWalletInstance = ReturnType<typeof createPolkadotJsWallet>;
9953
9964
  type TalismanWalletInstance = ReturnType<typeof createTalismanWallet>;
9965
+ type MetaMaskWalletInstance = ReturnType<typeof createMetaMaskWallet>;
9954
9966
  //#endregion
9955
9967
  //#region src/context-playwright/types.d.ts
9956
- type WalletType = 'polkadot-js' | 'talisman';
9968
+ type WalletType = 'polkadot-js' | 'talisman' | 'metamask';
9957
9969
  interface WalletAccount {
9958
9970
  seed: string;
9959
9971
  name?: string;
@@ -9966,6 +9978,7 @@ interface WalletConfig {
9966
9978
  interface WalletTypeMap {
9967
9979
  'polkadot-js': PolkadotJsWalletInstance;
9968
9980
  'talisman': TalismanWalletInstance;
9981
+ 'metamask': MetaMaskWalletInstance;
9969
9982
  }
9970
9983
  type Wallets = WalletTypeMap;
9971
9984
  type ConfiguredWallets<T extends readonly WalletConfig[]> = { [K in T[number]['type']]: WalletTypeMap[K] };
package/dist/index.mjs CHANGED
@@ -3,6 +3,129 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import process from "node:process";
5
5
 
6
+ //#region src/wallets/metamask.ts
7
+ const VERSION$2 = "13.17.0";
8
+ const METAMASK_CONFIG = {
9
+ downloadUrl: `https://github.com/MetaMask/metamask-extension/releases/download/v${VERSION$2}/metamask-flask-chrome-${VERSION$2}-flask.0.zip`,
10
+ extensionName: `metamask-extension-${VERSION$2}`
11
+ };
12
+ async function getMetaMaskExtensionPath() {
13
+ const extensionsDir = path.resolve(process.cwd(), ".chroma");
14
+ const extensionDir = path.join(extensionsDir, METAMASK_CONFIG.extensionName);
15
+ if (!fs.existsSync(extensionDir) || fs.readdirSync(extensionDir).length === 0) throw new Error(`MetaMask extension not found at: ${extensionDir}\n\nPlease download the extension first by running:\n npx @avalix/chroma download-extensions\n`);
16
+ return extensionDir;
17
+ }
18
+ /* c8 ignore start */
19
+ async function findOnboardingPage$1(context, extensionId) {
20
+ const maxAttempts = 10;
21
+ const retryDelay = 500;
22
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
23
+ const pages = context.pages();
24
+ for (const p of pages) if (p.url().includes(`chrome-extension://${extensionId}/`)) {
25
+ await p.waitForLoadState("domcontentloaded");
26
+ await p.getByText("I accept the risks").click();
27
+ return p;
28
+ }
29
+ if (attempt < maxAttempts - 1) await new Promise((resolve) => setTimeout(resolve, retryDelay));
30
+ }
31
+ throw new Error(`MetaMask extension page not found for ID: ${extensionId}`);
32
+ }
33
+ async function findExtensionPopup$2(context, extensionId) {
34
+ const maxAttempts = 10;
35
+ const retryDelay = 500;
36
+ const page0 = context.pages()[0];
37
+ if (!page0) throw new Error("No pages available to create CDP session");
38
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
39
+ const client = await context.newCDPSession(page0);
40
+ const { targetInfos } = await client.send("Target.getTargets");
41
+ const sidePanelTarget = targetInfos.find((t) => t.url.includes(`chrome-extension://${extensionId}/`) && t.url.includes("sidepanel"));
42
+ await client.detach();
43
+ if (sidePanelTarget) {
44
+ const sidePanelPage = await context.newPage();
45
+ await sidePanelPage.goto(sidePanelTarget.url);
46
+ await sidePanelPage.waitForLoadState("domcontentloaded");
47
+ return sidePanelPage;
48
+ }
49
+ if (attempt < maxAttempts - 1) await new Promise((resolve) => setTimeout(resolve, retryDelay));
50
+ }
51
+ throw new Error(`MetaMask side panel not found for ID: ${extensionId}`);
52
+ }
53
+ const METAMASK_PASSWORD = "h3llop0lkadot!";
54
+ async function completeOnboarding$1(extensionPage, seedPhrase) {
55
+ await extensionPage.bringToFront();
56
+ await extensionPage.waitForLoadState("domcontentloaded");
57
+ await extensionPage.getByTestId("onboarding-import-wallet").click();
58
+ await extensionPage.getByTestId("onboarding-import-with-srp-button").click();
59
+ await extensionPage.getByTestId("srp-input-import__srp-note").pressSequentially(seedPhrase, { delay: 50 });
60
+ await extensionPage.getByTestId("import-srp-confirm").click();
61
+ await extensionPage.getByTestId("create-password-new-input").fill(METAMASK_PASSWORD);
62
+ await extensionPage.getByTestId("create-password-confirm-input").fill(METAMASK_PASSWORD);
63
+ await extensionPage.getByTestId("create-password-terms").click();
64
+ await extensionPage.getByTestId("create-password-submit").click();
65
+ await extensionPage.getByTestId("metametrics-checkbox").click();
66
+ await extensionPage.getByTestId("metametrics-i-agree").click();
67
+ await extensionPage.getByTestId("manage-default-settings").click();
68
+ await extensionPage.getByTestId("category-item-General").click();
69
+ await extensionPage.getByTestId("basic-functionality-toggle").locator(".toggle-button").click();
70
+ await extensionPage.getByText("I understand and want to continue").click();
71
+ await extensionPage.getByTestId("basic-configuration-modal-toggle-button").click();
72
+ await extensionPage.getByTestId("category-back-button").click();
73
+ await extensionPage.getByTestId("category-item-Assets").click();
74
+ await extensionPage.getByTestId("privacy-settings-settings").locator(".toggle-button").nth(0).click();
75
+ await extensionPage.getByTestId("privacy-settings-settings").locator(".toggle-button").nth(1).click();
76
+ await extensionPage.getByTestId("privacy-settings-settings").locator(".toggle-button").nth(2).click();
77
+ await extensionPage.getByTestId("privacy-settings-settings").locator(".toggle-button").nth(3).click();
78
+ await extensionPage.getByTestId("privacy-settings-settings").locator(".toggle-button").nth(4).click();
79
+ await extensionPage.getByTestId("category-back-button").click();
80
+ await extensionPage.getByTestId("privacy-settings-back-button").click();
81
+ await extensionPage.getByTestId("onboarding-complete-done").click();
82
+ await extensionPage.close();
83
+ }
84
+ async function authorizeMetaMask(page) {
85
+ const context = page.__extensionContext;
86
+ const extensionId = page.__extensionId;
87
+ const extensionPopup = await findExtensionPopup$2(context, extensionId);
88
+ await extensionPopup.getByTestId("confirm-btn").click();
89
+ await extensionPopup.close();
90
+ }
91
+ async function confirmMetaMask(page) {
92
+ const context = page.__extensionContext;
93
+ const extensionId = page.__extensionId;
94
+ const extensionPopup = await findExtensionPopup$2(context, extensionId);
95
+ await extensionPopup.getByTestId("confirm-footer-button").click();
96
+ await extensionPopup.close();
97
+ }
98
+ async function rejectMetaMask(page) {
99
+ const context = page.__extensionContext;
100
+ const extensionId = page.__extensionId;
101
+ const extensionPopup = await findExtensionPopup$2(context, extensionId);
102
+ await extensionPopup.getByTestId("confirm-footer-cancel").or(extensionPopup.getByTestId("page-container-footer-cancel")).or(extensionPopup.getByRole("button", { name: /Reject|Cancel/i })).first().click();
103
+ await extensionPopup.close();
104
+ }
105
+ async function unlockMetaMask(page) {
106
+ const context = page.__extensionContext;
107
+ const unlockUrl = `chrome-extension://${page.__extensionId}/home.html#/onboarding/unlock`;
108
+ const unlockPage = await context.newPage();
109
+ await unlockPage.goto(unlockUrl);
110
+ await unlockPage.waitForLoadState("domcontentloaded");
111
+ await unlockPage.getByTestId("unlock-password").fill(METAMASK_PASSWORD);
112
+ await unlockPage.getByTestId("unlock-submit").click();
113
+ await unlockPage.close();
114
+ }
115
+ async function importSeedPhrase(page, { seedPhrase }) {
116
+ const context = page.__extensionContext;
117
+ const extensionId = page.__extensionId;
118
+ const extensionPage = await findOnboardingPage$1(context, extensionId);
119
+ try {
120
+ await completeOnboarding$1(extensionPage, seedPhrase);
121
+ } catch (error) {
122
+ console.error("❌ Error during MetaMask Ethereum account import:", error);
123
+ throw error;
124
+ }
125
+ }
126
+ /* c8 ignore stop */
127
+
128
+ //#endregion
6
129
  //#region src/wallets/polkadot-js.ts
7
130
  const VERSION$1 = "0.62.6";
8
131
  const POLKADOT_JS_CONFIG = {
@@ -79,7 +202,7 @@ async function rejectPolkadotJSTx(page) {
79
202
 
80
203
  //#endregion
81
204
  //#region src/wallets/talisman.ts
82
- const VERSION = "3.1.13";
205
+ const VERSION = "3.2.0";
83
206
  const TALISMAN_CONFIG = {
84
207
  downloadUrl: `https://github.com/avalix-labs/polkadot-wallets/raw/refs/heads/main/talisman/talisman-${VERSION}.zip`,
85
208
  extensionName: `talisman-extension-${VERSION}`
@@ -272,9 +395,33 @@ function createTalismanWallet(extensionId, context) {
272
395
  };
273
396
  }
274
397
  /* c8 ignore stop */
398
+ /* c8 ignore start */
399
+ function createMetaMaskWallet(extensionId, context) {
400
+ return {
401
+ extensionId,
402
+ type: "metamask",
403
+ importSeedPhrase: async (options) => {
404
+ await importSeedPhrase(createExtendedPage(context.pages()[0] || await context.newPage(), context, extensionId), { seedPhrase: options.seedPhrase });
405
+ },
406
+ unlock: async () => {
407
+ await unlockMetaMask(createExtendedPage(context.pages()[0] || await context.newPage(), context, extensionId));
408
+ },
409
+ authorize: async () => {
410
+ await authorizeMetaMask(createExtendedPage(context.pages()[0] || await context.newPage(), context, extensionId));
411
+ },
412
+ reject: async () => {
413
+ await rejectMetaMask(createExtendedPage(context.pages()[0] || await context.newPage(), context, extensionId));
414
+ },
415
+ confirm: async () => {
416
+ await confirmMetaMask(createExtendedPage(context.pages()[0] || await context.newPage(), context, extensionId));
417
+ }
418
+ };
419
+ }
420
+ /* c8 ignore stop */
275
421
  const walletFactories = {
276
422
  "polkadot-js": createPolkadotJsWallet,
277
- "talisman": createTalismanWallet
423
+ "talisman": createTalismanWallet,
424
+ "metamask": createMetaMaskWallet
278
425
  };
279
426
 
280
427
  //#endregion
@@ -284,6 +431,7 @@ async function getExtensionPathForWallet(config) {
284
431
  switch (type) {
285
432
  case "polkadot-js": return await getPolkadotJSExtensionPath();
286
433
  case "talisman": return await getTalismanExtensionPath();
434
+ case "metamask": return await getMetaMaskExtensionPath();
287
435
  default: throw new Error(`Unsupported wallet type: ${type}`);
288
436
  }
289
437
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@avalix/chroma",
3
3
  "type": "module",
4
- "version": "0.0.14",
4
+ "version": "0.0.16",
5
5
  "description": "End-to-end testing library for Polkadot wallet interactions",
6
6
  "author": "Avalix Labs",
7
7
  "license": "MIT",
@@ -4,6 +4,7 @@ import path from 'node:path'
4
4
  import process from 'node:process'
5
5
  import { fileURLToPath } from 'node:url'
6
6
  import { downloadAndExtractExtension } from '../src/utils/download-extension.js'
7
+ import { METAMASK_CONFIG } from '../src/wallets/metamask.js'
7
8
  import { POLKADOT_JS_CONFIG } from '../src/wallets/polkadot-js.js'
8
9
  import { TALISMAN_CONFIG } from '../src/wallets/talisman.js'
9
10
 
@@ -45,6 +46,12 @@ async function main() {
45
46
  extensionName: TALISMAN_CONFIG.extensionName,
46
47
  })
47
48
 
49
+ // Download MetaMask extension
50
+ await downloadAndExtractExtension({
51
+ downloadUrl: METAMASK_CONFIG.downloadUrl,
52
+ extensionName: METAMASK_CONFIG.extensionName,
53
+ })
54
+
48
55
  console.log('\n✅ All extensions downloaded successfully!')
49
56
  console.log('You can now run your Playwright tests.')
50
57
  }
@@ -1,4 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { getMetaMaskExtensionPath } from '../wallets/metamask.js'
2
3
  import { getPolkadotJSExtensionPath } from '../wallets/polkadot-js.js'
3
4
  import { getTalismanExtensionPath } from '../wallets/talisman.js'
4
5
  import { createWalletTest, getExtensionPathForWallet } from './index.js'
@@ -29,11 +30,16 @@ vi.mock('../wallets/talisman.js', () => ({
29
30
  getTalismanExtensionPath: vi.fn().mockResolvedValue('/mock/path/talisman-extension'),
30
31
  }))
31
32
 
33
+ vi.mock('../wallets/metamask.js', () => ({
34
+ getMetaMaskExtensionPath: vi.fn().mockResolvedValue('/mock/path/metamask-extension'),
35
+ }))
36
+
32
37
  // Mock wallet factories
33
38
  vi.mock('./wallet-factory.js', () => ({
34
39
  walletFactories: {
35
40
  'polkadot-js': vi.fn().mockReturnValue({ type: 'polkadot-js' }),
36
41
  'talisman': vi.fn().mockReturnValue({ type: 'talisman' }),
42
+ 'metamask': vi.fn().mockReturnValue({ type: 'metamask' }),
37
43
  },
38
44
  }))
39
45
 
@@ -139,6 +145,13 @@ describe('context-playwright/index', () => {
139
145
  expect(getTalismanExtensionPath).toHaveBeenCalled()
140
146
  })
141
147
 
148
+ it('should return metamask extension path', async () => {
149
+ const result = await getExtensionPathForWallet({ type: 'metamask' })
150
+
151
+ expect(result).toBe('/mock/path/metamask-extension')
152
+ expect(getMetaMaskExtensionPath).toHaveBeenCalled()
153
+ })
154
+
142
155
  it('should throw error for unsupported wallet type', async () => {
143
156
  await expect(
144
157
  getExtensionPathForWallet({ type: 'unsupported' as any }),
@@ -8,6 +8,7 @@ import type {
8
8
  WalletWorkerFixtures,
9
9
  } from './types.js'
10
10
  import { test as base, chromium } from '@playwright/test'
11
+ import { getMetaMaskExtensionPath } from '../wallets/metamask.js'
11
12
  import { getPolkadotJSExtensionPath } from '../wallets/polkadot-js.js'
12
13
  import { getTalismanExtensionPath } from '../wallets/talisman.js'
13
14
  import { walletFactories } from './wallet-factory.js'
@@ -21,6 +22,8 @@ export async function getExtensionPathForWallet(config: WalletConfig): Promise<s
21
22
  return await getPolkadotJSExtensionPath()
22
23
  case 'talisman':
23
24
  return await getTalismanExtensionPath()
25
+ case 'metamask':
26
+ return await getMetaMaskExtensionPath()
24
27
  default:
25
28
  throw new Error(`Unsupported wallet type: ${type}`)
26
29
  }
@@ -1,15 +1,16 @@
1
1
  import type { BrowserContext, Page } from '@playwright/test'
2
2
  import type {
3
+ MetaMaskWalletInstance,
3
4
  PolkadotJsWalletInstance,
4
5
  TalismanWalletInstance,
5
6
  WalletInstance,
6
7
  } from './wallet-factory.js'
7
8
 
8
9
  // Re-export wallet instance types
9
- export type { PolkadotJsWalletInstance, TalismanWalletInstance, WalletInstance }
10
+ export type { MetaMaskWalletInstance, PolkadotJsWalletInstance, TalismanWalletInstance, WalletInstance }
10
11
 
11
12
  // Wallet types - single source of truth
12
- export type WalletType = 'polkadot-js' | 'talisman'
13
+ export type WalletType = 'polkadot-js' | 'talisman' | 'metamask'
13
14
 
14
15
  // Wallet account configuration
15
16
  export interface WalletAccount {
@@ -28,6 +29,7 @@ export interface WalletConfig {
28
29
  export interface WalletTypeMap {
29
30
  'polkadot-js': PolkadotJsWalletInstance
30
31
  'talisman': TalismanWalletInstance
32
+ 'metamask': MetaMaskWalletInstance
31
33
  }
32
34
 
33
35
  // Wallets collection - all wallet types
@@ -1,5 +1,12 @@
1
1
  import type { BrowserContext, Page } from '@playwright/test'
2
2
  import type { WalletAccount } from './types.js'
3
+ import {
4
+ authorizeMetaMask,
5
+ confirmMetaMask,
6
+ importSeedPhrase as importMetaMaskSeedPhrase,
7
+ rejectMetaMask,
8
+ unlockMetaMask,
9
+ } from '../wallets/metamask.js'
3
10
  import {
4
11
  approvePolkadotJSTx,
5
12
  authorizePolkadotJS,
@@ -104,13 +111,53 @@ export function createTalismanWallet(extensionId: string, context: BrowserContex
104
111
  }
105
112
  /* c8 ignore stop */
106
113
 
114
+ /*
115
+ * Factory function for MetaMask wallet
116
+ * Coverage excluded: methods interact with Chrome extension APIs via browser context.
117
+ */
118
+ /* c8 ignore start */
119
+ export function createMetaMaskWallet(extensionId: string, context: BrowserContext) {
120
+ return {
121
+ extensionId,
122
+ type: 'metamask' as const,
123
+ importSeedPhrase: async (options: { seedPhrase: string }) => {
124
+ const page = context.pages()[0] || await context.newPage()
125
+ const extPage = createExtendedPage(page, context, extensionId)
126
+ await importMetaMaskSeedPhrase(extPage, { seedPhrase: options.seedPhrase })
127
+ },
128
+ unlock: async () => {
129
+ const page = context.pages()[0] || await context.newPage()
130
+ const extPage = createExtendedPage(page, context, extensionId)
131
+ await unlockMetaMask(extPage)
132
+ },
133
+ authorize: async () => {
134
+ const page = context.pages()[0] || await context.newPage()
135
+ const extPage = createExtendedPage(page, context, extensionId)
136
+ await authorizeMetaMask(extPage)
137
+ },
138
+ reject: async () => {
139
+ const page = context.pages()[0] || await context.newPage()
140
+ const extPage = createExtendedPage(page, context, extensionId)
141
+ await rejectMetaMask(extPage)
142
+ },
143
+ confirm: async () => {
144
+ const page = context.pages()[0] || await context.newPage()
145
+ const extPage = createExtendedPage(page, context, extensionId)
146
+ await confirmMetaMask(extPage)
147
+ },
148
+ }
149
+ }
150
+ /* c8 ignore stop */
151
+
107
152
  // Wallet factories map - auto-inferred types
108
153
  export const walletFactories = {
109
154
  'polkadot-js': createPolkadotJsWallet,
110
155
  'talisman': createTalismanWallet,
156
+ 'metamask': createMetaMaskWallet,
111
157
  }
112
158
 
113
159
  // Auto-inferred types from factory functions
114
160
  export type PolkadotJsWalletInstance = ReturnType<typeof createPolkadotJsWallet>
115
161
  export type TalismanWalletInstance = ReturnType<typeof createTalismanWallet>
116
- export type WalletInstance = PolkadotJsWalletInstance | TalismanWalletInstance
162
+ export type MetaMaskWalletInstance = ReturnType<typeof createMetaMaskWallet>
163
+ export type WalletInstance = PolkadotJsWalletInstance | TalismanWalletInstance | MetaMaskWalletInstance
@@ -3,6 +3,9 @@ import fs from 'node:fs'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
5
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6
+ import { METAMASK_CONFIG } from '../wallets/metamask.js'
7
+ import { POLKADOT_JS_CONFIG } from '../wallets/polkadot-js.js'
8
+ import { TALISMAN_CONFIG } from '../wallets/talisman.js'
6
9
  import { downloadAndExtractExtension } from './download-extension.js'
7
10
 
8
11
  /**
@@ -10,8 +13,9 @@ import { downloadAndExtractExtension } from './download-extension.js'
10
13
  * Tests actual download and extraction with real extension files
11
14
  *
12
15
  * Important test cases:
13
- * - Nested zip extraction (Talisman has a zip inside a zip)
16
+ * - Single wrapper directory extraction (Talisman zips into a subdirectory)
14
17
  * - Standard zip extraction (Polkadot-JS)
18
+ * - Standard zip extraction (MetaMask)
15
19
  * - Skip download if already exists
16
20
  * - Error handling for invalid URLs
17
21
  */
@@ -29,36 +33,40 @@ describe('downloadAndExtractExtension (integration tests)', () => {
29
33
  }
30
34
  })
31
35
 
32
- it('should handle nested zip extraction (Talisman)', async () => {
33
- // Talisman extension has a nested zip structure
34
- const VERSION = '3.1.13'
35
- const options: DownloadExtensionOptions = {
36
- downloadUrl: `https://github.com/avalix-labs/polkadot-wallets/raw/refs/heads/main/talisman/talisman-${VERSION}.zip`,
37
- extensionName: `talisman-extension-${VERSION}`,
36
+ it('should handle single wrapper directory extraction (Talisman)', async () => {
37
+ const result = await downloadAndExtractExtension({
38
+ ...TALISMAN_CONFIG,
38
39
  targetDir: tempDir,
39
- }
40
-
41
- const result = await downloadAndExtractExtension(options)
40
+ })
42
41
 
43
- expect(result).toBe(path.join(tempDir, `talisman-extension-${VERSION}`))
42
+ expect(result).toBe(path.join(tempDir, TALISMAN_CONFIG.extensionName))
44
43
  expect(fs.existsSync(result)).toBe(true)
45
44
 
46
- // Talisman should have manifest.json after nested extraction
45
+ // Talisman zips into a single subdirectory — should be unwrapped
47
46
  const files = await fs.promises.readdir(result)
48
47
  expect(files).toContain('manifest.json')
49
48
  }, 60000)
50
49
 
51
50
  it('should handle standard zip extraction (Polkadot-JS)', async () => {
52
- const VERSION = '0.62.6'
53
- const options: DownloadExtensionOptions = {
54
- downloadUrl: `https://github.com/polkadot-js/extension/releases/download/v${VERSION}/master-chrome-build.zip`,
55
- extensionName: `polkadot-extension-${VERSION}`,
51
+ const result = await downloadAndExtractExtension({
52
+ ...POLKADOT_JS_CONFIG,
56
53
  targetDir: tempDir,
57
- }
54
+ })
58
55
 
59
- const result = await downloadAndExtractExtension(options)
56
+ expect(result).toBe(path.join(tempDir, POLKADOT_JS_CONFIG.extensionName))
57
+ expect(fs.existsSync(result)).toBe(true)
58
+
59
+ const files = await fs.promises.readdir(result)
60
+ expect(files).toContain('manifest.json')
61
+ }, 60000)
62
+
63
+ it('should handle standard zip extraction (MetaMask)', async () => {
64
+ const result = await downloadAndExtractExtension({
65
+ ...METAMASK_CONFIG,
66
+ targetDir: tempDir,
67
+ })
60
68
 
61
- expect(result).toBe(path.join(tempDir, `polkadot-extension-${VERSION}`))
69
+ expect(result).toBe(path.join(tempDir, METAMASK_CONFIG.extensionName))
62
70
  expect(fs.existsSync(result)).toBe(true)
63
71
 
64
72
  const files = await fs.promises.readdir(result)
@@ -1,3 +1,4 @@
1
+ import type { Mock } from 'vitest'
1
2
  import type { DownloadExtensionOptions } from './download-extension.js'
2
3
  import fs from 'node:fs'
3
4
  import path from 'node:path'
@@ -12,11 +13,10 @@ import { downloadAndExtractExtension } from './download-extension.js'
12
13
  // Mock adm-zip
13
14
  const mockExtractAllTo = vi.fn()
14
15
  vi.mock('adm-zip', () => {
15
- return {
16
- default: vi.fn().mockImplementation(() => ({
17
- extractAllTo: mockExtractAllTo,
18
- })),
19
- }
16
+ const MockAdmZip = vi.fn(function (this: any) {
17
+ this.extractAllTo = mockExtractAllTo
18
+ })
19
+ return { default: MockAdmZip }
20
20
  })
21
21
 
22
22
  // Mock fetch
@@ -29,6 +29,8 @@ vi.mock('node:stream/promises', () => ({
29
29
  }))
30
30
 
31
31
  // Mock fs module
32
+ // - `fs.existsSync`, `fs.readdirSync`, `fs.promises.*` are accessed via the default import
33
+ // - `createWriteStream` is accessed as a named import
32
34
  vi.mock('node:fs', async () => {
33
35
  const actual = await vi.importActual<typeof import('node:fs')>('node:fs')
34
36
  return {
@@ -43,15 +45,9 @@ vi.mock('node:fs', async () => {
43
45
  rename: vi.fn().mockResolvedValue(undefined),
44
46
  unlink: vi.fn().mockResolvedValue(undefined),
45
47
  readdir: vi.fn().mockResolvedValue([]),
48
+ stat: vi.fn().mockResolvedValue({ isDirectory: () => true }),
46
49
  },
47
- createWriteStream: vi.fn().mockReturnValue({
48
- on: vi.fn(),
49
- write: vi.fn(),
50
- end: vi.fn(),
51
- }),
52
50
  },
53
- existsSync: vi.fn(),
54
- readdirSync: vi.fn(),
55
51
  createWriteStream: vi.fn().mockReturnValue({
56
52
  on: vi.fn(),
57
53
  write: vi.fn(),
@@ -119,6 +115,14 @@ describe('downloadAndExtractExtension (unit tests)', () => {
119
115
  )
120
116
  })
121
117
 
118
+ it('should stringify non-Error rejection in thrown message', async () => {
119
+ mockFetch.mockRejectedValue('plain string failure')
120
+
121
+ await expect(downloadAndExtractExtension(mockOptions)).rejects.toThrow(
122
+ 'Failed to download/extract test-extension: plain string failure',
123
+ )
124
+ })
125
+
122
126
  it('should cleanup files on error', async () => {
123
127
  const mockedFs = vi.mocked(fs)
124
128
 
@@ -138,11 +142,91 @@ describe('downloadAndExtractExtension (unit tests)', () => {
138
142
 
139
143
  await expect(downloadAndExtractExtension(mockOptions)).rejects.toThrow()
140
144
 
141
- expect(mockedFs.promises.unlink).toHaveBeenCalled()
142
145
  expect(mockedFs.promises.rm).toHaveBeenCalled()
143
146
  })
144
147
  })
145
148
 
149
+ describe('successful download and extraction', () => {
150
+ beforeEach(() => {
151
+ const mockedFs = vi.mocked(fs)
152
+ mockedFs.existsSync.mockReturnValue(false)
153
+ mockedFs.readdirSync.mockReturnValue([])
154
+
155
+ mockFetch.mockResolvedValue({
156
+ ok: true,
157
+ status: 200,
158
+ body: new ReadableStream(),
159
+ })
160
+ })
161
+
162
+ it('should extract and return correct path', async () => {
163
+ const mockedFs = vi.mocked(fs)
164
+ const mockReaddir = mockedFs.promises.readdir as unknown as Mock
165
+
166
+ mockReaddir.mockResolvedValue(['manifest.json', 'popup.html'])
167
+
168
+ const result = await downloadAndExtractExtension({
169
+ ...mockOptions,
170
+ targetDir: '/tmp/test',
171
+ })
172
+
173
+ expect(result).toBe(path.join('/tmp/test', 'test-extension'))
174
+
175
+ // AdmZip called once for extraction
176
+ const AdmZip = (await import('adm-zip')).default
177
+ expect(AdmZip).toHaveBeenCalledTimes(1)
178
+ })
179
+
180
+ it('should unwrap single wrapper directory', async () => {
181
+ const mockedFs = vi.mocked(fs)
182
+ const mockReaddir = mockedFs.promises.readdir as unknown as Mock
183
+ const mockStat = mockedFs.promises.stat as unknown as Mock
184
+
185
+ // readdir returns a single directory -> unwrap
186
+ mockReaddir.mockResolvedValue(['extension-folder'])
187
+ mockStat.mockResolvedValue({ isDirectory: () => true })
188
+
189
+ const result = await downloadAndExtractExtension({
190
+ ...mockOptions,
191
+ targetDir: '/tmp/test',
192
+ })
193
+
194
+ expect(result).toBe(path.join('/tmp/test', 'test-extension'))
195
+
196
+ // Should rename the subdirectory (unwrap) and clean up source
197
+ expect(mockedFs.promises.rename).toHaveBeenCalledWith(
198
+ expect.stringContaining('extension-folder'),
199
+ expect.stringContaining('test-extension'),
200
+ )
201
+ expect(mockedFs.promises.rm).toHaveBeenCalledWith(
202
+ expect.stringContaining('test-extension-temp'),
203
+ expect.objectContaining({ recursive: true, force: true }),
204
+ )
205
+ })
206
+
207
+ it('should not unwrap when single entry is a file', async () => {
208
+ const mockedFs = vi.mocked(fs)
209
+ const mockReaddir = mockedFs.promises.readdir as unknown as Mock
210
+ const mockStat = mockedFs.promises.stat as unknown as Mock
211
+
212
+ mockReaddir.mockResolvedValue(['only-file.dat'])
213
+ mockStat.mockResolvedValue({ isDirectory: () => false })
214
+
215
+ const result = await downloadAndExtractExtension({
216
+ ...mockOptions,
217
+ targetDir: '/tmp/test',
218
+ })
219
+
220
+ expect(result).toBe(path.join('/tmp/test', 'test-extension'))
221
+
222
+ // Should rename the temp dir directly (no unwrap)
223
+ expect(mockedFs.promises.rename).toHaveBeenCalledWith(
224
+ expect.stringContaining('test-extension-temp'),
225
+ expect.stringContaining('test-extension'),
226
+ )
227
+ })
228
+ })
229
+
146
230
  describe('targetDir option', () => {
147
231
  it('should use custom targetDir when provided', async () => {
148
232
  const mockedFs = vi.mocked(fs)
@@ -15,6 +15,30 @@ function unzipFile(zipPath: string, destDir: string): void {
15
15
  zip.extractAllTo(destDir, true)
16
16
  }
17
17
 
18
+ /**
19
+ * Move extracted contents to the final destination.
20
+ * If the source directory contains a single subdirectory, unwrap it
21
+ * (move the subdirectory contents up) so the extension files live
22
+ * directly inside `destDir`.
23
+ */
24
+ async function moveExtractedToFinal(sourceDir: string, destDir: string): Promise<void> {
25
+ const entries = await fs.promises.readdir(sourceDir)
26
+
27
+ if (entries.length === 1) {
28
+ const singleEntry = path.join(sourceDir, entries[0])
29
+ const stat = await fs.promises.stat(singleEntry)
30
+ if (stat.isDirectory()) {
31
+ // Unwrap: move the single subdirectory to the final destination
32
+ await fs.promises.rename(singleEntry, destDir)
33
+ await fs.promises.rm(sourceDir, { recursive: true, force: true })
34
+ return
35
+ }
36
+ }
37
+
38
+ // No unwrapping needed, just rename the whole directory
39
+ await fs.promises.rename(sourceDir, destDir)
40
+ }
41
+
18
42
  export async function downloadAndExtractExtension(options: DownloadExtensionOptions): Promise<string> {
19
43
  const { downloadUrl, extensionName, targetDir } = options
20
44
 
@@ -52,25 +76,8 @@ export async function downloadAndExtractExtension(options: DownloadExtensionOpti
52
76
  await fs.promises.mkdir(tempExtractDir, { recursive: true })
53
77
  unzipFile(zipPath, tempExtractDir)
54
78
 
55
- // Check if it's a nested zip (contains another .zip file)
56
- const files = await fs.promises.readdir(tempExtractDir)
57
- const nestedZip = files.find(f => f.endsWith('.zip'))
58
-
59
- if (nestedZip) {
60
- console.log(`📦 Found nested zip: ${nestedZip}, extracting...`)
61
- const nestedZipPath = path.join(tempExtractDir, nestedZip)
62
-
63
- // Extract the nested zip to final location
64
- await fs.promises.mkdir(extensionDir, { recursive: true })
65
- unzipFile(nestedZipPath, extensionDir)
66
-
67
- // Clean up temp directory
68
- await fs.promises.rm(tempExtractDir, { recursive: true, force: true })
69
- }
70
- else {
71
- // No nested zip, just rename temp to final
72
- await fs.promises.rename(tempExtractDir, extensionDir)
73
- }
79
+ // Move extracted contents to final destination (unwrapping single dir if needed)
80
+ await moveExtractedToFinal(tempExtractDir, extensionDir)
74
81
 
75
82
  // Clean up ZIP file
76
83
  await fs.promises.unlink(zipPath)
@@ -80,14 +87,10 @@ export async function downloadAndExtractExtension(options: DownloadExtensionOpti
80
87
  }
81
88
  catch (error) {
82
89
  // Clean up on error
83
- if (fs.existsSync(zipPath)) {
84
- await fs.promises.unlink(zipPath).catch(() => {})
85
- }
86
- if (fs.existsSync(extensionDir)) {
87
- await fs.promises.rm(extensionDir, { recursive: true, force: true }).catch(() => {})
88
- }
89
- if (fs.existsSync(tempExtractDir)) {
90
- await fs.promises.rm(tempExtractDir, { recursive: true, force: true }).catch(() => {})
90
+ for (const dir of [zipPath, extensionDir, tempExtractDir]) {
91
+ if (fs.existsSync(dir)) {
92
+ await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {})
93
+ }
91
94
  }
92
95
 
93
96
  throw new Error(`Failed to download/extract ${extensionName}: ${error instanceof Error ? error.message : String(error)}`)
@@ -0,0 +1,97 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import {
5
+ getMetaMaskExtensionPath,
6
+ METAMASK_CONFIG,
7
+ } from './metamask.js'
8
+
9
+ // Mock node:fs module
10
+ vi.mock('node:fs', async () => {
11
+ const actual = await vi.importActual<typeof import('node:fs')>('node:fs')
12
+ return {
13
+ ...actual,
14
+ default: {
15
+ ...actual,
16
+ existsSync: vi.fn(),
17
+ readdirSync: vi.fn(),
18
+ },
19
+ existsSync: vi.fn(),
20
+ readdirSync: vi.fn(),
21
+ }
22
+ })
23
+
24
+ describe('metamask wallet', () => {
25
+ beforeEach(() => {
26
+ vi.clearAllMocks()
27
+ })
28
+
29
+ afterEach(() => {
30
+ vi.restoreAllMocks()
31
+ })
32
+
33
+ describe('metamask_config', () => {
34
+ it('should have correct extension name format', () => {
35
+ expect(METAMASK_CONFIG.extensionName).toMatch(/^metamask-extension-\d+\.\d+\.\d+$/)
36
+ })
37
+
38
+ it('should have valid download URL', () => {
39
+ expect(METAMASK_CONFIG.downloadUrl).toContain('github.com')
40
+ expect(METAMASK_CONFIG.downloadUrl).toContain('metamask')
41
+ expect(METAMASK_CONFIG.downloadUrl.endsWith('.zip')).toBe(true)
42
+ })
43
+ })
44
+
45
+ describe('getMetaMaskExtensionPath', () => {
46
+ it('should return extension path when extension exists', async () => {
47
+ const mockedFs = vi.mocked(fs)
48
+ mockedFs.existsSync.mockReturnValue(true)
49
+ mockedFs.readdirSync.mockReturnValue(['manifest.json'] as any)
50
+
51
+ const result = await getMetaMaskExtensionPath()
52
+
53
+ expect(result).toContain('.chroma')
54
+ expect(result).toContain(METAMASK_CONFIG.extensionName)
55
+ expect(mockedFs.existsSync).toHaveBeenCalled()
56
+ })
57
+
58
+ it('should throw error when extension directory does not exist', async () => {
59
+ const mockedFs = vi.mocked(fs)
60
+ mockedFs.existsSync.mockReturnValue(false)
61
+
62
+ await expect(getMetaMaskExtensionPath()).rejects.toThrow(
63
+ 'MetaMask extension not found',
64
+ )
65
+ })
66
+
67
+ it('should throw error when extension directory is empty', async () => {
68
+ const mockedFs = vi.mocked(fs)
69
+ mockedFs.existsSync.mockReturnValue(true)
70
+ mockedFs.readdirSync.mockReturnValue([])
71
+
72
+ await expect(getMetaMaskExtensionPath()).rejects.toThrow(
73
+ 'MetaMask extension not found',
74
+ )
75
+ })
76
+
77
+ it('should include download instructions in error message', async () => {
78
+ const mockedFs = vi.mocked(fs)
79
+ mockedFs.existsSync.mockReturnValue(false)
80
+
81
+ await expect(getMetaMaskExtensionPath()).rejects.toThrow(
82
+ 'npx @avalix/chroma download-extensions',
83
+ )
84
+ })
85
+
86
+ it('should use correct path structure', async () => {
87
+ const mockedFs = vi.mocked(fs)
88
+ mockedFs.existsSync.mockReturnValue(true)
89
+ mockedFs.readdirSync.mockReturnValue(['manifest.json'] as any)
90
+
91
+ const result = await getMetaMaskExtensionPath()
92
+
93
+ const expectedPath = path.join(process.cwd(), '.chroma', METAMASK_CONFIG.extensionName)
94
+ expect(result).toBe(expectedPath)
95
+ })
96
+ })
97
+ })
@@ -0,0 +1,245 @@
1
+ import type { BrowserContext, Page } from '@playwright/test'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import process from 'node:process'
5
+
6
+ // MetaMask specific configuration
7
+ // https://github.com/MetaMask/metamask-extension/releases
8
+ const VERSION = '13.17.0'
9
+ export const METAMASK_CONFIG = {
10
+ downloadUrl: `https://github.com/MetaMask/metamask-extension/releases/download/v${VERSION}/metamask-flask-chrome-${VERSION}-flask.0.zip`,
11
+ extensionName: `metamask-extension-${VERSION}`,
12
+ } as const
13
+
14
+ // Get MetaMask extension path
15
+ export async function getMetaMaskExtensionPath(): Promise<string> {
16
+ const extensionsDir = path.resolve(process.cwd(), '.chroma')
17
+ const extensionDir = path.join(extensionsDir, METAMASK_CONFIG.extensionName)
18
+
19
+ // Check if extension exists
20
+ if (!fs.existsSync(extensionDir) || fs.readdirSync(extensionDir).length === 0) {
21
+ throw new Error(
22
+ `MetaMask extension not found at: ${extensionDir}\n\n`
23
+ + `Please download the extension first by running:\n`
24
+ + ` npx @avalix/chroma download-extensions\n`,
25
+ )
26
+ }
27
+
28
+ return extensionDir
29
+ }
30
+
31
+ /*
32
+ * Wallet interaction functions below are excluded from coverage because:
33
+ * - They require a real Chromium browser with extension support
34
+ * - They interact with Chrome extension popup pages
35
+ */
36
+ /* c8 ignore start */
37
+
38
+ // Helper function to find existing MetaMask extension page
39
+ async function findOnboardingPage(
40
+ context: BrowserContext,
41
+ extensionId: string,
42
+ ): Promise<Page> {
43
+ const maxAttempts = 10
44
+ const retryDelay = 500
45
+
46
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
47
+ const pages = context.pages()
48
+ for (const p of pages) {
49
+ if (p.url().includes(`chrome-extension://${extensionId}/`)) {
50
+ await p.waitForLoadState('domcontentloaded')
51
+ await p.getByText('I accept the risks').click()
52
+ return p
53
+ }
54
+ }
55
+
56
+ // If not found, wait a bit before retrying
57
+ if (attempt < maxAttempts - 1) {
58
+ await new Promise(resolve => setTimeout(resolve, retryDelay))
59
+ }
60
+ }
61
+
62
+ throw new Error(`MetaMask extension page not found for ID: ${extensionId}`)
63
+ }
64
+
65
+ // Helper function to find MetaMask side panel via CDP, open in new tab, and return the page
66
+ async function findExtensionPopup(
67
+ context: BrowserContext,
68
+ extensionId: string,
69
+ ): Promise<Page> {
70
+ const maxAttempts = 10
71
+ const retryDelay = 500
72
+
73
+ const page0 = context.pages()[0]
74
+ if (!page0) {
75
+ throw new Error('No pages available to create CDP session')
76
+ }
77
+
78
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
79
+ const client = await context.newCDPSession(page0)
80
+ const { targetInfos } = await client.send('Target.getTargets')
81
+ const sidePanelTarget = targetInfos.find(
82
+ (t: any) => t.url.includes(`chrome-extension://${extensionId}/`) && t.url.includes('sidepanel'),
83
+ )
84
+ await client.detach()
85
+
86
+ if (sidePanelTarget) {
87
+ // Open the side panel URL in a new tab to interact with it
88
+ const sidePanelPage = await context.newPage()
89
+ await sidePanelPage.goto(sidePanelTarget.url)
90
+ await sidePanelPage.waitForLoadState('domcontentloaded')
91
+ return sidePanelPage
92
+ }
93
+
94
+ // If not found, wait a bit before retrying
95
+ if (attempt < maxAttempts - 1) {
96
+ await new Promise(resolve => setTimeout(resolve, retryDelay))
97
+ }
98
+ }
99
+
100
+ throw new Error(`MetaMask side panel not found for ID: ${extensionId}`)
101
+ }
102
+
103
+ const METAMASK_PASSWORD = 'h3llop0lkadot!'
104
+
105
+ // Helper function to complete MetaMask onboarding flow
106
+ async function completeOnboarding(
107
+ extensionPage: Page,
108
+ seedPhrase: string,
109
+ ): Promise<void> {
110
+ // Bring the onboarding page to front
111
+ await extensionPage.bringToFront()
112
+ await extensionPage.waitForLoadState('domcontentloaded')
113
+
114
+ // Click "Import an existing wallet"
115
+ await extensionPage.getByTestId('onboarding-import-wallet').click()
116
+
117
+ // Click "Import with Secret Recovery Phrase"
118
+ await extensionPage.getByTestId('onboarding-import-with-srp-button').click()
119
+
120
+ // Enter seed phrase
121
+ // Must use pressSequentially because MetaMask listens for Space keypress to separate words
122
+ await extensionPage.getByTestId('srp-input-import__srp-note').pressSequentially(seedPhrase, { delay: 50 })
123
+
124
+ // Confirm seed phrase
125
+ await extensionPage.getByTestId('import-srp-confirm').click()
126
+
127
+ // Set password
128
+ await extensionPage.getByTestId('create-password-new-input').fill(METAMASK_PASSWORD)
129
+ await extensionPage.getByTestId('create-password-confirm-input').fill(METAMASK_PASSWORD)
130
+ await extensionPage.getByTestId('create-password-terms').click()
131
+ await extensionPage.getByTestId('create-password-submit').click()
132
+
133
+ // Agree to metrics
134
+ await extensionPage.getByTestId('metametrics-checkbox').click()
135
+ await extensionPage.getByTestId('metametrics-i-agree').click()
136
+
137
+ // Complete onboarding
138
+ await extensionPage.getByTestId('manage-default-settings').click()
139
+ await extensionPage.getByTestId('category-item-General').click()
140
+ await extensionPage.getByTestId('basic-functionality-toggle').locator('.toggle-button').click()
141
+ await extensionPage.getByText('I understand and want to continue').click()
142
+ await extensionPage.getByTestId('basic-configuration-modal-toggle-button').click()
143
+ await extensionPage.getByTestId('category-back-button').click()
144
+
145
+ await extensionPage.getByTestId('category-item-Assets').click()
146
+ await extensionPage.getByTestId('privacy-settings-settings').locator('.toggle-button').nth(0).click()
147
+ await extensionPage.getByTestId('privacy-settings-settings').locator('.toggle-button').nth(1).click()
148
+ await extensionPage.getByTestId('privacy-settings-settings').locator('.toggle-button').nth(2).click()
149
+ await extensionPage.getByTestId('privacy-settings-settings').locator('.toggle-button').nth(3).click()
150
+ await extensionPage.getByTestId('privacy-settings-settings').locator('.toggle-button').nth(4).click()
151
+ await extensionPage.getByTestId('category-back-button').click()
152
+ await extensionPage.getByTestId('privacy-settings-back-button').click()
153
+
154
+ await extensionPage.getByTestId('onboarding-complete-done').click()
155
+ await extensionPage.close()
156
+ }
157
+
158
+ // MetaMask specific authorization implementation
159
+ // Handles the "connect" popup when a dapp requests wallet connection
160
+ export async function authorizeMetaMask(
161
+ page: Page & { __extensionContext: BrowserContext, __extensionId: string },
162
+ ): Promise<void> {
163
+ const context = page.__extensionContext
164
+ const extensionId = page.__extensionId
165
+
166
+ const extensionPopup = await findExtensionPopup(context, extensionId)
167
+
168
+ // Click "Connect" to authorize the dapp
169
+ await extensionPopup.getByTestId('confirm-btn').click()
170
+ await extensionPopup.close()
171
+ }
172
+
173
+ // MetaMask specific confirm implementation
174
+ // Handles the confirm popup (e.g. sign message, send transaction)
175
+ export async function confirmMetaMask(
176
+ page: Page & { __extensionContext: BrowserContext, __extensionId: string },
177
+ ): Promise<void> {
178
+ const context = page.__extensionContext
179
+ const extensionId = page.__extensionId
180
+
181
+ const extensionPopup = await findExtensionPopup(context, extensionId)
182
+
183
+ // Click "Confirm"
184
+ await extensionPopup.getByTestId('confirm-footer-button').click()
185
+ await extensionPopup.close()
186
+ }
187
+
188
+ // MetaMask specific reject implementation
189
+ // Handles the reject/cancel popup (e.g. reject transaction, switch chain)
190
+ export async function rejectMetaMask(
191
+ page: Page & { __extensionContext: BrowserContext, __extensionId: string },
192
+ ): Promise<void> {
193
+ const context = page.__extensionContext
194
+ const extensionId = page.__extensionId
195
+
196
+ const extensionPopup = await findExtensionPopup(context, extensionId)
197
+
198
+ // Click "Reject" or "Cancel" - MetaMask uses confirm-footer-cancel for tx/sign reject
199
+ const rejectButton = extensionPopup.getByTestId('confirm-footer-cancel')
200
+ .or(extensionPopup.getByTestId('page-container-footer-cancel'))
201
+ .or(extensionPopup.getByRole('button', { name: /Reject|Cancel/i }))
202
+ await rejectButton.first().click()
203
+ await extensionPopup.close()
204
+ }
205
+
206
+ // Unlock MetaMask by navigating to unlock page and filling password
207
+ export async function unlockMetaMask(
208
+ page: Page & { __extensionContext: BrowserContext, __extensionId: string },
209
+ ): Promise<void> {
210
+ const context = page.__extensionContext
211
+ const extensionId = page.__extensionId
212
+
213
+ const unlockUrl = `chrome-extension://${extensionId}/home.html#/onboarding/unlock`
214
+ const unlockPage = await context.newPage()
215
+ await unlockPage.goto(unlockUrl)
216
+ await unlockPage.waitForLoadState('domcontentloaded')
217
+
218
+ // Fill password and unlock
219
+ await unlockPage.getByTestId('unlock-password').fill(METAMASK_PASSWORD)
220
+ await unlockPage.getByTestId('unlock-submit').click()
221
+
222
+ // await unlockPage.getByTestId('onboarding-complete-done').click()
223
+ await unlockPage.close()
224
+ }
225
+
226
+ // MetaMask specific seed phrase import implementation
227
+ export async function importSeedPhrase(
228
+ page: Page & { __extensionContext: BrowserContext, __extensionId: string },
229
+ { seedPhrase }: { seedPhrase: string },
230
+ ): Promise<void> {
231
+ const context = page.__extensionContext
232
+ const extensionId = page.__extensionId
233
+
234
+ const extensionPage = await findOnboardingPage(context, extensionId)
235
+
236
+ try {
237
+ await completeOnboarding(extensionPage, seedPhrase)
238
+ }
239
+ catch (error) {
240
+ console.error('❌ Error during MetaMask Ethereum account import:', error)
241
+ throw error
242
+ }
243
+ }
244
+
245
+ /* c8 ignore stop */
@@ -6,7 +6,7 @@ import process from 'node:process'
6
6
 
7
7
  // Talisman specific configuration
8
8
  // https://github.com/avalix-labs/polkadot-wallets/tree/main/talisman
9
- const VERSION = '3.1.13'
9
+ const VERSION = '3.2.0'
10
10
  export const TALISMAN_CONFIG = {
11
11
  downloadUrl: `https://github.com/avalix-labs/polkadot-wallets/raw/refs/heads/main/talisman/talisman-${VERSION}.zip`,
12
12
  extensionName: `talisman-extension-${VERSION}`,