@deeeed/metamask-harness 0.2.0

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 (158) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/README.md +140 -0
  3. package/bin/mm-harness +99 -0
  4. package/docs/CHEATSHEET.md +61 -0
  5. package/docs/CLI-SPEC.md +915 -0
  6. package/docs/MENTAL-MODEL.md +295 -0
  7. package/docs/architecture.md +367 -0
  8. package/docs/extension-runtime-commands.md +60 -0
  9. package/docs/harness-cli.md +43 -0
  10. package/docs/live-adapter-contract.md +188 -0
  11. package/docs/package-boundaries.md +47 -0
  12. package/docs/perps-flow-catalog.md +235 -0
  13. package/docs/recipe-libraries.md +95 -0
  14. package/docs/runtime-file-conventions.md +36 -0
  15. package/library/actions/core/perps/_controller.mjs +727 -0
  16. package/library/actions/core/perps/assert_orders.mjs +53 -0
  17. package/library/actions/core/perps/assert_positions.mjs +52 -0
  18. package/library/actions/core/perps/close_orders.mjs +97 -0
  19. package/library/actions/core/perps/close_positions.mjs +118 -0
  20. package/library/actions/core/perps/ensure_orders.mjs +40 -0
  21. package/library/actions/core/perps/ensure_positions.mjs +37 -0
  22. package/library/actions/core/perps/place_order.mjs +201 -0
  23. package/library/actions/core/perps/read_account.mjs +30 -0
  24. package/library/actions/core/perps/read_orders.mjs +27 -0
  25. package/library/actions/core/perps/read_positions.mjs +27 -0
  26. package/library/actions/core/perps/start_state.mjs +92 -0
  27. package/library/actions/core/perps/teardown_state.mjs +86 -0
  28. package/library/actions/extension/perps/assert_orders.mjs +11 -0
  29. package/library/actions/extension/perps/assert_positions.mjs +11 -0
  30. package/library/actions/extension/perps/close_orders.mjs +8 -0
  31. package/library/actions/extension/perps/close_positions.mjs +8 -0
  32. package/library/actions/extension/perps/ensure_orders.mjs +4 -0
  33. package/library/actions/extension/perps/ensure_positions.mjs +4 -0
  34. package/library/actions/extension/perps/perps.mjs +730 -0
  35. package/library/actions/extension/perps/place_order.mjs +7 -0
  36. package/library/actions/extension/perps/read_orders.mjs +4 -0
  37. package/library/actions/extension/perps/read_positions.mjs +3 -0
  38. package/library/actions/extension/platform/cdp.mjs +541 -0
  39. package/library/actions/extension/ui/navigate.mjs +44 -0
  40. package/library/actions/extension/wallet/ensure_unlocked.mjs +36 -0
  41. package/library/actions/extension/wallet/read_state.mjs +27 -0
  42. package/library/actions/extension/wallet/select_account.mjs +48 -0
  43. package/library/actions/extension/wallet/setup.mjs +35 -0
  44. package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +185 -0
  45. package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +1662 -0
  46. package/library/actions/mobile/bridge-runtime/cdp-bridge.cjs +686 -0
  47. package/library/actions/mobile/bridge-runtime/lib/cdp-eval.cjs +110 -0
  48. package/library/actions/mobile/bridge-runtime/lib/config.cjs +39 -0
  49. package/library/actions/mobile/bridge-runtime/lib/issue-capture.cjs +446 -0
  50. package/library/actions/mobile/bridge-runtime/lib/target-discovery.cjs +204 -0
  51. package/library/actions/mobile/bridge-runtime/lib/ws-client.cjs +108 -0
  52. package/library/actions/mobile/bridge-runtime/setup-wallet.sh +442 -0
  53. package/library/actions/mobile/perps/assert_orders.mjs +11 -0
  54. package/library/actions/mobile/perps/assert_positions.mjs +11 -0
  55. package/library/actions/mobile/perps/close_orders.mjs +8 -0
  56. package/library/actions/mobile/perps/close_positions.mjs +8 -0
  57. package/library/actions/mobile/perps/ensure_orders.mjs +4 -0
  58. package/library/actions/mobile/perps/ensure_positions.mjs +4 -0
  59. package/library/actions/mobile/perps/perps.mjs +709 -0
  60. package/library/actions/mobile/perps/place_order.mjs +7 -0
  61. package/library/actions/mobile/perps/read_orders.mjs +4 -0
  62. package/library/actions/mobile/perps/read_positions.mjs +3 -0
  63. package/library/actions/mobile/platform/bridge.mjs +283 -0
  64. package/library/actions/mobile/ui/navigate.mjs +38 -0
  65. package/library/actions/mobile/wallet/ensure_unlocked.mjs +107 -0
  66. package/library/actions/mobile/wallet/home.mjs +35 -0
  67. package/library/actions/mobile/wallet/read_state.mjs +40 -0
  68. package/library/actions/mobile/wallet/select_account.mjs +48 -0
  69. package/library/actions/mobile/wallet/setup.mjs +220 -0
  70. package/library/flows/perps.flows.json +64 -0
  71. package/library/library.json +7 -0
  72. package/library/manifests/core.action-manifest.json +1282 -0
  73. package/library/manifests/extension.action-manifest.json +1749 -0
  74. package/library/manifests/mobile.action-manifest.json +1753 -0
  75. package/library/recipes/action-validation.extension.recipe.json +417 -0
  76. package/library/recipes/action-validation.mobile.recipe.json +422 -0
  77. package/library/recipes/order-lifecycle.core.recipe.json +78 -0
  78. package/library/recipes/perps-lifecycle.recipe.json +194 -0
  79. package/library/recipes/read-markets.core.recipe.json +38 -0
  80. package/library/recipes/smoke.extension.recipe.json +31 -0
  81. package/library/recipes/smoke.mobile.recipe.json +31 -0
  82. package/library/recipes/trading-lifecycle.core.recipe.json +76 -0
  83. package/orchestration/compat-overlays/README.md +19 -0
  84. package/orchestration/compat-overlays/mobile/README.md +13 -0
  85. package/orchestration/compat-overlays/mobile/rn81-message-event-source.patch +42 -0
  86. package/orchestration/core/cleanup.sh +37 -0
  87. package/orchestration/core/inject.sh +154 -0
  88. package/orchestration/doctor.mjs +72 -0
  89. package/orchestration/extension/cleanup.mjs +60 -0
  90. package/orchestration/extension/console-tail.mjs +228 -0
  91. package/orchestration/extension/ensure-browser.sh +416 -0
  92. package/orchestration/extension/ensure-ready.ts +185 -0
  93. package/orchestration/extension/extension-id.ts +107 -0
  94. package/orchestration/extension/inject.mjs +266 -0
  95. package/orchestration/extension/launch-browser.cjs +216 -0
  96. package/orchestration/extension/launch.sh +175 -0
  97. package/orchestration/extension/live.sh +320 -0
  98. package/orchestration/extension/pin-remote-flags.cjs +45 -0
  99. package/orchestration/extension/readiness.mjs +414 -0
  100. package/orchestration/extension/refresh-build.sh +190 -0
  101. package/orchestration/extension/runtime-decision.ts +445 -0
  102. package/orchestration/extension/runtime.ts +407 -0
  103. package/orchestration/extension/seed-fixture.sh +177 -0
  104. package/orchestration/extension/sidepanel-toggle.sh +291 -0
  105. package/orchestration/extension/snapshot-dist.sh +84 -0
  106. package/orchestration/extension/start-watch.sh +339 -0
  107. package/orchestration/extension/wallet-fixture-state.cjs +1086 -0
  108. package/orchestration/lib/activate-repo-node.sh +144 -0
  109. package/orchestration/lib/cli-color.mjs +84 -0
  110. package/orchestration/lib/cli-commands.mjs +243 -0
  111. package/orchestration/lib/cli-home.mjs +354 -0
  112. package/orchestration/lib/cli-ux.sh +252 -0
  113. package/orchestration/lib/cli-version.mjs +123 -0
  114. package/orchestration/lib/ensure-runner-deps.sh +56 -0
  115. package/orchestration/lib/harness-path.sh +55 -0
  116. package/orchestration/lib/hash-helpers.sh +44 -0
  117. package/orchestration/lib/json-field.sh +23 -0
  118. package/orchestration/lib/log-tui.mjs +304 -0
  119. package/orchestration/lib/open-debug.mjs +317 -0
  120. package/orchestration/lib/path-defaults.json +4 -0
  121. package/orchestration/lib/progress.mjs +107 -0
  122. package/orchestration/lib/recipe-paths.mjs +26 -0
  123. package/orchestration/lib/resolve-farmslot-ports.sh +144 -0
  124. package/orchestration/manifest.json +358 -0
  125. package/orchestration/mobile/cleanup.sh +192 -0
  126. package/orchestration/mobile/deps-markers.ts +21 -0
  127. package/orchestration/mobile/inject.sh +681 -0
  128. package/orchestration/mobile/launch.sh +137 -0
  129. package/orchestration/mobile/live.sh +125 -0
  130. package/orchestration/mobile/runtime-decision.ts +292 -0
  131. package/orchestration/porcelain/metamask-recipe +99 -0
  132. package/orchestration/porcelain/mm-recipe +1591 -0
  133. package/orchestration/porcelain/mme-recipe +1181 -0
  134. package/package.json +59 -0
  135. package/runner/extension/verify.sh +511 -0
  136. package/runner/mobile/verify.sh +501 -0
  137. package/runner/src/adapters.ts +601 -0
  138. package/runner/src/cli.ts +1820 -0
  139. package/runner/src/commands/debug.ts +44 -0
  140. package/runner/src/commands/fixtures.ts +99 -0
  141. package/runner/src/commands/launch.ts +397 -0
  142. package/runner/src/commands/logs.ts +60 -0
  143. package/runner/src/commands/shared.ts +138 -0
  144. package/runner/src/completions-cache.ts +86 -0
  145. package/runner/src/doctor.ts +203 -0
  146. package/runner/src/harness.ts +516 -0
  147. package/runner/src/heal-bounds.ts +179 -0
  148. package/runner/src/index.ts +6 -0
  149. package/runner/src/live-adapter-contract.ts +274 -0
  150. package/runner/src/manifest.ts +47 -0
  151. package/runner/src/mm-harness-cli.ts +488 -0
  152. package/runner/src/paths.ts +198 -0
  153. package/runner/src/recording-target.ts +147 -0
  154. package/runner/src/run-recording.ts +329 -0
  155. package/runner/src/runner.ts +108 -0
  156. package/runner/src/types.ts +57 -0
  157. package/scripts/completions.sh +125 -0
  158. package/scripts/install-completions.sh +62 -0
