@marigoldlabs/web3-tester 0.1.2 → 0.4.2

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.
Files changed (96) hide show
  1. package/.env.example +26 -17
  2. package/LICENSE +21 -0
  3. package/README.md +480 -48
  4. package/dist/anvil.d.ts +90 -2
  5. package/dist/anvil.d.ts.map +1 -1
  6. package/dist/anvil.js +215 -13
  7. package/dist/anvil.js.map +1 -1
  8. package/dist/benchmark.d.ts +55 -0
  9. package/dist/benchmark.d.ts.map +1 -0
  10. package/dist/benchmark.js +168 -0
  11. package/dist/benchmark.js.map +1 -0
  12. package/dist/contracts/test-erc20.d.ts +227 -0
  13. package/dist/contracts/test-erc20.d.ts.map +1 -0
  14. package/dist/contracts/test-erc20.js +8 -0
  15. package/dist/contracts/test-erc20.js.map +1 -0
  16. package/dist/erc20.d.ts +38 -0
  17. package/dist/erc20.d.ts.map +1 -0
  18. package/dist/erc20.js +229 -0
  19. package/dist/erc20.js.map +1 -0
  20. package/dist/fixtures.d.ts +44 -2
  21. package/dist/fixtures.d.ts.map +1 -1
  22. package/dist/fixtures.js +162 -17
  23. package/dist/fixtures.js.map +1 -1
  24. package/dist/index.d.ts +26 -5
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +11 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/injected-provider.d.ts.map +1 -1
  29. package/dist/injected-provider.js +863 -77
  30. package/dist/injected-provider.js.map +1 -1
  31. package/dist/live-fixtures.d.ts +32 -3
  32. package/dist/live-fixtures.d.ts.map +1 -1
  33. package/dist/live-fixtures.js +64 -27
  34. package/dist/live-fixtures.js.map +1 -1
  35. package/dist/matchers.d.ts +90 -0
  36. package/dist/matchers.d.ts.map +1 -0
  37. package/dist/matchers.js +268 -0
  38. package/dist/matchers.js.map +1 -0
  39. package/dist/metamask-extension.d.ts +34 -0
  40. package/dist/metamask-extension.d.ts.map +1 -0
  41. package/dist/metamask-extension.js +97 -0
  42. package/dist/metamask-extension.js.map +1 -0
  43. package/dist/mock-wallet-controller.d.ts +354 -4
  44. package/dist/mock-wallet-controller.d.ts.map +1 -1
  45. package/dist/mock-wallet-controller.js +1444 -58
  46. package/dist/mock-wallet-controller.js.map +1 -1
  47. package/dist/private-key-rpc-client.d.ts +1744 -2
  48. package/dist/private-key-rpc-client.d.ts.map +1 -1
  49. package/dist/private-key-rpc-client.js +245 -30
  50. package/dist/private-key-rpc-client.js.map +1 -1
  51. package/dist/real-wallet-cache.d.ts +103 -0
  52. package/dist/real-wallet-cache.d.ts.map +1 -0
  53. package/dist/real-wallet-cache.js +331 -0
  54. package/dist/real-wallet-cache.js.map +1 -0
  55. package/dist/real-wallet-extension-fixtures.d.ts +35 -0
  56. package/dist/real-wallet-extension-fixtures.d.ts.map +1 -0
  57. package/dist/real-wallet-extension-fixtures.js +96 -0
  58. package/dist/real-wallet-extension-fixtures.js.map +1 -0
  59. package/dist/real-wallet-extension.d.ts +86 -0
  60. package/dist/real-wallet-extension.d.ts.map +1 -0
  61. package/dist/real-wallet-extension.js +245 -0
  62. package/dist/real-wallet-extension.js.map +1 -0
  63. package/dist/real-wallet-fixtures.d.ts +52 -0
  64. package/dist/real-wallet-fixtures.d.ts.map +1 -0
  65. package/dist/real-wallet-fixtures.js +88 -0
  66. package/dist/real-wallet-fixtures.js.map +1 -0
  67. package/dist/real-wallet.d.ts +119 -19
  68. package/dist/real-wallet.d.ts.map +1 -1
  69. package/dist/real-wallet.js +1656 -144
  70. package/dist/real-wallet.js.map +1 -1
  71. package/dist/safe.d.ts +311 -0
  72. package/dist/safe.d.ts.map +1 -0
  73. package/dist/safe.js +743 -0
  74. package/dist/safe.js.map +1 -0
  75. package/dist/transactions.d.ts +118 -0
  76. package/dist/transactions.d.ts.map +1 -0
  77. package/dist/transactions.js +207 -0
  78. package/dist/transactions.js.map +1 -0
  79. package/dist/types.d.ts +36 -1
  80. package/dist/types.d.ts.map +1 -1
  81. package/dist/wallet-personas.d.ts +99 -0
  82. package/dist/wallet-personas.d.ts.map +1 -0
  83. package/dist/wallet-personas.js +666 -0
  84. package/dist/wallet-personas.js.map +1 -0
  85. package/dist/walletconnect.d.ts +373 -0
  86. package/dist/walletconnect.d.ts.map +1 -0
  87. package/dist/walletconnect.js +799 -0
  88. package/dist/walletconnect.js.map +1 -0
  89. package/examples/live-sepolia.spec.ts +20 -1
  90. package/examples/playwright.config.ts +8 -0
  91. package/package.json +90 -8
  92. package/docs/API.md +0 -223
  93. package/docs/ARCHITECTURE.md +0 -81
  94. package/docs/CONSUMING_FROM_FJORD.md +0 -123
  95. package/docs/FJORD_LIVE_QA.md +0 -87
  96. package/docs/RELEASE_CHECKLIST.md +0 -55
package/README.md CHANGED
@@ -1,23 +1,27 @@
1
1
  # Web3 Tester
