@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.
@@ -1,12 +1,14 @@
1
1
  import type {
2
2
  ChromaTestOptions,
3
3
  ConfiguredWallets,
4
- ExtendedPage,
5
4
  WalletConfig,
6
5
  WalletFixtures,
7
- Wallets,
6
+ WalletType,
7
+ WalletTypeMap,
8
8
  WalletWorkerFixtures,
9
9
  } from './types.js'
10
+ import { cp, rm } from 'node:fs/promises'
11
+ import { resolve as resolvePath } from 'node:path'
10
12
  import { test as base, chromium } from '@playwright/test'
11
13
  import { getMetaMaskExtensionPath } from '../wallets/metamask.js'
12
14
  import { getPolkadotJSExtensionPath } from '../wallets/polkadot-js.js'
@@ -14,7 +16,7 @@ import { getTalismanExtensionPath } from '../wallets/talisman.js'
14
16
  import { walletFactories } from './wallet-factory.js'
15
17
 
16
18
  // Helper function to get extension path for a wallet config
17
- export async function getExtensionPathForWallet(config: WalletConfig): Promise<string> {
19
+ async function getExtensionPathForWallet(config: WalletConfig): Promise<string> {
18
20
  const { type } = config
19
21
 
20
22
  switch (type) {
@@ -41,10 +43,8 @@ export function createWalletTest<const T extends readonly WalletConfig[]>(
41
43
  ? options.wallets
42
44
  : [{ type: 'polkadot-js' }]
43
45
 
44
- const isMultiWallet = walletConfigs.length > 1
45
-
46
46
  // Compute the expected wallets type
47
- type ExpectedWallets = T extends readonly WalletConfig[] ? ConfiguredWallets<T> : Wallets
47
+ type ExpectedWallets = T extends readonly WalletConfig[] ? ConfiguredWallets<T> : WalletTypeMap
48
48
 
49
49
  /*
50
50
  * Playwright Fixtures - Coverage Exclusion
@@ -58,7 +58,7 @@ export function createWalletTest<const T extends readonly WalletConfig[]>(
58
58
  return base.extend<WalletFixtures<ExpectedWallets>, WalletWorkerFixtures>({
59
59
  // Worker-scoped: Browser context with extension(s) (persists across all tests in worker)
60
60
  // eslint-disable-next-line no-empty-pattern
61
- walletContext: [async ({}, use) => {
61
+ walletContext: [async ({}, use, workerInfo) => {
62
62
  // Get all extension paths
63
63
  const extensionPaths = await Promise.all(
64
64
  walletConfigs.map(config => getExtensionPathForWallet(config)),
@@ -67,7 +67,29 @@ export function createWalletTest<const T extends readonly WalletConfig[]>(
67
67
  // Join paths with comma for Chrome args
68
68
  const extensionPathsString = extensionPaths.join(',')
69
69
 
70
- const context = await chromium.launchPersistentContext('', {
70
+ // Resolve userDataDir (string, function, or default empty)
71
+ const userDataDirOption = options.userDataDir
72
+ const userDataDir = typeof userDataDirOption === 'function'
73
+ ? await userDataDirOption({ workerIndex: workerInfo.workerIndex })
74
+ : userDataDirOption ?? ''
75
+
76
+ // Optional clone: reset target then copy from source. Skipped when
77
+ // userDataDir is empty (clone into a temp dir would defeat its purpose).
78
+ if (options.cloneUserDataDirFrom && userDataDir) {
79
+ // Guard against rm wiping the source: resolve both to absolute paths
80
+ // and refuse if they point at the same location.
81
+ const sourceAbs = resolvePath(options.cloneUserDataDirFrom)
82
+ const targetAbs = resolvePath(userDataDir)
83
+ if (sourceAbs === targetAbs) {
84
+ throw new Error(
85
+ `cloneUserDataDirFrom and userDataDir must be different paths; both resolved to "${sourceAbs}"`,
86
+ )
87
+ }
88
+ await rm(userDataDir, { recursive: true, force: true })
89
+ await cp(options.cloneUserDataDirFrom, userDataDir, { recursive: true })
90
+ }
91
+
92
+ const context = await chromium.launchPersistentContext(userDataDir, {
71
93
  headless,
72
94
  channel: 'chromium',
73
95
  args: [
@@ -83,44 +105,36 @@ export function createWalletTest<const T extends readonly WalletConfig[]>(
83
105
 
84
106
  // Worker-scoped: Map of wallet type to extension ID
85
107
  walletExtensionIds: [async ({ walletContext }, use) => {
86
- const extensionIds = new Map<string, string>()
87
-
88
- // Wait for all service workers to load
89
- const serviceWorkers = walletContext.serviceWorkers()
90
- if (serviceWorkers.length === 0) {
91
- // Wait for at least one service worker
92
- await walletContext.waitForEvent('serviceworker')
93
- }
94
-
95
- // Give some time for all extensions to load
96
- if (isMultiWallet) {
97
- await new Promise(resolve => setTimeout(resolve, 1000))
108
+ const extensionIds = new Map<WalletType, string>()
109
+
110
+ // Wait until one service worker per configured wallet has registered.
111
+ // Bounded by 10s so a stuck worker fails fast instead of hanging the suite.
112
+ const expected = walletConfigs.length
113
+ const deadline = Date.now() + 10_000
114
+ while (walletContext.serviceWorkers().length < expected) {
115
+ if (Date.now() > deadline) {
116
+ break
117
+ }
118
+ await Promise.race([
119
+ walletContext.waitForEvent('serviceworker', { timeout: 2_000 }).catch(() => {}),
120
+ new Promise(resolve => setTimeout(resolve, 200)),
121
+ ])
98
122
  }
99
123
 
100
- // Get all service workers (one per extension)
124
+ // Map service workers to wallet types (order matches walletConfigs order)
101
125
  const allServiceWorkers = walletContext.serviceWorkers()
102
-
103
- // Map service workers to wallet types
104
- // Note: The order should match the walletConfigs order
105
126
  for (let i = 0; i < walletConfigs.length && i < allServiceWorkers.length; i++) {
106
127
  const extensionId = allServiceWorkers[i].url().split('/')[2]
107
- const walletType = walletConfigs[i].type
108
- extensionIds.set(walletType, extensionId)
128
+ extensionIds.set(walletConfigs[i].type, extensionId)
109
129
  }
110
130
 
111
131
  await use(extensionIds)
112
132
  }, { scope: 'worker' }],
113
133
 
114
- // Main page with extension context (uses worker-scoped context)
115
- page: async ({ walletContext, walletExtensionIds }, use) => {
134
+ // Main page (uses worker-scoped context)
135
+ page: async ({ walletContext }, use) => {
116
136
  const page = walletContext.pages()[0] || await walletContext.newPage()
117
-
118
- // Store context and extension IDs on page
119
- const extendedPage = page as ExtendedPage
120
- extendedPage.__extensionContext = walletContext
121
- extendedPage.__walletExtensionIds = walletExtensionIds
122
-
123
- await use(extendedPage)
137
+ await use(page)
124
138
  // Note: Don't close the page or context here since they're worker-scoped
125
139
  },
126
140
 
@@ -130,7 +144,7 @@ export function createWalletTest<const T extends readonly WalletConfig[]>(
130
144
 
131
145
  // Create wallet instance for each configured wallet
132
146
  for (const [walletType, extensionId] of walletExtensionIds) {
133
- const factory = walletFactories[walletType as keyof typeof walletFactories]
147
+ const factory = walletFactories[walletType]
134
148
  if (factory) {
135
149
  walletMap[walletType as keyof ExpectedWallets] = factory(extensionId, walletContext) as ExpectedWallets[keyof ExpectedWallets]
136
150
  }
@@ -3,12 +3,8 @@ import type {
3
3
  MetaMaskWalletInstance,
4
4
  PolkadotJsWalletInstance,
5
5
  TalismanWalletInstance,
6
- WalletInstance,
7
6
  } from './wallet-factory.js'
8
7
 
9
- // Re-export wallet instance types
10
- export type { MetaMaskWalletInstance, PolkadotJsWalletInstance, TalismanWalletInstance, WalletInstance }
11
-
12
8
  // Wallet types - single source of truth
13
9
  export type WalletType = 'polkadot-js' | 'talisman' | 'metamask'
14
10
 
@@ -22,7 +18,6 @@ export interface WalletAccount {
22
18
  // Configuration for a single wallet
23
19
  export interface WalletConfig {
24
20
  type: WalletType
25
- downloadUrl?: string
26
21
  }
27
22
 
28
23
  // Map wallet type to its instance
@@ -32,20 +27,11 @@ export interface WalletTypeMap {
32
27
  'metamask': MetaMaskWalletInstance
33
28
  }
34
29
 
35
- // Wallets collection - all wallet types
36
- export type Wallets = WalletTypeMap
37
-
38
30
  // Helper type to build a wallets object based on configured wallet types
39
31
  export type ConfiguredWallets<T extends readonly WalletConfig[]> = {
40
32
  [K in T[number]['type']]: WalletTypeMap[K]
41
33
  }
42
34
 
43
- // Extended page with wallet context
44
- export type ExtendedPage = Page & {
45
- __extensionContext: BrowserContext
46
- __walletExtensionIds: Map<string, string>
47
- }
48
-
49
35
  // Complete test configuration - supports single and multi-wallet
50
36
  export interface ChromaTestOptions<T extends readonly WalletConfig[] = WalletConfig[]> {
51
37
  // Wallet configuration (single or multiple)
@@ -53,16 +39,32 @@ export interface ChromaTestOptions<T extends readonly WalletConfig[] = WalletCon
53
39
  // Common options
54
40
  headless?: boolean
55
41
  slowMo?: number
42
+ /**
43
+ * Persistent profile dir for the browser context.
44
+ * - Empty/undefined (default): temp dir is used; state is lost each run.
45
+ * - String: shared profile path. Requires `workers: 1` if used by multiple workers.
46
+ * - Function: receives the worker index, returns the path. Use for parallel
47
+ * isolation (e.g. `({ workerIndex }) => `.cache/wallet-w${workerIndex}``).
48
+ */
49
+ userDataDir?: string | ((info: { workerIndex: number }) => string | Promise<string>)
50
+ /**
51
+ * If set, the source dir is copied into `userDataDir` before launch (target is
52
+ * removed first). Use with the Playwright setup-project pattern: a setup
53
+ * project writes to the source dir, then test projects clone it per worker so
54
+ * each parallel worker boots from the same prepared state.
55
+ * No-op if `userDataDir` resolves to an empty string.
56
+ */
57
+ cloneUserDataDirFrom?: string
56
58
  }
57
59
 
58
60
  // Test fixtures (test-scoped: recreated per test)
59
- export interface WalletFixtures<W = Wallets> {
60
- page: ExtendedPage
61
+ export interface WalletFixtures<W = WalletTypeMap> {
62
+ page: Page
61
63
  wallets: W
62
64
  }
63
65
 
64
66
  // Worker fixtures (worker-scoped: persisted across tests)
65
67
  export interface WalletWorkerFixtures {
66
68
  walletContext: BrowserContext
67
- walletExtensionIds: Map<string, string>
69
+ walletExtensionIds: Map<WalletType, string>
68
70
  }
@@ -1,6 +1,7 @@
1
1
  import type { BrowserContext, Page } from '@playwright/test'
2
2
  import { beforeEach, describe, expect, it, vi } from 'vitest'
3
3
  import {
4
+ createMetaMaskWallet,
4
5
  createPolkadotJsWallet,
5
6
  createTalismanWallet,
6
7
  walletFactories,
@@ -22,6 +23,13 @@ vi.mock('../wallets/talisman.js', () => ({
22
23
  rejectTalismanTx: vi.fn(),
23
24
  }))
24
25
 
26
+ vi.mock('../wallets/metamask.js', () => ({
27
+ importSeedPhrase: vi.fn(),
28
+ unlockMetaMask: vi.fn(),
29
+ approveMetaMask: vi.fn(),
30
+ rejectMetaMask: vi.fn(),
31
+ }))
32
+
25
33
  // Create mock browser context
26
34
  function createMockContext(): BrowserContext {
27
35
  const mockPage = {
@@ -51,6 +59,11 @@ describe('wallet-factory', () => {
51
59
  expect(walletFactories.talisman).toBeDefined()
52
60
  expect(typeof walletFactories.talisman).toBe('function')
53
61
  })
62
+
63
+ it('should have metamask factory', () => {
64
+ expect(walletFactories.metamask).toBeDefined()
65
+ expect(typeof walletFactories.metamask).toBe('function')
66
+ })
54
67
  })
55
68
 
56
69
  describe('createPolkadotJsWallet', () => {
@@ -103,4 +116,29 @@ describe('wallet-factory', () => {
103
116
  expect(typeof wallet.rejectTx).toBe('function')
104
117
  })
105
118
  })
119
+
120
+ describe('createMetaMaskWallet', () => {
121
+ const extensionId = 'test-extension-id'
122
+ let mockContext: BrowserContext
123
+
124
+ beforeEach(() => {
125
+ mockContext = createMockContext()
126
+ })
127
+
128
+ it('should create wallet with correct type and extensionId', () => {
129
+ const wallet = createMetaMaskWallet(extensionId, mockContext)
130
+
131
+ expect(wallet.type).toBe('metamask')
132
+ expect(wallet.extensionId).toBe(extensionId)
133
+ })
134
+
135
+ it('should have all required methods', () => {
136
+ const wallet = createMetaMaskWallet(extensionId, mockContext)
137
+
138
+ expect(typeof wallet.importSeedPhrase).toBe('function')
139
+ expect(typeof wallet.unlock).toBe('function')
140
+ expect(typeof wallet.approve).toBe('function')
141
+ expect(typeof wallet.reject).toBe('function')
142
+ })
143
+ })
106
144
  })
@@ -1,8 +1,7 @@
1
- import type { BrowserContext, Page } from '@playwright/test'
1
+ import type { BrowserContext } from '@playwright/test'
2
2
  import type { WalletAccount } from './types.js'
3
3
  import {
4
- authorizeMetaMask,
5
- confirmMetaMask,
4
+ approveMetaMask,
6
5
  importSeedPhrase as importMetaMaskSeedPhrase,
7
6
  rejectMetaMask,
8
7
  unlockMetaMask,
@@ -21,16 +20,6 @@ import {
21
20
  rejectTalismanTx,
22
21
  } from '../wallets/talisman.js'
23
22
 
24
- // Helper to create extended page with wallet context
25
- /* c8 ignore start */
26
- function createExtendedPage(page: Page, context: BrowserContext, extensionId: string) {
27
- const extPage = page as Page & { __extensionContext: BrowserContext, __extensionId: string }
28
- extPage.__extensionContext = context
29
- extPage.__extensionId = extensionId
30
- return extPage
31
- }
32
- /* c8 ignore stop */
33
-
34
23
  /*
35
24
  * Factory function for Polkadot-JS wallet
36
25
  * Coverage excluded: methods interact with Chrome extension APIs via browser context.
@@ -40,26 +29,10 @@ export function createPolkadotJsWallet(extensionId: string, context: BrowserCont
40
29
  return {
41
30
  extensionId,
42
31
  type: 'polkadot-js' as const,
43
- importMnemonic: async (options: WalletAccount) => {
44
- const page = context.pages()[0] || await context.newPage()
45
- const extPage = createExtendedPage(page, context, extensionId)
46
- await importPolkadotJSAccount(extPage, options)
47
- },
48
- authorize: async () => {
49
- const page = context.pages()[0] || await context.newPage()
50
- const extPage = createExtendedPage(page, context, extensionId)
51
- await authorizePolkadotJS(extPage)
52
- },
53
- approveTx: async (options: { password?: string } = {}) => {
54
- const page = context.pages()[0] || await context.newPage()
55
- const extPage = createExtendedPage(page, context, extensionId)
56
- await approvePolkadotJSTx(extPage, options)
57
- },
58
- rejectTx: async () => {
59
- const page = context.pages()[0] || await context.newPage()
60
- const extPage = createExtendedPage(page, context, extensionId)
61
- await rejectPolkadotJSTx(extPage)
62
- },
32
+ importMnemonic: (options: WalletAccount) => importPolkadotJSAccount(context, extensionId, options),
33
+ authorize: () => authorizePolkadotJS(context, extensionId),
34
+ approveTx: (options: { password?: string } = {}) => approvePolkadotJSTx(context, extensionId, options),
35
+ rejectTx: () => rejectPolkadotJSTx(context, extensionId),
63
36
  }
64
37
  }
65
38
  /* c8 ignore stop */
@@ -75,38 +48,24 @@ export function createTalismanWallet(extensionId: string, context: BrowserContex
75
48
  return {
76
49
  extensionId,
77
50
  type: 'talisman' as const,
78
- importPolkadotMnemonic: async (options: WalletAccount) => {
79
- const page = context.pages()[0] || await context.newPage()
80
- const extPage = createExtendedPage(page, context, extensionId)
51
+ importPolkadotMnemonic: (options: WalletAccount) => {
81
52
  importedAccountName = options.name || 'Test Account'
82
- await importPolkadotMnemonic(extPage, options)
53
+ return importPolkadotMnemonic(context, extensionId, options)
83
54
  },
84
- importEthPrivateKey: async (options: { privateKey: string, name?: string, password?: string }) => {
85
- const page = context.pages()[0] || await context.newPage()
86
- const extPage = createExtendedPage(page, context, extensionId)
55
+ importEthPrivateKey: (options: { privateKey: string, name?: string, password?: string }) => {
87
56
  importedAccountName = options.name || 'Test Account'
88
- await importEthPrivateKey(extPage, {
57
+ return importEthPrivateKey(context, extensionId, {
89
58
  seed: options.privateKey,
90
59
  name: options.name,
91
60
  password: options.password,
92
61
  })
93
62
  },
94
- authorize: async (options: { accountName?: string } = {}) => {
95
- const page = context.pages()[0] || await context.newPage()
96
- const extPage = createExtendedPage(page, context, extensionId)
63
+ authorize: (options: { accountName?: string } = {}) => {
97
64
  const accountName = options.accountName || importedAccountName
98
- await authorizeTalisman(extPage, { accountName })
99
- },
100
- approveTx: async () => {
101
- const page = context.pages()[0] || await context.newPage()
102
- const extPage = createExtendedPage(page, context, extensionId)
103
- await approveTalismanTx(extPage)
104
- },
105
- rejectTx: async () => {
106
- const page = context.pages()[0] || await context.newPage()
107
- const extPage = createExtendedPage(page, context, extensionId)
108
- await rejectTalismanTx(extPage)
65
+ return authorizeTalisman(context, extensionId, { accountName })
109
66
  },
67
+ approveTx: () => approveTalismanTx(context, extensionId),
68
+ rejectTx: () => rejectTalismanTx(context, extensionId),
110
69
  }
111
70
  }
112
71
  /* c8 ignore stop */
@@ -120,31 +79,10 @@ export function createMetaMaskWallet(extensionId: string, context: BrowserContex
120
79
  return {
121
80
  extensionId,
122
81
  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
- },
82
+ importSeedPhrase: (options: { seedPhrase: string }) => importMetaMaskSeedPhrase(context, extensionId, options),
83
+ unlock: () => unlockMetaMask(context, extensionId),
84
+ approve: () => approveMetaMask(context, extensionId),
85
+ reject: () => rejectMetaMask(context, extensionId),
148
86
  }
149
87
  }
150
88
  /* c8 ignore stop */
@@ -160,4 +98,3 @@ export const walletFactories = {
160
98
  export type PolkadotJsWalletInstance = ReturnType<typeof createPolkadotJsWallet>
161
99
  export type TalismanWalletInstance = ReturnType<typeof createTalismanWallet>
162
100
  export type MetaMaskWalletInstance = ReturnType<typeof createMetaMaskWallet>
163
- export type WalletInstance = PolkadotJsWalletInstance | TalismanWalletInstance | MetaMaskWalletInstance
@@ -28,7 +28,7 @@ describe('downloadAndExtractExtension (integration tests)', () => {
28
28
  })
29
29
 
30
30
  afterEach(async () => {
31
- if (tempDir && fs.existsSync(tempDir)) {
31
+ if (tempDir) {
32
32
  await fs.promises.rm(tempDir, { recursive: true, force: true })
33
33
  }
34
34
  })
@@ -29,7 +29,7 @@ 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
32
+ // - `fs.promises.*` is accessed via the default import
33
33
  // - `createWriteStream` is accessed as a named import
34
34
  vi.mock('node:fs', async () => {
35
35
  const actual = await vi.importActual<typeof import('node:fs')>('node:fs')
@@ -37,8 +37,6 @@ vi.mock('node:fs', async () => {
37
37
  ...actual,
38
38
  default: {
39
39
  ...actual,
40
- existsSync: vi.fn(),
41
- readdirSync: vi.fn(),
42
40
  promises: {
43
41
  mkdir: vi.fn().mockResolvedValue(undefined),
44
42
  rm: vi.fn().mockResolvedValue(undefined),
@@ -73,9 +71,8 @@ describe('downloadAndExtractExtension (unit tests)', () => {
73
71
 
74
72
  describe('when extension already exists', () => {
75
73
  it('should skip download and return existing path', async () => {
76
- const mockedFs = vi.mocked(fs)
77
- mockedFs.existsSync.mockReturnValue(true)
78
- mockedFs.readdirSync.mockReturnValue(['manifest.json'] as any)
74
+ const mockedReaddir = vi.mocked(fs.promises.readdir) as unknown as Mock
75
+ mockedReaddir.mockResolvedValueOnce(['manifest.json'])
79
76
 
80
77
  const result = await downloadAndExtractExtension(mockOptions)
81
78
 
@@ -90,9 +87,8 @@ describe('downloadAndExtractExtension (unit tests)', () => {
90
87
 
91
88
  describe('when extension does not exist', () => {
92
89
  beforeEach(() => {
93
- const mockedFs = vi.mocked(fs)
94
- mockedFs.existsSync.mockReturnValue(false)
95
- mockedFs.readdirSync.mockReturnValue([])
90
+ const mockedReaddir = vi.mocked(fs.promises.readdir) as unknown as Mock
91
+ mockedReaddir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
96
92
  })
97
93
 
98
94
  it('should throw error when download fails with bad status', async () => {
@@ -132,14 +128,6 @@ describe('downloadAndExtractExtension (unit tests)', () => {
132
128
  statusText: 'Internal Server Error',
133
129
  })
134
130
 
135
- mockedFs.existsSync.mockImplementation((p: fs.PathLike) => {
136
- const pathStr = p.toString()
137
- if (pathStr.endsWith('test-extension') && !pathStr.includes('.zip')) {
138
- return false
139
- }
140
- return true
141
- })
142
-
143
131
  await expect(downloadAndExtractExtension(mockOptions)).rejects.toThrow()
144
132
 
145
133
  expect(mockedFs.promises.rm).toHaveBeenCalled()
@@ -148,10 +136,6 @@ describe('downloadAndExtractExtension (unit tests)', () => {
148
136
 
149
137
  describe('successful download and extraction', () => {
150
138
  beforeEach(() => {
151
- const mockedFs = vi.mocked(fs)
152
- mockedFs.existsSync.mockReturnValue(false)
153
- mockedFs.readdirSync.mockReturnValue([])
154
-
155
139
  mockFetch.mockResolvedValue({
156
140
  ok: true,
157
141
  status: 200,
@@ -160,10 +144,9 @@ describe('downloadAndExtractExtension (unit tests)', () => {
160
144
  })
161
145
 
162
146
  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'])
147
+ const mockReaddir = vi.mocked(fs.promises.readdir) as unknown as Mock
148
+ mockReaddir.mockResolvedValueOnce([]) // existence check
149
+ mockReaddir.mockResolvedValueOnce(['manifest.json', 'popup.html']) // post-extraction
167
150
 
168
151
  const result = await downloadAndExtractExtension({
169
152
  ...mockOptions,
@@ -182,8 +165,8 @@ describe('downloadAndExtractExtension (unit tests)', () => {
182
165
  const mockReaddir = mockedFs.promises.readdir as unknown as Mock
183
166
  const mockStat = mockedFs.promises.stat as unknown as Mock
184
167
 
185
- // readdir returns a single directory -> unwrap
186
- mockReaddir.mockResolvedValue(['extension-folder'])
168
+ mockReaddir.mockResolvedValueOnce([]) // existence check
169
+ mockReaddir.mockResolvedValueOnce(['extension-folder']) // post-extraction
187
170
  mockStat.mockResolvedValue({ isDirectory: () => true })
188
171
 
189
172
  const result = await downloadAndExtractExtension({
@@ -209,7 +192,8 @@ describe('downloadAndExtractExtension (unit tests)', () => {
209
192
  const mockReaddir = mockedFs.promises.readdir as unknown as Mock
210
193
  const mockStat = mockedFs.promises.stat as unknown as Mock
211
194
 
212
- mockReaddir.mockResolvedValue(['only-file.dat'])
195
+ mockReaddir.mockResolvedValueOnce([]) // existence check
196
+ mockReaddir.mockResolvedValueOnce(['only-file.dat']) // post-extraction
213
197
  mockStat.mockResolvedValue({ isDirectory: () => false })
214
198
 
215
199
  const result = await downloadAndExtractExtension({
@@ -229,9 +213,8 @@ describe('downloadAndExtractExtension (unit tests)', () => {
229
213
 
230
214
  describe('targetDir option', () => {
231
215
  it('should use custom targetDir when provided', async () => {
232
- const mockedFs = vi.mocked(fs)
233
- mockedFs.existsSync.mockReturnValue(true)
234
- mockedFs.readdirSync.mockReturnValue(['manifest.json'] as any)
216
+ const mockReaddir = vi.mocked(fs.promises.readdir) as unknown as Mock
217
+ mockReaddir.mockResolvedValueOnce(['manifest.json'])
235
218
 
236
219
  const customOptions: DownloadExtensionOptions = {
237
220
  ...mockOptions,
@@ -244,9 +227,8 @@ describe('downloadAndExtractExtension (unit tests)', () => {
244
227
  })
245
228
 
246
229
  it('should use default .chroma directory when targetDir not provided', async () => {
247
- const mockedFs = vi.mocked(fs)
248
- mockedFs.existsSync.mockReturnValue(true)
249
- mockedFs.readdirSync.mockReturnValue(['manifest.json'] as any)
230
+ const mockReaddir = vi.mocked(fs.promises.readdir) as unknown as Mock
231
+ mockReaddir.mockResolvedValueOnce(['manifest.json'])
250
232
 
251
233
  const result = await downloadAndExtractExtension(mockOptions)
252
234
 
@@ -4,6 +4,10 @@ import process from 'node:process'
4
4
  import { pipeline } from 'node:stream/promises'
5
5
  import AdmZip from 'adm-zip'
6
6
 
7
+ async function safeReaddir(dir: string): Promise<string[]> {
8
+ return fs.promises.readdir(dir).catch(() => [] as string[])
9
+ }
10
+
7
11
  export interface DownloadExtensionOptions {
8
12
  downloadUrl: string
9
13
  extensionName: string
@@ -52,7 +56,7 @@ export async function downloadAndExtractExtension(options: DownloadExtensionOpti
52
56
  await fs.promises.mkdir(extensionsDir, { recursive: true })
53
57
 
54
58
  // Check if extension is already downloaded and extracted
55
- if (fs.existsSync(extensionDir) && fs.readdirSync(extensionDir).length > 0) {
59
+ if ((await safeReaddir(extensionDir)).length > 0) {
56
60
  console.log(`✅ ${extensionName} already exists at:`, extensionDir)
57
61
  return extensionDir
58
62
  }
@@ -86,12 +90,12 @@ export async function downloadAndExtractExtension(options: DownloadExtensionOpti
86
90
  return extensionDir
87
91
  }
88
92
  catch (error) {
89
- // Clean up on error
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
- }
94
- }
93
+ // Clean up on error (force: true silently ignores missing paths)
94
+ await Promise.all(
95
+ [zipPath, extensionDir, tempExtractDir].map(dir =>
96
+ fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {}),
97
+ ),
98
+ )
95
99
 
96
100
  throw new Error(`Failed to download/extract ${extensionName}: ${error instanceof Error ? error.message : String(error)}`)
97
101
  }
@@ -0,0 +1,39 @@
1
+ import type { BrowserContext, Page } from '@playwright/test'
2
+
3
+ export interface FindExtensionPopupOptions {
4
+ maxAttempts?: number
5
+ retryDelay?: number
6
+ viewport?: { width: number, height: number }
7
+ }
8
+
9
+ /**
10
+ * Poll the BrowserContext for a page whose URL points at the given Chrome
11
+ * extension. Returns once the page is reachable and DOM-loaded.
12
+ */
13
+ /* c8 ignore start */
14
+ export async function findExtensionPopup(
15
+ context: BrowserContext,
16
+ extensionId: string,
17
+ options: FindExtensionPopupOptions = {},
18
+ ): Promise<Page> {
19
+ const { maxAttempts = 10, retryDelay = 500, viewport } = options
20
+
21
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
22
+ for (const p of context.pages()) {
23
+ if (p.url().includes(`chrome-extension://${extensionId}/`)) {
24
+ if (viewport) {
25
+ await p.setViewportSize(viewport)
26
+ }
27
+ await p.waitForLoadState('domcontentloaded')
28
+ return p
29
+ }
30
+ }
31
+
32
+ if (attempt < maxAttempts - 1) {
33
+ await new Promise(resolve => setTimeout(resolve, retryDelay))
34
+ }
35
+ }
36
+
37
+ throw new Error(`Extension popup not found for ID: ${extensionId}`)
38
+ }
39
+ /* c8 ignore stop */
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Default password used by chroma to bootstrap wallets in tests.
3
+ *
4
+ * Wallet methods accept an explicit `password` option that overrides this
5
+ * value, so callers that already supply their own password are unaffected.
6
+ */
7
+ export const DEFAULT_TEST_PASSWORD = 'h3llop0lkadot!'