@avalix/chroma 0.0.13 → 0.0.15
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/dist/index.d.mts +14 -1
- package/dist/index.mjs +151 -3
- package/package.json +1 -1
- package/scripts/download-extensions.ts +7 -0
- package/src/context-playwright/index.test.ts +13 -0
- package/src/context-playwright/index.ts +3 -0
- package/src/context-playwright/types.ts +4 -2
- package/src/context-playwright/wallet-factory.ts +48 -1
- package/src/utils/download-extension.integration.test.ts +27 -19
- package/src/utils/download-extension.test.ts +97 -13
- package/src/utils/download-extension.ts +30 -27
- package/src/wallets/metamask.test.ts +97 -0
- package/src/wallets/metamask.ts +245 -0
- package/src/wallets/talisman.ts +2 -1
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.
|
|
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}`
|
|
@@ -206,7 +329,7 @@ async function rejectTalismanTx(page) {
|
|
|
206
329
|
const context = page.__extensionContext;
|
|
207
330
|
const extensionId = page.__extensionId;
|
|
208
331
|
const extensionPopup = await findExtensionPopup(context, extensionId);
|
|
209
|
-
await extensionPopup.getByTestId("connection-reject-button").or(extensionPopup.getByRole("button", { name: "Cancel" })).click();
|
|
332
|
+
await extensionPopup.getByTestId("connection-reject-button").or(extensionPopup.getByRole("button", { name: "Cancel" })).or(extensionPopup.getByRole("button", { name: "Reject" })).click();
|
|
210
333
|
}
|
|
211
334
|
/* c8 ignore stop */
|
|
212
335
|
|
|
@@ -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
|
@@ -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
|
|
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
|
-
* -
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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,
|
|
42
|
+
expect(result).toBe(path.join(tempDir, TALISMAN_CONFIG.extensionName))
|
|
44
43
|
expect(fs.existsSync(result)).toBe(true)
|
|
45
44
|
|
|
46
|
-
// Talisman
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
//
|
|
56
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 */
|
package/src/wallets/talisman.ts
CHANGED
|
@@ -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.
|
|
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}`,
|
|
@@ -244,6 +244,7 @@ export async function rejectTalismanTx(
|
|
|
244
244
|
|
|
245
245
|
const rejectButton = extensionPopup.getByTestId('connection-reject-button')
|
|
246
246
|
.or(extensionPopup.getByRole('button', { name: 'Cancel' }))
|
|
247
|
+
.or(extensionPopup.getByRole('button', { name: 'Reject' }))
|
|
247
248
|
|
|
248
249
|
await rejectButton.click()
|
|
249
250
|
}
|