2
2
 
3
- `@marigoldlabs/web3-tester` is a Playwright, Anvil, Viem, and MetaMask harness for Web3 end-to-end tests.
3
+ `@marigoldlabs/web3-tester` is a Playwright, Anvil, Viem, and wallet harness for Web3 end-to-end tests.
4
4
 
5
- The injected fixtures test dApp behavior with a programmable EIP-1193 provider. The real-wallet adapter launches a persistent Chromium profile with MetaMask, imports or unlocks the profile when configured, and exposes wallet-side actions so consumer apps do not carry extension automation code.
5
+ The injected fixtures test dApp behavior with a programmable EIP-1193 provider. The real-wallet adapter launches a persistent Chromium profile with MetaMask, imports or unlocks the profile when configured, and exposes wallet-side actions so consumer apps do not carry extension automation code. A lower-level extension launcher covers other unpacked Chromium wallets so wallet-specific adapters can share the same profile, headless, extension-ID, and page-opening behavior.
6
6
 
7
7
  ## What It Provides
8
8
 
9
9
  - Playwright fixtures that start one Anvil node per worker for parallel-safe local EVM tests.
10
+ - Browser-project portable injected and live fixtures for Chromium, Firefox, and WebKit; real extension fixtures stay Chromium-only because Chrome extensions require Chromium.
10
11
  - Automatic `evm_snapshot` and `evm_revert` around every wallet test.
11
- - A programmable `MockWalletController` for approval, rejection, disconnects, account changes, and network-change events.
12
- - EIP-6963 provider announcements for wallet selector testing.
12
+ - A programmable `MockWalletController` for approval, rejection, pending-approval holds, hardware-wallet confirmation states, disconnects, account changes, and network-change events, with transaction/token-watch recording (`sentTransactions`, `waitForNextTransaction`, `watchedAssets`, `waitForNextWatchedAsset`).
13
+ - EIP-6963 provider announcements, frozen `provider.info` identity metadata, and legacy `window.ethereum.providers` arrays (one distinct provider object per wallet) for wallet selector testing.
14
+ - Coinbase/Base Account simulation for `wallet_connect`, sub-account RPCs, and spend-permission lookup/fetch flows, auto-enabled by the Coinbase persona.
13
15
  - Viem-backed chain helpers for impersonation, balance setup, time travel, and block mining.
14
- - Optional live-chain fixtures for controlled Sepolia QA with a runtime-only private key.
15
- - Real MetaMask launch, profile resolution, unlock/import, dapp connection, signature confirmation, transaction confirmation, and token approval helpers.
16
- - Fjord v4 QA specs and reports that document the current state of `https://v4.fjordfoundry.com`.
16
+ - Optional live-chain fixtures for controlled testnet QA with a runtime-only private key (`createLiveFixtures` for custom chains/env names).
17
+ - Optional WalletConnect/AppKit simulation (`@marigoldlabs/web3-tester/walletconnect`): a headless WC v2 wallet peer that pairs with the dapp's QR modal, handles One-Click Auth/SIWE `session_authenticate`, and answers EVM plus Solana namespace requests through the same wallet gating — needs the optional `@walletconnect/*` peers and a Reown project id.
18
+ - Built-in wallet personas for major wallet selector coverage (MetaMask, Rabby, Coinbase Wallet, Phantom EVM, Rainbow, OKX, Trust, Brave, Zerion, Backpack, Solflare, Ledger, Trezor, Safe, Bitget, TokenPocket, SafePal, Binance Wallet, imToken, MathWallet, Frame, Enkrypt, Core, Frontier, OneKey, CTRL, Uniswap Wallet, Argent, Exodus, and Fireblocks): EIP-6963 metadata, provider flags, known globals, legacy provider arrays, Phantom/Backpack/SafePal/Solflare Solana discovery surfaces, and WalletConnect metadata.
19
+ - Real MetaMask mode: pinned-version extension download (`prepareMetaMaskExtension`), one-time onboarding into a cached profile with disposable per-test clones (`buildWalletProfile`/`cloneWalletProfile`), Playwright fixtures (`@marigoldlabs/web3-tester/real-wallet-fixtures`), wallet-side network add/switch, dapp connection, signature/transaction confirmation and rejection, and token approval helpers — validated end to end by an opt-in smoke suite against the pinned MetaMask build.
20
+ - Generic Chromium wallet extension launcher (`@marigoldlabs/web3-tester/real-wallet-extension`) for Rabby, Coinbase Wallet, Phantom, OKX, Trust, Brave, and other unpacked Chrome extensions: persistent profile launch, explicit headed/headless handling, extension-ID discovery, manifest helpers, default popup/options page resolution, and extension page openers. Wallet-specific UI automation layers can build on this without reimplementing browser setup.
17
21
 
18
22
  ## Install In A Consumer App
19
23
 
20
- From the Fjord v4 package, install this repo as a dev dependency:
24
+ Install as a dev dependency:
21
25
 
22
26
  ```bash
23
27
  npm install --save-dev @marigoldlabs/web3-tester
@@ -41,34 +45,118 @@ test('user can submit a wallet transaction', async ({ page, wallet }) => {
41
45
  });
42
46
  ```
43
47
 
