@marigoldlabs/web3-tester 0.1.2 → 0.4.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.
Files changed (75) hide show
  1. package/.env.example +26 -17
  2. package/LICENSE +21 -0
  3. package/README.md +167 -41
  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/contracts/test-erc20.d.ts +227 -0
  9. package/dist/contracts/test-erc20.d.ts.map +1 -0
  10. package/dist/contracts/test-erc20.js +8 -0
  11. package/dist/contracts/test-erc20.js.map +1 -0
  12. package/dist/erc20.d.ts +38 -0
  13. package/dist/erc20.d.ts.map +1 -0
  14. package/dist/erc20.js +229 -0
  15. package/dist/erc20.js.map +1 -0
  16. package/dist/fixtures.d.ts +44 -2
  17. package/dist/fixtures.d.ts.map +1 -1
  18. package/dist/fixtures.js +162 -17
  19. package/dist/fixtures.js.map +1 -1
  20. package/dist/index.d.ts +17 -5
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +7 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/injected-provider.d.ts.map +1 -1
  25. package/dist/injected-provider.js +142 -79
  26. package/dist/injected-provider.js.map +1 -1
  27. package/dist/live-fixtures.d.ts +32 -3
  28. package/dist/live-fixtures.d.ts.map +1 -1
  29. package/dist/live-fixtures.js +64 -27
  30. package/dist/live-fixtures.js.map +1 -1
  31. package/dist/matchers.d.ts +90 -0
  32. package/dist/matchers.d.ts.map +1 -0
  33. package/dist/matchers.js +268 -0
  34. package/dist/matchers.js.map +1 -0
  35. package/dist/metamask-extension.d.ts +34 -0
  36. package/dist/metamask-extension.d.ts.map +1 -0
  37. package/dist/metamask-extension.js +97 -0
  38. package/dist/metamask-extension.js.map +1 -0
  39. package/dist/mock-wallet-controller.d.ts +205 -3
  40. package/dist/mock-wallet-controller.d.ts.map +1 -1
  41. package/dist/mock-wallet-controller.js +843 -46
  42. package/dist/mock-wallet-controller.js.map +1 -1
  43. package/dist/private-key-rpc-client.d.ts +1730 -0
  44. package/dist/private-key-rpc-client.d.ts.map +1 -1
  45. package/dist/private-key-rpc-client.js +105 -12
  46. package/dist/private-key-rpc-client.js.map +1 -1
  47. package/dist/real-wallet-cache.d.ts +65 -0
  48. package/dist/real-wallet-cache.d.ts.map +1 -0
  49. package/dist/real-wallet-cache.js +245 -0
  50. package/dist/real-wallet-cache.js.map +1 -0
  51. package/dist/real-wallet-fixtures.d.ts +52 -0
  52. package/dist/real-wallet-fixtures.d.ts.map +1 -0
  53. package/dist/real-wallet-fixtures.js +73 -0
  54. package/dist/real-wallet-fixtures.js.map +1 -0
  55. package/dist/real-wallet.d.ts +123 -14
  56. package/dist/real-wallet.d.ts.map +1 -1
  57. package/dist/real-wallet.js +1336 -57
  58. package/dist/real-wallet.js.map +1 -1
  59. package/dist/transactions.d.ts +118 -0
  60. package/dist/transactions.d.ts.map +1 -0
  61. package/dist/transactions.js +207 -0
  62. package/dist/transactions.js.map +1 -0
  63. package/dist/types.d.ts +1 -0
  64. package/dist/types.d.ts.map +1 -1
  65. package/dist/walletconnect.d.ts +206 -0
  66. package/dist/walletconnect.d.ts.map +1 -0
  67. package/dist/walletconnect.js +359 -0
  68. package/dist/walletconnect.js.map +1 -0
  69. package/examples/live-sepolia.spec.ts +20 -1
  70. package/package.json +62 -6
  71. package/docs/API.md +0 -223
  72. package/docs/ARCHITECTURE.md +0 -81
  73. package/docs/CONSUMING_FROM_FJORD.md +0 -123
  74. package/docs/FJORD_LIVE_QA.md +0 -87
  75. package/docs/RELEASE_CHECKLIST.md +0 -55
@@ -1,12 +1,72 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { chromium } from '@playwright/test';
4
+ import { extensionManifestVersion } from './metamask-extension.js';
4
5
  import { passwordForSetup } from './real-wallet-setup.js';
5
6
  const DEFAULT_EXTENSION_NAME = 'MetaMask';
6
7
  const DEFAULT_TIMEOUT_MS = 30_000;
7
8
  const SHORT_TIMEOUT_MS = 2_000;
8
9
  const LOCATOR_PROBE_MS = 250;
10
+ // 13.x derives many accounts from a heavily-used SRP; the account-tree
11
+ // backup-and-sync races createNextMultichainAccountGroup and silently drops
12
+ // ~1/3 of add clicks until it settles. Dwell before the first create.
13
+ const ACCOUNT_TREE_SETTLE_MS = 12_000;
14
+ const FULL_ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/;
9
15
  const testId = (id) => `[data-testid="${id}"]`;
16
+ /**
17
+ * @internal Resolves a generation-annotated selector stack against the
18
+ * configured generation: tagged entries of the other generation are dropped,
19
+ * bare entries pass through, and relative order is preserved.
20
+ */
21
+ export function resolveGenLocators(locators, generation) {
22
+ const resolved = [];
23
+ for (const entry of locators) {
24
+ if ('gen' in entry) {
25
+ if (entry.gen === generation)
26
+ resolved.push(entry.loc);
27
+ }
28
+ else {
29
+ resolved.push(entry);
30
+ }
31
+ }
32
+ return resolved;
33
+ }
34
+ /** @internal Maps an extension manifest version to the UI generation it ships. */
35
+ export function walletGenerationForVersion(version) {
36
+ const major = Number.parseInt(version, 10);
37
+ return Number.isNaN(major) || major >= 13 ? '13x' : '12x';
38
+ }
39
+ function detectWalletGeneration(extensionPath) {
40
+ try {
41
+ return walletGenerationForVersion(extensionManifestVersion(extensionPath));
42
+ }
43
+ catch {
44
+ // No readable manifest version: assume the pinned default generation.
45
+ return '13x';
46
+ }
47
+ }
48
+ /**
49
+ * @internal Resolves the headed/headless choice. There is deliberately no
50
+ * default: the two modes have different validation status (headed is fully
51
+ * validated end to end; headless is validated for extension load and the
52
+ * clipboard round-trip only), so every run must pick one explicitly.
53
+ */
54
+ export function resolveRealWalletHeadless(explicit) {
55
+ if (explicit !== undefined)
56
+ return explicit;
57
+ const env = process.env.WEB3_TESTER_REAL_WALLET_HEADLESS;
58
+ if (env === 'true')
59
+ return true;
60
+ if (env === 'false')
61
+ return false;
62
+ if (env) {
63
+ throw new Error(`WEB3_TESTER_REAL_WALLET_HEADLESS must be "true" or "false", got "${env}".`);
64
+ }
65
+ throw new Error('Real-wallet launches need an explicit headed/headless choice: pass headless: true|false ' +
66
+ '(launchRealWallet / buildWalletProfile / realWalletOptions) or set ' +
67
+ 'WEB3_TESTER_REAL_WALLET_HEADLESS=true|false. Headed is the fully validated mode; ' +
68
+ 'headless needs the full Chromium build (npx playwright install chromium).');
69
+ }
10
70
  function extensionUrl(extensionId, page = 'home.html') {
11
71
  return `chrome-extension://${extensionId}/${page}`;
12
72
  }
@@ -60,9 +120,9 @@ async function clickFirstVisible(locators, timeout = SHORT_TIMEOUT_MS, options =
60
120
  requireEnabled: options.requireEnabled ?? true,
61
121
  });
62
122
  if (!target)
63
- return false;
123
+ return undefined;
64
124
  await target.click({ force: options.force, timeout });
65
- return true;
125
+ return target;
66
126
  }