@@ -0,0 +1,1086 @@
1
+ #!/usr/bin/env node
2
+ // wallet-fixture-state.cjs — wallet fixture state generate/prefill/seed
3
+ // runner/extension/wallet-fixture-state.cjs)
4
+ //
5
+ // Purpose:
6
+ // App control (instance wallet state): derives deterministic wallet state from a
7
+ // fixture (generate), prefills a Chrome profile before launch
8
+ // (prefill-profile), and validates/seeds the live wallet over CDP
9
+ // (seed-cdp).
10
+ //
11
+ // Inputs: subcommand (generate|prefill-profile|seed-cdp) + flags per
12
+ // usage(); env RECIPE_RUNTIME_DIR (else lib path-defaults).
13
+ // Outputs: --out/--state JSON files; progress on stderr.
14
+ // Exit 0 — phase done; 1 — phase failed (FAIL message); 2 — unknown
15
+ // subcommand.
16
+ // Never touches: product source files; the fixture file (read-only).
17
+ 'use strict';
18
+
19
+ const crypto = require('node:crypto');
20
+ const fs = require('node:fs');
21
+ const http = require('node:http');
22
+ const path = require('node:path');
23
+
24
+ const EOA_METHODS = [
25
+ 'personal_sign',
26
+ 'eth_sign',
27
+ 'eth_signTransaction',
28
+ 'eth_signTypedData_v1',
29
+ 'eth_signTypedData_v3',
30
+ 'eth_signTypedData_v4',
31
+ ];
32
+
33
+ function usage() {
34
+ console.error(`Usage:
35
+ wallet-fixture-state.cjs generate --target <metamask-extension> --fixture <wallet-fixture.json> --out <fixture-state.json>
36
+ wallet-fixture-state.cjs prefill-profile --target <metamask-extension> --state <fixture-state.json> --profile <chrome-profile> --extension-dir <runtime-dist> [--extension-id-file <path>]
37
+ wallet-fixture-state.cjs seed-cdp --target <metamask-extension> --fixture <wallet-fixture.json> --state <fixture-state.json> --cdp-port <port> --extension-dir <runtime-dist> --extension-id-file <path> --out <report.json>`);
38
+ }
39
+
40
+
41
+ function pathDefault(key) {
42
+ for (const candidate of [
43
+ path.join(__dirname, 'lib/path-defaults.json'),
44
+ path.join(__dirname, '../lib/path-defaults.json'),
45
+ path.join(__dirname, '../../orchestration/lib/path-defaults.json'),
46
+ ]) {
47
+ if (!fs.existsSync(candidate)) continue;
48
+ const value = JSON.parse(fs.readFileSync(candidate, 'utf8'))[key];
49
+ if (value) return value;
50
+ }
51
+ throw new Error(`Missing path default: ${key}`);
52
+ }
53
+
54
+ function recipeRuntimeDir() {
55
+ return process.env.RECIPE_RUNTIME_DIR || pathDefault('recipeRuntimeDir');
56
+ }
57
+
58
+ function parseArgs(argv) {
59
+ const [command, ...rest] = argv;
60
+ const args = { command };
61
+ for (let index = 0; index < rest.length; index += 1) {
62
+ const arg = rest[index];
63
+ if (!arg.startsWith('--')) {
64
+ throw new Error(`Unknown positional argument: ${arg}`);
65
+ }
66
+ if (index + 1 >= rest.length) {
67
+ throw new Error(`Missing value for ${arg}`);
68
+ }
69
+ args[arg.slice(2)] = rest[index + 1];
70
+ index += 1;
71
+ }
72
+ return args;
73
+ }
74
+
75
+ function readJson(filePath) {
76
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
77
+ }
78
+
79
+ function writeJson(filePath, value) {
80
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
81
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
82
+ }
83
+
84
+ function requireFromTarget(target, moduleName) {
85
+ return require(require.resolve(moduleName, { paths: [target] }));
86
+ }
87
+
88
+ function normalizePrivateKey(value, label) {
89
+ const raw = String(value || '').replace(/^0x/u, '').toLowerCase();
90
+ if (!/^[0-9a-f]{64}$/u.test(raw)) {
91
+ throw new Error(`Invalid private key for ${label}`);
92
+ }
93
+ return raw;
94
+ }
95
+
96
+ function deterministicUuid(input) {
97
+ const bytes = crypto.createHash('sha256').update(input).digest();
98
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
99
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
100
+ const hex = bytes.subarray(0, 16).toString('hex');
101
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
102
+ }
103
+
104
+ function deterministicEntropyId(input) {
105
+ const alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
106
+ const bytes = crypto.createHash('sha256').update(input).digest();
107
+ let id = '01';
108
+ for (let index = 0; id.length < 26; index += 1) {
109
+ id += alphabet[bytes[index % bytes.length] % alphabet.length];
110
+ }
111
+ return id;
112
+ }
113
+
114
+ function getFixtureAccounts(wallet) {
115
+ const accounts = Array.isArray(wallet.accounts) ? wallet.accounts : [];
116
+ if (accounts.length === 0) {
117
+ throw new Error('wallet fixture must include accounts[]');
118
+ }
119
+ const supported = accounts.filter(
120
+ (account) => account && (account.type === 'mnemonic' || account.type === 'privateKey'),
121
+ );
122
+ if (supported.length !== accounts.length) {
123
+ throw new Error('wallet fixture accounts must use type mnemonic or privateKey');
124
+ }
125
+ if (!supported.some((account) => account.type === 'mnemonic')) {
126
+ throw new Error('wallet fixture must include at least one mnemonic account for Extension vault setup');
127
+ }
128
+ return supported;
129
+ }
130
+
131
+ function readMnemonicCount(account, label) {
132
+ const raw = account.count ?? account.numberOfAccounts ?? 1;
133
+ const count = Number(raw);
134
+ if (!Number.isInteger(count) || count < 1 || count > 100) {
135
+ throw new Error(`Invalid mnemonic account count for ${label}: ${raw}`);
136
+ }
137
+ return count;
138
+ }
139
+
140
+ function readAccountNames(account, fallbackName, count) {
141
+ const names = Array.isArray(account.names) ? account.names : [];
142
+ return Array.from({ length: count }, (_unused, index) => {
143
+ const explicitName = names[index];
144
+ if (typeof explicitName === 'string' && explicitName.trim()) {
145
+ return explicitName.trim();
146
+ }
147
+ if (index === 0 && typeof account.name === 'string' && account.name.trim()) {
148
+ return account.name.trim();
149
+ }
150
+ if (index === 0) {
151
+ return fallbackName;
152
+ }
153
+ return `Account ${index + 1}`;
154
+ });
155
+ }
156
+
157
+ async function buildKeyringEntries(target, wallet) {
158
+ const { HdKeyring } = requireFromTarget(target, '@metamask/eth-hd-keyring');
159
+ const SimpleKeyring = requireFromTarget(target, '@metamask/eth-simple-keyring').default;
160
+ const { privateToAddress, bytesToHex } = requireFromTarget(target, '@ethereumjs/util');
161
+ const accounts = getFixtureAccounts(wallet);
162
+ const entries = [];
163
+
164
+ for (const [index, account] of accounts.entries()) {
165
+ const name =
166
+ typeof account.name === 'string' && account.name.trim()
167
+ ? account.name.trim()
168
+ : account.type === 'mnemonic'
169
+ ? index === 0
170
+ ? 'Primary'
171
+ : `SRP ${index + 1}`
172
+ : `Imported ${index + 1}`;
173
+
174
+ if (account.type === 'mnemonic') {
175
+ const mnemonic = String(account.value || '').trim();
176
+ if (!mnemonic) {
177
+ throw new Error(`Missing mnemonic value for ${name}`);
178
+ }
179
+ const count = readMnemonicCount(account, name);
180
+ const names = readAccountNames(account, name, count);
181
+ const keyring = new HdKeyring();
182
+ await keyring.deserialize({ mnemonic, numberOfAccounts: count });
183
+ const addresses = await keyring.getAccounts();
184
+ const keyringId = deterministicEntropyId(`mnemonic:${mnemonic}:${index}`);
185
+ const serializedKeyring = {
186
+ type: 'HD Key Tree',
187
+ data: await keyring.serialize(),
188
+ metadata: { id: keyringId, name: '' },
189
+ };
190
+ addresses.forEach((address, accountIndex) => {
191
+ entries.push({
192
+ fixtureType: 'mnemonic',
193
+ groupIndex: accountIndex,
194
+ keyringId,
195
+ keyring: accountIndex === 0 ? serializedKeyring : null,
196
+ keyringType: serializedKeyring.type,
197
+ address,
198
+ name: names[accountIndex],
199
+ });
200
+ });
201
+ continue;
202
+ }
203
+
204
+ const rawPrivateKey = normalizePrivateKey(account.value, name);
205
+ const keyring = new SimpleKeyring();
206
+ await keyring.deserialize([rawPrivateKey]);
207
+ const [address] = await keyring.getAccounts();
208
+ const derivedAddress = bytesToHex(privateToAddress(Buffer.from(rawPrivateKey, 'hex')));
209
+ entries.push({
210
+ fixtureType: 'privateKey',
211
+ keyring: {
212
+ type: 'Simple Key Pair',
213
+ data: await keyring.serialize(),
214
+ metadata: { id: deterministicEntropyId(`privateKey:${derivedAddress}:${index}`), name: '' },
215
+ },
216
+ address: address || derivedAddress,
217
+ name,
218
+ });
219
+ }
220
+
221
+ return entries;
222
+ }
223
+
224
+ function patchAccountTracker(data, addresses) {
225
+ if (!data.AccountTracker?.accountsByChainId) {
226
+ return;
227
+ }
228
+ for (const chain of Object.values(data.AccountTracker.accountsByChainId)) {
229
+ if (!chain || typeof chain !== 'object') {
230
+ continue;
231
+ }
232
+ for (const oldAddress of Object.keys(chain)) {
233
+ delete chain[oldAddress];
234
+ }
235
+ for (const address of addresses) {
236
+ chain[address] = { balance: '0x0' };
237
+ }
238
+ }
239
+ }
240
+
241
+ function patchNetworkState(data) {
242
+ const mainnetChainId = '0x1';
243
+ const mainnetClientId = 'mainnet';
244
+
245
+ if (data.NetworkController) {
246
+ data.NetworkController.selectedNetworkClientId = mainnetClientId;
247
+
248
+ const configs = data.NetworkController.networkConfigurationsByChainId || {};
249
+ for (const chainId of Object.keys(configs)) {
250
+ if (chainId !== mainnetChainId) {
251
+ delete configs[chainId];
252
+ }
253
+ }
254
+
255
+ const metadata = data.NetworkController.networksMetadata || {};
256
+ for (const clientId of Object.keys(metadata)) {
257
+ if (clientId !== mainnetClientId) {
258
+ delete metadata[clientId];
259
+ }
260
+ }
261
+ }
262
+
263
+ if (data.NetworkEnablementController?.enabledNetworkMap?.eip155) {
264
+ data.NetworkEnablementController.enabledNetworkMap.eip155 = { [mainnetChainId]: true };
265
+ }
266
+
267
+ if (Array.isArray(data.NetworkOrderController?.orderedNetworkList)) {
268
+ data.NetworkOrderController.orderedNetworkList =
269
+ data.NetworkOrderController.orderedNetworkList.filter((entry) => entry?.networkId === 'eip155:1');
270
+ }
271
+ }
272
+
273
+ function patchSyncState(data) {
274
+ data.UserStorageController = {
275
+ ...(data.UserStorageController || {}),
276
+ isAccountSyncingEnabled: false,
277
+ isBackupAndSyncEnabled: false,
278
+ isContactSyncingEnabled: false,
279
+ };
280
+
281
+ if (data.ProfileMetricsController) {
282
+ data.ProfileMetricsController.syncQueue = {};
283
+ data.ProfileMetricsController.initialEnqueueCompleted = false;
284
+ }
285
+ }
286
+
287
+ function resolveSelectedAccount(wallet, accountRows) {
288
+ const wanted = wallet.selectedAccount || wallet.selectedAddress || wallet.address;
289
+ if (typeof wanted !== 'string' || !wanted.trim()) {
290
+ return accountRows[0];
291
+ }
292
+ const normalized = wanted.trim().toLowerCase();
293
+ return (
294
+ accountRows.find(
295
+ (account) =>
296
+ (account.metadata?.name || '').toLowerCase() === normalized ||
297
+ account.address.toLowerCase() === normalized,
298
+ ) || accountRows[0]
299
+ );
300
+ }
301
+
302
+ function accountGroupId(account) {
303
+ if (account.fixtureType === 'mnemonic') {
304
+ return `entropy:${account.keyringId}/${account.groupIndex}`;
305
+ }
306
+ return `keyring:${account.metadata.keyring.type}/${account.address}`;
307
+ }
308
+
309
+ function patchAccountTree(data, accountRows, selected) {
310
+ const wallets = {};
311
+ const accountGroupsMetadata = {};
312
+ const accountWalletsMetadata = {};
313
+
314
+ for (const account of accountRows) {
315
+ const groupId = accountGroupId(account);
316
+ accountGroupsMetadata[groupId] = {
317
+ name: {
318
+ value: account.metadata.name,
319
+ lastUpdatedAt: account.metadata.importTime || 0,
320
+ },
321
+ lastSelected: account.metadata.lastSelected || 0,
322
+ };
323
+
324
+ if (account.fixtureType === 'mnemonic') {
325
+ const walletId = `entropy:${account.keyringId}`;
326
+ wallets[walletId] ??= {
327
+ id: walletId,
328
+ type: 'entropy',
329
+ status: 'ready',
330
+ groups: {},
331
+ metadata: {
332
+ name: 'Wallet 1',
333
+ entropy: { id: account.keyringId },
334
+ },
335
+ };
336
+ wallets[walletId].groups[groupId] = {
337
+ id: groupId,
338
+ type: 'multichain-account',
339
+ accounts: [account.id],
340
+ metadata: {
341
+ name: account.metadata.name,
342
+ pinned: false,
343
+ hidden: false,
344
+ lastSelected: account.metadata.lastSelected || 0,
345
+ entropy: { groupIndex: account.groupIndex },
346
+ },
347
+ };
348
+ continue;
349
+ }
350
+
351
+ const walletId = `keyring:${account.metadata.keyring.type}`;
352
+ wallets[walletId] ??= {
353
+ id: walletId,
354
+ type: 'keyring',
355
+ status: 'ready',
356
+ groups: {},
357
+ metadata: {
358
+ name: 'Imported accounts',
359
+ keyring: { type: account.metadata.keyring.type },
360
+ },
361
+ };
362
+ wallets[walletId].groups[groupId] = {
363
+ id: groupId,
364
+ type: 'single-account',
365
+ accounts: [account.id],
366
+ metadata: {
367
+ name: account.metadata.name,
368
+ pinned: false,
369
+ hidden: false,
370
+ lastSelected: account.metadata.lastSelected || 0,
371
+ },
372
+ };
373
+ }
374
+
375
+ data.AccountTreeController = {
376
+ accountGroupsMetadata,
377
+ accountTree: { wallets },
378
+ accountWalletsMetadata,
379
+ hasAccountTreeSyncingSyncedAtLeastOnce: true,
380
+ selectedAccountGroup: accountGroupId(selected),
381
+ };
382
+ }
383
+
384
+ async function generate(args) {
385
+ // Validate raw arg VALUES before path.resolve — path.resolve('') returns the
386
+ // cwd, so a missing --fixture/--out would otherwise silently resolve to cwd.
387
+ if (!args.fixture || !args.out) {
388
+ throw new Error('generate requires --fixture <wallet-fixture.json> and --out <fixture-state.json>');
389
+ }
390
+ const target = path.resolve(args.target || process.cwd());
391
+ const fixturePath = path.resolve(args.fixture);
392
+ const outputPath = path.resolve(args.out);
393
+ const wallet = readJson(fixturePath);
394
+ if (typeof wallet.password !== 'string' || wallet.password.length === 0) {
395
+ throw new Error('wallet fixture must include password');
396
+ }
397
+
398
+ const defaultFixturePath = path.join(target, 'test/e2e/fixtures/default-fixture.json');
399
+ if (!fs.existsSync(defaultFixturePath)) {
400
+ throw new Error(`default-fixture.json not found at ${defaultFixturePath}`);
401
+ }
402
+ const fixture = readJson(defaultFixturePath);
403
+ const data = fixture.data || fixture;
404
+ const browserPassworder = requireFromTarget(target, '@metamask/browser-passworder');
405
+ let keyringEntries = await buildKeyringEntries(target, wallet);
406
+ const hasExplicitMnemonic = getFixtureAccounts(wallet).some(
407
+ (account) => account.type === 'mnemonic' && typeof account.value === 'string' && account.value.trim(),
408
+ );
409
+ // NOTE: currently unreachable. getFixtureAccounts()/buildKeyringEntries() above
410
+ // require an explicit mnemonic and throw earlier when none is present, so
411
+ // `!hasExplicitMnemonic` is never true at this point. Retained as the intended
412
+ // future path for reusing an existing encrypted vault when a fixture supplies a
413
+ // `vault` blob instead of a mnemonic; relax the upstream mnemonic requirement
414
+ // before relying on it.
415
+ if (!hasExplicitMnemonic && typeof wallet.vault === 'string' && wallet.vault.length > 0) {
416
+ const existingKeyrings = await browserPassworder.decrypt(wallet.password, wallet.vault);
417
+ const existingHd = existingKeyrings.find((keyring) => keyring?.type === 'HD Key Tree');
418
+ if (existingHd) {
419
+ let replacedPrimary = false;
420
+ keyringEntries = keyringEntries.map((entry) => {
421
+ if (entry.fixtureType !== 'mnemonic' || replacedPrimary) {
422
+ return entry;
423
+ }
424
+ replacedPrimary = true;
425
+ return {
426
+ ...entry,
427
+ keyring: existingHd,
428
+ address:
429
+ typeof wallet.address === 'string' && wallet.address
430
+ ? wallet.address
431
+ : entry.address,
432
+ };
433
+ });
434
+ }
435
+ }
436
+ data.KeyringController = {
437
+ vault: await browserPassworder.encrypt(
438
+ wallet.password,
439
+ keyringEntries.filter((entry) => entry.keyring).map((entry) => entry.keyring),
440
+ ),
441
+ };
442
+
443
+ if (!data.AccountsController) {
444
+ data.AccountsController = { internalAccounts: { accounts: {}, selectedAccount: null } };
445
+ }
446
+ if (!data.AccountsController.internalAccounts) {
447
+ data.AccountsController.internalAccounts = { accounts: {}, selectedAccount: null };
448
+ }
449
+ const internalAccounts = data.AccountsController.internalAccounts;
450
+ internalAccounts.accounts = {};
451
+ const now = Date.now();
452
+ const accountRows = keyringEntries.map((entry, index) => {
453
+ const id = deterministicUuid(`${entry.fixtureType}:${entry.address}:${index}`);
454
+ const row = {
455
+ id,
456
+ address: entry.address.toLowerCase(),
457
+ fixtureType: entry.fixtureType,
458
+ groupIndex: entry.groupIndex ?? 0,
459
+ keyringId: entry.keyringId || '',
460
+ metadata: {
461
+ name: entry.name,
462
+ importTime: now + index,
463
+ keyring: { type: entry.keyringType || entry.keyring.type },
464
+ lastSelected: 0,
465
+ },
466
+ options:
467
+ entry.fixtureType === 'mnemonic'
468
+ ? {
469
+ entropySource: entry.keyringId,
470
+ derivationPath: `m/44'/60'/0'/0/${entry.groupIndex ?? 0}`,
471
+ groupIndex: entry.groupIndex ?? 0,
472
+ entropy: {
473
+ type: 'mnemonic',
474
+ id: entry.keyringId,
475
+ derivationPath: `m/44'/60'/0'/0/${entry.groupIndex ?? 0}`,
476
+ groupIndex: entry.groupIndex ?? 0,
477
+ },
478
+ }
479
+ : {},
480
+ methods: EOA_METHODS,
481
+ scopes: ['eip155:0'],
482
+ type: 'eip155:eoa',
483
+ };
484
+ internalAccounts.accounts[id] = row;
485
+ return row;
486
+ });
487
+ const selected = resolveSelectedAccount(wallet, accountRows);
488
+ selected.metadata.lastSelected = now + accountRows.length;
489
+ internalAccounts.selectedAccount = selected.id;
490
+ patchAccountTree(data, accountRows, selected);
491
+
492
+ data.OnboardingController = {
493
+ completedOnboarding: true,
494
+ firstTimeFlowType: 'import',
495
+ seedPhraseBackedUp: true,
496
+ };
497
+ data.PreferencesController ??= {};
498
+ data.PreferencesController.useExternalServices = true;
499
+ data.PreferencesController.preferences ??= {};
500
+ data.PreferencesController.preferences.useSidePanelAsDefault = true;
501
+ if (wallet.settings?.autoLockNever) {
502
+ data.PreferencesController.autoLockTimeLimit = 0;
503
+ }
504
+ data.PerpsController ??= {};
505
+ data.PerpsController.isFirstTimeUser = { mainnet: false, testnet: false };
506
+ data.PerpsController.hasPlacedFirstOrder = { mainnet: true, testnet: true };
507
+ patchAccountTracker(
508
+ data,
509
+ accountRows.map((account) => account.address),
510
+ );
511
+ patchNetworkState(data);
512
+ patchSyncState(data);
513
+
514
+ writeJson(outputPath, fixture);
515
+ const summary = {
516
+ status: 'READY',
517
+ accountCount: accountRows.length,
518
+ selectedAccount: { name: selected.metadata.name, address: selected.address },
519
+ accounts: accountRows.map((account) => ({
520
+ name: account.metadata.name,
521
+ address: account.address,
522
+ type: account.fixtureType,
523
+ keyringType: account.metadata.keyring.type,
524
+ })),
525
+ };
526
+ writeJson(`${outputPath}.summary.json`, summary);
527
+ console.error(
528
+ `[fixture] Generated Extension fixture state: accounts=${summary.accountCount} selected=${summary.selectedAccount.name}`,
529
+ );
530
+ }
531
+
532
+ function httpJson(port, pathname) {
533
+ return new Promise((resolve) => {
534
+ const req = http.get(`http://127.0.0.1:${port}${pathname}`, { timeout: 1000 }, (res) => {
535
+ let body = '';
536
+ res.on('data', (chunk) => {
537
+ body += chunk;
538
+ });
539
+ res.on('end', () => {
540
+ try {
541
+ resolve(JSON.parse(body));
542
+ } catch (_error) {
543
+ // CDP can briefly return a non-JSON error page while Chrome is still
544
+ // starting. Treat that as "not ready yet" so waitForCdp can retry.
545
+ resolve(null);
546
+ }
547
+ });
548
+ });
549
+ req.on('timeout', () => {
550
+ req.destroy();
551
+ resolve(null);
552
+ });
553
+ req.on('error', () => resolve(null));
554
+ });
555
+ }
556
+
557
+ async function waitForCdp(port) {
558
+ const deadline = Date.now() + 30000;
559
+ while (Date.now() < deadline) {
560
+ const version = await httpJson(port, '/json/version');
561
+ if (version) {
562
+ return version;
563
+ }
564
+ await new Promise((resolve) => setTimeout(resolve, 500));
565
+ }
566
+ throw new Error(`CDP not reachable on port ${port}`);
567
+ }
568
+
569
+ function extensionIdFromUrl(url) {
570
+ if (!String(url || '').startsWith('chrome-extension://')) {
571
+ return '';
572
+ }
573
+ return String(url).split('/')[2] || '';
574
+ }
575
+
576
+ function extensionIdFromManifestKey(key) {
577
+ if (!key) {
578
+ return '';
579
+ }
580
+ const digest = crypto.createHash('sha256').update(Buffer.from(key, 'base64')).digest();
581
+ return [...digest.subarray(0, 16)]
582
+ .map((byte) => `${'abcdefghijklmnop'[byte >> 4]}${'abcdefghijklmnop'[byte & 0x0f]}`)
583
+ .join('');
584
+ }
585
+
586
+ function versionedStorageState(fixtureState) {
587
+ return fixtureState.data
588
+ ? { data: fixtureState.data, meta: { ...(fixtureState.meta || {}), storageKind: 'data' } }
589
+ : fixtureState;
590
+ }
591
+
592
+ async function prefillProfile(args) {
593
+ // Validate raw arg VALUES before path.resolve (path.resolve('') === cwd).
594
+ if (!args.state || !args.profile || !args['extension-dir']) {
595
+ throw new Error('prefill-profile requires --state, --profile, and --extension-dir');
596
+ }
597
+ const target = path.resolve(args.target || process.cwd());
598
+ const statePath = path.resolve(args.state);
599
+ const profilePath = path.resolve(args.profile);
600
+ const extensionDir = path.resolve(args['extension-dir']);
601
+ const extensionIdFile = args['extension-id-file'] ? path.resolve(args['extension-id-file']) : '';
602
+ const manifest = readJson(path.join(extensionDir, 'manifest.json'));
603
+ const candidateIds = new Set();
604
+ const manifestId = extensionIdFromManifestKey(manifest.key);
605
+ if (manifestId) {
606
+ candidateIds.add(manifestId);
607
+ }
608
+ if (extensionIdFile && fs.existsSync(extensionIdFile)) {
609
+ const marker = fs.readFileSync(extensionIdFile, 'utf8').trim();
610
+ if (/^[a-p]{32}$/u.test(marker)) {
611
+ candidateIds.add(marker);
612
+ }
613
+ }
614
+ if (candidateIds.size === 0) {
615
+ console.error('[fixture] No deterministic extension id available for profile prefill; CDP seeding will run after launch.');
616
+ return;
617
+ }
618
+ const { ClassicLevel } = requireFromTarget(target, 'classic-level');
619
+ const stateEntries = Object.entries(versionedStorageState(readJson(statePath)));
620
+ const settingsRoot = path.join(profilePath, 'Default', 'Local Extension Settings');
621
+ for (const extensionId of candidateIds) {
622
+ const dbPath = path.join(settingsRoot, extensionId);
623
+ fs.mkdirSync(dbPath, { recursive: true });
624
+ const db = new ClassicLevel(dbPath, { valueEncoding: 'json' });
625
+ await db.open();
626
+ try {
627
+ for (const [key, value] of stateEntries) {
628
+ await db.put(key, value);
629
+ }
630
+ } finally {
631
+ await db.close();
632
+ }
633
+ console.error(`[fixture] Prefilled Extension profile storage for ${extensionId} (${stateEntries.length} keys)`);
634
+ }
635
+ }
636
+
637
+ async function detectExtension(context, extensionDir, extensionIdFile) {
638
+ const manifest = readJson(path.join(extensionDir, 'manifest.json'));
639
+ const expectedServiceWorker = manifest.background?.service_worker || '';
640
+ const manifestId = extensionIdFromManifestKey(manifest.key);
641
+ const rejected = new Set();
642
+
643
+ for (let attempt = 0; attempt < 30; attempt += 1) {
644
+ const candidates = [];
645
+ const push = (id, reason) => {
646
+ if (!id || rejected.has(id) || candidates.some((candidate) => candidate.id === id)) {
647
+ return;
648
+ }
649
+ candidates.push({ id, reason });
650
+ };
651
+ if (extensionIdFile && fs.existsSync(extensionIdFile)) {
652
+ push(fs.readFileSync(extensionIdFile, 'utf8').trim(), 'extension id marker');
653
+ }
654
+ push(manifestId, 'manifest key');
655
+ for (const worker of context.serviceWorkers()) {
656
+ const id = extensionIdFromUrl(worker.url());
657
+ if (expectedServiceWorker && worker.url().endsWith(`/${expectedServiceWorker}`)) {
658
+ push(id, `manifest service worker ${expectedServiceWorker}`);
659
+ } else {
660
+ push(id, 'extension service worker');
661
+ }
662
+ }
663
+ for (const page of context.pages()) {
664
+ push(extensionIdFromUrl(page.url()), `extension page ${page.url()}`);
665
+ }
666
+
667
+ for (const candidate of candidates) {
668
+ const page = await context.newPage();
669
+ try {
670
+ await page.goto(`chrome-extension://${candidate.id}/home.html`, {
671
+ waitUntil: 'load',
672
+ timeout: 10000,
673
+ });
674
+ if (page.url().startsWith('chrome-error://')) {
675
+ throw new Error('candidate resolved to chrome-error page');
676
+ }
677
+ return { extensionId: candidate.id, page };
678
+ } catch (error) {
679
+ rejected.add(candidate.id);
680
+ await page.close().catch((closeError) => {
681
+ console.error(`[fixture] WARN: failed to close rejected extension page: ${closeError.message}`);
682
+ });
683
+ console.error(`[fixture] Rejected extension id ${candidate.id} (${candidate.reason}): ${error.message}`);
684
+ }
685
+ }
686
+ await new Promise((resolve) => setTimeout(resolve, 1000));
687
+ }
688
+ throw new Error('Could not detect MetaMask extension ID from CDP targets');
689
+ }
690
+
691
+ async function locatorVisible(locator) {
692
+ try {
693
+ return await locator.first().isVisible();
694
+ } catch (_error) {
695
+ // During startup the Extension page can navigate between onboarding, lock,
696
+ // and home. A stale/missing locator means "not visible in this poll", not
697
+ // a fixture failure; the caller keeps polling until the deadline.
698
+ return false;
699
+ }
700
+ }
701
+
702
+ async function waitForWalletScreen(page) {
703
+ const readySelectors = [
704
+ '[data-testid="account-menu-icon"]',
705
+ '[data-testid="account-options-menu-button"]',
706
+ '[data-testid="account-overview__asset-tab"]',
707
+ '.wallet-overview',
708
+ '.home__container',
709
+ ];
710
+ const unlockSelector = '[data-testid="unlock-password"]';
711
+ const deadline = Date.now() + 45000;
712
+ while (Date.now() < deadline) {
713
+ for (const selector of readySelectors) {
714
+ if (await locatorVisible(page.locator(selector))) {
715
+ return { state: 'unlocked', selector };
716
+ }
717
+ }
718
+ if (await locatorVisible(page.locator(unlockSelector))) {
719
+ return { state: 'locked', selector: unlockSelector };
720
+ }
721
+ if (page.url().includes('/onboarding')) {
722
+ return { state: 'onboarding', selector: null };
723
+ }
724
+ await page.waitForTimeout(500);
725
+ }
726
+ return { state: 'unknown', selector: null };
727
+ }
728
+
729
+ async function unlockIfNeeded(page, password) {
730
+ let state = await waitForWalletScreen(page);
731
+ if (state.state === 'locked') {
732
+ await page.fill('[data-testid="unlock-password"]', password);
733
+ try {
734
+ await page.locator('[data-testid="unlock-submit"]').first().click({ timeout: 15000 });
735
+ } catch (error) {
736
+ const message = error && error.message ? error.message : String(error);
737
+ if (!message.includes('Timeout')) throw error;
738
+ const clicked = await page.evaluate(() => {
739
+ const button = document.querySelector('[data-testid="unlock-submit"]');
740
+ if (!button) return false;
741
+ button.click();
742
+ return true;
743
+ });
744
+ if (!clicked) throw new Error(`Unlock submit timed out and DOM fallback could not find the button: ${message}`);
745
+ }
746
+ const deadline = Date.now() + 45000;
747
+ while (Date.now() < deadline) {
748
+ state = await waitForWalletScreen(page);
749
+ if (state.state === 'unlocked') {
750
+ break;
751
+ }
752
+ await page.waitForTimeout(750);
753
+ }
754
+ }
755
+ if (state.state !== 'unlocked') {
756
+ throw new Error(`Wallet did not reach unlocked home screen after fixture seeding (state=${state.state})`);
757
+ }
758
+ return state;
759
+ }
760
+
761
+ async function readLiveAccounts(page) {
762
+ const raw = await page.evaluate(() => {
763
+ const metamask = (window.stateHooks?.store?.getState?.() || {}).metamask || {};
764
+ const accts = metamask.internalAccounts || {};
765
+ const byId = accts.accounts || {};
766
+ const selectedId = accts.selectedAccount || null;
767
+ const groupNamesByAccountId = {};
768
+ const wallets = metamask.accountTree?.wallets || {};
769
+ for (const wallet of Object.values(wallets)) {
770
+ for (const group of Object.values(wallet?.groups || {})) {
771
+ for (const accountId of group?.accounts || []) {
772
+ groupNamesByAccountId[accountId] = group?.metadata?.name || '';
773
+ }
774
+ }
775
+ }
776
+ const list = Object.keys(byId).map((id) => {
777
+ const account = byId[id] || {};
778
+ const meta = account.metadata || {};
779
+ return {
780
+ id,
781
+ name: meta.name || '',
782
+ groupName: groupNamesByAccountId[id] || '',
783
+ address: account.address || '',
784
+ keyringType: (meta.keyring || {}).type || '',
785
+ type: account.type || '',
786
+ };
787
+ });
788
+ const selected = selectedId && byId[selectedId] ? byId[selectedId] : null;
789
+ return JSON.stringify({
790
+ selectedAccountId: selectedId,
791
+ selectedAddress: selected?.address || null,
792
+ selectedName: selected?.metadata?.name || null,
793
+ accounts: list,
794
+ });
795
+ });
796
+ return JSON.parse(raw);
797
+ }
798
+
799
+ function expectedFixtureFromState(state) {
800
+ const internalAccounts = state.data?.AccountsController?.internalAccounts || {};
801
+ const accounts = internalAccounts.accounts || {};
802
+ const rows = Object.values(accounts).map((account) => ({
803
+ id: account.id || '',
804
+ name: account.metadata?.name || '',
805
+ address: String(account.address || '').toLowerCase(),
806
+ keyringType: account.metadata?.keyring?.type || '',
807
+ }));
808
+ const selected = accounts[internalAccounts.selectedAccount] || rows[0] || null;
809
+ return {
810
+ accounts: rows,
811
+ selected: selected
812
+ ? {
813
+ id: selected.id || '',
814
+ name: selected.metadata?.name || selected.name || '',
815
+ address: String(selected.address || '').toLowerCase(),
816
+ }
817
+ : null,
818
+ };
819
+ }
820
+
821
+ function getEvmAccounts(live) {
822
+ return live.accounts.filter((account) => String(account.address || '').startsWith('0x'));
823
+ }
824
+
825
+ function compareImportParity(live, expectedFixture) {
826
+ const evmAccounts = getEvmAccounts(live);
827
+ const missing = expectedFixture.accounts.filter(
828
+ (expectedAccount) =>
829
+ !evmAccounts.some(
830
+ (actual) =>
831
+ actual.address.toLowerCase() === expectedAccount.address &&
832
+ actual.keyringType === expectedAccount.keyringType,
833
+ ),
834
+ );
835
+ const unexpectedEvm = evmAccounts.filter(
836
+ (actual) =>
837
+ !expectedFixture.accounts.some(
838
+ (expectedAccount) => expectedAccount.address === actual.address.toLowerCase(),
839
+ ),
840
+ );
841
+ const selectedMatches = expectedFixture.selected
842
+ ? String(live.selectedAddress || '').toLowerCase() === expectedFixture.selected.address
843
+ : true;
844
+ return {
845
+ status:
846
+ missing.length === 0 &&
847
+ unexpectedEvm.length === 0 &&
848
+ evmAccounts.length === expectedFixture.accounts.length
849
+ ? 'PASS'
850
+ : 'FAIL',
851
+ expectedEvmAccountCount: expectedFixture.accounts.length,
852
+ liveEvmAccountCount: evmAccounts.length,
853
+ missing,
854
+ unexpectedEvm,
855
+ selectedExpected: expectedFixture.selected,
856
+ selectedActual: {
857
+ id: live.selectedAccountId,
858
+ name: live.selectedName,
859
+ address: live.selectedAddress,
860
+ },
861
+ selectedMatches,
862
+ };
863
+ }
864
+
865
+ function compareAccountNames(live, expectedFixture) {
866
+ const evmAccounts = getEvmAccounts(live);
867
+ const mismatched = expectedFixture.accounts.filter(
868
+ (expectedAccount) =>
869
+ !evmAccounts.some(
870
+ (actual) =>
871
+ actual.address.toLowerCase() === expectedAccount.address &&
872
+ (actual.groupName || actual.name) === expectedAccount.name,
873
+ ),
874
+ );
875
+ return {
876
+ status: mismatched.length === 0 ? 'PASS' : 'FAIL',
877
+ mismatched,
878
+ };
879
+ }
880
+
881
+ async function applyAccountNames(page, expectedAccounts) {
882
+ for (const account of expectedAccounts) {
883
+ if (!account.name || !account.address.startsWith('0x')) {
884
+ continue;
885
+ }
886
+ await page.evaluate(
887
+ async ({ address, name }) => {
888
+ const metamask = window.stateHooks?.store?.getState?.()?.metamask || {};
889
+ const normalizedAddress = String(address || '').toLowerCase();
890
+ const accountsById = metamask.internalAccounts?.accounts || {};
891
+ const accountId = Object.keys(accountsById).find(
892
+ (id) => String(accountsById[id]?.address || '').toLowerCase() === normalizedAddress,
893
+ );
894
+ let groupId = '';
895
+ const wallets = metamask.accountTree?.wallets || {};
896
+ for (const wallet of Object.values(wallets)) {
897
+ for (const group of Object.values(wallet?.groups || {})) {
898
+ if (Array.isArray(group?.accounts) && group.accounts.includes(accountId)) {
899
+ groupId = group.id || '';
900
+ }
901
+ }
902
+ }
903
+ await window.stateHooks.submitRequestToBackground('setAccountLabel', [address, name]);
904
+ if (!groupId) {
905
+ throw new Error(`No account group found for fixture account ${address}`);
906
+ }
907
+ await window.stateHooks.submitRequestToBackground('setAccountGroupName', [groupId, name]);
908
+ },
909
+ { address: account.address, name: account.name },
910
+ );
911
+ }
912
+ }
913
+
914
+ async function applySelectedAccount(page, expectedSelected) {
915
+ if (!expectedSelected?.address) {
916
+ return;
917
+ }
918
+ await page.evaluate(
919
+ async ({ address }) => {
920
+ const accounts = window.stateHooks?.store?.getState?.()?.metamask?.internalAccounts?.accounts || {};
921
+ const accountId = Object.keys(accounts).find(
922
+ (id) => String(accounts[id]?.address || '').toLowerCase() === String(address || '').toLowerCase(),
923
+ );
924
+ if (!accountId) {
925
+ throw new Error(`Could not find live account to select for ${address}`);
926
+ }
927
+ await window.stateHooks.submitRequestToBackground('setSelectedInternalAccount', [accountId]);
928
+ },
929
+ { address: expectedSelected.address },
930
+ );
931
+ }
932
+
933
+ async function waitForFixtureSetup(page, expectedFixture) {
934
+ const deadline = Date.now() + 15000;
935
+ let live = await readLiveAccounts(page);
936
+ while (Date.now() < deadline) {
937
+ const names = compareAccountNames(live, expectedFixture);
938
+ const importParity = compareImportParity(live, expectedFixture);
939
+ if (names.status === 'PASS' && importParity.selectedMatches) {
940
+ return { live, names, importParity };
941
+ }
942
+ await page.waitForTimeout(500);
943
+ live = await readLiveAccounts(page);
944
+ }
945
+ return {
946
+ live,
947
+ names: compareAccountNames(live, expectedFixture),
948
+ importParity: compareImportParity(live, expectedFixture),
949
+ };
950
+ }
951
+
952
+ async function disconnectCdpBrowser(browser) {
953
+ try {
954
+ if (typeof browser.disconnect === 'function') {
955
+ await browser.disconnect();
956
+ } else {
957
+ await browser.close();
958
+ }
959
+ } catch (error) {
960
+ console.error(`[fixture] WARN: failed to disconnect CDP session cleanly: ${error.message}`);
961
+ }
962
+ }
963
+
964
+ async function seedCdp(args) {
965
+ // Validate raw arg VALUES before path.resolve (path.resolve('') === cwd).
966
+ const port = Number(args['cdp-port']);
967
+ if (!args.fixture || !args.state || !args['extension-dir'] || !port) {
968
+ throw new Error('seed-cdp requires --fixture, --state, --extension-dir, and --cdp-port');
969
+ }
970
+ const target = path.resolve(args.target || process.cwd());
971
+ const fixturePath = path.resolve(args.fixture);
972
+ const statePath = path.resolve(args.state);
973
+ const extensionDir = path.resolve(args['extension-dir']);
974
+ const extensionIdFile = args['extension-id-file'] ? path.resolve(args['extension-id-file']) : '';
975
+ const outPath = path.resolve(args.out || path.join(target, recipeRuntimeDir(), 'fixture-state-validation.json'));
976
+ const wallet = readJson(fixturePath);
977
+ const fixtureState = readJson(statePath);
978
+ const versionedState = versionedStorageState(fixtureState);
979
+
980
+ await waitForCdp(port);
981
+ const playwright = requireFromTarget(target, 'playwright');
982
+ const browser = await playwright.chromium.connectOverCDP(`http://127.0.0.1:${port}`);
983
+ const context = browser.contexts()[0] || (await browser.newContext());
984
+ const { extensionId, page } = await detectExtension(context, extensionDir, extensionIdFile);
985
+ if (extensionIdFile) {
986
+ fs.mkdirSync(path.dirname(extensionIdFile), { recursive: true });
987
+ fs.writeFileSync(extensionIdFile, `${extensionId}\n`);
988
+ }
989
+
990
+ await page.evaluate(async (state) => {
991
+ await chrome.storage.local.set(state);
992
+ }, versionedState);
993
+ await page.goto(`chrome-extension://${extensionId}/home.html`, {
994
+ waitUntil: 'load',
995
+ timeout: 30000,
996
+ });
997
+ await page.waitForTimeout(1500);
998
+ const screen = await unlockIfNeeded(page, wallet.password);
999
+ const expectedFixture = expectedFixtureFromState(fixtureState);
1000
+ const liveBeforeSetup = await readLiveAccounts(page);
1001
+ const importParityBeforeSetup = compareImportParity(liveBeforeSetup, expectedFixture);
1002
+ const namesBeforeSetup = compareAccountNames(liveBeforeSetup, expectedFixture);
1003
+
1004
+ // Name and selected-account calls are an explicit fixture setup phase, not
1005
+ // import-parity proof. The report records account import parity before these
1006
+ // calls so validation cannot pass by repairing the imported account set it
1007
+ // claims to prove. The setup phase only applies user-facing labels/selection
1008
+ // after the expected EVM accounts and keyring types already exist.
1009
+ if (importParityBeforeSetup.status === 'PASS' && namesBeforeSetup.status !== 'PASS') {
1010
+ await applyAccountNames(page, expectedFixture.accounts);
1011
+ }
1012
+ if (importParityBeforeSetup.status === 'PASS' && !importParityBeforeSetup.selectedMatches) {
1013
+ await applySelectedAccount(page, expectedFixture.selected);
1014
+ }
1015
+ const setupResult = await waitForFixtureSetup(page, expectedFixture);
1016
+ const finalImportParity = compareImportParity(setupResult.live, expectedFixture);
1017
+ const finalNames = compareAccountNames(setupResult.live, expectedFixture);
1018
+ const fixtureSetupStatus =
1019
+ importParityBeforeSetup.status === 'PASS' && finalImportParity.selectedMatches && finalNames.status === 'PASS'
1020
+ ? 'PASS'
1021
+ : 'FAIL';
1022
+ const report = {
1023
+ status: fixtureSetupStatus,
1024
+ extensionId,
1025
+ unlockedVia: screen.selector,
1026
+ importParity: importParityBeforeSetup,
1027
+ fixtureSetup: {
1028
+ status: fixtureSetupStatus,
1029
+ namesBeforeSetup,
1030
+ namesAfterSetup: finalNames,
1031
+ selectedAfterSetup: finalImportParity.selectedActual,
1032
+ selectedExpected: finalImportParity.selectedExpected,
1033
+ note:
1034
+ 'Account-label/selection calls are setup-time fixture finalization only; account importParity is measured before these calls, and final selected account/name setup is validated separately.',
1035
+ },
1036
+ expectedAccountCount: expectedFixture.accounts.length,
1037
+ liveAccountCount: setupResult.live.accounts.length,
1038
+ liveEvmAccountCount: getEvmAccounts(setupResult.live).length,
1039
+ selected: {
1040
+ name: setupResult.live.selectedName,
1041
+ address: setupResult.live.selectedAddress,
1042
+ },
1043
+ expectedAccounts: expectedFixture.accounts,
1044
+ liveAccounts: setupResult.live.accounts.map((account) => ({
1045
+ name: account.name,
1046
+ groupName: account.groupName,
1047
+ address: account.address,
1048
+ keyringType: account.keyringType,
1049
+ type: account.type,
1050
+ })),
1051
+ missing: importParityBeforeSetup.missing,
1052
+ unexpectedEvm: importParityBeforeSetup.unexpectedEvm,
1053
+ generatedAt: new Date().toISOString(),
1054
+ };
1055
+ writeJson(outPath, report);
1056
+ await disconnectCdpBrowser(browser);
1057
+ if (report.status !== 'PASS') {
1058
+ throw new Error(`Extension fixture account parity failed; see ${outPath}`);
1059
+ }
1060
+ console.error(
1061
+ `[fixture] CDP validated Extension wallet fixture: accounts=${report.liveAccountCount} selected=${report.selected.name || report.selected.address}`,
1062
+ );
1063
+ }
1064
+
1065
+ (async () => {
1066
+ try {
1067
+ const args = parseArgs(process.argv.slice(2));
1068
+ if (args.command === '-h' || args.command === '--help') {
1069
+ usage();
1070
+ process.exit(0);
1071
+ }
1072
+ if (args.command === 'generate') {
1073
+ await generate(args);
1074
+ } else if (args.command === 'prefill-profile') {
1075
+ await prefillProfile(args);
1076
+ } else if (args.command === 'seed-cdp') {
1077
+ await seedCdp(args);
1078
+ } else {
1079
+ usage();
1080
+ process.exit(2);
1081
+ }
1082
+ } catch (error) {
1083
+ console.error(`FAIL: ${error.message || error}`);
1084
+ process.exit(1);
1085
+ }
1086
+ })();