44
- For live Sepolia tests, import the live fixture instead:
48
+ > Note: fixtures are lazy. The provider is only injected when a test
49
+ > references the `wallet` fixture — a test that destructures only `page`
50
+ > will have no `window.ethereum`.
51
+
52
+ Run injected/mock-wallet and live-key tests across Playwright's browser
53
+ projects with the normal project matrix:
54
+
55
+ ```ts
56
+ import { defineConfig, devices } from '@playwright/test';
57
+
58
+ export default defineConfig({
59
+ fullyParallel: true,
60
+ use: {
61
+ baseURL: process.env.DAPP_URL ?? 'http://localhost:3000',
62
+ trace: 'on-first-retry',
63
+ },
64
+ projects: [
65
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
66
+ { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
67
+ { name: 'webkit', use: { ...devices['Desktop Safari'] } },
68
+ ],
69
+ });
70
+ ```
71
+
72
+ Keep `@marigoldlabs/web3-tester/real-wallet-fixtures` and
73
+ `/real-wallet-extension-fixtures` in a dedicated Chromium project. Those
74
+ fixtures launch a persistent Chromium extension context and fail fast when a
75
+ Firefox or WebKit project tries to use them.
76
+
77
+ Testing pending-approval UI and rejection paths:
78
+
79
+ ```ts
80
+ test('shows a pending state until the user confirms', async ({ page, wallet }) => {
81
+ const held = wallet.holdNextRequest('eth_sendTransaction');
82
+ await page.getByRole('button', { name: /swap/i }).click();
83
+ await expect(page.getByText(/confirm in your wallet/i)).toBeVisible();
84
+
85
+ (await held).approve();
86
+ await expect(page.getByText(/success/i)).toBeVisible();
87
+ });
88
+ ```
89
+
90
+ For live testnet tests, import the live fixture (Sepolia by default; use
91
+ `createLiveFixtures({ chain })` for other chains):
45
92
 
46
93
  ```ts
47
94
  import { expect, test } from '@marigoldlabs/web3-tester/live-fixtures';
48
95
 
49
96
  test('signs in through SIWE on Sepolia', async ({ page, wallet }) => {
50
97
  await page.goto('/');
98
+
99
+ // Live wallets are deny-by-default: arm each prompt before triggering it.
100
+ wallet.approveNext('eth_requestAccounts');
51
101
  await page.getByRole('button', { name: /connect/i }).click();
102
+
103
+ wallet.approveNext('personal_sign');
104
+ await page.getByRole('button', { name: /sign in/i }).click();
105
+
52
106
  await expect(page.getByText(wallet.primaryAccount.slice(0, 6))).toBeVisible();
53
107
  });
54
108
  ```
55
109
 
56
- For fully in-UI real wallet tests, launch MetaMask through the package:
110
+ To deliberately auto-approve a whole test instead, use
111
+ `test.use({ liveOptions: { walletOptions: { autoApprove: true } } })`.
112
+
113
+ For fully in-UI real wallet tests, use the real-wallet fixtures. The pinned
114
+ MetaMask build is downloaded automatically, onboarding runs once into a
115
+ cached profile, and every test gets a disposable clone of that profile:
116
+
117
+ ```ts
118
+ import { expect, test } from '@marigoldlabs/web3-tester/real-wallet-fixtures';
119
+
120
+ test.use({
121
+ realWalletOptions: {
122
+ setup: { seedPhrase: process.env.WEB3_TESTER_REAL_WALLET_SECRET_RECOVERY_PHRASE },
123
+ baseURL: 'https://app.example.com',
124
+ // Required: pick headed or headless explicitly (or set
125
+ // WEB3_TESTER_REAL_WALLET_HEADLESS). Headed is the fully validated mode.
126
+ headless: false,
127
+ },
128
+ });
129
+
130
+ test('confirms a real MetaMask transaction', async ({ page, realWallet }) => {
131
+ await realWallet.addNetwork({
132
+ name: 'Anvil Local',
133
+ rpcUrl: 'http://127.0.0.1:8645',
134
+ chainId: 31337,
135
+ symbol: 'ETH',
136
+ });
137
+ await realWallet.switchNetwork('Anvil Local');
138
+
139
+ await page.goto('/');
140
+ await page.getByRole('button', { name: /connect/i }).click();
141
+ await realWallet.connectToDapp();
142
+
143
+ await page.getByRole('button', { name: /swap/i }).click();
144
+ await realWallet.confirmTransaction();
145
+ });
146
+ ```
147
+
148
+ The imperative API remains available for custom setups (preconfigured
149
+ profiles, attaching to a real Chrome profile):
57
150
 
58
151
  ```ts
59
152
  import { launchRealWallet } from '@marigoldlabs/web3-tester/real-wallet';
153
+ import { prepareMetaMaskExtension } from '@marigoldlabs/web3-tester/metamask-extension';
60
154
 
61
155
  const wallet = await launchRealWallet({
62
- baseURL: 'https://v4.fjordfoundry.com',
63
- expectedAddress: process.env.FJORD_REAL_WALLET_ADDRESS,
64
- extensionPath: process.env.FJORD_REAL_WALLET_EXTENSION_PATH as string,
65
- profileDir: process.env.FJORD_REAL_WALLET_PROFILE_DIR as string,
66
- setup: process.env.FJORD_REAL_WALLET_PASSWORD || process.env.FJORD_REAL_WALLET_SECRET_RECOVERY_PHRASE
67
- ? {
68
- password: process.env.FJORD_REAL_WALLET_PASSWORD,
69
- seedPhrase: process.env.FJORD_REAL_WALLET_SECRET_RECOVERY_PHRASE,
70
- }
71
- : undefined,
156
+ extensionPath: await prepareMetaMaskExtension(),
157
+ profileDir: process.env.WEB3_TESTER_REAL_WALLET_PROFILE_DIR as string,
158
+ setup: { seedPhrase: process.env.WEB3_TESTER_REAL_WALLET_SECRET_RECOVERY_PHRASE },
159
+ headless: false,
72
160
  });
73
161
 
74
162
  await wallet.connectToDapp();
@@ -77,14 +165,105 @@ await wallet.confirmTransaction();
77
165
  await wallet.close();
78
166
  ```
79
167
 
