@avalix/chroma 0.0.10 → 0.0.12

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.
@@ -0,0 +1,95 @@
1
+ import type { DownloadExtensionOptions } from './download-extension.js'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6
+ import { downloadAndExtractExtension } from './download-extension.js'
7
+
8
+ /**
9
+ * Integration Tests (real download) - Requires network
10
+ * Tests actual download and extraction with real extension files
11
+ *
12
+ * Important test cases:
13
+ * - Nested zip extraction (Talisman has a zip inside a zip)
14
+ * - Standard zip extraction (Polkadot-JS)
15
+ * - Skip download if already exists
16
+ * - Error handling for invalid URLs
17
+ */
18
+ describe('downloadAndExtractExtension (integration tests)', () => {
19
+ let tempDir: string
20
+
21
+ beforeEach(async () => {
22
+ tempDir = path.join(os.tmpdir(), `chroma-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
23
+ await fs.promises.mkdir(tempDir, { recursive: true })
24
+ })
25
+
26
+ afterEach(async () => {
27
+ if (tempDir && fs.existsSync(tempDir)) {
28
+ await fs.promises.rm(tempDir, { recursive: true, force: true })
29
+ }
30
+ })
31
+
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}`,
38
+ targetDir: tempDir,
39
+ }
40
+
41
+ const result = await downloadAndExtractExtension(options)
42
+
43
+ expect(result).toBe(path.join(tempDir, `talisman-extension-${VERSION}`))
44
+ expect(fs.existsSync(result)).toBe(true)
45
+
46
+ // Talisman should have manifest.json after nested extraction
47
+ const files = await fs.promises.readdir(result)
48
+ expect(files).toContain('manifest.json')
49
+ }, 60000)
50
+
51
+ 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}`,
56
+ targetDir: tempDir,
57
+ }
58
+
59
+ const result = await downloadAndExtractExtension(options)
60
+
61
+ expect(result).toBe(path.join(tempDir, `polkadot-extension-${VERSION}`))
62
+ expect(fs.existsSync(result)).toBe(true)
63
+
64
+ const files = await fs.promises.readdir(result)
65
+ expect(files).toContain('manifest.json')
66
+ }, 60000)
67
+
68
+ it('should skip download if extension already exists', async () => {
69
+ const extensionDir = path.join(tempDir, 'existing-extension')
70
+ await fs.promises.mkdir(extensionDir, { recursive: true })
71
+ await fs.promises.writeFile(path.join(extensionDir, 'manifest.json'), '{"name": "test"}')
72
+
73
+ const options: DownloadExtensionOptions = {
74
+ downloadUrl: 'https://example.com/should-not-be-called.zip',
75
+ extensionName: 'existing-extension',
76
+ targetDir: tempDir,
77
+ }
78
+
79
+ const result = await downloadAndExtractExtension(options)
80
+
81
+ expect(result).toBe(extensionDir)
82
+ })
83
+
84
+ it('should throw error for invalid URL', async () => {
85
+ const options: DownloadExtensionOptions = {
86
+ downloadUrl: 'https://github.com/invalid-user-12345/nonexistent-repo/releases/download/v0.0.0/nonexistent.zip',
87
+ extensionName: 'invalid-extension',
88
+ targetDir: tempDir,
89
+ }
90
+
91
+ await expect(downloadAndExtractExtension(options)).rejects.toThrow(
92
+ /Failed to download\/extract invalid-extension/,
93
+ )
94
+ }, 15000)
95
+ })
@@ -0,0 +1,173 @@
1
+ import type { DownloadExtensionOptions } from './download-extension.js'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+ import { downloadAndExtractExtension } from './download-extension.js'
6
+
7
+ /**
8
+ * Unit Tests (with mocks) - Fast, no network required
9
+ * Tests error handling, cleanup logic, and path resolution
10
+ */
11
+
12
+ // Mock adm-zip
13
+ const mockExtractAllTo = vi.fn()
14
+ vi.mock('adm-zip', () => {
15
+ return {
16
+ default: vi.fn().mockImplementation(() => ({
17
+ extractAllTo: mockExtractAllTo,
18
+ })),
19
+ }
20
+ })
21
+
22
+ // Mock fetch
23
+ const mockFetch = vi.fn()
24
+ vi.stubGlobal('fetch', mockFetch)
25
+
26
+ // Mock stream/promises pipeline
27
+ vi.mock('node:stream/promises', () => ({
28
+ pipeline: vi.fn().mockResolvedValue(undefined),
29
+ }))
30
+
31
+ // Mock fs module
32
+ vi.mock('node:fs', async () => {
33
+ const actual = await vi.importActual<typeof import('node:fs')>('node:fs')
34
+ return {
35
+ ...actual,
36
+ default: {
37
+ ...actual,
38
+ existsSync: vi.fn(),
39
+ readdirSync: vi.fn(),
40
+ promises: {
41
+ mkdir: vi.fn().mockResolvedValue(undefined),
42
+ rm: vi.fn().mockResolvedValue(undefined),
43
+ rename: vi.fn().mockResolvedValue(undefined),
44
+ unlink: vi.fn().mockResolvedValue(undefined),
45
+ readdir: vi.fn().mockResolvedValue([]),
46
+ },
47
+ createWriteStream: vi.fn().mockReturnValue({
48
+ on: vi.fn(),
49
+ write: vi.fn(),
50
+ end: vi.fn(),
51
+ }),
52
+ },
53
+ existsSync: vi.fn(),
54
+ readdirSync: vi.fn(),
55
+ createWriteStream: vi.fn().mockReturnValue({
56
+ on: vi.fn(),
57
+ write: vi.fn(),
58
+ end: vi.fn(),
59
+ }),
60
+ }
61
+ })
62
+
63
+ describe('downloadAndExtractExtension (unit tests)', () => {
64
+ const mockOptions: DownloadExtensionOptions = {
65
+ downloadUrl: 'https://example.com/extension.zip',
66
+ extensionName: 'test-extension',
67
+ }
68
+
69
+ beforeEach(() => {
70
+ vi.clearAllMocks()
71
+ vi.spyOn(console, 'log').mockImplementation(() => {})
72
+ })
73
+
74
+ afterEach(() => {
75
+ vi.restoreAllMocks()
76
+ })
77
+
78
+ describe('when extension already exists', () => {
79
+ it('should skip download and return existing path', async () => {
80
+ const mockedFs = vi.mocked(fs)
81
+ mockedFs.existsSync.mockReturnValue(true)
82
+ mockedFs.readdirSync.mockReturnValue(['manifest.json'] as any)
83
+
84
+ const result = await downloadAndExtractExtension(mockOptions)
85
+
86
+ expect(result).toContain('test-extension')
87
+ expect(mockFetch).not.toHaveBeenCalled()
88
+ expect(console.log).toHaveBeenCalledWith(
89
+ expect.stringContaining('already exists'),
90
+ expect.any(String),
91
+ )
92
+ })
93
+ })
94
+
95
+ describe('when extension does not exist', () => {
96
+ beforeEach(() => {
97
+ const mockedFs = vi.mocked(fs)
98
+ mockedFs.existsSync.mockReturnValue(false)
99
+ mockedFs.readdirSync.mockReturnValue([])
100
+ })
101
+
102
+ it('should throw error when download fails with bad status', async () => {
103
+ mockFetch.mockResolvedValue({
104
+ ok: false,
105
+ status: 404,
106
+ statusText: 'Not Found',
107
+ })
108
+
109
+ await expect(downloadAndExtractExtension(mockOptions)).rejects.toThrow(
110
+ 'Failed to download/extract test-extension',
111
+ )
112
+ })
113
+
114
+ it('should throw error when fetch throws network error', async () => {
115
+ mockFetch.mockRejectedValue(new Error('Network error'))
116
+
117
+ await expect(downloadAndExtractExtension(mockOptions)).rejects.toThrow(
118
+ 'Failed to download/extract test-extension: Network error',
119
+ )
120
+ })
121
+
122
+ it('should cleanup files on error', async () => {
123
+ const mockedFs = vi.mocked(fs)
124
+
125
+ mockFetch.mockResolvedValue({
126
+ ok: false,
127
+ status: 500,
128
+ statusText: 'Internal Server Error',
129
+ })
130
+
131
+ mockedFs.existsSync.mockImplementation((p: fs.PathLike) => {
132
+ const pathStr = p.toString()
133
+ if (pathStr.endsWith('test-extension') && !pathStr.includes('.zip')) {
134
+ return false
135
+ }
136
+ return true
137
+ })
138
+
139
+ await expect(downloadAndExtractExtension(mockOptions)).rejects.toThrow()
140
+
141
+ expect(mockedFs.promises.unlink).toHaveBeenCalled()
142
+ expect(mockedFs.promises.rm).toHaveBeenCalled()
143
+ })
144
+ })
145
+
146
+ describe('targetDir option', () => {
147
+ it('should use custom targetDir when provided', async () => {
148
+ const mockedFs = vi.mocked(fs)
149
+ mockedFs.existsSync.mockReturnValue(true)
150
+ mockedFs.readdirSync.mockReturnValue(['manifest.json'] as any)
151
+
152
+ const customOptions: DownloadExtensionOptions = {
153
+ ...mockOptions,
154
+ targetDir: '/custom/path',
155
+ }
156
+
157
+ const result = await downloadAndExtractExtension(customOptions)
158
+
159
+ expect(result).toBe(path.join('/custom/path', 'test-extension'))
160
+ })
161
+
162
+ it('should use default .chroma directory when targetDir not provided', async () => {
163
+ const mockedFs = vi.mocked(fs)
164
+ mockedFs.existsSync.mockReturnValue(true)
165
+ mockedFs.readdirSync.mockReturnValue(['manifest.json'] as any)
166
+
167
+ const result = await downloadAndExtractExtension(mockOptions)
168
+
169
+ expect(result).toContain('.chroma')
170
+ expect(result).toContain('test-extension')
171
+ })
172
+ })
173
+ })
@@ -34,7 +34,7 @@ export async function downloadAndExtractExtension(options: DownloadExtensionOpti
34
34
  }
35
35
 
36
36
  try {
37
- console.log(`📥 Downloading ${extensionName}...`)
37
+ console.log(`\n📥 Downloading ${extensionName}...`)
38
38
 
39
39
  // Download the ZIP file
40
40
  const response = await fetch(downloadUrl)
@@ -54,65 +54,114 @@ export async function getTalismanExtensionPath(): Promise<string> {
54
54
  return extensionDir
55
55
  }
56
56
 
57
- // Talisman specific Ethereum private key import implementation
58
- export async function importEthPrivateKey(
57
+ // Helper function to find Talisman onboarding page
58
+ async function findOnboardingPage(
59
+ context: BrowserContext,
60
+ extensionId: string,
61
+ ): Promise<Page> {
62
+ // Open new dashboard page
63
+ const popupUrl = `chrome-extension://${extensionId}/dashboard.html`
64
+ const newPage = await context.newPage()
65
+ await newPage.goto(popupUrl)
66
+ await newPage.waitForLoadState('domcontentloaded')
67
+
68
+ // Close any other extension tabs that may have been opened automatically
69
+ for (const p of context.pages()) {
70
+ if (p !== newPage && p.url().includes(`chrome-extension://${extensionId}/`)) {
71
+ await p.close()
72
+ }
73
+ }
74
+
75
+ return newPage
76
+ }
77
+
78
+ // Helper function to complete Talisman onboarding flow
79
+ async function completeOnboarding(
80
+ extensionPage: Page,
81
+ password: string,
82
+ ): Promise<void> {
83
+ // Bring the onboarding page to front
84
+ await extensionPage.bringToFront()
85
+
86
+ // Wait for the page to load and become interactive
87
+ await extensionPage.waitForLoadState('domcontentloaded')
88
+
89
+ if (await extensionPage.getByRole('button', { name: 'Settings' }).isVisible()) {
90
+ await extensionPage.getByRole('button', { name: 'Settings' }).click({ force: true })
91
+ return
92
+ }
93
+
94
+ // Click the get started button
95
+ await extensionPage.getByTestId('onboarding-get-started-button').click()
96
+
97
+ // Fill the password
98
+ await extensionPage.getByRole('textbox', { name: 'Enter password' }).fill(password)
99
+ await extensionPage.getByRole('textbox', { name: 'Confirm password' }).fill(password)
100
+ await extensionPage.getByTestId('onboarding-password-confirm-button').click()
101
+
102
+ // Click the no thanks button
103
+ await extensionPage.getByRole('button', { name: 'No thanks' }).click()
104
+ await extensionPage.getByTestId('onboarding-enter-talisman-button').click()
105
+
106
+ // Navigate directly to settings/general page
107
+ const extensionId = extensionPage.url().match(/chrome-extension:\/\/([^/]+)/)?.[1]
108
+ await extensionPage.goto(`chrome-extension://${extensionId}/dashboard.html#/settings/general`)
109
+ await extensionPage.waitForLoadState('domcontentloaded')
110
+ await extensionPage.getByRole('link', { name: 'Security & Privacy' }).click()
111
+
112
+ // Toggle the risk scan setting
113
+ await extensionPage.getByTestId('component-toggle-button').first().click()
114
+ }
115
+
116
+ // Talisman specific Polkadot mnemonic import implementation
117
+ export async function importPolkadotMnemonic(
59
118
  page: Page & { __extensionContext: BrowserContext, __extensionId: string },
60
119
  { seed, name = 'Test Account', password = 'h3llop0lkadot!' }: WalletAccount,
61
120
  ): Promise<void> {
62
121
  const context = page.__extensionContext
63
122
  const extensionId = page.__extensionId
64
123
 
65
- // Wait for Talisman to open its onboarding tab with retry logic
66
- const maxAttempts = 20
67
- const retryDelay = 500
68
- let extensionPage: Page | null = null
69
-
70
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
71
- const pages = context.pages()
124
+ const extensionPage = await findOnboardingPage(context, extensionId)
72
125
 
73
- for (const p of pages) {
74
- const url = p.url()
75
- if (url.includes('onboarding.html') || url.includes(`chrome-extension://${extensionId}/`)) {
76
- extensionPage = p
77
- break
78
- }
79
- }
126
+ try {
127
+ await completeOnboarding(extensionPage, password!)
80
128
 
81
- if (extensionPage) {
82
- break
83
- }
129
+ // Import Polkadot account via Recovery Phrase
130
+ await extensionPage.getByRole('link', { name: 'Manage Accounts' }).click()
131
+ await extensionPage.getByRole('button', { name: 'Get Started' }).click()
132
+ await extensionPage.getByRole('button', { name: 'Add Account' }).click()
133
+ await extensionPage.getByRole('button', { name: 'Import Import an existing' }).click()
134
+ await extensionPage.getByRole('button', { name: 'Import via Recovery Phrase' }).click()
135
+ await extensionPage.getByRole('button', { name: 'Polkadot Relay Chain, Asset' }).click()
136
+ await extensionPage.getByRole('textbox', { name: 'Choose a name' }).fill(name!)
137
+ await extensionPage.getByRole('textbox', { name: 'Enter your 12 or 24 word' }).fill(seed!)
138
+ await extensionPage.getByTestId('account-add-mnemonic-import-button').click()
84
139
 
85
- // If not found, wait before retrying
86
- if (attempt < maxAttempts - 1) {
87
- await new Promise(resolve => setTimeout(resolve, retryDelay))
88
- }
140
+ await extensionPage.close()
89
141
  }
90
-
91
- if (!extensionPage) {
92
- throw new Error(`Talisman onboarding page not found after ${maxAttempts} attempts`)
142
+ catch (error) {
143
+ console.error('❌ Error during Talisman Polkadot account import:', error)
144
+ throw error
93
145
  }
146
+ }
94
147
 
95
- try {
96
- // Bring the onboarding page to front
97
- await extensionPage.bringToFront()
98
-
99
- // Wait for the page to load and become interactive
100
- await extensionPage.waitForLoadState('domcontentloaded')
101
-
102
- // Click the get started button
103
- await extensionPage.getByTestId('onboarding-get-started-button').click()
148
+ // Talisman specific Ethereum private key import implementation
149
+ export async function importEthPrivateKey(
150
+ page: Page & { __extensionContext: BrowserContext, __extensionId: string },
151
+ { seed, name = 'Test Account', password = 'h3llop0lkadot!' }: WalletAccount,
152
+ ): Promise<void> {
153
+ const context = page.__extensionContext
154
+ const extensionId = page.__extensionId
104
155
 
105
- // Fill the password
106
- await extensionPage.getByRole('textbox', { name: 'Enter password' }).fill(password!)
107
- await extensionPage.getByRole('textbox', { name: 'Confirm password' }).fill(password!)
108
- await extensionPage.getByTestId('onboarding-password-confirm-button').click()
156
+ const extensionPage = await findOnboardingPage(context, extensionId)
109
157
 
110
- // Click the no thanks button
111
- await extensionPage.getByRole('button', { name: 'No thanks' }).click()
112
- await extensionPage.getByTestId('onboarding-enter-talisman-button').click()
158
+ try {
159
+ await completeOnboarding(extensionPage, password!)
113
160
 
114
- // Import Ethereum account
115
- await extensionPage.getByRole('button', { name: 'Add account Create or import' }).click()
161
+ // Import Ethereum account via Private Key
162
+ await extensionPage.getByRole('link', { name: 'Manage Accounts' }).click()
163
+ await extensionPage.getByRole('button', { name: 'Get Started' }).click()
164
+ await extensionPage.getByRole('button', { name: 'Add Account' }).click()
116
165
  await extensionPage.getByRole('button', { name: 'Import Import an existing' }).click()
117
166
  await extensionPage.getByRole('button', { name: 'Import via Private Key' }).click()
118
167
  await extensionPage.getByRole('button', { name: 'Select account platform' }).click()
@@ -124,7 +173,7 @@ export async function importEthPrivateKey(
124
173
  await extensionPage.close()
125
174
  }
126
175
  catch (error) {
127
- console.error('❌ Error during Talisman account import:', error)
176
+ console.error('❌ Error during Talisman Ethereum account import:', error)
128
177
  throw error
129
178
  }
130
179
  }
@@ -134,14 +183,18 @@ export async function authorizeTalisman(
134
183
  page: Page & { __extensionContext: BrowserContext, __extensionId: string },
135
184
  options: { accountName?: string } = {},
136
185
  ): Promise<void> {
137
- const { accountName = 'Test Account' } = options
138
186
  const context = page.__extensionContext
139
187
  const extensionId = page.__extensionId
188
+ const { accountName = 'Test Account' } = options
140
189
 
141
190
  const extensionPopup = await findExtensionPopup(context, extensionId)
191
+ await extensionPopup.waitForLoadState('domcontentloaded')
142
192
 
143
193
  // Authorize Talisman account
144
- await extensionPopup.getByRole('button', { name: accountName }).click()
194
+ const accountButton = extensionPopup.getByRole('button', { name: accountName })
195
+ await accountButton.waitFor({ state: 'visible' })
196
+ await accountButton.scrollIntoViewIfNeeded()
197
+ await accountButton.click({ force: true })
145
198
  await extensionPopup.getByTestId('connection-connect-button').click()
146
199
 
147
200
  try {
@@ -161,12 +214,9 @@ export async function approveTalismanTx(
161
214
 
162
215
  const extensionPopup = await findExtensionPopup(context, extensionId)
163
216
 
164
- try {
217
+ if (await extensionPopup.getByRole('button', { name: 'Yes' }).isVisible()) {
165
218
  await extensionPopup.getByRole('button', { name: 'Yes' }).click()
166
219
  }
167
- catch {
168
- console.log('No another popup found, skipping')
169
- }
170
220
 
171
221
  await extensionPopup.getByRole('button', { name: 'Approve' }).click()
172
222
  }
@@ -179,5 +229,9 @@ export async function rejectTalismanTx(
179
229
  const extensionId = page.__extensionId
180
230
 
181
231
  const extensionPopup = await findExtensionPopup(context, extensionId)
182
- await extensionPopup.getByTestId('connection-reject-button').click()
232
+
233
+ const rejectButton = extensionPopup.getByTestId('connection-reject-button')
234
+ .or(extensionPopup.getByRole('button', { name: 'Cancel' }))
235
+
236
+ await rejectButton.click()
183
237
  }