@avalix/chroma 0.0.16 → 1.0.1
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 +104 -4
- package/dist/index.d.mts +115 -20
- package/dist/index.mjs +122 -162
- package/package.json +4 -5
- package/scripts/download-extensions.ts +17 -22
- package/src/context-playwright/index.test.ts +19 -29
- package/src/context-playwright/index.ts +50 -36
- package/src/context-playwright/types.ts +19 -17
- package/src/context-playwright/wallet-factory.test.ts +38 -0
- package/src/context-playwright/wallet-factory.ts +18 -81
- package/src/utils/download-extension.integration.test.ts +1 -1
- package/src/utils/download-extension.test.ts +16 -34
- package/src/utils/download-extension.ts +11 -7
- package/src/utils/find-extension-popup.ts +39 -0
- package/src/utils/test-defaults.ts +7 -0
- package/src/wallets/metamask.test.ts +12 -18
- package/src/wallets/metamask.ts +91 -60
- package/src/wallets/polkadot-js.test.ts +12 -18
- package/src/wallets/polkadot-js.ts +19 -54
- package/src/wallets/talisman.test.ts +12 -18
- package/src/wallets/talisman.ts +20 -49
|
@@ -13,11 +13,11 @@ vi.mock('node:fs', async () => {
|
|
|
13
13
|
...actual,
|
|
14
14
|
default: {
|
|
15
15
|
...actual,
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
promises: {
|
|
17
|
+
...actual.promises,
|
|
18
|
+
readdir: vi.fn(),
|
|
19
|
+
},
|
|
18
20
|
},
|
|
19
|
-
existsSync: vi.fn(),
|
|
20
|
-
readdirSync: vi.fn(),
|
|
21
21
|
}
|
|
22
22
|
})
|
|
23
23
|
|
|
@@ -43,21 +43,20 @@ describe('metamask wallet', () => {
|
|
|
43
43
|
})
|
|
44
44
|
|
|
45
45
|
describe('getMetaMaskExtensionPath', () => {
|
|
46
|
+
const mockReaddir = () => vi.mocked(fs.promises.readdir) as unknown as ReturnType<typeof vi.fn>
|
|
47
|
+
|
|
46
48
|
it('should return extension path when extension exists', async () => {
|
|
47
|
-
|
|
48
|
-
mockedFs.existsSync.mockReturnValue(true)
|
|
49
|
-
mockedFs.readdirSync.mockReturnValue(['manifest.json'] as any)
|
|
49
|
+
mockReaddir().mockResolvedValueOnce(['manifest.json'])
|
|
50
50
|
|
|
51
51
|
const result = await getMetaMaskExtensionPath()
|
|
52
52
|
|
|
53
53
|
expect(result).toContain('.chroma')
|
|
54
54
|
expect(result).toContain(METAMASK_CONFIG.extensionName)
|
|
55
|
-
expect(
|
|
55
|
+
expect(fs.promises.readdir).toHaveBeenCalled()
|
|
56
56
|
})
|
|
57
57
|
|
|
58
58
|
it('should throw error when extension directory does not exist', async () => {
|
|
59
|
-
|
|
60
|
-
mockedFs.existsSync.mockReturnValue(false)
|
|
59
|
+
mockReaddir().mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
|
61
60
|
|
|
62
61
|
await expect(getMetaMaskExtensionPath()).rejects.toThrow(
|
|
63
62
|
'MetaMask extension not found',
|
|
@@ -65,9 +64,7 @@ describe('metamask wallet', () => {
|
|
|
65
64
|
})
|
|
66
65
|
|
|
67
66
|
it('should throw error when extension directory is empty', async () => {
|
|
68
|
-
|
|
69
|
-
mockedFs.existsSync.mockReturnValue(true)
|
|
70
|
-
mockedFs.readdirSync.mockReturnValue([])
|
|
67
|
+
mockReaddir().mockResolvedValueOnce([])
|
|
71
68
|
|
|
72
69
|
await expect(getMetaMaskExtensionPath()).rejects.toThrow(
|
|
73
70
|
'MetaMask extension not found',
|
|
@@ -75,8 +72,7 @@ describe('metamask wallet', () => {
|
|
|
75
72
|
})
|
|
76
73
|
|
|
77
74
|
it('should include download instructions in error message', async () => {
|
|
78
|
-
|
|
79
|
-
mockedFs.existsSync.mockReturnValue(false)
|
|
75
|
+
mockReaddir().mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
|
80
76
|
|
|
81
77
|
await expect(getMetaMaskExtensionPath()).rejects.toThrow(
|
|
82
78
|
'npx @avalix/chroma download-extensions',
|
|
@@ -84,9 +80,7 @@ describe('metamask wallet', () => {
|
|
|
84
80
|
})
|
|
85
81
|
|
|
86
82
|
it('should use correct path structure', async () => {
|
|
87
|
-
|
|
88
|
-
mockedFs.existsSync.mockReturnValue(true)
|
|
89
|
-
mockedFs.readdirSync.mockReturnValue(['manifest.json'] as any)
|
|
83
|
+
mockReaddir().mockResolvedValueOnce(['manifest.json'])
|
|
90
84
|
|
|
91
85
|
const result = await getMetaMaskExtensionPath()
|
|
92
86
|
|
package/src/wallets/metamask.ts
CHANGED
|
@@ -2,12 +2,13 @@ import type { BrowserContext, Page } from '@playwright/test'
|
|
|
2
2
|
import fs from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import process from 'node:process'
|
|
5
|
+
import { DEFAULT_TEST_PASSWORD } from '../utils/test-defaults.js'
|
|
5
6
|
|
|
6
7
|
// MetaMask specific configuration
|
|
7
8
|
// https://github.com/MetaMask/metamask-extension/releases
|
|
8
|
-
const VERSION = '13.
|
|
9
|
+
const VERSION = '13.28.0'
|
|
9
10
|
export const METAMASK_CONFIG = {
|
|
10
|
-
downloadUrl: `https://github.com/MetaMask/metamask-extension/releases/download/v${VERSION}/metamask-
|
|
11
|
+
downloadUrl: `https://github.com/MetaMask/metamask-extension/releases/download/v${VERSION}/metamask-chrome-${VERSION}.zip`,
|
|
11
12
|
extensionName: `metamask-extension-${VERSION}`,
|
|
12
13
|
} as const
|
|
13
14
|
|
|
@@ -16,8 +17,9 @@ export async function getMetaMaskExtensionPath(): Promise<string> {
|
|
|
16
17
|
const extensionsDir = path.resolve(process.cwd(), '.chroma')
|
|
17
18
|
const extensionDir = path.join(extensionsDir, METAMASK_CONFIG.extensionName)
|
|
18
19
|
|
|
19
|
-
// Check if extension exists
|
|
20
|
-
|
|
20
|
+
// Check if extension exists (readdir rejects if missing → treat as empty)
|
|
21
|
+
const entries = await fs.promises.readdir(extensionDir).catch(() => [] as string[])
|
|
22
|
+
if (entries.length === 0) {
|
|
21
23
|
throw new Error(
|
|
22
24
|
`MetaMask extension not found at: ${extensionDir}\n\n`
|
|
23
25
|
+ `Please download the extension first by running:\n`
|
|
@@ -48,7 +50,6 @@ async function findOnboardingPage(
|
|
|
48
50
|
for (const p of pages) {
|
|
49
51
|
if (p.url().includes(`chrome-extension://${extensionId}/`)) {
|
|
50
52
|
await p.waitForLoadState('domcontentloaded')
|
|
51
|
-
await p.getByText('I accept the risks').click()
|
|
52
53
|
return p
|
|
53
54
|
}
|
|
54
55
|
}
|
|
@@ -100,8 +101,6 @@ async function findExtensionPopup(
|
|
|
100
101
|
throw new Error(`MetaMask side panel not found for ID: ${extensionId}`)
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
const METAMASK_PASSWORD = 'h3llop0lkadot!'
|
|
104
|
-
|
|
105
104
|
// Helper function to complete MetaMask onboarding flow
|
|
106
105
|
async function completeOnboarding(
|
|
107
106
|
extensionPage: Page,
|
|
@@ -125,8 +124,8 @@ async function completeOnboarding(
|
|
|
125
124
|
await extensionPage.getByTestId('import-srp-confirm').click()
|
|
126
125
|
|
|
127
126
|
// Set password
|
|
128
|
-
await extensionPage.getByTestId('create-password-new-input').fill(
|
|
129
|
-
await extensionPage.getByTestId('create-password-confirm-input').fill(
|
|
127
|
+
await extensionPage.getByTestId('create-password-new-input').fill(DEFAULT_TEST_PASSWORD)
|
|
128
|
+
await extensionPage.getByTestId('create-password-confirm-input').fill(DEFAULT_TEST_PASSWORD)
|
|
130
129
|
await extensionPage.getByTestId('create-password-terms').click()
|
|
131
130
|
await extensionPage.getByTestId('create-password-submit').click()
|
|
132
131
|
|
|
@@ -136,12 +135,6 @@ async function completeOnboarding(
|
|
|
136
135
|
|
|
137
136
|
// Complete onboarding
|
|
138
137
|
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
138
|
await extensionPage.getByTestId('category-item-Assets').click()
|
|
146
139
|
await extensionPage.getByTestId('privacy-settings-settings').locator('.toggle-button').nth(0).click()
|
|
147
140
|
await extensionPage.getByTestId('privacy-settings-settings').locator('.toggle-button').nth(1).click()
|
|
@@ -155,44 +148,30 @@ async function completeOnboarding(
|
|
|
155
148
|
await extensionPage.close()
|
|
156
149
|
}
|
|
157
150
|
|
|
158
|
-
// MetaMask
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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 },
|
|
151
|
+
// Approve a MetaMask popup — covers both the dapp connect popup ("Connect"
|
|
152
|
+
// button = `confirm-btn`) and the sign / transaction popup ("Confirm" button
|
|
153
|
+
// = `confirm-footer-button`). The two flows share the same find-popup,
|
|
154
|
+
// click, close shape, so a single function accepts whichever button the
|
|
155
|
+
// popup presents.
|
|
156
|
+
export async function approveMetaMask(
|
|
157
|
+
context: BrowserContext,
|
|
158
|
+
extensionId: string,
|
|
177
159
|
): Promise<void> {
|
|
178
|
-
const context = page.__extensionContext
|
|
179
|
-
const extensionId = page.__extensionId
|
|
180
|
-
|
|
181
160
|
const extensionPopup = await findExtensionPopup(context, extensionId)
|
|
182
161
|
|
|
183
|
-
|
|
184
|
-
|
|
162
|
+
const approveButton = extensionPopup.getByTestId('confirm-btn')
|
|
163
|
+
.or(extensionPopup.getByTestId('confirm-footer-button'))
|
|
164
|
+
.or(extensionPopup.getByTestId('confirm-sign-message-confirm-snap-footer-button'))
|
|
165
|
+
await approveButton.first().click()
|
|
185
166
|
await extensionPopup.close()
|
|
186
167
|
}
|
|
187
168
|
|
|
188
169
|
// MetaMask specific reject implementation
|
|
189
170
|
// Handles the reject/cancel popup (e.g. reject transaction, switch chain)
|
|
190
171
|
export async function rejectMetaMask(
|
|
191
|
-
|
|
172
|
+
context: BrowserContext,
|
|
173
|
+
extensionId: string,
|
|
192
174
|
): Promise<void> {
|
|
193
|
-
const context = page.__extensionContext
|
|
194
|
-
const extensionId = page.__extensionId
|
|
195
|
-
|
|
196
175
|
const extensionPopup = await findExtensionPopup(context, extensionId)
|
|
197
176
|
|
|
198
177
|
// Click "Reject" or "Cancel" - MetaMask uses confirm-footer-cancel for tx/sign reject
|
|
@@ -203,34 +182,86 @@ export async function rejectMetaMask(
|
|
|
203
182
|
await extensionPopup.close()
|
|
204
183
|
}
|
|
205
184
|
|
|
206
|
-
// Unlock MetaMask
|
|
185
|
+
// Unlock MetaMask and leave its side panel open for the rest of the session.
|
|
186
|
+
//
|
|
187
|
+
// Idempotent: callers can invoke this from a fixture on every test without
|
|
188
|
+
// tracking state. The function:
|
|
189
|
+
// 1. Reuses the unlock tab MetaMask auto-opens on a locked profile (e.g. a
|
|
190
|
+
// cloned userDataDir) — opening a second one leaves the auto-opened tab
|
|
191
|
+
// around and queues dapp requests behind it.
|
|
192
|
+
// 2. After unlock succeeds, navigates that same tab to `sidepanel.html` so
|
|
193
|
+
// the wallet UI stays visible throughout the test session.
|
|
194
|
+
// 3. If MetaMask is already unlocked, just ensures a side panel tab exists.
|
|
207
195
|
export async function unlockMetaMask(
|
|
208
|
-
|
|
196
|
+
context: BrowserContext,
|
|
197
|
+
extensionId: string,
|
|
209
198
|
): Promise<void> {
|
|
210
|
-
const
|
|
211
|
-
const
|
|
199
|
+
const sidePanelUrl = `chrome-extension://${extensionId}/sidepanel.html`
|
|
200
|
+
const extensionUrlPrefix = `chrome-extension://${extensionId}/`
|
|
201
|
+
|
|
202
|
+
// Poll for one of two signals:
|
|
203
|
+
// - an unlock tab → MetaMask auto-opened it on a locked profile
|
|
204
|
+
// - any non-unlock extension page → MetaMask is already unlocked
|
|
205
|
+
// (e.g. the sidepanel left over from a prior test in this worker)
|
|
206
|
+
// 10s deadline accommodates slow CI cold starts; the already-unlocked
|
|
207
|
+
// branch short-circuits within one tick on worker-scoped reuse, so the
|
|
208
|
+
// longer ceiling only applies on the first unlock per worker.
|
|
209
|
+
let unlockPage: Page | undefined
|
|
210
|
+
const deadline = Date.now() + 10_000
|
|
211
|
+
while (Date.now() < deadline) {
|
|
212
|
+
const extensionPages = context.pages().filter(p =>
|
|
213
|
+
p.url().startsWith(extensionUrlPrefix),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
unlockPage = extensionPages.find(p => p.url().includes('unlock'))
|
|
217
|
+
if (unlockPage)
|
|
218
|
+
break
|
|
219
|
+
|
|
220
|
+
// A non-unlock extension page means MetaMask has booted unlocked; fall
|
|
221
|
+
// through to the sidepanel branch.
|
|
222
|
+
if (extensionPages.length > 0)
|
|
223
|
+
break
|
|
224
|
+
|
|
225
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (unlockPage) {
|
|
229
|
+
await unlockPage.bringToFront()
|
|
230
|
+
await unlockPage.waitForLoadState('domcontentloaded')
|
|
212
231
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
await unlockPage.waitForLoadState('domcontentloaded')
|
|
232
|
+
// Fill password and unlock
|
|
233
|
+
await unlockPage.getByTestId('unlock-password').fill(DEFAULT_TEST_PASSWORD)
|
|
234
|
+
await unlockPage.getByTestId('unlock-submit').click()
|
|
217
235
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
236
|
+
// Some MetaMask versions show a post-unlock completion screen. Click it
|
|
237
|
+
// if present, otherwise continue — the absence is not an error.
|
|
238
|
+
await unlockPage.getByTestId('onboarding-complete-done').click({ timeout: 3_000 }).catch(() => {})
|
|
239
|
+
|
|
240
|
+
// Navigate to the side panel and leave the tab open for the session.
|
|
241
|
+
await unlockPage.goto(sidePanelUrl)
|
|
242
|
+
await unlockPage.waitForLoadState('domcontentloaded')
|
|
243
|
+
return
|
|
244
|
+
}
|
|
221
245
|
|
|
222
|
-
//
|
|
223
|
-
|
|
246
|
+
// No unlock tab seen — either MetaMask is already unlocked, or it never
|
|
247
|
+
// booted within the deadline. Ensure a sidepanel tab exists; downstream
|
|
248
|
+
// calls will surface a clear failure if the profile turns out to still be
|
|
249
|
+
// locked.
|
|
250
|
+
const existing = context.pages().find(p => p.url().startsWith(sidePanelUrl))
|
|
251
|
+
if (existing)
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
const sidePanel = await context.newPage()
|
|
255
|
+
await sidePanel.goto(sidePanelUrl)
|
|
256
|
+
await sidePanel.waitForLoadState('domcontentloaded')
|
|
224
257
|
}
|
|
225
258
|
|
|
226
259
|
// MetaMask specific seed phrase import implementation
|
|
227
260
|
export async function importSeedPhrase(
|
|
228
|
-
|
|
261
|
+
context: BrowserContext,
|
|
262
|
+
extensionId: string,
|
|
229
263
|
{ seedPhrase }: { seedPhrase: string },
|
|
230
264
|
): Promise<void> {
|
|
231
|
-
const context = page.__extensionContext
|
|
232
|
-
const extensionId = page.__extensionId
|
|
233
|
-
|
|
234
265
|
const extensionPage = await findOnboardingPage(context, extensionId)
|
|
235
266
|
|
|
236
267
|
try {
|
|
@@ -13,11 +13,11 @@ vi.mock('node:fs', async () => {
|
|
|
13
13
|
...actual,
|
|
14
14
|
default: {
|
|
15
15
|
...actual,
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
promises: {
|
|
17
|
+
...actual.promises,
|
|
18
|
+
readdir: vi.fn(),
|
|
19
|
+
},
|
|
18
20
|
},
|
|
19
|
-
existsSync: vi.fn(),
|
|
20
|
-
readdirSync: vi.fn(),
|
|
21
21
|
}
|
|
22
22
|
})
|
|
23
23
|
|
|
@@ -43,21 +43,20 @@ describe('polkadot-js wallet', () => {
|
|
|
43
43
|
})
|
|
44
44
|
|
|
45
45
|
describe('getPolkadotJSExtensionPath', () => {
|
|
46
|
+
const mockReaddir = () => vi.mocked(fs.promises.readdir) as unknown as ReturnType<typeof vi.fn>
|
|
47
|
+
|
|
46
48
|
it('should return extension path when extension exists', async () => {
|
|
47
|
-
|
|
48
|
-
mockedFs.existsSync.mockReturnValue(true)
|
|
49
|
-
mockedFs.readdirSync.mockReturnValue(['manifest.json'] as any)
|
|
49
|
+
mockReaddir().mockResolvedValueOnce(['manifest.json'])
|
|
50
50
|
|
|
51
51
|
const result = await getPolkadotJSExtensionPath()
|
|
52
52
|
|
|
53
53
|
expect(result).toContain('.chroma')
|
|
54
54
|
expect(result).toContain(POLKADOT_JS_CONFIG.extensionName)
|
|
55
|
-
expect(
|
|
55
|
+
expect(fs.promises.readdir).toHaveBeenCalled()
|
|
56
56
|
})
|
|
57
57
|
|
|
58
58
|
it('should throw error when extension directory does not exist', async () => {
|
|
59
|
-
|
|
60
|
-
mockedFs.existsSync.mockReturnValue(false)
|
|
59
|
+
mockReaddir().mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
|
61
60
|
|
|
62
61
|
await expect(getPolkadotJSExtensionPath()).rejects.toThrow(
|
|
63
62
|
'Polkadot-JS extension not found',
|
|
@@ -65,9 +64,7 @@ describe('polkadot-js wallet', () => {
|
|
|
65
64
|
})
|
|
66
65
|
|
|
67
66
|
it('should throw error when extension directory is empty', async () => {
|
|
68
|
-
|
|
69
|
-
mockedFs.existsSync.mockReturnValue(true)
|
|
70
|
-
mockedFs.readdirSync.mockReturnValue([])
|
|
67
|
+
mockReaddir().mockResolvedValueOnce([])
|
|
71
68
|
|
|
72
69
|
await expect(getPolkadotJSExtensionPath()).rejects.toThrow(
|
|
73
70
|
'Polkadot-JS extension not found',
|
|
@@ -75,8 +72,7 @@ describe('polkadot-js wallet', () => {
|
|
|
75
72
|
})
|
|
76
73
|
|
|
77
74
|
it('should include download instructions in error message', async () => {
|
|
78
|
-
|
|
79
|
-
mockedFs.existsSync.mockReturnValue(false)
|
|
75
|
+
mockReaddir().mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
|
80
76
|
|
|
81
77
|
await expect(getPolkadotJSExtensionPath()).rejects.toThrow(
|
|
82
78
|
'npx @avalix/chroma download-extensions',
|
|
@@ -84,9 +80,7 @@ describe('polkadot-js wallet', () => {
|
|
|
84
80
|
})
|
|
85
81
|
|
|
86
82
|
it('should use correct path structure', async () => {
|
|
87
|
-
|
|
88
|
-
mockedFs.existsSync.mockReturnValue(true)
|
|
89
|
-
mockedFs.readdirSync.mockReturnValue(['manifest.json'] as any)
|
|
83
|
+
mockReaddir().mockResolvedValueOnce(['manifest.json'])
|
|
90
84
|
|
|
91
85
|
const result = await getPolkadotJSExtensionPath()
|
|
92
86
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import type { BrowserContext
|
|
1
|
+
import type { BrowserContext } from '@playwright/test'
|
|
2
2
|
import type { WalletAccount } from '../context-playwright/types.js'
|
|
3
3
|
import fs from 'node:fs'
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import process from 'node:process'
|
|
6
|
+
import { findExtensionPopup } from '../utils/find-extension-popup.js'
|
|
7
|
+
import { DEFAULT_TEST_PASSWORD } from '../utils/test-defaults.js'
|
|
6
8
|
|
|
7
9
|
// Polkadot-JS specific configuration
|
|
8
10
|
// https://github.com/polkadot-js/extension/releases
|
|
@@ -12,42 +14,14 @@ export const POLKADOT_JS_CONFIG = {
|
|
|
12
14
|
extensionName: `polkadot-extension-${VERSION}`,
|
|
13
15
|
} as const
|
|
14
16
|
|
|
15
|
-
/*
|
|
16
|
-
* Helper function to find extension popup
|
|
17
|
-
* Coverage excluded: requires real browser context with Chrome extension APIs.
|
|
18
|
-
*/
|
|
19
|
-
/* c8 ignore start */
|
|
20
|
-
async function findExtensionPopup(context: BrowserContext, extensionId: string): Promise<Page> {
|
|
21
|
-
// Wait for extension popup to appear with retry logic
|
|
22
|
-
const maxAttempts = 10
|
|
23
|
-
const retryDelay = 500
|
|
24
|
-
|
|
25
|
-
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
26
|
-
const pages = context.pages()
|
|
27
|
-
for (const p of pages) {
|
|
28
|
-
if (p.url().includes(`chrome-extension://${extensionId}/`)) {
|
|
29
|
-
await p.waitForLoadState('domcontentloaded')
|
|
30
|
-
return p
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// If not found, wait a bit before retrying
|
|
35
|
-
if (attempt < maxAttempts - 1) {
|
|
36
|
-
await new Promise(resolve => setTimeout(resolve, retryDelay))
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
throw new Error(`Extension popup not found for ID: ${extensionId}`)
|
|
41
|
-
}
|
|
42
|
-
/* c8 ignore stop */
|
|
43
|
-
|
|
44
17
|
// Get Polkadot-JS extension path
|
|
45
18
|
export async function getPolkadotJSExtensionPath(): Promise<string> {
|
|
46
19
|
const extensionsDir = path.resolve(process.cwd(), '.chroma')
|
|
47
20
|
const extensionDir = path.join(extensionsDir, POLKADOT_JS_CONFIG.extensionName)
|
|
48
21
|
|
|
49
|
-
// Check if extension exists
|
|
50
|
-
|
|
22
|
+
// Check if extension exists (readdir rejects if missing → treat as empty)
|
|
23
|
+
const entries = await fs.promises.readdir(extensionDir).catch(() => [] as string[])
|
|
24
|
+
if (entries.length === 0) {
|
|
51
25
|
throw new Error(
|
|
52
26
|
`Polkadot-JS extension not found at: ${extensionDir}\n\n`
|
|
53
27
|
+ `Please download the extension first by running:\n`
|
|
@@ -67,12 +41,10 @@ export async function getPolkadotJSExtensionPath(): Promise<string> {
|
|
|
67
41
|
|
|
68
42
|
// Polkadot-JS specific account import implementation
|
|
69
43
|
export async function importPolkadotJSAccount(
|
|
70
|
-
|
|
71
|
-
|
|
44
|
+
context: BrowserContext,
|
|
45
|
+
extensionId: string,
|
|
46
|
+
{ seed, name = 'Test Account', password = DEFAULT_TEST_PASSWORD }: WalletAccount,
|
|
72
47
|
): Promise<void> {
|
|
73
|
-
const context = page.__extensionContext
|
|
74
|
-
const extensionId = page.__extensionId
|
|
75
|
-
|
|
76
48
|
const extensionPopupUrl = `chrome-extension://${extensionId}/index.html`
|
|
77
49
|
const extensionPage = await context.newPage()
|
|
78
50
|
|
|
@@ -83,12 +55,11 @@ export async function importPolkadotJSAccount(
|
|
|
83
55
|
const understoodButton = extensionPage.getByRole('button', { name: 'Understood, let me continue' })
|
|
84
56
|
if (await understoodButton.count() > 0) {
|
|
85
57
|
await understoodButton.click()
|
|
86
|
-
await extensionPage.waitForTimeout(100)
|
|
87
58
|
}
|
|
88
59
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
60
|
+
// The "I Understand" disclaimer may follow; wait briefly for it to settle.
|
|
61
|
+
const iUnderstand = extensionPage.getByRole('button', { name: 'I Understand' })
|
|
62
|
+
await iUnderstand.click({ timeout: 1000 }).catch(() => {})
|
|
92
63
|
|
|
93
64
|
// Navigate to import seed page
|
|
94
65
|
await extensionPage.goto(`${extensionPopupUrl}#/account/import-seed`)
|
|
@@ -108,11 +79,9 @@ export async function importPolkadotJSAccount(
|
|
|
108
79
|
|
|
109
80
|
// Polkadot-JS specific authorization implementation
|
|
110
81
|
export async function authorizePolkadotJS(
|
|
111
|
-
|
|
82
|
+
context: BrowserContext,
|
|
83
|
+
extensionId: string,
|
|
112
84
|
): Promise<void> {
|
|
113
|
-
const context = page.__extensionContext
|
|
114
|
-
const extensionId = page.__extensionId
|
|
115
|
-
|
|
116
85
|
const extensionPopup = await findExtensionPopup(context, extensionId)
|
|
117
86
|
|
|
118
87
|
if (await extensionPopup.getByRole('button', { name: 'I Understand' }).isVisible()) {
|
|
@@ -132,13 +101,11 @@ export async function authorizePolkadotJS(
|
|
|
132
101
|
|
|
133
102
|
// Polkadot-JS specific transaction approval implementation
|
|
134
103
|
export async function approvePolkadotJSTx(
|
|
135
|
-
|
|
104
|
+
context: BrowserContext,
|
|
105
|
+
extensionId: string,
|
|
136
106
|
options: { password?: string } = {},
|
|
137
107
|
): Promise<void> {
|
|
138
|
-
const { password =
|
|
139
|
-
const context = page.__extensionContext
|
|
140
|
-
const extensionId = page.__extensionId
|
|
141
|
-
|
|
108
|
+
const { password = DEFAULT_TEST_PASSWORD } = options
|
|
142
109
|
const extensionPopup = await findExtensionPopup(context, extensionId)
|
|
143
110
|
await extensionPopup.getByRole('textbox').fill(password)
|
|
144
111
|
await extensionPopup.getByRole('button', { name: 'Sign the transaction' }).click()
|
|
@@ -146,11 +113,9 @@ export async function approvePolkadotJSTx(
|
|
|
146
113
|
|
|
147
114
|
// Polkadot-JS specific transaction rejection implementation
|
|
148
115
|
export async function rejectPolkadotJSTx(
|
|
149
|
-
|
|
116
|
+
context: BrowserContext,
|
|
117
|
+
extensionId: string,
|
|
150
118
|
): Promise<void> {
|
|
151
|
-
const context = page.__extensionContext
|
|
152
|
-
const extensionId = page.__extensionId
|
|
153
|
-
|
|
154
119
|
const extensionPopup = await findExtensionPopup(context, extensionId)
|
|
155
120
|
await extensionPopup.getByRole('link', { name: 'Cancel' }).click()
|
|
156
121
|
}
|
|
@@ -13,11 +13,11 @@ vi.mock('node:fs', async () => {
|
|
|
13
13
|
...actual,
|
|
14
14
|
default: {
|
|
15
15
|
...actual,
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
promises: {
|
|
17
|
+
...actual.promises,
|
|
18
|
+
readdir: vi.fn(),
|
|
19
|
+
},
|
|
18
20
|
},
|
|
19
|
-
existsSync: vi.fn(),
|
|
20
|
-
readdirSync: vi.fn(),
|
|
21
21
|
}
|
|
22
22
|
})
|
|
23
23
|
|
|
@@ -43,21 +43,20 @@ describe('talisman wallet', () => {
|
|
|
43
43
|
})
|
|
44
44
|
|
|
45
45
|
describe('getTalismanExtensionPath', () => {
|
|
46
|
+
const mockReaddir = () => vi.mocked(fs.promises.readdir) as unknown as ReturnType<typeof vi.fn>
|
|
47
|
+
|
|
46
48
|
it('should return extension path when extension exists', async () => {
|
|
47
|
-
|
|
48
|
-
mockedFs.existsSync.mockReturnValue(true)
|
|
49
|
-
mockedFs.readdirSync.mockReturnValue(['manifest.json'] as any)
|
|
49
|
+
mockReaddir().mockResolvedValueOnce(['manifest.json'])
|
|
50
50
|
|
|
51
51
|
const result = await getTalismanExtensionPath()
|
|
52
52
|
|
|
53
53
|
expect(result).toContain('.chroma')
|
|
54
54
|
expect(result).toContain(TALISMAN_CONFIG.extensionName)
|
|
55
|
-
expect(
|
|
55
|
+
expect(fs.promises.readdir).toHaveBeenCalled()
|
|
56
56
|
})
|
|
57
57
|
|
|
58
58
|
it('should throw error when extension directory does not exist', async () => {
|
|
59
|
-
|
|
60
|
-
mockedFs.existsSync.mockReturnValue(false)
|
|
59
|
+
mockReaddir().mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
|
61
60
|
|
|
62
61
|
await expect(getTalismanExtensionPath()).rejects.toThrow(
|
|
63
62
|
'Talisman extension not found',
|
|
@@ -65,9 +64,7 @@ describe('talisman wallet', () => {
|
|
|
65
64
|
})
|
|
66
65
|
|
|
67
66
|
it('should throw error when extension directory is empty', async () => {
|
|
68
|
-
|
|
69
|
-
mockedFs.existsSync.mockReturnValue(true)
|
|
70
|
-
mockedFs.readdirSync.mockReturnValue([])
|
|
67
|
+
mockReaddir().mockResolvedValueOnce([])
|
|
71
68
|
|
|
72
69
|
await expect(getTalismanExtensionPath()).rejects.toThrow(
|
|
73
70
|
'Talisman extension not found',
|
|
@@ -75,8 +72,7 @@ describe('talisman wallet', () => {
|
|
|
75
72
|
})
|
|
76
73
|
|
|
77
74
|
it('should include download instructions in error message', async () => {
|
|
78
|
-
|
|
79
|
-
mockedFs.existsSync.mockReturnValue(false)
|
|
75
|
+
mockReaddir().mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
|
|
80
76
|
|
|
81
77
|
await expect(getTalismanExtensionPath()).rejects.toThrow(
|
|
82
78
|
'npx @avalix/chroma download-extensions',
|
|
@@ -84,9 +80,7 @@ describe('talisman wallet', () => {
|
|
|
84
80
|
})
|
|
85
81
|
|
|
86
82
|
it('should use correct path structure', async () => {
|
|
87
|
-
|
|
88
|
-
mockedFs.existsSync.mockReturnValue(true)
|
|
89
|
-
mockedFs.readdirSync.mockReturnValue(['manifest.json'] as any)
|
|
83
|
+
mockReaddir().mockResolvedValueOnce(['manifest.json'])
|
|
90
84
|
|
|
91
85
|
const result = await getTalismanExtensionPath()
|
|
92
86
|
|