168
+ For real extension coverage beyond MetaMask, use the generic launcher and
169
+ drive wallet-specific UI with Playwright locators:
170
+
171
+ ```ts
172
+ import { launchRealWalletExtension } from '@marigoldlabs/web3-tester/real-wallet-extension';
173
+
174
+ const rabby = await launchRealWalletExtension({
175
+ extensionPath: process.env.RABBY_EXTENSION_PATH as string,
176
+ extensionName: 'Rabby Wallet',
177
+ profileDir: process.env.RABBY_PROFILE_DIR as string,
178
+ headless: false,
179
+ });
180
+
181
+ const popup = rabby.page ?? await rabby.openPage('popup.html');
182
+ await popup.getByRole('button', { name: /unlock/i }).click();
183
+ await rabby.close();
184
+ ```
185
+
186
+ When onboarding is expensive, cache a prepared non-MetaMask profile with the
187
+ same clone model the MetaMask fixture uses:
188
+
189
+ ```ts
190
+ import {
191
+ buildWalletExtensionProfile,
192
+ cloneWalletProfile,
193
+ } from '@marigoldlabs/web3-tester/real-wallet-cache';
194
+
195
+ const cached = await buildWalletExtensionProfile({
196
+ cacheKey: 'rabby-anvil-seed-v1',
197
+ extensionPath: process.env.RABBY_EXTENSION_PATH as string,
198
+ extensionName: 'Rabby Wallet',
199
+ headless: false,
200
+ setup: {
201
+ run: async (session) => {
202
+ const popup = session.page ?? await session.openPage('popup.html');
203
+ // Drive Rabby onboarding/unlock/import with Playwright locators here.
204
+ await popup.getByRole('button', { name: /unlock/i }).click();
205
+ },
206
+ },
207
+ });
208
+
209
+ const profileDir = await cloneWalletProfile(cached, '/tmp/rabby-test-profile');
210
+ ```
211
+
212
+ For Playwright suites, the generic fixture wraps that cache/clone/teardown
213
+ flow and exposes the persistent extension context as `context` / `page`:
214
+
215
+ ```ts
216
+ import {
217
+ expect,
218
+ test,
219
+ } from '@marigoldlabs/web3-tester/real-wallet-extension-fixtures';
220
+
221
+ test.use({
222
+ realWalletExtensionOptions: {
223
+ extensionPath: process.env.RABBY_EXTENSION_PATH,
224
+ extensionName: 'Rabby Wallet',
225
+ profileCacheKey: 'rabby-anvil-seed-v1',
226
+ headless: false,
227
+ profileSetup: {
228
+ run: async (session) => {
229
+ const popup = session.page ?? await session.openPage('popup.html');
230
+ await popup.getByRole('button', { name: /unlock/i }).click();
231
+ },
232
+ },
233
+ },
234
+ });
235
+
236
+ test('opens with a prepared wallet profile', async ({ page, realWalletExtension }) => {
237
+ await page.goto('https://app.example.test');
238
+ expect(realWalletExtension.extensionId).toMatch(/^[a-p]{32}$/);
239
+ });
240
+ ```
241
+
242
+ MetaMask version pinning: selectors are maintained against
243
+ `DEFAULT_METAMASK_VERSION` (currently 13.34.1, current MetaMask) and validated
244
+ by the opt-in smoke suite (`npm run smoke:real-wallet`), which runs the full
245
+ journey — onboarding, add/switch network, connect, sign, send, reject — plus
246
+ the account/token/settings surface against the real extension. The UI
247
+ generation (13.x "multichain" vs the older 12.x) is an explicit configuration,
248
+ derived from the extension manifest at launch and overridable via the
249
+ `generation` option: only the configured generation's selectors are driven —
250
+ the other generation is never probed as a fallback. Set
251
+ `WEB3_TESTER_METAMASK_VERSION` to pin a specific build (e.g. `12.23.1`; 12.x
252
+ is supported on a best-effort validation cadence — 13.x gates releases). Bump
253
+ the pin deliberately and re-run the smoke suite, since MetaMask UI selectors
254
+ can drift between releases.
255
+
80
256
  ## Local Development
81
257
 
82
258
  ```bash
83
259
  npm install
84
- npx playwright install chromium
260
+ npx playwright install chromium firefox webkit
85
261
  npm run typecheck
262
+ npm run typecheck:examples
86
263
  npm run build
87
- npm test
264
+ npm test # hermetic library tests (needs anvil)
265
+ npm run test:browsers # focused Chromium/Firefox/WebKit fixture matrix
266
+ npm run smoke:real-wallet # opt-in real-MetaMask smoke suite (headed)
88
267
  ```
89
268
 
90
269
  Foundry's `anvil` executable must be available on `PATH`, or set `ANVIL_EXECUTABLE`.
@@ -117,26 +296,45 @@ Copy `.env.example` for local reference. Do not commit real private keys.
117
296
 
118
297
  | Variable | Default | Purpose |
119
298
  | --- | --- | --- |
120
- | `DAPP_URL` | `https://v4.fjordfoundry.com` | Playwright base URL for app tests. |
121
299
  | `ANVIL_EXECUTABLE` | `anvil` | Path to the Anvil binary. |
122
300
  | `ANVIL_RUNTIME` | `binary` | Set to `docker` to run Anvil through Docker Desktop. |
123
301
  | `ANVIL_DOCKER_IMAGE` | `ghcr.io/foundry-rs/foundry:latest` | Docker image used when `ANVIL_RUNTIME=docker`. |