67
127
  async function fillFirstVisible(locators, value, timeout = DEFAULT_TIMEOUT_MS) {
68
128
  const target = await findVisibleLocator(locators, timeout);
@@ -76,8 +136,12 @@ async function startSeedPhraseWordGrid(target, firstWord) {
76
136
  await wait(250);
77
137
  await target.press('Space');
78
138
  }
139
+ // MetaMask 12.x routes are home.html#onboarding/..., 13.x uses #/onboarding/...
140
+ const isOnboardingRoute = (url) => /home\.html#\/?onboarding/.test(url);
79
141
  function metaMaskOnboardingLocators(page) {
80
142
  return [
143
+ page.locator(testId('onboarding-get-started-button')),
144
+ page.locator(testId('onboarding-welcome-banner-title')),
81
145
  page.locator(testId('onboarding-import-wallet')),
82
146
  page.locator(testId('onboarding-import-with-srp-button')),
83
147
  page.locator(testId('onboarding-create-wallet')),
@@ -86,6 +150,7 @@ function metaMaskOnboardingLocators(page) {
86
150
  page.locator(testId('import-srp-confirm')),
87
151
  page.locator(testId('create-password-new-input')),
88
152
  page.locator(testId('create-password-confirm-input')),
153
+ page.getByRole('button', { name: 'Get started' }),
89
154
  page.getByRole('button', { name: 'I have an existing wallet' }),
90
155
  page.getByRole('button', { name: 'Create a new wallet' }),
91
156
  page.getByRole('button', { name: 'Import using Secret Recovery Phrase' }),
@@ -93,7 +158,7 @@ function metaMaskOnboardingLocators(page) {
93
158
  ];
94
159
  }
95
160
  async function isMetaMaskOnboardingVisible(page) {
96
- return page.url().includes('/home.html#/onboarding') || Boolean(await findVisibleLocator(metaMaskOnboardingLocators(page), SHORT_TIMEOUT_MS));
161
+ return isOnboardingRoute(page.url()) || Boolean(await findVisibleLocator(metaMaskOnboardingLocators(page), SHORT_TIMEOUT_MS));
97
162
  }
98
163
  function metaMaskSeedPhraseInputLocators(page) {
99
164
  return [
@@ -130,11 +195,11 @@ async function fillMetaMaskSeedPhraseWordGrid(page, words, startIndex = 0) {
130
195
  }
131
196
  }
132
197
  async function isMetaMaskSeedPhraseImportVisible(page) {
133
- return (page.url().includes('/onboarding/import-with-recovery-phrase') ||
198
+ return (/#\/?onboarding\/import-with-recovery-phrase/.test(page.url()) ||
134
199
  Boolean(await findVisibleLocator(metaMaskSeedPhraseImportLocators(page), SHORT_TIMEOUT_MS)));
135
200
  }
136
201
  async function isMetaMaskCreatePasswordVisible(page) {
137
- return (page.url().includes('/onboarding/create-password') ||
202
+ return (/#\/?onboarding\/create-password/.test(page.url()) ||
138
203
  Boolean(await findVisibleLocator([page.locator(testId('create-password-new-input')), page.locator('input[type="password"]').nth(0)], SHORT_TIMEOUT_MS)));
139
204
  }
140
205
  async function waitForMetaMaskReady(page) {
@@ -151,10 +216,26 @@ async function isMetaMaskUnlockVisible(page) {
151
216
  return Boolean(await findVisibleLocator(metaMaskUnlockPasswordLocators(page), SHORT_TIMEOUT_MS));
152
217
  }
153
218
  async function closeMetaMaskOverlay(page) {
154
- await clickFirstVisible([
155
- page.locator(testId('popover-close')),
156
- page.locator('.mm-modal-content .mm-modal-header button').first(),
157
- ], SHORT_TIMEOUT_MS).catch(() => false);
219
+ // Dismiss stacked overlays ("what's new" modals, popovers) that intercept
220
+ // pointer events over the whole home screen.
221
+ for (let attempt = 0; attempt < 4; attempt += 1) {
222
+ const closed = await clickFirstVisible([
223
+ page.locator(testId('not-now-button')),
224
+ page.locator(testId('popover-close')),
225
+ page.locator('.mm-modal-content button[aria-label="Close"]'),
226
+ page.locator('.mm-modal-content .mm-modal-header button').first(),
227
+ ], SHORT_TIMEOUT_MS).catch(() => undefined);
228
+ if (!closed)
229
+ return;
230
+ await wait(250);
231
+ }
232
+ }
233
+ function metaMaskNetworkPickerLocators(page) {
234
+ return [
235
+ page.locator(testId('network-display')),
236
+ page.locator(testId('sort-by-networks')),
237
+ page.getByRole('button', { name: /select a network|network menu/i }),
238
+ ];
158
239
  }
159
240
  function metaMaskAccountMenuLocators(page) {
160
241
  return [
@@ -267,9 +348,9 @@ function extensionPageUrlPrefix(extensionId) {
267
348
  function metaMaskActionContentLocators(page) {
268
349
  return [
269
350
  page.getByRole('heading', {
270
- name: /Spending cap request|Transaction request|Signature request|Sign-in request|Permission request/i,
351
+ name: /Spending cap request|Transaction request|Signature request|Sign-in request|Permission request|Add suggested tokens?/i,
271
352
  }),
272
- page.getByText(/Spending cap request|Transaction request|Signature request|Sign-in request|Permission request|This site wants permission/i),
353
+ page.getByText(/Spending cap request|Transaction request|Signature request|Sign-in request|Permission request|This site wants permission|Add suggested tokens?/i),
273
354
  ];
274
355
  }
275
356
  function metaMaskActionControlLocators(page) {
@@ -325,24 +406,41 @@ async function getNotificationPage(context, extensionId, timeout = DEFAULT_TIMEO
325
406
  const prefix = notificationUrlPrefix(extensionId);
326
407
  const extensionPrefix = extensionPageUrlPrefix(extensionId);
327
408
  const startedAt = Date.now();
409
+ // MetaMask suppresses its popup window when extension tabs are already
410
+ // open; after a short grace period for a spontaneous popup, open
411
+ // notification.html ourselves — pending confirmations render there.
412
+ const forceAt = startedAt + Math.min(5_000, timeout / 2);
413
+ let forcedPage;
328
414
  let page = await findMetaMaskActionPage(context, extensionId);
329
- let notificationPage = findMetaMaskNotificationPage(context, extensionId);
330
415
  while (!page && Date.now() - startedAt < timeout) {
331
416
  const remaining = Math.max(timeout - (Date.now() - startedAt), 1_000);
332
- const candidate = await context.waitForEvent('page', { timeout: Math.min(remaining, 1_000) }).catch(() => undefined);
333
- if (candidate) {
334
- await candidate
335
- .waitForURL((url) => url.href.startsWith(prefix) || url.href.startsWith(extensionPrefix), {
336
- timeout: Math.min(remaining, 5_000),
337
- })
338
- .catch(() => undefined);
417
+ if (!forcedPage && Date.now() >= forceAt) {
418
+ forcedPage = await context.newPage();
419
+ await forcedPage.goto(extensionUrl(extensionId, 'notification.html')).catch(() => undefined);
420
+ await waitForMetaMaskReady(forcedPage);
421
+ }
422
+ else {
423
+ const candidate = await context.waitForEvent('page', { timeout: Math.min(remaining, 1_000) }).catch(() => undefined);
424
+ if (candidate) {
425
+ await candidate
426
+ .waitForURL((url) => url.href.startsWith(prefix) || url.href.startsWith(extensionPrefix), {
427
+ timeout: Math.min(remaining, 5_000),
428
+ })
429
+ .catch(() => undefined);
430
+ }
339
431
  }
340
432
  page = await findMetaMaskActionPage(context, extensionId);
341
- notificationPage = findMetaMaskNotificationPage(context, extensionId) ?? notificationPage;
342
433
  }
343
- page ??= notificationPage;
344
- if (!page)
434
+ // Last resort: any notification.html page (including the forced one) even
435
+ // if the action-content matchers did not recognize the confirmation copy.
436
+ page ??= findMetaMaskNotificationPage(context, extensionId);
437
+ if (!page) {
438
+ await forcedPage?.close().catch(() => undefined);
345
439
  throw new Error('Timed out waiting for MetaMask notification window.');
440
+ }
441
+ if (forcedPage && forcedPage !== page && !forcedPage.isClosed()) {
442
+ await forcedPage.close().catch(() => undefined);
443
+ }
346
444
  await waitForMetaMaskReady(page);
347
445
  await page.bringToFront().catch(() => undefined);
348
446
  return page;
@@ -350,6 +448,34 @@ async function getNotificationPage(context, extensionId, timeout = DEFAULT_TIMEO
350
448
  function shortAddress(address) {
351
449
  return `${address.slice(0, 6)}...${address.slice(-4)}`.toLowerCase();
352
450
  }
451
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
452
+ /** @internal Validates and trims a 32-byte hex private key (0x optional). */
453
+ export function normalizePrivateKey(privateKey) {
454
+ const trimmed = privateKey.trim();
455
+ if (!/^(0x)?[0-9a-fA-F]{64}$/.test(trimmed)) {
456
+ throw new Error('importWalletFromPrivateKey requires a 32-byte hex private key (with or without 0x).');
457
+ }
458
+ return trimmed;
459
+ }
460
+ /** @internal */
461
+ export function isFullTxHash(value) {
462
+ return typeof value === 'string' && /^0x[0-9a-fA-F]{64}$/.test(value);
463
+ }
464
+ /**
465
+ * @internal Matches an account picker row by display name, or by full /
466
+ * shortened address when the identifier is an address.
467
+ */
468
+ export function accountRowMatcher(identifier) {
469
+ if (FULL_ADDRESS_PATTERN.test(identifier)) {
470
+ return new RegExp(`${escapeRegExp(identifier)}|${escapeRegExp(shortAddress(identifier))}`, 'i');
471
+ }
472
+ return new RegExp(escapeRegExp(identifier), 'i');
473
+ }
474
+ // Account picker rows across UI generations: 12.x popover items, 13.x
475
+ // multichain account cells.
476
+ function accountRowLocator(page) {
477
+ return page.locator('.multichain-account-menu-popover__list--menu-item, .multichain-account-cell, .multichain-account-list-item');
478
+ }
353
479
  async function pageContainsAddress(page, address) {
354
480
  const normalizedAddress = address.toLowerCase();
355
481
  const text = ((await page.locator('body').textContent({ timeout: SHORT_TIMEOUT_MS }).catch(() => '')) ?? '')
@@ -393,6 +519,18 @@ async function importMetaMaskWallet(page, setup) {
393
519
  throw new Error('MetaMask is on onboarding. Provide setup.seedPhrase to import a wallet through web3-tester, or use a preconfigured persistent profile.');
394
520
  }
395
521
  await page.bringToFront().catch(() => undefined);
522
+ // 12.23+ shows a welcome interstitial followed by a Terms of Use dialog
523
+ // (scroll to bottom, check, agree) before the create/import choice.
524
+ await clickFirstVisible([page.locator(testId('onboarding-get-started-button'))], SHORT_TIMEOUT_MS);
525
+ const termsOfUseCheckbox = page.locator(testId('terms-of-use-checkbox'));
526
+ if (await isVisible(termsOfUseCheckbox, SHORT_TIMEOUT_MS).catch(() => false)) {
527
+ await clickFirstVisible([page.locator(testId('terms-of-use-scroll-button'))], SHORT_TIMEOUT_MS);
528
+ await termsOfUseCheckbox.click();
529
+ const agreed = await clickFirstVisible([page.locator(testId('terms-of-use-agree-button'))], DEFAULT_TIMEOUT_MS);
530
+ if (!agreed)
531
+ throw new Error('Unable to accept the MetaMask Terms of Use.');
532
+ await waitForMetaMaskReady(page);
533
+ }
396
534
  const terms = page.locator(testId('onboarding-terms-checkbox'));
397
535
  if (await isVisible(terms, SHORT_TIMEOUT_MS).catch(() => false)) {
398
536
  await terms.check();
@@ -442,7 +580,7 @@ async function importMetaMaskWallet(page, setup) {
442
580
  const passwordSubmitted = await clickFirstVisible([page.locator(testId('create-password-submit')), page.getByRole('button', { name: 'Import my wallet' })], DEFAULT_TIMEOUT_MS);
443
581
  if (!passwordSubmitted)
444
582
  throw new Error('Unable to submit MetaMask import password.');
445
- await finishMetaMaskOnboarding(page);
583
+ await finishMetaMaskOnboarding(page, password);
446
584
  }
447
585
  async function unlockMetaMask(page, password) {
448
586
  const passwordFilled = await fillFirstVisible(metaMaskUnlockPasswordLocators(page), password, DEFAULT_TIMEOUT_MS);
@@ -463,24 +601,40 @@ async function unlockMetaMaskIfNeeded(page, password) {
463
601
  await unlockMetaMask(page, password);
464
602
  return true;
465
603
  }
466
- async function finishMetaMaskOnboarding(page) {
604
+ async function finishMetaMaskOnboarding(page, password) {
467
605
  for (let attempt = 0; attempt < 6; attempt += 1) {
468
606
  await waitForMetaMaskReady(page);
469
607
  const actions = [...metaMaskPromptActions(page), ...metaMaskTextPromptActions(page)];
470
- const onSetupScreen = page.url().includes('/home.html#/onboarding') ||
608
+ const onSetupScreen = isOnboardingRoute(page.url()) ||
471
609
  Boolean(await findVisibleLocator(actions, SHORT_TIMEOUT_MS));
472
610
  if (!onSetupScreen)
473
- return;
611
+ break;
474
612
  const advanced = await clickMetaMaskPromptAction(page, 5_000);
475
613
  if (!advanced)
476
614
  break;
477
615
  }
478
- if (await waitForMetaMaskHome(page, 10_000))
479
- return;
480
- if (page.url().includes('/home.html#/onboarding') ||
481
- (await findVisibleLocator([...metaMaskPromptActions(page), ...metaMaskTextPromptActions(page)], SHORT_TIMEOUT_MS))) {
616
+ // MetaMask 13.x defaults to opening the wallet in Chrome's side panel, so
617
+ // clicking "Open wallet" disables the button and leaves the onboarding tab
618
+ // parked on #/onboarding/completion (Playwright can't drive the side
619
+ // panel). Route the tab to the wallet home ourselves.
620
+ if (isOnboardingRoute(page.url())) {
621
+ const homeUrl = page.url().split('#')[0];
622
+ await page.goto(homeUrl).catch(() => undefined);
623
+ await waitForMetaMaskReady(page);
624
+ // Navigating away from completion can re-lock the freshly created vault.
625
+ await unlockMetaMaskIfNeeded(page, password);
626
+ }
627
+ if (!(await waitForMetaMaskHome(page, DEFAULT_TIMEOUT_MS))) {
482
628
  throw new Error('MetaMask wallet import did not complete onboarding.');
483
629
  }
630
+ // MetaMask 13.x persists extension state through a debounced write
631
+ // (app/scripts/lib/safe-reload.ts OperationSafener — a lodash
632
+ // trailing-edge debounce, wait 1000ms, no maxWait — wrapping
633
+ // persistenceManager.set()). Give the UI a short beat to enqueue the
634
+ // post-onboarding write; the real flush guarantee lives in
635
+ // buildWalletProfile, which polls the profile's storage until the write
636
+ // lands and goes quiet before closing.
637
+ await page.waitForTimeout(500);
484
638
  }
485
639
  class MetaMaskRealWallet {
486
640
  context;
@@ -488,20 +642,40 @@ class MetaMaskRealWallet {
488
642
  extensionId;
489
643
  expectedAddress;
490
644
  walletPassword;
491
- constructor(context, homePage, extensionId, expectedAddress, walletPassword) {
645
+ generation;
646
+ constructor(context, homePage, extensionId,
647
+ // Mutable: account mutations (switchAccount, imports, new accounts) must
648
+ // invalidate it, or getAccountAddress's fast-path returns stale results.
649
+ expectedAddress, walletPassword, generation = '13x') {
492
650
  this.context = context;
493
651
  this.homePage = homePage;
494
652
  this.extensionId = extensionId;
495
653
  this.expectedAddress = expectedAddress;
496
654
  this.walletPassword = walletPassword;
655
+ this.generation = generation;
656
+ }
657
+ // Generation-aware variant of clickFirstVisible: the annotated stack is
658
+ // resolved against the configured generation first, and an empty resolution
659
+ // short-circuits without probing the page at all.
660
+ async clickFirst(locators, timeout, options) {
661
+ const resolved = resolveGenLocators(locators, this.generation);
662
+ if (!resolved.length)
663
+ return undefined;
664
+ return clickFirstVisible(resolved, timeout, options);
497
665
  }
498
666
  async approveTokenPermission(options) {
499
667
  const page = await this.notificationPage();
500
668
  if (options?.spendLimit === 'max') {
501
- await clickFirstVisible([page.locator(testId('custom-spending-cap-max-button'))], SHORT_TIMEOUT_MS);
669
+ const clicked = await clickFirstVisible([page.locator(testId('custom-spending-cap-max-button'))], SHORT_TIMEOUT_MS);
670
+ if (!clicked) {
671
+ throw new Error('A max spendLimit was requested but the MetaMask spending-cap "Max" control was not found.');
672
+ }
502
673
  }
503
674
  else if (typeof options?.spendLimit === 'number') {
504
- await fillFirstVisible([page.locator(testId('custom-spending-cap-input'))], String(options.spendLimit), SHORT_TIMEOUT_MS);
675
+ const filled = await fillFirstVisible([page.locator(testId('custom-spending-cap-input'))], String(options.spendLimit), SHORT_TIMEOUT_MS);
676
+ if (!filled) {
677
+ throw new Error('A numeric spendLimit was requested but the MetaMask spending-cap input was not found.');
678
+ }
505
679
  }
506
680
  await this.confirmFooterAction(page);
507
681
  if (!page.isClosed()) {
@@ -539,9 +713,32 @@ class MetaMaskRealWallet {
539
713
  for (let attempt = 0; attempt < 3; attempt += 1) {
540
714
  await this.applyGasSetting(page, options?.gasSetting);
541
715
  await clickFirstVisible([page.locator('.set-approval-for-all-warning__footer__approve-button')], SHORT_TIMEOUT_MS);
542
- await this.confirmFooterAction(page);
543
- await wait(1_000);
544
- const nextPage = await findMetaMaskActionPage(this.context, this.extensionId);
716
+ const clicked = await this.confirmFooterAction(page);
717
+ // The click registered once the confirmed control leaves the view or
718
+ // the popup closes no fixed sleep deciding "settled".
719
+ await Promise.race([
720
+ clicked.waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => undefined),
721
+ page.waitForEvent('close', { timeout: 10_000 }).catch(() => undefined),
722
+ ]);
723
+ if (!page.isClosed()) {
724
+ // Same window advanced to another confirmation step.
725
+ const nextStep = await findVisibleLocator(metaMaskActionLocators(page), SHORT_TIMEOUT_MS, {
726
+ requireEnabled: false,
727
+ });
728
+ if (!nextStep)
729
+ return;
730
+ await waitForMetaMaskReady(page);
731
+ continue;
732
+ }
733
+ // Popup closed: give a follow-up notification window (approve + action
734
+ // flows) a moment to appear.
735
+ const deadline = Date.now() + 1_500;
736
+ let nextPage;
737
+ while (Date.now() < deadline && !nextPage) {
738
+ nextPage = await findMetaMaskActionPage(this.context, this.extensionId);
739
+ if (!nextPage)
740
+ await wait(LOCATOR_PROBE_MS);
741
+ }
545
742
  if (!nextPage)
546
743
  return;
547
744
  page = nextPage;
@@ -566,19 +763,224 @@ class MetaMaskRealWallet {
566
763
  if (this.expectedAddress && await pageContainsAddress(page, this.expectedAddress)) {
567
764
  return this.expectedAddress;
568
765
  }
569
- const openedMenu = await clickFirstVisible(metaMaskAccountMenuLocators(page), DEFAULT_TIMEOUT_MS);
570
- if (!openedMenu)
571
- throw new Error('Unable to open MetaMask account options menu.');
572
- const openedDetails = await clickFirstVisible([page.locator(testId('account-list-menu-details'))], DEFAULT_TIMEOUT_MS);
573
- if (!openedDetails)
574
- throw new Error('Unable to open MetaMask account details.');
575
- const addressText = page.locator(testId('address-copy-button-text')).first();
576
- await addressText.waitFor({ state: 'visible', timeout: DEFAULT_TIMEOUT_MS });
577
- const address = (await addressText.textContent())?.trim();
766
+ // 12.x exposes a header copy button (an extension copy control that uses
767
+ // document.execCommand('copy')) that puts the full address on the
768
+ // clipboard; reading it via a synthetic paste avoids the account-details
769
+ // modal entirely (extension pages block evaluate via LavaMoat, normal
770
+ // pages do not). 13.x has no header copy control — the tag skips it.
771
+ const headerCopied = await this.clickFirst([{ gen: '12x', loc: page.locator(testId('app-header-copy-button')) }], SHORT_TIMEOUT_MS);
772
+ if (headerCopied) {
773
+ const pasted = await this.readClipboardViaPaste();
774
+ if (pasted && FULL_ADDRESS_PATTERN.test(pasted))
775
+ return pasted;
776
+ }
777
+ await this.openAccountDetailsModal(page);
778
+ // Read the full address from whichever copy affordance the modal exposes
779
+ // (12.x: address-copy-button-text; 13.x addresses view:
780
+ // multichain-address-row-copy-button). The visible text may be shortened,
781
+ // so prefer clicking the copy control and reading the clipboard.
782
+ const addressCopy = await findVisibleLocator([
783
+ page.locator(testId('address-copy-button-text')),
784
+ page.locator(testId('multichain-address-row-copy-button')),
785
+ page.locator(testId('address-qr-code-modal-copy-button')),
786
+ ], DEFAULT_TIMEOUT_MS, { requireEnabled: false });
787
+ if (!addressCopy) {
788
+ throw new Error('Unable to find the MetaMask account address in the details view.');
789
+ }
790
+ const elementText = (await addressCopy.textContent())?.trim();
791
+ if (elementText && FULL_ADDRESS_PATTERN.test(elementText)) {
792
+ await closeMetaMaskOverlay(page);
793
+ return elementText;
794
+ }
795
+ await addressCopy.click().catch(() => undefined);
796
+ const pasted = await this.readClipboardViaPaste();
797
+ await closeMetaMaskOverlay(page);
798
+ if (pasted && FULL_ADDRESS_PATTERN.test(pasted)) {
799
+ return pasted;
800
+ }
801
+ throw new Error('Unable to read the selected MetaMask account address from the UI.');
802
+ }
803
+ // Opens the account address view. 12.x: account-options (3-dot) menu ->
804
+ // "Account details". 13.x multichain UI: account picker -> the selected
805
+ // account row's address menu -> "Addresses" (the "Account details" item
806
+ // there is the export-keys view, not the address).
807
+ async openAccountDetailsModal(page) {
808
+ if (this.generation === '12x') {
809
+ const menuOpened = await clickFirstVisible([page.locator(testId('account-options-menu-button'))], SHORT_TIMEOUT_MS);
810
+ const detailsOpened = menuOpened &&
811
+ (await clickFirstVisible([page.locator(testId('account-list-menu-details'))], DEFAULT_TIMEOUT_MS));
812
+ if (!detailsOpened)
813
+ throw new Error('Unable to open the MetaMask account details view.');
814
+ return;
815
+ }
816
+ // 13.x multichain path. Prefer the home header's active-account address
817
+ // menu (default-address-menu-button) so we read the *selected* account
818
+ // rather than an arbitrary cell — a single SRP import derives many
819
+ // accounts whose picker order does not start at the active one.
820
+ let addressMenuOpened = await clickFirstVisible([page.locator(testId('default-address-menu-button'))], SHORT_TIMEOUT_MS);
821
+ if (!addressMenuOpened) {
822
+ const pickerOpened = await clickFirstVisible([page.locator(testId('account-menu-icon'))], DEFAULT_TIMEOUT_MS);
823
+ if (!pickerOpened)
824
+ throw new Error('Unable to open the MetaMask account picker.');
825
+ const expected = this.expectedAddress?.toLowerCase();
826
+ const selectedRow = expected
827
+ ? page
828
+ .locator('[data-testid^="multichain-account-cell"]')
829
+ .filter({ hasText: new RegExp(`${expected.slice(0, 6)}|${shortAddress(expected)}`, 'i') })
830
+ .first()
831
+ : page.locator('[data-testid^="multichain-account-cell"]').first();
832
+ addressMenuOpened =
833
+ (await clickFirstVisible([selectedRow.locator(testId('multichain-account-cell-end-accessory'))], DEFAULT_TIMEOUT_MS)) ??
834
+ (await clickFirstVisible([page.getByRole('button', { name: 'Open multichain account address menu' }).first()], SHORT_TIMEOUT_MS));
835
+ }
836
+ if (!addressMenuOpened)
837
+ throw new Error('Unable to open the MetaMask account address menu.');
838
+ const detailsOpened = await clickFirstVisible([
839
+ page.locator(testId('multichain-account-menu-item-addresses')),
840
+ page.getByRole('menuitem', { name: /^Addresses$/i }),
841
+ page.getByText('Addresses', { exact: true }),
842
+ page.locator(testId('multichain-account-menu-item-accountDetails')),
843
+ ], DEFAULT_TIMEOUT_MS);
844
+ if (!detailsOpened)
845
+ throw new Error('Unable to open the MetaMask account address view.');
846
+ }
847
+ async readClipboardViaPaste() {
848
+ const page = await this.context.newPage();
849
+ try {
850
+ await page.goto('data:text/html,<input id="paste-target" autofocus>');
851
+ const input = page.locator('#paste-target');
852
+ await input.click();
853
+ await page.keyboard.press('ControlOrMeta+v');
854
+ const value = (await input.inputValue()).trim();
855
+ return value || undefined;
856
+ }
857
+ catch {
858
+ return undefined;
859
+ }
860
+ finally {
861
+ await page.close().catch(() => undefined);
862
+ }
863
+ }
864
+ async addNetwork(network) {
865
+ const page = await this.home();
866
+ await waitForMetaMaskHome(page);
867
+ await closeMetaMaskOverlay(page);
868
+ const pickerOpened = await clickFirstVisible(metaMaskNetworkPickerLocators(page), DEFAULT_TIMEOUT_MS);
869
+ if (!pickerOpened)
870
+ throw new Error('Unable to open the MetaMask network picker.');
871
+ // 13.x splits the picker into "Default"/popular and "Custom" tabs; the
872
+ // custom RPC form lives behind the Custom tab. (The bare "Add network"
873
+ // buttons on the default tab add preconfigured popular networks, not a
874
+ // custom RPC.) Playwright's actionability checks wait out the modal
875
+ // slide-in; 12.x has no Custom tab.
876
+ if (this.generation === '13x') {
877
+ await clickFirstVisible([page.getByRole('tab', { name: /^Custom$/i })], 8_000);
878
+ }
879
+ const addStarted = await clickFirstVisible([
880
+ page.locator(testId('network-list-menu-add-button')),
881
+ page.getByRole('button', { name: /Add a custom network|Add custom network/i }),
882
+ page.getByText(/Add a custom network/i),
883
+ page.getByRole('button', { name: /^Add network$/i }),
884
+ ], DEFAULT_TIMEOUT_MS);
885
+ if (!addStarted)
886
+ throw new Error('Unable to start the MetaMask add-network flow.');
887
+ // Versions that list popular networks first need one more hop.
888
+ await clickFirstVisible([page.locator(testId('add-network-manually')), page.getByText(/Add a network manually/i)], SHORT_TIMEOUT_MS);
889
+ const nameFilled = await fillFirstVisible([page.locator(testId('network-form-network-name')), page.locator('input[name="networkName"]')], network.name);
890
+ if (!nameFilled)
891
+ throw new Error('Unable to fill the MetaMask network name field.');
892
+ // RPC URL: newer MetaMask uses a dropdown with a dedicated add-RPC form;
893
+ // older versions render a plain input.
894
+ const rpcDirect = await fillFirstVisible([page.locator(testId('network-form-rpc-url')), page.locator('input[name="rpcUrl"]')], network.rpcUrl, SHORT_TIMEOUT_MS);
895
+ if (!rpcDirect) {
896
+ const dropdownOpened = await clickFirstVisible([page.locator(testId('test-add-rpc-drop-down')), page.getByText(/Add RPC URL/i)], SHORT_TIMEOUT_MS);
897
+ if (!dropdownOpened)
898
+ throw new Error('Unable to find the MetaMask RPC URL input.');
899
+ await clickFirstVisible([page.getByRole('button', { name: /Add RPC URL/i })], SHORT_TIMEOUT_MS);
900
+ const rpcFilled = await fillFirstVisible([page.locator(testId('rpc-url-input-test')), page.locator('input[name="rpcUrl"]')], network.rpcUrl);
901
+ if (!rpcFilled)
902
+ throw new Error('Unable to fill the MetaMask RPC URL field.');
903
+ const rpcConfirmed = await clickFirstVisible([page.getByRole('button', { name: /^Add URL$/i })], SHORT_TIMEOUT_MS);
904
+ if (!rpcConfirmed)
905
+ throw new Error('Unable to confirm the MetaMask RPC URL.');
906
+ }
907
+ const chainFilled = await fillFirstVisible([page.locator(testId('network-form-chain-id')), page.locator('input[name="chainId"]')], String(network.chainId));
908
+ if (!chainFilled)
909
+ throw new Error('Unable to fill the MetaMask chain id field.');
910
+ const symbolFilled = await fillFirstVisible([page.locator(testId('network-form-ticker-input')), page.locator('input[name="symbol"]')], network.symbol);
911
+ if (!symbolFilled)
912
+ throw new Error('Unable to fill the MetaMask currency symbol field.');
913
+ if (network.blockExplorerUrl) {
914
+ await fillFirstVisible([
915
+ page.locator(testId('network-form-block-explorer-url')),
916
+ page.locator('input[name="blockExplorerUrl"]'),
917
+ ], network.blockExplorerUrl, SHORT_TIMEOUT_MS);
918
+ }
919
+ const saved = await clickFirstVisible([page.locator(testId('network-form-save')), page.getByRole('button', { name: /^Save$/i })], DEFAULT_TIMEOUT_MS);
920
+ if (!saved) {
921
+ throw new Error('Unable to save the MetaMask network. Note that 13.x refuses custom networks under a ' +
922
+ 'known chain id ("edit the original network") — add those via a dapp ' +
923
+ 'wallet_addEthereumChain request plus approveNewNetwork() instead.');
924
+ }
925
+ await waitForMetaMaskReady(page);
926
+ await clickMetaMaskPromptAction(page, SHORT_TIMEOUT_MS);
578
927
  await closeMetaMaskOverlay(page);
579
- if (!address?.startsWith('0x'))
580
- throw new Error('Unable to read selected MetaMask account address.');
581
- return address;
928
+ }
929
+ async switchNetwork(name, options = {}) {
930
+ const page = await this.home();
931
+ await waitForMetaMaskHome(page);
932
+ await closeMetaMaskOverlay(page);
933
+ const pickerOpened = await clickFirstVisible(metaMaskNetworkPickerLocators(page), DEFAULT_TIMEOUT_MS);
934
+ if (!pickerOpened)
935
+ throw new Error('Unable to open the MetaMask network picker.');
936
+ const candidates = () => [
937
+ // 13.x multichain rows are keyed by CAIP-2 chain id.
938
+ ...(options.chainId !== undefined
939
+ ? [page.locator(testId(`network-list-item-eip155:${options.chainId}`))]
940
+ : []),
941
+ // 12.x rows have historically used the network name as test id.
942
+ page.locator(testId(name)),
943
+ page.locator('[data-testid="network-list-item"]').filter({ hasText: name }),
944
+ page.locator('[data-testid^="network-list-item"]').filter({ hasText: name }),
945
+ page.locator('.multichain-network-list-item').filter({ hasText: name }),
946
+ page.getByText(name, { exact: true }),
947
+ ];
948
+ // Custom RPC networks live under the 13.x "Custom" tab; try the current
949
+ // (default) tab first, then the Custom tab. 12.x has no Custom tab, so
950
+ // its retry just re-probes the rows with the full budget.
951
+ let selected = await clickFirstVisible(candidates(), SHORT_TIMEOUT_MS);
952
+ if (!selected) {
953
+ if (this.generation === '13x') {
954
+ await clickFirstVisible([page.getByRole('tab', { name: /^Custom$/i })], SHORT_TIMEOUT_MS);
955
+ }
956
+ selected = await clickFirstVisible(candidates(), DEFAULT_TIMEOUT_MS);
957
+ }
958
+ if (!selected) {
959
+ throw new Error(`Unable to select MetaMask network "${name}". Add it first with addNetwork(), and check "Show test networks" if it is a testnet.`);
960
+ }
961
+ await waitForMetaMaskReady(page);
962
+ await closeMetaMaskOverlay(page);
963
+ }
964
+ async approveNewNetwork() {
965
+ const page = await this.notificationPage();
966
+ await this.confirmFooterAction(page);
967
+ // MetaMask follows up with a "switch to this network" step.
968
+ if (!page.isClosed()) {
969
+ await waitForMetaMaskReady(page);
970
+ await this.confirmFooterAction(page, SHORT_TIMEOUT_MS).catch(() => undefined);
971
+ }
972
+ }
973
+ async rejectNewNetwork() {
974
+ const page = await this.notificationPage();
975
+ await this.rejectFooterAction(page);
976
+ }
977
+ async approveSwitchNetwork() {
978
+ const page = await this.notificationPage();
979
+ await this.confirmFooterAction(page);
980
+ }
981
+ async rejectSwitchNetwork() {
982
+ const page = await this.notificationPage();
983
+ await this.rejectFooterAction(page);
582
984
  }
583
985
  async rejectSignature() {
584
986
  const page = await this.notificationPage();
@@ -591,6 +993,820 @@ class MetaMaskRealWallet {
591
993
  const page = await this.notificationPage();
592
994
  await this.rejectFooterAction(page);
593
995
  }
996
+ async rejectTokenPermission() {
997
+ const page = await this.notificationPage();
998
+ await this.rejectFooterAction(page);
999
+ }
1000
+ async approveAddToken() {
1001
+ const page = await this.notificationPage();
1002
+ await this.confirmFooterAction(page);
1003
+ }
1004
+ async addNewToken() {
1005
+ await this.approveAddToken();
1006
+ }
1007
+ async rejectAddToken() {
1008
+ const page = await this.notificationPage();
1009
+ await this.rejectFooterAction(page);
1010
+ }
1011
+ async importWalletFromPrivateKey(privateKey) {
1012
+ const normalized = normalizePrivateKey(privateKey);
1013
+ const page = await this.preparedHome();
1014
+ await this.openAccountPicker(page);
1015
+ let importOpened = false;
1016
+ if (this.generation === '12x') {
1017
+ // 12.x: action button → "Import account".
1018
+ if (await clickFirstVisible([page.locator(testId('multichain-account-menu-popover-action-button'))], SHORT_TIMEOUT_MS)) {
1019
+ importOpened = Boolean(await clickFirstVisible([page.locator(testId('multichain-account-menu-popover-add-imported-account'))], SHORT_TIMEOUT_MS));
1020
+ }
1021
+ }
1022
+ else {
1023
+ // 13.x: the add-wallet button sits below the (possibly long) account
1024
+ // list, so scroll it into view first, then pick "Import an account"
1025
+ // (runtime testid choose-wallet-type-import-account). The import page
1026
+ // defaults to the Private key tab, so no further wallet-type choice is
1027
+ // needed. Retry the picker → add-wallet → choose-type hop (it can race
1028
+ // the account-list render under load).
1029
+ for (let attempt = 0; attempt < 3 && !importOpened; attempt += 1) {
1030
+ if (attempt > 0) {
1031
+ await page.keyboard.press('Escape').catch(() => undefined);
1032
+ await this.openAccountPicker(page);
1033
+ }
1034
+ const addWallet = page.locator(testId('account-list-add-wallet-button')).first();
1035
+ await addWallet.scrollIntoViewIfNeeded({ timeout: SHORT_TIMEOUT_MS }).catch(() => undefined);
1036
+ if (await clickFirstVisible([addWallet], DEFAULT_TIMEOUT_MS)) {
1037
+ importOpened = Boolean(await clickFirstVisible([
1038
+ page.locator(testId('choose-wallet-type-import-account')),
1039
+ page.getByText('Import an account', { exact: true }),
1040
+ ], DEFAULT_TIMEOUT_MS));
1041
+ }
1042
+ }
1043
+ }
1044
+ if (!importOpened) {
1045
+ throw new Error('Unable to open the MetaMask import-account flow — update web3-tester for this MetaMask version.');
1046
+ }
1047
+ const keyFilled = await fillFirstVisible([page.locator('#private-key-box')], normalized);
1048
+ if (!keyFilled)
1049
+ throw new Error('Unable to find the MetaMask private key input.');
1050
+ const confirmed = await clickFirstVisible([page.locator(testId('import-account-confirm-button'))], DEFAULT_TIMEOUT_MS);
1051
+ if (!confirmed)
1052
+ throw new Error('Unable to confirm the MetaMask private key import.');
1053
+ // Success closes the dialog (the keyring import can take a moment);
1054
+ // failure keeps it open with inline help text.
1055
+ const dialogClosed = await isHidden(page.locator('#private-key-box'), DEFAULT_TIMEOUT_MS).catch(() => false);
1056
+ if (!dialogClosed) {
1057
+ const helpText = (await page.locator('.mm-help-text').first().textContent({ timeout: SHORT_TIMEOUT_MS }).catch(() => null))?.trim();
1058
+ throw new Error(`MetaMask rejected the private key import${helpText ? `: ${helpText}` : '.'}`);
1059
+ }
1060
+ // The imported account becomes active. 13.x leaves the SPA parked on
1061
+ // #/choose-new-wallet-type, so route back to the wallet home or later
1062
+ // home-screen lookups (account-menu-icon, etc.) time out.
1063
+ if (this.generation === '13x') {
1064
+ await page.goto(`${page.url().split('#')[0]}#/`).catch(() => undefined);
1065
+ await waitForMetaMaskReady(page);
1066
+ }
1067
+ this.expectedAddress = undefined;
1068
+ await closeMetaMaskOverlay(page);
1069
+ }
1070
+ async addNewAccount(name) {
1071
+ const page = await this.preparedHome();
1072
+ if (this.generation === '12x') {
1073
+ await this.openAccountPicker(page);
1074
+ // 12.x: action button → Add account → optional name → submit.
1075
+ const actionOpened = await clickFirstVisible([page.locator(testId('multichain-account-menu-popover-action-button'))], SHORT_TIMEOUT_MS);
1076
+ const addOpened = actionOpened &&
1077
+ (await clickFirstVisible([page.locator(testId('multichain-account-menu-popover-add-account'))], SHORT_TIMEOUT_MS));
1078
+ if (!addOpened) {
1079
+ throw new Error('Unable to find the MetaMask add-account control — update web3-tester for this MetaMask version.');
1080
+ }
1081
+ if (name) {
1082
+ await fillFirstVisible([page.locator('#account-name'), page.locator(testId('account-name-input'))], name, SHORT_TIMEOUT_MS);
1083
+ }
1084
+ const submitted = await clickFirstVisible([
1085
+ page.locator(testId('submit-add-account-with-name')),
1086
+ page.getByRole('button', { name: /^(Add account|Create)$/i }),
1087
+ ], DEFAULT_TIMEOUT_MS);
1088
+ if (!submitted)
1089
+ throw new Error('Unable to submit the MetaMask add-account dialog.');
1090
+ this.expectedAddress = undefined;
1091
+ await closeMetaMaskOverlay(page);
1092
+ return;
1093
+ }
1094
+ // 13.x multichain: the "add account" control is NOT in the account picker
1095
+ // (its only add affordance, account-list-add-wallet-button, opens the
1096
+ // import/hardware "Add a wallet" flow). Adding a derived account lives on
1097
+ // the SRP wallet's details page. The create is a background dispatch that
1098
+ // the account-tree sync silently drops ~1/3 of the time (no toast, the new
1099
+ // account is not auto-selected), so the reliable approach is: settle the
1100
+ // tree, gate on a non-syncing button, click, then wait for the specific
1101
+ // new cell (the wallet's max index + 1) to attach — retrying across fresh
1102
+ // navigations. LavaMoat blocks bulk text reads, so everything is
1103
+ // count()/getAttribute()/testid based.
1104
+ const home = page.url().split('#')[0];
1105
+ await this.openAccountPicker(page);
1106
+ const walletId = await this.activeSrpWalletId(page);
1107
+ await page.keyboard.press('Escape').catch(() => undefined);
1108
+ await wait(ACCOUNT_TREE_SETTLE_MS);
1109
+ const walletCellPrefix = `[data-testid^="multichain-account-cell-${walletId}/"]`;
1110
+ const createAttempts = 8;
1111
+ let newIndex = -1;
1112
+ for (let attempt = 0; attempt < createAttempts && newIndex < 0; attempt += 1) {
1113
+ // Between attempts, let the account-tree backup-and-sync (which drops
1114
+ // the create when it overlaps, and retries on its own backoff) calm
1115
+ // before re-navigating — back off a little further each time.
1116
+ if (attempt > 0)
1117
+ await wait(2_000 + attempt * 1_500);
1118
+ await page.goto(`${home}#/multichain-wallet-details-page?id=${encodeURIComponent(walletId)}`);
1119
+ await waitForMetaMaskReady(page);
1120
+ const addButton = page.locator(testId('add-multichain-account-button')).first();
1121
+ if (!(await addButton.waitFor({ state: 'visible', timeout: DEFAULT_TIMEOUT_MS }).then(() => true).catch(() => false))) {
1122
+ throw new Error('Unable to find the MetaMask add-account control on the wallet details page — update web3-tester for this MetaMask version.');
1123
+ }
1124
+ await this.waitForAddAccountReady(addButton);
1125
+ // The wallet's cells are index-ordered and contiguous; the new account
1126
+ // is one past the current maximum. Read the last cell's id with a single
1127
+ // call (enumerating all cells perturbs timing and flakes detection).
1128
+ const lastTestId = await page.locator(walletCellPrefix).last().getAttribute('data-testid').catch(() => null);
1129
+ const maxIndex = Number.parseInt(lastTestId?.split('/').pop() ?? '', 10);
1130
+ if (Number.isNaN(maxIndex)) {
1131
+ throw new Error('Unable to read the MetaMask wallet account indices to add an account.');
1132
+ }
1133
+ const candidate = maxIndex + 1;
1134
+ await addButton.click();
1135
+ const created = await page
1136
+ .locator(testId(`multichain-account-cell-${walletId}/${candidate}`))
1137
+ .waitFor({ state: 'attached', timeout: 15_000 })
1138
+ .then(() => true)
1139
+ .catch(() => false);
1140
+ if (created)
1141
+ newIndex = candidate;
1142
+ }
1143
+ if (newIndex < 0) {
1144
+ throw new Error(`MetaMask did not create a new account after ${createAttempts} attempts (account-tree sync ` +
1145
+ 'race) — retry the operation.');
1146
+ }
1147
+ this.expectedAddress = undefined;
1148
+ if (name) {
1149
+ // Rename via the account-details route: the new high-index cell's row
1150
+ // menu is unclickable at the foot of a long (un-virtualized) wallet list.
1151
+ await page.goto(`${home}#/multichain-account-details?accountGroupId=${encodeURIComponent(`${walletId}/${newIndex}`)}`);
1152
+ await waitForMetaMaskReady(page);
1153
+ const editOpened = await clickFirstVisible([page.locator(testId('account-name-action'))], DEFAULT_TIMEOUT_MS);
1154
+ if (!editOpened)
1155
+ throw new Error('Unable to open the MetaMask account name editor.');
1156
+ await this.fillAccountNameAndSave(page, name);
1157
+ }
1158
+ await page.goto(home);
1159
+ await waitForMetaMaskReady(page);
1160
+ await closeMetaMaskOverlay(page);
1161
+ }
1162
+ // The SRP (entropy) wallet id, parsed from the first multichain account
1163
+ // cell's test id (form: multichain-account-cell-<entropy:UID>/<index>).
1164
+ // Needed to address the wallet details page where account creation lives.
1165
+ async activeSrpWalletId(page) {
1166
+ const testIdAttr = await page
1167
+ .locator('[data-testid^="multichain-account-cell-entropy:"]')
1168
+ .first()
1169
+ .getAttribute('data-testid')
1170
+ .catch(() => null);
1171
+ const match = testIdAttr?.match(/^multichain-account-cell-(entropy:[^/]+)/);
1172
+ if (!match) {
1173
+ throw new Error('Unable to determine the MetaMask SRP wallet id from the account list — update web3-tester for this MetaMask version.');
1174
+ }
1175
+ return match[1];
1176
+ }
1177
+ async switchAccount(nameOrAddress) {
1178
+ const page = await this.preparedHome();
1179
+ await this.openAccountPicker(page);
1180
+ if (this.generation === '13x') {
1181
+ const cell = await this.find13xAccountCell(page, nameOrAddress);
1182
+ if (!cell) {
1183
+ throw new Error(`Unable to find MetaMask account "${nameOrAddress}" in the picker (searched the ` +
1184
+ 'virtualized account list by name/address).');
1185
+ }
1186
+ await cell.click();
1187
+ await waitForMetaMaskReady(page);
1188
+ this.expectedAddress = FULL_ADDRESS_PATTERN.test(nameOrAddress) ? nameOrAddress : undefined;
1189
+ await closeMetaMaskOverlay(page);
1190
+ return;
1191
+ }
1192
+ const row = accountRowLocator(page).filter({ hasText: accountRowMatcher(nameOrAddress) }).first();
1193
+ const clicked = await clickFirstVisible([row], DEFAULT_TIMEOUT_MS, { requireEnabled: false });
1194
+ if (!clicked) {
1195
+ throw new Error(`Unable to find MetaMask account "${nameOrAddress}" in the picker. On 13.x the rows show ` +
1196
+ 'names, so address matching is best-effort — prefer account names.');
1197
+ }
1198
+ await waitForMetaMaskReady(page);
1199
+ this.expectedAddress = FULL_ADDRESS_PATTERN.test(nameOrAddress) ? nameOrAddress : undefined;
1200
+ await closeMetaMaskOverlay(page);
1201
+ }
1202
+ async renameAccount(currentName, newName) {
1203
+ const page = await this.preparedHome();
1204
+ await this.openAccountPicker(page);
1205
+ if (this.generation === '13x') {
1206
+ const cell = await this.findAccountCellByName(page, currentName);
1207
+ if (!cell)
1208
+ throw new Error(`Unable to find MetaMask account "${currentName}" to rename.`);
1209
+ await this.rename13xCellViaMenu(page, cell, newName);
1210
+ await page.keyboard.press('Escape').catch(() => undefined);
1211
+ await closeMetaMaskOverlay(page);
1212
+ return;
1213
+ }
1214
+ const row = accountRowLocator(page).filter({ hasText: accountRowMatcher(currentName) }).first();
1215
+ if (!(await isVisible(row, DEFAULT_TIMEOUT_MS).catch(() => false))) {
1216
+ throw new Error(`Unable to find MetaMask account "${currentName}" to rename.`);
1217
+ }
1218
+ await this.renameAccountRow(page, row, newName, currentName);
1219
+ await page.keyboard.press('Escape').catch(() => undefined);
1220
+ await closeMetaMaskOverlay(page);
1221
+ }
1222
+ async lock() {
1223
+ const page = await this.preparedHome();
1224
+ await this.openGlobalMenu(page);
1225
+ const locked = await clickFirstVisible([page.locator(testId('global-menu-lock')), page.getByText(/^Lock( MetaMask)?$/i)], DEFAULT_TIMEOUT_MS);
1226
+ if (!locked)
1227
+ throw new Error('Unable to find the MetaMask lock action in the global menu.');
1228
+ const lockScreen = await findVisibleLocator(metaMaskUnlockPasswordLocators(page), DEFAULT_TIMEOUT_MS);
1229
+ if (!lockScreen)
1230
+ throw new Error('MetaMask did not show the unlock screen after locking.');
1231
+ }
1232
+ async unlock(password) {
1233
+ const target = password ?? this.walletPassword;
1234
+ if (!target) {
1235
+ throw new Error('unlock() needs a password — pass one or provide setup.password at launch.');
1236
+ }
1237
+ const page = await openExtensionHome(this.context, this.extensionId);
1238
+ if (!(await isMetaMaskUnlockVisible(page)))
1239
+ return;
1240
+ await unlockMetaMask(page, target);
1241
+ }
1242
+ async resetAccount() {
1243
+ const page = await this.preparedHome();
1244
+ await this.openGlobalMenu(page);
1245
+ const settingsOpened = await clickFirstVisible([page.locator(testId('global-menu-settings')), page.getByText(/^Settings$/i)], DEFAULT_TIMEOUT_MS);
1246
+ if (!settingsOpened)
1247
+ throw new Error('Unable to open MetaMask settings.');
1248
+ await waitForMetaMaskReady(page);
1249
+ if (this.generation === '12x') {
1250
+ // 12.x: "Clear activity tab data" lives on the Advanced tab (or is
1251
+ // already visible when settings opens straight onto it).
1252
+ if (await clickFirstVisible([page.locator(testId('advanced-setting-reset-account')).getByRole('button')], SHORT_TIMEOUT_MS)) {
1253
+ await this.confirmResetModal(page);
1254
+ await this.leaveSettings(page);
1255
+ return;
1256
+ }
1257
+ if (await clickFirstVisible([
1258
+ page.getByRole('tab', { name: /^Advanced$/i }),
1259
+ page.locator('.tab-bar__tab').filter({ hasText: /^Advanced$/ }),
1260
+ ], SHORT_TIMEOUT_MS)) {
1261
+ if (await clickFirstVisible([
1262
+ page.locator(testId('advanced-setting-reset-account')).getByRole('button'),
1263
+ page.getByRole('button', { name: /Clear activity( tab)? data/i }),
1264
+ ], SHORT_TIMEOUT_MS)) {
1265
+ await this.confirmResetModal(page);
1266
+ await this.leaveSettings(page);
1267
+ return;
1268
+ }
1269
+ }
1270
+ }
1271
+ else if (
1272
+ // 13.x: Developer tools tab → "Delete activity and nonce data".
1273
+ await clickFirstVisible([
1274
+ page.locator(testId('settings-tab-item-developer-tools')),
1275
+ page.getByText(/^Developer tools$/i),
1276
+ ], SHORT_TIMEOUT_MS)) {
1277
+ if (await clickFirstVisible([
1278
+ page.locator(testId('developer-options-delete-activity-and-nonce-data')).getByRole('button'),
1279
+ page.locator(testId('developer-options-delete-activity-and-nonce-data')),
1280
+ ], SHORT_TIMEOUT_MS)) {
1281
+ await this.confirmResetModal(page);
1282
+ await this.leaveSettings(page);
1283
+ return;
1284
+ }
1285
+ }
1286
+ // Fallback: the settings search (the Developer tools tab can be
1287
+ // feature-flag hidden in production profiles).
1288
+ if (await clickFirstVisible([page.locator(testId('settings-header-search-button'))], SHORT_TIMEOUT_MS)) {
1289
+ await fillFirstVisible([page.locator(testId('settings-header-search-input')), page.locator('input[type="search"]')], 'Clear activity', SHORT_TIMEOUT_MS);
1290
+ if (await clickFirstVisible([page.locator(testId('settings-search-result-item')).first()], SHORT_TIMEOUT_MS)) {
1291
+ const cleared = await clickFirstVisible([
1292
+ page.locator(testId('advanced-setting-reset-account')).getByRole('button'),
1293
+ page.locator(testId('developer-options-delete-activity-and-nonce-data')).getByRole('button'),
1294
+ page.getByRole('button', { name: /Clear activity( tab)? data|Delete activity/i }),
1295
+ ], DEFAULT_TIMEOUT_MS);
1296
+ if (cleared) {
1297
+ await this.confirmResetModal(page);
1298
+ await this.leaveSettings(page);
1299
+ return;
1300
+ }
1301
+ }
1302
+ }
1303
+ throw new Error('Unable to find the MetaMask reset-account control (Clear activity / Delete activity and ' +
1304
+ 'nonce data) — update web3-tester for this MetaMask version.');
1305
+ }
1306
+ async toggleShowTestNetworks(on) {
1307
+ const page = await this.preparedHome();
1308
+ if (this.generation === '13x') {
1309
+ // 13.x hosts the toggle on the standalone #/networks page (the network
1310
+ // picker popover only renders it once non-default testnets already
1311
+ // exist). It is a hidden <input type=checkbox> under a styled overlay.
1312
+ // react-toggle-button binds its `value` attribute (not the `checked`
1313
+ // property), so Playwright's check()/uncheck() — which assert the
1314
+ // never-updated `checked` — throw "did not change its state"; instead
1315
+ // force-click and confirm the `value` attribute flipped.
1316
+ const home = page.url().split('#')[0];
1317
+ const toggle = page.locator(testId('networks-page-show-test-networks')).first();
1318
+ // The first navigation to #/networks can race the React render; renavigate
1319
+ // until the toggle attaches.
1320
+ let toggleReady = false;
1321
+ for (let attempt = 0; attempt < 3 && !toggleReady; attempt += 1) {
1322
+ await page.goto(`${home}#/networks`);
1323
+ // Reaching #/networks as a hash-only change from the home route leaves
1324
+ // the toggle rendered but non-interactive (its click never fires);
1325
+ // force a full document load so its handlers bind.
1326
+ await page.reload().catch(() => undefined);
1327
+ await waitForMetaMaskReady(page);
1328
+ toggleReady = await toggle
1329
+ .waitFor({ state: 'attached', timeout: 10_000 })
1330
+ .then(() => true)
1331
+ .catch(() => false);
1332
+ }
1333
+ if (!toggleReady) {
1334
+ throw new Error('Unable to find the MetaMask "Show test networks" toggle on the networks page.');
1335
+ }
1336
+ // The testid sits on a hidden 1×1 screen-reader <input> bound by `value`
1337
+ // (not `checked`); force-clicking that tiny input is unreliable. Click the
1338
+ // wrapping label.toggle-button instead — the visible, hit-testable control
1339
+ // — and confirm the input's `value` attribute flipped (give the
1340
+ // controlled re-render a beat so a re-click can't flip it back).
1341
+ const toggleLabel = toggle.locator('xpath=ancestor-or-self::label[contains(@class,"toggle-button")][1]');
1342
+ const readOn = async () => (await toggle.getAttribute('value').catch(() => null)) === 'true';
1343
+ const target = on ?? !(await readOn());
1344
+ for (let attempt = 0; attempt < 5 && (await readOn()) !== target; attempt += 1) {
1345
+ await toggleLabel.click({ force: true }).catch(() => undefined);
1346
+ await wait(1_000);
1347
+ }
1348
+ if ((await readOn()) !== target) {
1349
+ throw new Error('Unable to flip the MetaMask "Show test networks" toggle on the networks page.');
1350
+ }
1351
+ await waitForMetaMaskReady(page);
1352
+ await page.goto(home);
1353
+ await waitForMetaMaskReady(page);
1354
+ await closeMetaMaskOverlay(page);
1355
+ return;
1356
+ }
1357
+ const pickerOpened = await clickFirstVisible(metaMaskNetworkPickerLocators(page), DEFAULT_TIMEOUT_MS);
1358
+ if (!pickerOpened)
1359
+ throw new Error('Unable to open the MetaMask network picker.');
1360
+ await waitForMetaMaskReady(page);
1361
+ // react-toggle-button renders the data-testid onto a 1×1 screen-reader
1362
+ // <input>; clicking that fails Playwright's hit-target check. Click the
1363
+ // wrapping label.toggle-button (the hit-testable track) instead.
1364
+ const toggle = await findVisibleLocator([
1365
+ page
1366
+ .getByText(/Show test networks/i)
1367
+ .locator('xpath=following::label[contains(@class,"toggle-button")][1]'),
1368
+ page.locator('label.toggle-button').first(),
1369
+ ], DEFAULT_TIMEOUT_MS, { requireEnabled: false });
1370
+ if (!toggle) {
1371
+ throw new Error('Unable to find the MetaMask "Show test networks" toggle.');
1372
+ }
1373
+ if (on === undefined) {
1374
+ await toggle.click();
1375
+ }
1376
+ else {
1377
+ const current = await this.readToggleState(toggle);
1378
+ if (current !== on) {
1379
+ await toggle.click();
1380
+ }
1381
+ }
1382
+ await waitForMetaMaskReady(page);
1383
+ await page.keyboard.press('Escape').catch(() => undefined);
1384
+ await closeMetaMaskOverlay(page);
1385
+ }
1386
+ async importToken(token) {
1387
+ if (!FULL_ADDRESS_PATTERN.test(token.address)) {
1388
+ throw new Error('importToken requires a 20-byte 0x token address.');
1389
+ }
1390
+ const page = await this.preparedHome();
1391
+ // The token list (and its control bar) lives on the Tokens tab.
1392
+ await clickFirstVisible([
1393
+ page.locator(testId('account-overview__asset-tab')),
1394
+ page.getByRole('button', { name: /^Tokens$/i }),
1395
+ ], SHORT_TIMEOUT_MS);
1396
+ const menuOpened = await clickFirstVisible([page.locator(testId('asset-list-control-bar-action-button'))], DEFAULT_TIMEOUT_MS);
1397
+ if (!menuOpened)
1398
+ throw new Error('Unable to open the MetaMask token list menu.');
1399
+ if (this.generation === '13x') {
1400
+ await this.importTokenViaTokenManagement(page, token);
1401
+ }
1402
+ else {
1403
+ await this.importTokenViaModal(page, token);
1404
+ }
1405
+ await closeMetaMaskOverlay(page);
1406
+ }
1407
+ // 13.x: control-bar menu → "Manage tokens" → token-management page → "Add a
1408
+ // custom token" → the custom-token-import page (address, then auto-loaded
1409
+ // symbol/decimals, then "Add token"). Replaces the 12.x import-tokens modal.
1410
+ async importTokenViaTokenManagement(page, token) {
1411
+ const manageOpened = await clickFirstVisible([page.locator(testId('manageTokens')), page.getByRole('button', { name: /^Manage tokens$/i })], DEFAULT_TIMEOUT_MS);
1412
+ if (!manageOpened)
1413
+ throw new Error('Unable to open the MetaMask "Manage tokens" page.');
1414
+ const addCustomOpened = await clickFirstVisible([
1415
+ page.locator(testId('token-management-add-custom-token-button')),
1416
+ page.getByRole('button', { name: /Add a custom token/i }),
1417
+ ], DEFAULT_TIMEOUT_MS);
1418
+ if (!addCustomOpened)
1419
+ throw new Error('Unable to open the MetaMask custom-token import form.');
1420
+ if (token.networkName) {
1421
+ const selectorOpened = await clickFirstVisible([page.locator(testId('network-selector')), page.getByRole('button', { name: /^Network$/i })], SHORT_TIMEOUT_MS);
1422
+ if (selectorOpened) {
1423
+ const picked = await clickFirstVisible([page.getByText(token.networkName, { exact: true })], DEFAULT_TIMEOUT_MS);
1424
+ if (!picked) {
1425
+ throw new Error(`Unable to select network "${token.networkName}" in the token import page.`);
1426
+ }
1427
+ }
1428
+ }
1429
+ const addressFilled = await fillFirstVisible([page.locator(testId('custom-token-import-address-input'))], token.address);
1430
+ if (!addressFilled)
1431
+ throw new Error('Unable to fill the token contract address.');
1432
+ // MetaMask auto-loads symbol/decimals from the contract and enables "Add
1433
+ // token". Only fill the fields if they stay EMPTY (the RPC read failed) —
1434
+ // re-filling an auto-populated field on every tick re-triggers validation
1435
+ // and keeps the submit button from ever settling enabled.
1436
+ const submit = page.locator(testId('custom-token-import-submit-button')).first();
1437
+ const deadline = Date.now() + DEFAULT_TIMEOUT_MS;
1438
+ while (Date.now() < deadline && !(await submit.isEnabled().catch(() => false))) {
1439
+ if (token.symbol) {
1440
+ await this.fillIfEmpty(page.locator(testId('custom-token-import-symbol-input')).first(), token.symbol);
1441
+ }
1442
+ if (token.decimals !== undefined) {
1443
+ await this.fillIfEmpty(page.locator(testId('custom-token-import-decimal-input')).first(), String(token.decimals));
1444
+ }
1445
+ await wait(LOCATOR_PROBE_MS);
1446
+ }
1447
+ const added = await clickFirstVisible([submit, page.getByRole('button', { name: /^Add token$/i })], DEFAULT_TIMEOUT_MS);
1448
+ if (!added) {
1449
+ throw new Error('MetaMask never enabled the custom-token "Add token" button — check the token address, ' +
1450
+ 'network, and (for contracts the RPC cannot read) pass symbol/decimals explicitly.');
1451
+ }
1452
+ // The import is confirmed by the success toast (and the form unmounting);
1453
+ // a regression that dismisses the form without importing fails here.
1454
+ const confirmed = await findVisibleLocator([
1455
+ page.locator(testId('token-management-custom-token-success-toast')),
1456
+ page.getByText(/was successfully added/i),
1457
+ ], DEFAULT_TIMEOUT_MS, { requireEnabled: false });
1458
+ if (!confirmed) {
1459
+ await isHidden(page.locator(testId('custom-token-import-address-input')), DEFAULT_TIMEOUT_MS).catch(() => undefined);
1460
+ }
1461
+ }
1462
+ // Fills a metadata field only when it is currently empty, so an
1463
+ // auto-populated value (MetaMask reading the contract) is left intact. A
1464
+ // tight loop that unconditionally re-fills an auto-filled field re-triggers
1465
+ // validation and prevents the submit/Next button from settling enabled.
1466
+ async fillIfEmpty(field, value) {
1467
+ const current = await field.inputValue({ timeout: LOCATOR_PROBE_MS }).catch(() => null);
1468
+ if (current === '')
1469
+ await field.fill(value).catch(() => undefined);
1470
+ }
1471
+ // 12.x: control-bar menu → "Import tokens" → Custom token tab → modal form.
1472
+ async importTokenViaModal(page, token) {
1473
+ const importClicked = await clickFirstVisible([page.locator(testId('importTokens')), page.getByText(/^Import tokens$/i)], DEFAULT_TIMEOUT_MS);
1474
+ if (!importClicked)
1475
+ throw new Error('Unable to open the MetaMask Import tokens dialog.');
1476
+ await clickFirstVisible([
1477
+ page.locator(testId('import-tokens-modal-custom-token-tab')),
1478
+ page.getByRole('tab', { name: /Custom token/i }),
1479
+ page.getByText('Custom token', { exact: true }),
1480
+ ], SHORT_TIMEOUT_MS);
1481
+ if (token.networkName) {
1482
+ const dropdownOpened = await clickFirstVisible([
1483
+ page.locator(testId('import-tokens-drop-down-custom-import')),
1484
+ page.locator(testId('test-import-tokens-drop-down-custom-import')),
1485
+ ], SHORT_TIMEOUT_MS);
1486
+ if (dropdownOpened) {
1487
+ const picked = await clickFirstVisible([page.getByText(token.networkName, { exact: true })], DEFAULT_TIMEOUT_MS);
1488
+ if (!picked) {
1489
+ throw new Error(`Unable to select network "${token.networkName}" in the token import dialog.`);
1490
+ }
1491
+ }
1492
+ }
1493
+ const addressFilled = await fillFirstVisible([page.locator(testId('import-tokens-modal-custom-address'))], token.address);
1494
+ if (!addressFilled)
1495
+ throw new Error('Unable to fill the token contract address.');
1496
+ // MetaMask auto-loads symbol/decimals from the contract and enables Next.
1497
+ // Only fill the fields if they stay EMPTY (the RPC read failed) — re-filling
1498
+ // an auto-populated field on every tick re-triggers validation and keeps
1499
+ // Next from ever settling enabled (observed on 12.23.1).
1500
+ const nextButton = page.locator(testId('import-tokens-button-next')).first();
1501
+ const deadline = Date.now() + DEFAULT_TIMEOUT_MS;
1502
+ while (Date.now() < deadline && !(await nextButton.isEnabled().catch(() => false))) {
1503
+ if (token.symbol) {
1504
+ await this.fillIfEmpty(page.locator(testId('import-tokens-modal-custom-symbol')).first(), token.symbol);
1505
+ }
1506
+ if (token.decimals !== undefined) {
1507
+ await this.fillIfEmpty(page.locator(testId('import-tokens-modal-custom-decimals')).first(), String(token.decimals));
1508
+ }
1509
+ await wait(LOCATOR_PROBE_MS);
1510
+ }
1511
+ const next = await clickFirstVisible([nextButton], SHORT_TIMEOUT_MS);
1512
+ if (!next) {
1513
+ throw new Error('MetaMask never enabled the Import Tokens "Next" button — check the token address, ' +
1514
+ 'network, and (for contracts the RPC cannot read) pass symbol/decimals explicitly.');
1515
+ }
1516
+ const imported = await clickFirstVisible([
1517
+ page.locator(testId('import-tokens-modal-import-button')),
1518
+ page.getByRole('button', { name: /^Import( tokens)?$/i }),
1519
+ ], DEFAULT_TIMEOUT_MS);
1520
+ if (!imported)
1521
+ throw new Error('Unable to confirm the MetaMask token import.');
1522
+ await isHidden(page.locator(testId('import-tokens-modal-custom-address')), DEFAULT_TIMEOUT_MS).catch(() => undefined);
1523
+ }
1524
+ async confirmTransactionAndWaitForMining(options = {}) {
1525
+ await this.confirmTransaction({ gasSetting: options.gasSetting });
1526
+ const timeoutMs = options.timeoutMs ?? 60_000;
1527
+ const page = await this.preparedHome();
1528
+ const activityOpened = await clickFirstVisible([
1529
+ page.locator(testId('account-overview__activity-tab')),
1530
+ page.getByRole('button', { name: /^Activity$/i }),
1531
+ ], DEFAULT_TIMEOUT_MS);
1532
+ if (!activityOpened)
1533
+ throw new Error('Unable to open the MetaMask activity tab.');
1534
+ const row = page
1535
+ .locator('[data-testid="transaction-list-item"], [data-testid="activity-list-item"], .transaction-list-item, .activity-list-item')
1536
+ .first();
1537
+ const statusVisible = (status) => isVisible(page.locator(testId(`transaction-status-label--${status}`)).first(), LOCATOR_PROBE_MS).catch(() => false);
1538
+ const deadline = Date.now() + timeoutMs;
1539
+ let confirmed = false;
1540
+ while (Date.now() < deadline) {
1541
+ if ((await statusVisible('failed')) || (await statusVisible('dropped'))) {
1542
+ throw new Error('The newest MetaMask activity entry failed or was dropped.');
1543
+ }
1544
+ if (await statusVisible('confirmed')) {
1545
+ confirmed = true;
1546
+ break;
1547
+ }
1548
+ // Instant-mining nodes may never render a pending state: a visible row
1549
+ // with no pending/queued label counts as confirmed.
1550
+ if ((await isVisible(row, LOCATOR_PROBE_MS).catch(() => false)) &&
1551
+ !(await statusVisible('pending')) &&
1552
+ !(await statusVisible('queued'))) {
1553
+ confirmed = true;
1554
+ break;
1555
+ }
1556
+ await wait(LOCATOR_PROBE_MS);
1557
+ }
1558
+ if (!confirmed) {
1559
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for the transaction to confirm in the activity tab.`);
1560
+ }
1561
+ // Best-effort hash read — never throws; the mining wait already passed.
1562
+ let txHash;
1563
+ try {
1564
+ if (await clickFirstVisible([row], SHORT_TIMEOUT_MS, { requireEnabled: false })) {
1565
+ await waitForMetaMaskReady(page);
1566
+ const copied = await clickFirstVisible([
1567
+ page.getByRole('button', { name: /Copy transaction ID/i }),
1568
+ page.getByText(/Copy transaction ID/i),
1569
+ ], SHORT_TIMEOUT_MS, { requireEnabled: false });
1570
+ if (copied) {
1571
+ const pasted = await this.readClipboardViaPaste();
1572
+ if (isFullTxHash(pasted))
1573
+ txHash = pasted;
1574
+ }
1575
+ await closeMetaMaskOverlay(page);
1576
+ await page.keyboard.press('Escape').catch(() => undefined);
1577
+ }
1578
+ }
1579
+ catch {
1580
+ // txHash stays undefined.
1581
+ }
1582
+ return { txHash };
1583
+ }
1584
+ // ── shared private helpers for the account/settings surface ─────────────
1585
+ async preparedHome() {
1586
+ const page = await this.home();
1587
+ await waitForMetaMaskHome(page);
1588
+ await closeMetaMaskOverlay(page);
1589
+ return page;
1590
+ }
1591
+ async openAccountPicker(page) {
1592
+ // Under load the home screen can still be settling when the picker icon is
1593
+ // probed; on retry, re-navigate to a clean home render before re-clicking.
1594
+ // (The icon is an always-enabled control, so don't gate on enabled-ness.)
1595
+ const home = page.url().split('#')[0];
1596
+ let opened;
1597
+ for (let attempt = 0; attempt < 3 && !opened; attempt += 1) {
1598
+ if (attempt > 0) {
1599
+ await page.goto(`${home}#/`).catch(() => undefined);
1600
+ await waitForMetaMaskHome(page, DEFAULT_TIMEOUT_MS);
1601
+ }
1602
+ opened = await clickFirstVisible([page.locator(testId('account-menu-icon'))], DEFAULT_TIMEOUT_MS, {
1603
+ requireEnabled: false,
1604
+ });
1605
+ }
1606
+ if (!opened)
1607
+ throw new Error('Unable to open the MetaMask account picker.');
1608
+ await this.settleAccountList(page);
1609
+ }
1610
+ // Let the (virtualized) account list settle: wait for the first row, then
1611
+ // for the row count to stop growing across two probe ticks.
1612
+ async settleAccountList(page) {
1613
+ await waitForMetaMaskReady(page);
1614
+ await isVisible(accountRowLocator(page).first(), DEFAULT_TIMEOUT_MS).catch(() => undefined);
1615
+ const rows = accountRowLocator(page);
1616
+ const deadline = Date.now() + SHORT_TIMEOUT_MS;
1617
+ let lastCount = -1;
1618
+ while (Date.now() < deadline) {
1619
+ const count = await rows.count().catch(() => lastCount);
1620
+ if (count === lastCount)
1621
+ break;
1622
+ lastCount = count;
1623
+ await wait(LOCATOR_PROBE_MS);
1624
+ }
1625
+ }
1626
+ // ── 13.x virtualized-picker account helpers ─────────────────────────────
1627
+ // The 13.x picker renders only a small window of a (potentially huge)
1628
+ // account tree, so name/address lookups must narrow it via the search box
1629
+ // first. Entropy (SRP-derived) and keyring (imported) accounts both render
1630
+ // as multichain-account-cell-<groupId> cells.
1631
+ accountGroupCells(page) {
1632
+ return page.locator('[data-testid^="multichain-account-cell-entropy:"], [data-testid^="multichain-account-cell-keyring:"]');
1633
+ }
1634
+ // Fill the picker search box to narrow the list to `term`. The testid is on
1635
+ // a wrapper; the editable input is nested. No-op (returns false) when absent.
1636
+ async searchAccountPicker(page, term) {
1637
+ const input = page.locator(`${testId('multichain-account-list-search')} input`).first();
1638
+ if (!(await isVisible(input, SHORT_TIMEOUT_MS).catch(() => false)))
1639
+ return false;
1640
+ await input.fill('');
1641
+ await input.fill(term);
1642
+ // The filter debounces; give the list a beat to re-render.
1643
+ await wait(700);
1644
+ return true;
1645
+ }
1646
+ // The account cell whose display name (its first <p>) exactly equals `name`,
1647
+ // after narrowing by search. Search is a contains filter, so the exact
1648
+ // match is enforced client-side (cell.textContent concatenates
1649
+ // name+address+balance, so an anchored full-text regex cannot be used).
1650
+ async findAccountCellByName(page, name) {
1651
+ await this.searchAccountPicker(page, name);
1652
+ const cells = this.accountGroupCells(page);
1653
+ const count = await cells.count();
1654
+ for (let index = 0; index < count; index += 1) {
1655
+ const cell = cells.nth(index);
1656
+ const cellName = (await cell.locator('p').first().textContent({ timeout: SHORT_TIMEOUT_MS }).catch(() => null))?.trim();
1657
+ if (cellName === name)
1658
+ return cell;
1659
+ }
1660
+ return undefined;
1661
+ }
1662
+ // Resolves a 13.x account cell by exact display name, or — for an address —
1663
+ // the derivable imported-keyring cell, else any cell whose text carries the
1664
+ // (full or shortened) address.
1665
+ async find13xAccountCell(page, nameOrAddress) {
1666
+ if (!FULL_ADDRESS_PATTERN.test(nameOrAddress)) {
1667
+ return this.findAccountCellByName(page, nameOrAddress);
1668
+ }
1669
+ const addressLc = nameOrAddress.toLowerCase();
1670
+ await this.searchAccountPicker(page, nameOrAddress);
1671
+ // Imported (private-key) accounts have a fully derivable cell testid.
1672
+ const imported = page
1673
+ .locator(testId(`multichain-account-cell-keyring:Simple Key Pair/${addressLc}`))
1674
+ .first();
1675
+ if (await isVisible(imported, SHORT_TIMEOUT_MS).catch(() => false))
1676
+ return imported;
1677
+ const short = shortAddress(nameOrAddress);
1678
+ const cells = this.accountGroupCells(page);
1679
+ const count = await cells.count();
1680
+ for (let index = 0; index < count; index += 1) {
1681
+ const cell = cells.nth(index);
1682
+ const text = ((await cell.textContent({ timeout: SHORT_TIMEOUT_MS }).catch(() => '')) ?? '')
1683
+ .replace(/\s+/g, '')
1684
+ .toLowerCase();
1685
+ if (text.includes(addressLc) || text.includes(short))
1686
+ return cell;
1687
+ }
1688
+ return undefined;
1689
+ }
1690
+ // 13.x rename via a surfaced (searched/visible) account cell's row menu.
1691
+ async rename13xCellViaMenu(page, cell, newName) {
1692
+ await cell.hover().catch(() => undefined);
1693
+ await wait(LOCATOR_PROBE_MS);
1694
+ const accessoryOpened = await clickFirstVisible([cell.locator(testId('multichain-account-cell-end-accessory'))], SHORT_TIMEOUT_MS);
1695
+ const renameClicked = accessoryOpened &&
1696
+ (await clickFirstVisible([
1697
+ page.locator(testId('multichain-account-menu-item-rename')),
1698
+ page.getByRole('menuitem', { name: /^Rename$/i }),
1699
+ ], SHORT_TIMEOUT_MS));
1700
+ if (!renameClicked) {
1701
+ throw new Error('Unable to open the MetaMask rename dialog from the account row menu.');
1702
+ }
1703
+ await this.fillAccountNameAndSave(page, newName);
1704
+ }
1705
+ // Polls the wallet-details "Add account" button until it reads exactly
1706
+ // "Add account" (not "Syncing…"/"Adding account…") for two consecutive
1707
+ // reads — only then is the create dispatch not silently dropped.
1708
+ async waitForAddAccountReady(addButton) {
1709
+ const deadline = Date.now() + DEFAULT_TIMEOUT_MS;
1710
+ let stable = 0;
1711
+ while (Date.now() < deadline) {
1712
+ const label = (await addButton.textContent({ timeout: SHORT_TIMEOUT_MS }).catch(() => ''))?.trim();
1713
+ if (label === 'Add account') {
1714
+ stable += 1;
1715
+ if (stable >= 2) {
1716
+ await wait(1_500);
1717
+ return;
1718
+ }
1719
+ }
1720
+ else {
1721
+ stable = 0;
1722
+ }
1723
+ await wait(LOCATOR_PROBE_MS);
1724
+ }
1725
+ }
1726
+ async openGlobalMenu(page) {
1727
+ const opened = await clickFirstVisible([page.locator(testId('account-options-menu-button'))], DEFAULT_TIMEOUT_MS);
1728
+ if (!opened)
1729
+ throw new Error('Unable to open the MetaMask global menu.');
1730
+ }
1731
+ // 12.x rename: row options menu → Account details → editable label. (13.x
1732
+ // renames through rename13xCellViaMenu / the account-details route.)
1733
+ async renameAccountRow(page, row, newName, label) {
1734
+ const menuOpened = await clickFirstVisible([
1735
+ row.locator(testId('account-list-item-menu-button')),
1736
+ ...(label ? [page.getByRole('button', { name: `${label} Options` })] : []),
1737
+ ], SHORT_TIMEOUT_MS);
1738
+ if (!menuOpened) {
1739
+ throw new Error('Unable to open the MetaMask account row menu to rename.');
1740
+ }
1741
+ const detailsOpened = await clickFirstVisible([page.locator(testId('account-list-menu-details'))], DEFAULT_TIMEOUT_MS);
1742
+ if (!detailsOpened)
1743
+ throw new Error('Unable to open the MetaMask account details to rename.');
1744
+ const editOpened = await clickFirstVisible([page.locator(testId('editable-label-button'))], DEFAULT_TIMEOUT_MS);
1745
+ if (!editOpened)
1746
+ throw new Error('Unable to open the MetaMask account name editor.');
1747
+ await this.fillAccountNameAndSave(page, newName);
1748
+ await closeMetaMaskOverlay(page);
1749
+ }
1750
+ async fillAccountNameAndSave(page, newName) {
1751
+ const filled = await fillFirstVisible([
1752
+ page.locator(`${testId('account-name-input')} input`),
1753
+ page.locator(testId('account-name-input')),
1754
+ page.locator(`${testId('editable-input')} input`),
1755
+ page.locator(testId('editable-input')),
1756
+ ], newName, DEFAULT_TIMEOUT_MS);
1757
+ if (!filled)
1758
+ throw new Error('Unable to fill the MetaMask account name input.');
1759
+ const saved = await clickFirstVisible([
1760
+ page.locator(testId('save-account-label-input')),
1761
+ page.locator('.mm-button-base[aria-label="Confirm"]'),
1762
+ page.getByRole('button', { name: /^(Confirm|Save)$/i }),
1763
+ ], DEFAULT_TIMEOUT_MS);
1764
+ if (!saved)
1765
+ throw new Error('Unable to save the MetaMask account name.');
1766
+ await waitForMetaMaskReady(page);
1767
+ }
1768
+ async confirmResetModal(page) {
1769
+ // 12.x labels the confirm "Clear"; 13.x labels it "Delete".
1770
+ const confirmed = await clickFirstVisible([
1771
+ page.locator(testId('delete-activity-and-nonce-data-button')),
1772
+ page.getByRole('button', { name: /^(Clear|Delete)$/i }),
1773
+ page.locator('.modal button.btn-danger-primary'),
1774
+ ], DEFAULT_TIMEOUT_MS);
1775
+ if (!confirmed)
1776
+ throw new Error('Unable to confirm the MetaMask reset-account dialog.');
1777
+ await waitForMetaMaskReady(page);
1778
+ }
1779
+ async leaveSettings(page) {
1780
+ await clickFirstVisible([page.locator(testId('settings-back-button')), page.locator('.settings-page__close-button')], SHORT_TIMEOUT_MS);
1781
+ await page.goto(extensionUrl(this.extensionId)).catch(() => undefined);
1782
+ await waitForMetaMaskReady(page);
1783
+ await closeMetaMaskOverlay(page);
1784
+ }
1785
+ async readToggleState(toggle) {
1786
+ // react-toggle-button's state lives in the label's toggle-button--on/off
1787
+ // class. Its inner <input type=checkbox> is bound by `value`, NOT
1788
+ // `checked` (and is never clicked directly), so isChecked() always reads
1789
+ // false — read the class first. `toggle` is the label.toggle-button.
1790
+ const ownClass = (await toggle.getAttribute('class').catch(() => null)) ?? '';
1791
+ const labelClass = /toggle-button--(on|off)/.test(ownClass)
1792
+ ? ownClass
1793
+ : (await toggle
1794
+ .locator('xpath=ancestor-or-self::label[contains(@class,"toggle-button")][1]')
1795
+ .getAttribute('class')
1796
+ .catch(() => null)) ?? ownClass;
1797
+ if (/toggle-button--on/.test(labelClass))
1798
+ return true;
1799
+ if (/toggle-button--off/.test(labelClass))
1800
+ return false;
1801
+ const ariaChecked = await toggle.getAttribute('aria-checked').catch(() => null);
1802
+ if (ariaChecked === 'true' || ariaChecked === 'false')
1803
+ return ariaChecked === 'true';
1804
+ const checkbox = toggle.locator('input[type="checkbox"]').first();
1805
+ const checked = await checkbox.isChecked({ timeout: LOCATOR_PROBE_MS }).catch(() => undefined);
1806
+ if (checked !== undefined)
1807
+ return checked;
1808
+ return undefined;
1809
+ }
594
1810
  async home() {
595
1811
  const page = await openExtensionHome(this.context, this.extensionId);
596
1812
  await unlockMetaMaskIfNeeded(page, this.walletPassword);
@@ -627,6 +1843,7 @@ class MetaMaskRealWallet {
627
1843
  ], timeout);
628
1844
  if (!confirmed)
629
1845
  throw new Error('Unable to confirm MetaMask notification.');
1846
+ return confirmed;
630
1847
  }
631
1848
  async rejectFooterAction(page, extraLocators = []) {
632
1849
  const rejected = await clickFirstVisible([
@@ -642,16 +1859,38 @@ class MetaMaskRealWallet {
642
1859
  async selectAccounts(page, accounts) {
643
1860
  if (!accounts?.length)
644
1861
  return;
645
- const rows = page.locator('.choose-account-list .choose-account-list__account');
1862
+ // Newer MetaMask shows the currently selected account with an "Edit
1863
+ // accounts" affordance; expand it so the full list renders.
1864
+ await clickFirstVisible([
1865
+ page.locator(testId('edit-accounts')),
1866
+ page.getByRole('button', { name: /Edit accounts?/i }),
1867
+ ], SHORT_TIMEOUT_MS);
1868
+ const rowLocators = [
1869
+ page.locator('.choose-account-list .choose-account-list__account'),
1870
+ page.locator(testId('choose-account-list')).locator('[role="listitem"], li'),
1871
+ page.locator('.multichain-account-list-item'),
1872
+ page.locator('[data-testid="account-list-item"]'),
1873
+ ];
1874
+ let rows;
1875
+ for (const candidate of rowLocators) {
1876
+ if ((await candidate.count()) > 0) {
1877
+ rows = candidate;
1878
+ break;
1879
+ }
1880
+ }
1881
+ if (!rows) {
1882
+ throw new Error(`Unable to locate the MetaMask account list in the connect prompt to select: ${accounts.join(', ')}. ` +
1883
+ 'Connect without the accounts argument to use the default account, or update web3-tester for this MetaMask version.');
1884
+ }
646
1885
  const rowCount = await rows.count();
647
- if (!rowCount)
648
- return;
649
1886
  const expected = accounts.map((account) => account.toLowerCase());
650
1887
  const expectedShort = accounts.map(shortAddress);
1888
+ let matched = false;
651
1889
  for (let index = 0; index < rowCount; index += 1) {
652
1890
  const row = rows.nth(index);
653
1891
  const text = ((await row.textContent()) ?? '').toLowerCase();
654
- const matches = expected.some((account) => text.includes(account)) || expectedShort.some((account) => text.includes(account));
1892
+ const matches = expected.some((account) => text.includes(account)) ||
1893
+ expectedShort.some((account) => text.includes(account));
655
1894
  if (!matches)
656
1895
  continue;
657
1896
  const checkbox = row.locator('input[type="checkbox"]').first();
@@ -659,9 +1898,10 @@ class MetaMaskRealWallet {
659
1898
  await checkbox.check();
660
1899
  else
661
1900
  await row.click();
662
- return;
1901
+ matched = true;
1902
+ break;
663
1903
  }
664
- if (rowCount > 1) {
1904
+ if (!matched) {
665
1905
  throw new Error(`Unable to find requested MetaMask account in connect prompt: ${accounts.join(', ')}.`);
666
1906
  }
667
1907
  }
@@ -722,20 +1962,39 @@ export async function launchRealWallet(options) {
722
1962
  if (!fs.existsSync(options.extensionPath)) {
723
1963
  throw new Error(`MetaMask extension path does not exist: ${options.extensionPath}`);
724
1964
  }
1965
+ const generation = options.generation ?? detectWalletGeneration(options.extensionPath);
1966
+ const headless = resolveRealWalletHeadless(options.headless);
725
1967
  const profile = resolveRealWalletProfile(options.profileDir);
726
- const context = await chromium.launchPersistentContext(profile.userDataDir, {
1968
+ const context = await chromium
1969
+ .launchPersistentContext(profile.userDataDir, {
727
1970
  args: [
728
1971
  ...(profile.profileDirectory ? [`--profile-directory=${profile.profileDirectory}`] : []),
729
1972
  `--disable-extensions-except=${options.extensionPath}`,
730
1973
  `--load-extension=${options.extensionPath}`,
731
1974
  ],
732
1975
  baseURL: options.baseURL,
733
- headless: options.headless ?? false,
1976
+ // channel 'chromium' is the Playwright-blessed way to run extensions
1977
+ // both headed and headless: it selects the full Chromium build (the
1978
+ // default headless shell cannot load extensions).
1979
+ channel: 'chromium',
1980
+ headless,
1981
+ // Text fallbacks in the selector stacks are English; pin the UI locale.
1982
+ locale: 'en-US',
734
1983
  slowMo: options.slowMo,
1984
+ })
1985
+ .catch((error) => {
1986
+ const message = error instanceof Error ? error.message : String(error);
1987
+ if (/executable doesn't exist/i.test(message)) {
1988
+ throw new Error("Real-wallet mode needs the full Chromium build for channel 'chromium' — a " +
1989
+ 'chromium_headless_shell-only install cannot load extensions. ' +
1990
+ 'Run: npx playwright install chromium\n' +
1991
+ message);
1992
+ }
1993
+ throw error;
735
1994
  });
736
1995
  const extensionId = await getExtensionId(context, extensionName);
737
1996
  const page = await openExtensionHome(context, extensionId);
738
- const wallet = new MetaMaskRealWallet(context, page, extensionId, options.expectedAddress, passwordForSetup(options.setup));
1997
+ const wallet = new MetaMaskRealWallet(context, page, extensionId, options.expectedAddress, passwordForSetup(options.setup), generation);
739
1998
  await prepareMetaMask({
740
1999
  expectedAddress: options.expectedAddress,
741
2000
  page,
@@ -743,16 +2002,36 @@ export async function launchRealWallet(options) {
743
2002
  wallet,
744
2003
  });
745
2004
  return {
2005
+ addNetwork: (network) => wallet.addNetwork(network),
2006
+ addNewAccount: (name) => wallet.addNewAccount(name),
2007
+ addNewToken: () => wallet.addNewToken(),
2008
+ approveAddToken: () => wallet.approveAddToken(),
2009
+ approveNewNetwork: () => wallet.approveNewNetwork(),
2010
+ approveSwitchNetwork: () => wallet.approveSwitchNetwork(),
746
2011
  approveTokenPermission: (approvalOptions) => wallet.approveTokenPermission(approvalOptions),
747
2012
  close: () => context.close(),
748
2013
  confirmSignature: () => wallet.confirmSignature(),
749
2014
  confirmTransaction: (confirmationOptions) => wallet.confirmTransaction(confirmationOptions),
2015
+ confirmTransactionAndWaitForMining: (miningOptions) => wallet.confirmTransactionAndWaitForMining(miningOptions),
750
2016
  connectToDapp: (accounts) => wallet.connectToDapp(accounts),
751
2017
  context,
752
2018
  extensionId,
753
2019
  getAccountAddress: () => wallet.getAccountAddress(),
2020
+ importToken: (token) => wallet.importToken(token),
2021
+ importWalletFromPrivateKey: (privateKey) => wallet.importWalletFromPrivateKey(privateKey),
2022
+ lock: () => wallet.lock(),
2023
+ rejectAddToken: () => wallet.rejectAddToken(),
2024
+ rejectNewNetwork: () => wallet.rejectNewNetwork(),
754
2025
  rejectSignature: () => wallet.rejectSignature(),
2026
+ rejectSwitchNetwork: () => wallet.rejectSwitchNetwork(),
2027
+ rejectTokenPermission: () => wallet.rejectTokenPermission(),
755
2028
  rejectTransaction: () => wallet.rejectTransaction(),
2029
+ renameAccount: (currentName, newName) => wallet.renameAccount(currentName, newName),
2030
+ resetAccount: () => wallet.resetAccount(),
2031
+ switchAccount: (nameOrAddress) => wallet.switchAccount(nameOrAddress),
2032
+ switchNetwork: (name, switchOptions) => wallet.switchNetwork(name, switchOptions),
2033
+ toggleShowTestNetworks: (on) => wallet.toggleShowTestNetworks(on),
2034
+ unlock: (password) => wallet.unlock(password),
756
2035
  wallet,
757
2036
  };
758
2037
  }