124
- | `ANVIL_HOST` | `127.0.0.1` | Host for worker Anvil RPC endpoints. |
125
- | `ANVIL_PORT` | `8545` | Base port. Playwright worker index is added for isolation. |
302
+ | `ANVIL_HOST` | `127.0.0.1` | Host for worker Anvil RPC endpoints. Non-loopback hosts are refused unless `ANVIL_ALLOW_NON_LOOPBACK=true`. |
303
+ | `ANVIL_ALLOW_NON_LOOPBACK` | `false` | Explicit opt-in to bind Anvil beyond loopback (exposes its unauthenticated admin RPC to the network). |
304
+ | `ANVIL_PORT` | `8645` | Base port (worker index is added for isolation). Defaults off 8545 so a developer-run dev node never collides. |
126
305
  | `ANVIL_CHAIN_ID` | `31337` | Chain ID exposed by local Anvil and the injected provider. |
127
306
  | `ANVIL_FORK_URL` | unset | Optional fork RPC URL. |
128
307
  | `ANVIL_SILENT` | `true` | Set to `false` to stream Anvil logs. |
129
- | `FJORD_PRIVATE_KEY` | unset | Runtime-only private key for live Sepolia QA. |
130
- | `SEPOLIA_RPC_URL` | Viem default | Optional Sepolia RPC URL for live tests. |
131
- | `FJORD_RUN_TRANSACTIONS` | unset | Must be `true` to run live transaction-spending tests. |
132
- | `FJORD_MUTATE_STATE` | unset | Must be `true` to deploy QA tokens or create sale drafts. |
133
- | `FJORD_PUBLISH_SALES` | unset | Must be `true` to attempt live sale publishing. |
134
- | `FJORD_ADMIN_MUTATE` | unset | Must be `true` to attempt admin mutation tests. |
135
- | `FJORD_REAL_WALLET_EXTENSION_PATH` | unset | Path to the unpacked MetaMask extension for real-wallet tests. |
136
- | `FJORD_REAL_WALLET_PROFILE_DIR` | unset | Persistent Chromium user-data directory, or a Chrome profile directory such as `Profile 1`. |
137
- | `FJORD_REAL_WALLET_ADDRESS` | unset | Optional expected account address checked after unlock/import. |
138
- | `FJORD_REAL_WALLET_PASSWORD` | unset | Optional MetaMask password used to unlock the profile. When importing from a seed without a password, web3-tester uses a deterministic test profile password. |
139
- | `FJORD_REAL_WALLET_SECRET_RECOVERY_PHRASE` | unset | Optional seed phrase used when MetaMask opens on onboarding. |
308
+ | `WEB3_TESTER_PRIVATE_KEY` | unset | Runtime-only private key for live-chain fixtures. |
309
+ | `WEB3_TESTER_RPC_URL` | Viem default | Optional RPC URL for live fixtures (`SEPOLIA_RPC_URL` legacy alias). |
310
+ | `WEB3_TESTER_METAMASK_VERSION` | pinned default | MetaMask release downloaded by `prepareMetaMaskExtension`. |
311
+ | `WEB3_TESTER_REAL_WALLET_EXTENSION_PATH` | auto-download | Path to an unpacked MetaMask extension (skips the download). |
312
+ | `WEB3_TESTER_REAL_WALLET_PROFILE_DIR` | profile cache | Explicit persistent Chromium user-data directory, or a Chrome profile directory such as `Profile 1`. Disables the per-test profile cache. |
313
+ | `WEB3_TESTER_REAL_WALLET_PASSWORD` | deterministic test password | MetaMask password used to unlock profiles. |
314
+ | `WEB3_TESTER_REAL_WALLET_SECRET_RECOVERY_PHRASE` | unset | Seed phrase used to build the cached real-wallet profile. |
315
+ | `WEB3_TESTER_REAL_WALLET_HEADLESS` | none — explicit choice required | `true`/`false`. Real-wallet launches refuse to guess: pick headed (fully validated) or headless (needs the full Chromium from `npx playwright install chromium`) here or via the `headless` option. |
316
+ | `WEB3_TESTER_REAL_WALLET_SMOKE` | unset | Set `true` to run the real-MetaMask smoke suite (`npm run smoke:real-wallet`). |
317
+ | `WEB3_TESTER_WC_PROJECT_ID` | unset | Reown project id; set to run the opt-in WalletConnect relay suite. |
318
+ | `WEB3_TESTER_BENCHMARK` | unset | Set `true`/`1` to record opt-in benchmark spans for slow/flaky test runs. |
319
+ | `WEB3_TESTER_BENCHMARK_OUTPUT` | `reports/web3-tester-benchmark.ndjson` | NDJSON file where benchmark spans are appended as they finish. |
320
+ | `WEB3_TESTER_BENCHMARK_VERBOSE` | unset | Set `true` to also stream benchmark spans to stderr. |
321
+
322
+ ### Benchmarking slow or flaky runs
323
+
324
+ Benchmarking is disabled by default and is diagnostic-only: it records timings
325
+ without changing test behavior. When enabled, Playwright tests attach a
326
+ `web3-tester-benchmark.json` artifact per test and append every span to the
327
+ NDJSON report path.
328
+
329
+ ```bash
330
+ npm run smoke:real-wallet -- --benchmark
331
+ npm run smoke:real-wallet -- --benchmark --benchmark-output=reports/real-wallet-benchmark.ndjson
332
+ WEB3_TESTER_BENCHMARK=true WEB3_TESTER_WC_PROJECT_ID=<id> npm test -- walletconnect-live
333
+ ```
334
+
335
+ The real-wallet fixtures record extension/profile setup, launch, close, and
336
+ every wallet-side session method. The live WalletConnect relay test records
337
+ pairing, request, push-event, disconnect, and close phases.
140
338
 
141
339
  ## Package Surface
142
340
 
@@ -146,6 +344,14 @@ The installable package exports:
146
344
  - `@marigoldlabs/web3-tester/fixtures`
147
345
  - `@marigoldlabs/web3-tester/live-fixtures`
148
346
  - `@marigoldlabs/web3-tester/real-wallet`
347
+ - `@marigoldlabs/web3-tester/real-wallet-extension`
348
+ - `@marigoldlabs/web3-tester/real-wallet-extension-fixtures`
349
+ - `@marigoldlabs/web3-tester/real-wallet-cache`
350
+ - `@marigoldlabs/web3-tester/real-wallet-fixtures`
351
+ - `@marigoldlabs/web3-tester/metamask-extension`
352
+ - `@marigoldlabs/web3-tester/wallet-personas`
353
+ - `@marigoldlabs/web3-tester/safe`
354
+ - `@marigoldlabs/web3-tester/benchmark`
149
355
  - `@marigoldlabs/web3-tester/anvil`
150
356
  - `@marigoldlabs/web3-tester/mock-wallet-controller`
151
357
  - `@marigoldlabs/web3-tester/private-key-rpc-client`
@@ -159,47 +365,274 @@ await chain.impersonateAccount('0x0000000000000000000000000000000000000001');
159
365
  await chain.setBalance(wallet.primaryAccount, 10_000n * 10n ** 18n);
160
366
  await chain.fastForward(7 * 24 * 60 * 60);
161
367
  await chain.mine(3);
368
+
369
+ // Token seeding and deployment (forge-style cheatcodes):
370
+ const token = await chain.deployErc20({ symbol: 'USDX', decimals: 6 });
371
+ await chain.dealErc20(token.address, wallet.primaryAccount, 5_000_000_000n);
372
+ // dealErc20 also works on ANVIL_FORK_URL forks against real mainnet tokens.
162
373
  ```
163
374
 
164
375
  ## Wallet Control
165
376
 
166
377
  ```ts
167
378
  await wallet.simulateRejection('eth_sendTransaction');
379
+ await wallet.lock(); // hides accounts; _metamask.isUnlocked() -> false
380
+ await wallet.unlock(); // restores accounts for connected wallets
168
381
  await wallet.disconnect();
169
382
  await wallet.reconnect();
170
- await wallet.setAccounts(['0x0000000000000000000000000000000000000001']);
383
+ // Accounts are validated against the node's signers — use chain.accounts()
384
+ // entries (or chain.impersonateAccount(addr) first for send-only flows).
385
+ const [, second] = await chain.accounts();
386
+ await wallet.setAccounts([second]);
387
+ await wallet.switchAccount(second); // reorders + emits accountsChanged
171
388
  await wallet.switchNetwork(11155111);
389
+
390
+ // Pending-approval simulation and transaction assertions:
391
+ const held = wallet.holdNextRequest('personal_sign');
392
+ // ... trigger the dapp action, assert the pending UI ...
393
+ (await held).reject('User changed their mind.');
394
+
395
+ // Ledger/Trezor-style confirmation simulation:
396
+ wallet.configureHardwareWallet({
397
+ approvalDelayMs: 750,
398
+ requiredApps: { solana_signMessage: 'Solana' },
399
+ });
400
+ wallet.setHardwareWalletState('wrong-app'); // locked | wrong-app | blind-signing-disabled | disconnected | ready
401
+
402
+ const txPromise = wallet.waitForNextTransaction();
403
+ // ... trigger the dapp action ...
404
+ const hash = await txPromise;
405
+
406
+ const assetPromise = wallet.waitForNextWatchedAsset();
407
+ // ... trigger wallet_watchAsset ...
408
+ const watched = await assetPromise;
409
+ ```
410
+
411
+ Dapp-initiated `wallet_switchEthereumChain` follows MetaMask semantics: it
412
+ throws 4902 for chains the wallet does not know; chains become known via
413
+ `wallet_addEthereumChain` or a test-driven `wallet.switchNetwork(...)`.
414
+
415
+ Multi-account and multi-user testing:
416
+
417
+ ```ts
418
+ // Start connected with three anvil accounts:
419
+ test.use({ walletOptions: { accountIndexes: [0, 1, 2] } });
420
+
421
+ // Two users, one chain — seller lists, buyer purchases:
422
+ test('buyer sees the listing', async ({ page, wallet, createUser }) => {
423
+ const buyer = await createUser(); // own context + page, anvil account #1
424
+ await buyer.page.goto('/listings/1');
425
+ await buyer.page.getByRole('button', { name: 'Buy' }).click();
426
+ });
172
427
  ```
173
428
 
174
429
  ## Multiple Wallet Selectors
175
430
 
176
431
  ```ts
432
+ import { walletPersonas, walletProfiles } from '@marigoldlabs/web3-tester/wallet-personas';
433
+
177
434
  test.use({
178
435
  walletOptions: {
179
- providerInfo: { name: 'Mock MetaMask', rdns: 'io.metamask' },
180
- additionalProviders: [
181
- { name: 'Mock Rabby', rdns: 'io.rabby' },
182
- { name: 'Mock Rainbow', rdns: 'me.rainbow' },
436
+ persona: walletPersonas.metamask(),
437
+ additionalPersonas: [
438
+ walletPersonas.rabby(),
439
+ walletPersonas.coinbase(),
440
+ walletPersonas.phantomEvm(),
441
+ walletPersonas.solflare(),
442
+ walletPersonas.bitget(),
443
+ walletPersonas.tokenPocket(),
444
+ walletPersonas.safePal(),
445
+ walletPersonas.binance(),
446
+ walletPersonas.safe(),
183
447
  ],
184
448
  },
185
449
  });
186
450
  ```
187
451
 
452
+ Personas are still backed by the same deterministic controller: approvals,
453
+ rejections, holds, chain switching, and transaction recording work exactly as
454
+ they do for the default mock wallet. Legacy `providerInfo` and
455
+ `additionalProviders` remain supported for metadata-only cases.
456
+ Injected EVM providers include the common EventEmitter aliases used by wallet
457
+ SDKs (`addListener`, `off`, `listeners`, `listenerCount`) and emit an initial
458
+ `connect` event for already-connected wallets after page scripts can attach
459
+ listeners. Legacy callback batch calls return JSON-RPC response arrays with
460
+ per-payload `result` or `error` entries. Subscription streams are deliberately out of scope:
461
+ `eth_subscribe` and `eth_unsubscribe` return a wallet-shaped `4200`; use a
462
+ direct viem/WebSocket client when a test needs live chain subscriptions.
463
+ The Phantom, Backpack, SafePal, and Solflare personas also expose lightweight Solana
464
+ browser providers (`window.phantom.solana`, `window.solana`,
465
+ `window.backpack.solana`, `window.safepal`, `window.solflare`) and register
466
+ Wallet Standard wallets for wallet-adapter discovery. Solflare is modeled as
467
+ Solana-only (`evm: false`), so it does not add a `window.ethereum` provider or
468
+ an EIP-6963 announcement. These Solana surfaces are for selector, connect, and
469
+ sign-in/signing UI tests; they expose deterministic `signIn` and
470
+ `solana:signIn` results, trusted/silent reconnect behavior, the same
471
+ controller approval/hardware gates as EVM requests, and the same EventEmitter
472
+ aliases. `request({ method: 'getAccounts' | 'requestAccounts' })` returns
473
+ base58 public-key strings, while `solana_getAccounts` / `solana_requestAccounts`
474
+ return objects with `publicKey`, `pubkey`, and `address` fields for
475
+ WalletConnect-style probes. Controller lock/disconnect events hide direct Solana provider
476
+ accounts too (`publicKey: null`, Wallet Standard `accounts: []`), and unlock
477
+ restores providers that were connected before the lock. These surfaces do not
478
+ start a Solana validator or submit real Solana
479
+ transactions.
480
+ WalletConnect personas include peer metadata plus verified launch templates
481
+ for common mobile handoff tests via `formatWalletConnectUriForPersona`.
482
+ WalletConnect EVM namespaces advertise EIP-5792 batch methods by default, so
483
+ AppKit/wagmi `sendCalls` traffic reaches the same controller implementation
484
+ as injected `wallet_sendCalls`. EVM `accountsChanged` events also refresh the
485
+ session namespace's CAIP account list when accounts are visible; lock and
486
+ disconnect still emit `[]` without rewriting the approved namespace. Solana
487
+ WalletConnect namespaces receive `accountsChanged` events with Solana public
488
+ keys or `[]` as the controller locks, unlocks, or disconnects. One-Click
489
+ Auth/SIWE `session_authenticate`
490
+ requests produce CAIP-122 Cacao signatures through the same
491
+ `eth_requestAccounts` and `personal_sign` gates; pass
492
+ `sessionAuthenticate: false` to keep sign-client's fallback behavior.
493
+
494
+ Use wallet profiles when identity should also imply behavior:
495
+
496
+ ```ts
497
+ test.use({
498
+ walletOptions: walletProfiles.ledger({
499
+ hardwareWallet: { deviceState: 'wrong-app', approvalDelayMs: 0 },
500
+ }),
501
+ });
502
+ ```
503
+
504
+ For Coinbase/Base Account flows, the Coinbase persona enables wallet-specific
505
+ RPC methods and lets tests seed spend permissions and sub-accounts:
506
+
507
+ ```ts
508
+ test.use({
509
+ walletOptions: walletProfiles.coinbase({
510
+ coinbase: {
511
+ permissions: [{
512
+ createdAt: 1_700_000_000,
513
+ permissionHash: `0x${'11'.repeat(32)}`,
514
+ signature: `0x${'aa'.repeat(65)}`,
515
+ spendPermission: {
516
+ account: '0x0000000000000000000000000000000000000001',
517
+ spender: '0x0000000000000000000000000000000000000002',
518
+ token: '0x0000000000000000000000000000000000000003',
519
+ allowance: '1000000000000000000',
520
+ period: 86_400,
521
+ start: 1_700_000_000,
522
+ end: 4_102_444_800,
523
+ salt: '1',
524
+ extraData: '0x',
525
+ },
526
+ }],
527
+ subAccounts: [{
528
+ address: '0x0000000000000000000000000000000000000004',
529
+ account: '0x0000000000000000000000000000000000000001',
530
+ domain: 'https://app.example.com',
531
+ }],
532
+ },
533
+ }),
534
+ });
535
+ ```
536
+
537
+ `wallet_connect` supports Coinbase's `signInWithEthereum` capability,
538
+ `wallet_addSubAccount` stores generated or deployed sub-accounts for
539
+ `wallet_getSubAccounts`, and `coinbase_fetchPermissions` /
540
+ `coinbase_fetchPermission` return seeded spend-permission data. The Coinbase
541
+ WalletConnect persona advertises these methods in the EVM namespace by default.
542
+
543
+ ## Safe Transaction Service
544
+
545
+ ```ts
546
+ import {
547
+ SafeTransactionServiceClient,
548
+ SafeWalletHarness,
549
+ hashSafeTransactionTypedData,
550
+ } from '@marigoldlabs/web3-tester/safe';
551
+
552
+ const transactionService = new SafeTransactionServiceClient({
553
+ // Include the service API prefix used by your deployment.
554
+ baseUrl: 'https://safe-transaction-sepolia.safe.global/api/v1',
555
+ chainId: 11155111,
556
+ });
557
+
558
+ const safe = new SafeWalletHarness({
559
+ safeAddress: '0x...',
560
+ owners: [owner1, owner2],
561
+ threshold: 2,
562
+ chainId: 11155111,
563
+ transactionService,
564
+ });
565
+ ```
566
+
567
+ The Safe module treats Transaction Service support as required surface: the
568
+ REST client covers propose, confirm, fetch, list, and confirmation listing
569
+ against Safe Transaction Service deployments. `InMemorySafeTransactionService`
570
+ exists for hermetic tests and local workflow assertions only.
571
+
572
+ `SafeWalletHarness` uses protocol-compatible Safe EIP-712 transaction hashes by
573
+ default, and `SafeTransactionServiceClient` does the same when `chainId` is
574
+ configured. Use `hashSafeTransactionTypedData(safeAddress, chainId, tx)` when
575
+ you need to precompute or assert the `safeTxHash` yourself;
576
+ `hashSafeTransactionData` remains available as a deterministic fixture hash via
577
+ `safeTxHashStrategy: 'fixture'`.
578
+
579
+ For Safe Apps SDK iframe flows, install the parent bridge before loading the
580
+ app iframe:
581
+
582
+ ```ts
583
+ import { injectSafeAppBridge } from '@marigoldlabs/web3-tester/safe';
584
+
585
+ await page.setContent('<iframe id="safe-app"></iframe>');
586
+ await injectSafeAppBridge(page, safe);
587
+ await page.locator('#safe-app').evaluate((iframe, srcdoc) => {
588
+ (iframe as HTMLIFrameElement).srcdoc = srcdoc;
589
+ }, appHtml);
590
+ ```
591
+
592
+ The bridge answers Safe Apps SDK v1 `postMessage` requests such as
593
+ `getSafeInfo`, `getChainInfo`, `sendTransactions`, `getTxBySafeTxHash`,
594
+ `rpcCall`, signing requests, permissions, balances, and address book lookups.
595
+ `getSafeInfo` returns the extended SDK fields (`nonce`, `implementation`,
596
+ `modules`, `fallbackHandler`, `guard`, `version`), `getChainInfo` uses the Safe
597
+ Gateway `blockExplorerUriTemplate.txHash` key, and `getSafeBalances` returns
598
+ the SDK `{ fiatTotal, items }` response shape. When `allowedOrigins` is set,
599
+ the bridge rejects untrusted and missing/null iframe origins. Multi-call
600
+ `sendTransactions` requests are encoded as a delegatecall to Safe
601
+ MultiSendCallOnly; pass `multiSendAddress` for custom deployments.
602
+
188
603
  ## Repository Layout
189
604
 
190
605
  | Path | Purpose |
191
606
  | --- | --- |
192
607
  | `src/` | Reusable package source. |
193
- | `tests/provider-injection.spec.ts` | Harness self-tests. |
194
- | `tests/fjord*.spec.ts` | Fjord v4 public, live, and mutation QA specs. |
195
- | `docs/` | Dependency, API, architecture, and Fjord QA documentation. |
608
+ | `tests/` (library project) | Hermetic harness self-tests: `anvil`, `live-fixtures`, `mock-wallet`, `private-key-rpc-client`, `provider-injection`, `real-wallet`, `real-wallet-smoke` (opt-in). |
609
+ | `docs/` | API, architecture, and roadmap documentation. |
196
610
  | `examples/` | Copyable consumer-app snippets. |
197
- | `reports/` | Current Fjord v4 QA reports. |
198
611
 
199
612
  ## Safety Model
200
613
 
201
- - Local tests use deterministic Anvil accounts only.
614
+ - Local tests use deterministic Anvil accounts only, and Anvil refuses to
615
+ bind beyond loopback unless `ANVIL_ALLOW_NON_LOOPBACK=true` is set — its
616
+ admin RPC (impersonation, `setBalance`, the fork URL) is unauthenticated.
202
617
  - Live tests require explicit environment variables and never store private keys in source.
618
+ - Live wallets are deny-by-default: signing, sending (including
619
+ `eth_sendRawTransaction`), and wallet prompts (`eth_requestAccounts`
620
+ included, even though the wallet starts pre-connected) throw `4001` until
621
+ the test arms them — `wallet.approveNext(methods?, match?)` per request, or
622
+ `wallet.autoApprove(true)` /
623
+ `test.use({ liveOptions: { walletOptions: { autoApprove: true } } })` as a
624
+ deliberate whole-test opt-in. So page scripts — including third-party
625
+ includes on the dapp under test — cannot spend or sign unprompted.
626
+ - When Playwright's `baseURL` is configured, the live provider is
627
+ origin-scoped to it: out-of-scope frames get no `window.ethereum` at all
628
+ and the RPC bridge refuses them with `4100`. Override with
629
+ `allowedOrigins`; without a `baseURL`, every frame is served.
630
+ - `PrivateKeyRpcClient` refuses chains that are not testnets or local dev
631
+ chains unless constructed with `allowMainnet: true`, and verifies the RPC
632
+ endpoint's `eth_chainId` matches the configured chain before the first
633
+ broadcast (`eth_sendTransaction` / `eth_sendRawTransaction`). It only signs
634
+ transactions from its own account and preserves typed transaction fields
635
+ such as `type`, `accessList`, blob fee/hash fields, and `authorizationList`.
203
636
  - Real-wallet tests use a persistent browser profile and keep extension-side automation inside this package.
204
637
  - Mutation tests are skipped unless their opt-in flag is set.
205
638
  - Published reports redact secrets and record transaction hashes only when useful for auditability.
@@ -207,7 +640,6 @@ test.use({
207
640
  ## More Documentation
208
641
 
209
642
  - [docs/API.md](docs/API.md)
643
+ - [docs/ROADMAP.md](docs/ROADMAP.md)
210
644
  - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
211
- - [docs/CONSUMING_FROM_FJORD.md](docs/CONSUMING_FROM_FJORD.md)
212
- - [docs/FJORD_LIVE_QA.md](docs/FJORD_LIVE_QA.md)
213
645
  - [docs/RELEASE_CHECKLIST.md](docs/RELEASE_CHECKLIST.md)