@marigoldlabs/web3-tester 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +317 -11
  2. package/dist/anvil.d.ts.map +1 -1
  3. package/dist/anvil.js.map +1 -1
  4. package/dist/benchmark.d.ts +55 -0
  5. package/dist/benchmark.d.ts.map +1 -0
  6. package/dist/benchmark.js +168 -0
  7. package/dist/benchmark.js.map +1 -0
  8. package/dist/fixtures.js +2 -2
  9. package/dist/fixtures.js.map +1 -1
  10. package/dist/index.d.ts +13 -4
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +5 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/injected-provider.d.ts.map +1 -1
  15. package/dist/injected-provider.js +748 -25
  16. package/dist/injected-provider.js.map +1 -1
  17. package/dist/mock-wallet-controller.d.ts +150 -2
  18. package/dist/mock-wallet-controller.d.ts.map +1 -1
  19. package/dist/mock-wallet-controller.js +613 -24
  20. package/dist/mock-wallet-controller.js.map +1 -1
  21. package/dist/private-key-rpc-client.d.ts +144 -132
  22. package/dist/private-key-rpc-client.d.ts.map +1 -1
  23. package/dist/private-key-rpc-client.js +142 -20
  24. package/dist/private-key-rpc-client.js.map +1 -1
  25. package/dist/real-wallet-cache.d.ts +38 -0
  26. package/dist/real-wallet-cache.d.ts.map +1 -1
  27. package/dist/real-wallet-cache.js +143 -57
  28. package/dist/real-wallet-cache.js.map +1 -1
  29. package/dist/real-wallet-extension-fixtures.d.ts +35 -0
  30. package/dist/real-wallet-extension-fixtures.d.ts.map +1 -0
  31. package/dist/real-wallet-extension-fixtures.js +96 -0
  32. package/dist/real-wallet-extension-fixtures.js.map +1 -0
  33. package/dist/real-wallet-extension.d.ts +86 -0
  34. package/dist/real-wallet-extension.d.ts.map +1 -0
  35. package/dist/real-wallet-extension.js +245 -0
  36. package/dist/real-wallet-extension.js.map +1 -0
  37. package/dist/real-wallet-fixtures.d.ts.map +1 -1
  38. package/dist/real-wallet-fixtures.js +46 -31
  39. package/dist/real-wallet-fixtures.js.map +1 -1
  40. package/dist/real-wallet.d.ts +5 -14
  41. package/dist/real-wallet.d.ts.map +1 -1
  42. package/dist/real-wallet.js +668 -435
  43. package/dist/real-wallet.js.map +1 -1
  44. package/dist/safe.d.ts +311 -0
  45. package/dist/safe.d.ts.map +1 -0
  46. package/dist/safe.js +743 -0
  47. package/dist/safe.js.map +1 -0
  48. package/dist/types.d.ts +35 -1
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/wallet-personas.d.ts +99 -0
  51. package/dist/wallet-personas.d.ts.map +1 -0
  52. package/dist/wallet-personas.js +666 -0
  53. package/dist/wallet-personas.js.map +1 -0
  54. package/dist/walletconnect.d.ts +176 -9
  55. package/dist/walletconnect.d.ts.map +1 -1
  56. package/dist/walletconnect.js +514 -74
  57. package/dist/walletconnect.js.map +1 -1
  58. package/examples/playwright.config.ts +8 -0
  59. package/package.json +29 -3
@@ -1,16 +1,15 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { chromium } from '@playwright/test';
4
1
  import { extensionManifestVersion } from './metamask-extension.js';
2
+ import { extensionPageUrl, launchRealWalletExtension, } from './real-wallet-extension.js';
5
3
  import { passwordForSetup } from './real-wallet-setup.js';
4
+ export { resolveRealWalletHeadless, resolveRealWalletProfile } from './real-wallet-extension.js';
6
5
  const DEFAULT_EXTENSION_NAME = 'MetaMask';
7
6
  const DEFAULT_TIMEOUT_MS = 30_000;
8
7
  const SHORT_TIMEOUT_MS = 2_000;
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;
8
+ const LOCATOR_PROBE_MS = 100;
9
+ // A short settle before 13.x account creation. The retry loop handles the
10
+ // pathological public-SRP account-tree sync case; do not tax normal wallets
11
+ // with the old multi-second fixed dwell.
12
+ const ACCOUNT_TREE_SETTLE_MS = 250;
14
13
  const FULL_ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/;
15
14
  const testId = (id) => `[data-testid="${id}"]`;
16
15
  /**
@@ -45,43 +44,8 @@ function detectWalletGeneration(extensionPath) {
45
44
  return '13x';
46
45
  }
47
46
  }
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
- }
70
47
  function extensionUrl(extensionId, page = 'home.html') {
71
- return `chrome-extension://${extensionId}/${page}`;
72
- }
73
- function extensionIdFromUrl(url) {
74
- return /^chrome-extension:\/\/([^/]+)\//.exec(url)?.[1];
75
- }
76
- export function resolveRealWalletProfile(profileDir) {
77
- const resolved = path.resolve(profileDir);
78
- const profileDirectory = path.basename(resolved);
79
- const userDataDir = path.dirname(resolved);
80
- const looksLikeChromeProfile = /^(?:Default|Profile \d+)$/.test(profileDirectory);
81
- if (looksLikeChromeProfile && fs.existsSync(path.join(userDataDir, 'Local State'))) {
82
- return { profileDirectory, userDataDir };
83
- }
84
- return { userDataDir: resolved };
48
+ return extensionPageUrl(extensionId, page);
85
49
  }
86
50
  async function isVisible(locator, timeout = SHORT_TIMEOUT_MS) {
87
51
  await locator.first().waitFor({ state: 'visible', timeout });
@@ -96,6 +60,24 @@ function wait(ms) {
96
60
  setTimeout(resolve, ms);
97
61
  });
98
62
  }
63
+ async function realWalletTiming(label, action) {
64
+ if (process.env.WEB3_TESTER_REAL_WALLET_TIMING !== 'true')
65
+ return action();
66
+ const startedAt = Date.now();
67
+ try {
68
+ const result = await action();
69
+ process.stderr.write(`[web3-tester real-wallet timing] ${label} ${Date.now() - startedAt}ms\n`);
70
+ return result;
71
+ }
72
+ catch (error) {
73
+ process.stderr.write(`[web3-tester real-wallet timing] ${label} failed ${Date.now() - startedAt}ms\n`);
74
+ throw error;
75
+ }
76
+ }
77
+ function isRecoverablePageNavigationError(error) {
78
+ const message = error instanceof Error ? error.message : String(error);
79
+ return /page crashed|target page, context or browser has been closed|browser has disconnected/i.test(message);
80
+ }
99
81
  async function findVisibleLocator(locators, timeout = SHORT_TIMEOUT_MS, options = {}) {
100
82
  const deadline = Date.now() + timeout;
101
83
  do {
@@ -131,6 +113,26 @@ async function fillFirstVisible(locators, value, timeout = DEFAULT_TIMEOUT_MS) {
131
113
  await target.fill(value);
132
114
  return true;
133
115
  }
116
+ async function fillFirstVisibleStableValue(locators, value, timeout = DEFAULT_TIMEOUT_MS) {
117
+ const deadline = Date.now() + timeout;
118
+ do {
119
+ const remaining = Math.max(deadline - Date.now(), 1);
120
+ const target = await findVisibleLocator(locators, Math.min(DEFAULT_TIMEOUT_MS, remaining));
121
+ if (!target)
122
+ continue;
123
+ await target.fill(value, { timeout: Math.min(DEFAULT_TIMEOUT_MS, remaining) });
124
+ await wait(250);
125
+ const actual = await target
126
+ .inputValue({ timeout: 0 })
127
+ .catch(() => target.locator('input').first().inputValue({ timeout: 0 }).catch(() => undefined));
128
+ if (actual === value)
129
+ return true;
130
+ const delay = Math.min(LOCATOR_PROBE_MS, Math.max(deadline - Date.now(), 0));
131
+ if (delay > 0)
132
+ await wait(delay);
133
+ } while (Date.now() < deadline);
134
+ return false;
135
+ }
134
136
  async function startSeedPhraseWordGrid(target, firstWord) {
135
137
  await target.fill(firstWord);
136
138
  await wait(250);
@@ -212,19 +214,28 @@ function metaMaskUnlockPasswordLocators(page) {
212
214
  function metaMaskUnlockSubmitLocators(page) {
213
215
  return [page.locator(testId('unlock-submit')), page.getByRole('button', { name: 'Unlock' })];
214
216
  }
215
- async function isMetaMaskUnlockVisible(page) {
216
- return Boolean(await findVisibleLocator(metaMaskUnlockPasswordLocators(page), SHORT_TIMEOUT_MS));
217
+ async function isMetaMaskUnlockVisible(page, timeout = LOCATOR_PROBE_MS) {
218
+ const routeLooksLocked = /#\/(?:unlock|locked)/i.test(page.url());
219
+ return Boolean(await findVisibleLocator(metaMaskUnlockPasswordLocators(page), routeLooksLocked ? Math.max(timeout, SHORT_TIMEOUT_MS) : timeout));
217
220
  }
218
- async function closeMetaMaskOverlay(page) {
221
+ async function closeMetaMaskOverlay(page, timeout = LOCATOR_PROBE_MS) {
219
222
  // Dismiss stacked overlays ("what's new" modals, popovers) that intercept
220
223
  // pointer events over the whole home screen.
221
224
  for (let attempt = 0; attempt < 4; attempt += 1) {
225
+ const overlay = await findVisibleLocator([
226
+ page.locator(`${testId('whats-new-modal')}, .mm-modal-content, [role="dialog"]`),
227
+ ], timeout, { requireEnabled: false });
228
+ if (!overlay)
229
+ return;
222
230
  const closed = await clickFirstVisible([
223
- page.locator(testId('not-now-button')),
224
- page.locator(testId('popover-close')),
231
+ overlay.locator(testId('popover-close')),
232
+ overlay.locator(testId('not-now-button')),
233
+ overlay.locator('button[aria-label="Close"]'),
234
+ overlay.getByRole('button', { name: /^(Close|Got it|Done|Skip|Maybe later|Not now)$/i }),
235
+ overlay.locator('button').filter({ hasText: /^(Close|Got it|Done|Skip|Maybe later|Not now)$/i }),
225
236
  page.locator('.mm-modal-content button[aria-label="Close"]'),
226
237
  page.locator('.mm-modal-content .mm-modal-header button').first(),
227
- ], SHORT_TIMEOUT_MS).catch(() => undefined);
238
+ ], timeout, { force: true }).catch(() => undefined);
228
239
  if (!closed)
229
240
  return;
230
241
  await wait(250);
@@ -237,6 +248,17 @@ function metaMaskNetworkPickerLocators(page) {
237
248
  page.getByRole('button', { name: /select a network|network menu/i }),
238
249
  ];
239
250
  }
251
+ function metaMaskResetAccountControlLocators(page) {
252
+ return [
253
+ page.locator(testId('developer-options-delete-activity-and-nonce-data')),
254
+ page.locator(testId('developer-options-delete-activity-and-nonce-data')).getByRole('button'),
255
+ page.locator(testId('advanced-setting-reset-account')).getByRole('button'),
256
+ page.getByRole('button', {
257
+ name: /Clear activity( and nonce data| tab data)?|Delete activity and nonce data/i,
258
+ }),
259
+ page.getByText(/Clear activity( and nonce data| tab data)?|Delete activity and nonce data/i),
260
+ ];
261
+ }
240
262
  function metaMaskAccountMenuLocators(page) {
241
263
  return [
242
264
  page.locator(testId('account-options-menu-button')),
@@ -244,6 +266,13 @@ function metaMaskAccountMenuLocators(page) {
244
266
  page.getByRole('button', { name: /Account options|Account menu/i }),
245
267
  ];
246
268
  }
269
+ function metaMaskAccountPickerTriggerLocators(page) {
270
+ return [
271
+ page.locator(`button:has(${testId('account-menu-icon')})`),
272
+ page.locator(testId('account-menu-icon')),
273
+ page.getByRole('button', { name: /Account menu/i }),
274
+ ];
275
+ }
247
276
  function metaMaskPromptActions(page) {
248
277
  return [
249
278
  page.locator(testId('onboarding-complete-done')),
@@ -275,67 +304,34 @@ async function waitForMetaMaskHome(page, timeout = DEFAULT_TIMEOUT_MS) {
275
304
  return true;
276
305
  if (await clickMetaMaskPromptAction(page, SHORT_TIMEOUT_MS))
277
306
  continue;
307
+ if (isOnboardingRoute(page.url())) {
308
+ await routeMetaMaskTabToHome(page);
309
+ continue;
310
+ }
278
311
  const delay = Math.min(LOCATOR_PROBE_MS, Math.max(deadline - Date.now(), 0));
279
312
  if (delay > 0)
280
313
  await wait(delay);
281
314
  } while (Date.now() < deadline);
282
315
  return Boolean(await findVisibleLocator(metaMaskAccountMenuLocators(page), SHORT_TIMEOUT_MS));
283
316
  }
284
- async function discoverExtensionIdFromRuntime(context) {
285
- const workerId = context.serviceWorkers().map((worker) => extensionIdFromUrl(worker.url())).find(Boolean);
286
- if (workerId)
287
- return workerId;
288
- const pageId = context.pages().map((page) => extensionIdFromUrl(page.url())).find(Boolean);
289
- if (pageId)
290
- return pageId;
291
- const worker = await context.waitForEvent('serviceworker', { timeout: 10_000 }).catch(() => undefined);
292
- if (worker) {
293
- const extensionId = extensionIdFromUrl(worker.url());
294
- if (extensionId)
295
- return extensionId;
296
- }
297
- return undefined;
298
- }
299
- async function discoverExtensionIdFromManagementApi(context, extensionName) {
300
- const page = await context.newPage();
301
- try {
302
- await page.goto('chrome://extensions', { waitUntil: 'domcontentloaded' });
303
- const extensions = await page.evaluate(() => {
304
- const chromeApi = globalThis;
305
- return new Promise((resolve, reject) => {
306
- if (!chromeApi.chrome?.management?.getAll) {
307
- reject(new Error('chrome.management.getAll is not available on chrome://extensions.'));
308
- return;
309
- }
310
- chromeApi.chrome.management.getAll((items) => {
311
- const error = chromeApi.chrome?.runtime?.lastError;
312
- if (error)
313
- reject(new Error(error.message ?? 'Unable to enumerate Chrome extensions.'));
314
- else
315
- resolve(items);
316
- });
317
- });
318
- });
319
- const exact = extensions.find((extension) => extension.name.toLowerCase() === extensionName.toLowerCase());
320
- if (exact)
321
- return exact.id;
322
- const available = extensions.map((extension) => extension.name).sort().join(', ');
323
- throw new Error(`Unable to find extension "${extensionName}". Installed extensions: ${available || 'none'}.`);
324
- }
325
- finally {
326
- await page.close().catch(() => undefined);
327
- }
328
- }
329
- async function getExtensionId(context, extensionName) {
330
- return ((await discoverExtensionIdFromRuntime(context)) ??
331
- (await discoverExtensionIdFromManagementApi(context, extensionName)));
317
+ async function routeMetaMaskTabToHome(page) {
318
+ const homeUrl = page.url().split('#')[0];
319
+ await page.goto(`${homeUrl}#/`).catch(() => undefined);
320
+ await waitForMetaMaskReady(page);
332
321
  }
333
322
  async function openExtensionHome(context, extensionId) {
334
- const homeUrl = extensionUrl(extensionId);
335
- const existing = context.pages().find((page) => page.url().startsWith(homeUrl));
336
- const page = existing ?? (await context.newPage());
337
- if (page.url() !== homeUrl)
338
- await page.goto(homeUrl);
323
+ const homeUrl = extensionUrl(extensionId, 'home.html');
324
+ const page = context
325
+ .pages()
326
+ .find((candidate) => {
327
+ if (candidate.isClosed())
328
+ return false;
329
+ const url = candidate.url();
330
+ return url === homeUrl || url.startsWith(`${homeUrl}#`);
331
+ }) ?? (await context.newPage());
332
+ if (page.url() !== `${homeUrl}#/`) {
333
+ await page.goto(`${homeUrl}#/`, { waitUntil: 'domcontentloaded' });
334
+ }
339
335
  await waitForMetaMaskReady(page);
340
336
  return page;
341
337
  }
@@ -409,7 +405,7 @@ async function getNotificationPage(context, extensionId, timeout = DEFAULT_TIMEO
409
405
  // MetaMask suppresses its popup window when extension tabs are already
410
406
  // open; after a short grace period for a spontaneous popup, open
411
407
  // notification.html ourselves — pending confirmations render there.
412
- const forceAt = startedAt + Math.min(5_000, timeout / 2);
408
+ const forceAt = startedAt;
413
409
  let forcedPage;
414
410
  let page = await findMetaMaskActionPage(context, extensionId);
415
411
  while (!page && Date.now() - startedAt < timeout) {
@@ -420,7 +416,10 @@ async function getNotificationPage(context, extensionId, timeout = DEFAULT_TIMEO
420
416
  await waitForMetaMaskReady(forcedPage);
421
417
  }
422
418
  else {
423
- const candidate = await context.waitForEvent('page', { timeout: Math.min(remaining, 1_000) }).catch(() => undefined);
419
+ const waitForPopupMs = forcedPage ? 1_000 : Math.max(forceAt - Date.now(), 1);
420
+ const candidate = await context
421
+ .waitForEvent('page', { timeout: Math.min(remaining, waitForPopupMs, 1_000) })
422
+ .catch(() => undefined);
424
423
  if (candidate) {
425
424
  await candidate
426
425
  .waitForURL((url) => url.href.startsWith(prefix) || url.href.startsWith(extensionPrefix), {
@@ -476,6 +475,14 @@ export function accountRowMatcher(identifier) {
476
475
  function accountRowLocator(page) {
477
476
  return page.locator('.multichain-account-menu-popover__list--menu-item, .multichain-account-cell, .multichain-account-list-item');
478
477
  }
478
+ function accountPickerContentLocators(page) {
479
+ return [
480
+ accountRowLocator(page).first(),
481
+ page.locator(testId('account-list-add-wallet-button')),
482
+ page.locator(testId('multichain-account-list-search')),
483
+ page.locator(testId('multichain-account-menu-popover-action-button')),
484
+ ];
485
+ }
479
486
  async function pageContainsAddress(page, address) {
480
487
  const normalizedAddress = address.toLowerCase();
481
488
  const text = ((await page.locator('body').textContent({ timeout: SHORT_TIMEOUT_MS }).catch(() => '')) ?? '')
@@ -618,9 +625,7 @@ async function finishMetaMaskOnboarding(page, password) {
618
625
  // parked on #/onboarding/completion (Playwright can't drive the side
619
626
  // panel). Route the tab to the wallet home ourselves.
620
627
  if (isOnboardingRoute(page.url())) {
621
- const homeUrl = page.url().split('#')[0];
622
- await page.goto(homeUrl).catch(() => undefined);
623
- await waitForMetaMaskReady(page);
628
+ await routeMetaMaskTabToHome(page);
624
629
  // Navigating away from completion can re-lock the freshly created vault.
625
630
  await unlockMetaMaskIfNeeded(page, password);
626
631
  }
@@ -643,9 +648,13 @@ class MetaMaskRealWallet {
643
648
  expectedAddress;
644
649
  walletPassword;
645
650
  generation;
651
+ // Set only after web3-tester has actively selected an account by full
652
+ // address in this browser session. Do not seed it from launch options:
653
+ // launch-time expectedAddress is a validation target, not proof of selection.
654
+ trustedSelectedAddress;
646
655
  constructor(context, homePage, extensionId,
647
656
  // Mutable: account mutations (switchAccount, imports, new accounts) must
648
- // invalidate it, or getAccountAddress's fast-path returns stale results.
657
+ // invalidate it, or later address checks may target stale UI text.
649
658
  expectedAddress, walletPassword, generation = '13x') {
650
659
  this.context = context;
651
660
  this.homePage = homePage;
@@ -707,42 +716,56 @@ class MetaMaskRealWallet {
707
716
  if (!signed)
708
717
  throw new Error('Unable to confirm MetaMask signature request.');
709
718
  await clickFirstVisible([page.locator(testId('signature-warning-sign-button'))], SHORT_TIMEOUT_MS);
719
+ await Promise.race([
720
+ page.waitForEvent('close', { timeout: 5_000 }).catch(() => undefined),
721
+ signed.waitFor({ state: 'hidden', timeout: 5_000 }).catch(() => undefined),
722
+ ]);
723
+ if (!page.isClosed())
724
+ await page.close().catch(() => undefined);
710
725
  }
711
726
  async confirmTransaction(options) {
712
- let page = await this.notificationPage();
727
+ let page = await realWalletTiming('confirmTransaction.notificationPage', () => this.notificationPage());
713
728
  for (let attempt = 0; attempt < 3; attempt += 1) {
714
- await this.applyGasSetting(page, options?.gasSetting);
715
- await clickFirstVisible([page.locator('.set-approval-for-all-warning__footer__approve-button')], SHORT_TIMEOUT_MS);
716
- const clicked = await this.confirmFooterAction(page);
729
+ await realWalletTiming(`confirmTransaction.applyGasSetting.attempt${attempt + 1}`, () => this.applyGasSetting(page, options?.gasSetting));
730
+ await realWalletTiming(`confirmTransaction.warningApproval.attempt${attempt + 1}`, () => clickFirstVisible([page.locator('.set-approval-for-all-warning__footer__approve-button')], LOCATOR_PROBE_MS));
731
+ const clicked = await realWalletTiming(`confirmTransaction.confirmFooter.attempt${attempt + 1}`, () => this.confirmFooterAction(page));
717
732
  // The click registered once the confirmed control leaves the view or
718
733
  // the popup closes — no fixed sleep deciding "settled".
719
- await Promise.race([
734
+ await realWalletTiming(`confirmTransaction.waitClickSettled.attempt${attempt + 1}`, () => Promise.race([
720
735
  clicked.waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => undefined),
721
736
  page.waitForEvent('close', { timeout: 10_000 }).catch(() => undefined),
722
- ]);
737
+ ]));
723
738
  if (!page.isClosed()) {
724
739
  // Same window advanced to another confirmation step.
725
- const nextStep = await findVisibleLocator(metaMaskActionLocators(page), SHORT_TIMEOUT_MS, {
726
- requireEnabled: false,
727
- });
740
+ const nextStep = await realWalletTiming(`confirmTransaction.findNextStep.attempt${attempt + 1}`, () => findVisibleLocator([
741
+ page.locator([
742
+ testId('confirm-footer-button'),
743
+ testId('confirmation-submit-button'),
744
+ testId('page-container-footer-next'),
745
+ testId('request-signature__sign'),
746
+ testId('signature-sign-button'),
747
+ ].join(', ')),
748
+ ], LOCATOR_PROBE_MS, { requireEnabled: false }));
728
749
  if (!nextStep)
729
750
  return;
730
- await waitForMetaMaskReady(page);
751
+ await realWalletTiming(`confirmTransaction.waitNextStepReady.attempt${attempt + 1}`, () => waitForMetaMaskReady(page));
731
752
  continue;
732
753
  }
733
754
  // Popup closed: give a follow-up notification window (approve + action
734
755
  // flows) a moment to appear.
735
756
  const deadline = Date.now() + 1_500;
736
757
  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
- }
758
+ await realWalletTiming(`confirmTransaction.findFollowupPage.attempt${attempt + 1}`, async () => {
759
+ while (Date.now() < deadline && !nextPage) {
760
+ nextPage = await findMetaMaskActionPage(this.context, this.extensionId);
761
+ if (!nextPage)
762
+ await wait(LOCATOR_PROBE_MS);
763
+ }
764
+ });
742
765
  if (!nextPage)
743
766
  return;
744
767
  page = nextPage;
745
- await waitForMetaMaskReady(page);
768
+ await realWalletTiming(`confirmTransaction.waitFollowupReady.attempt${attempt + 1}`, () => waitForMetaMaskReady(page));
746
769
  await page.bringToFront().catch(() => undefined);
747
770
  }
748
771
  throw new Error('MetaMask transaction confirmation did not settle after multiple confirmation steps.');
@@ -757,6 +780,9 @@ class MetaMaskRealWallet {
757
780
  }
758
781
  }
759
782
  async getAccountAddress() {
783
+ if (this.generation === '13x' && this.trustedSelectedAddress) {
784
+ return this.trustedSelectedAddress;
785
+ }
760
786
  const page = await this.home();
761
787
  await waitForMetaMaskHome(page);
762
788
  await closeMetaMaskOverlay(page);
@@ -774,7 +800,7 @@ class MetaMaskRealWallet {
774
800
  if (pasted && FULL_ADDRESS_PATTERN.test(pasted))
775
801
  return pasted;
776
802
  }
777
- await this.openAccountDetailsModal(page);
803
+ await this.openAccountDetailsModal(page, 90_000);
778
804
  // Read the full address from whichever copy affordance the modal exposes
779
805
  // (12.x: address-copy-button-text; 13.x addresses view:
780
806
  // multichain-address-row-copy-button). The visible text may be shortened,
@@ -789,12 +815,12 @@ class MetaMaskRealWallet {
789
815
  }
790
816
  const elementText = (await addressCopy.textContent())?.trim();
791
817
  if (elementText && FULL_ADDRESS_PATTERN.test(elementText)) {
792
- await closeMetaMaskOverlay(page);
818
+ await this.closeAccountDetailsAfterRead(page);
793
819
  return elementText;
794
820
  }
795
821
  await addressCopy.click().catch(() => undefined);
796
822
  const pasted = await this.readClipboardViaPaste();
797
- await closeMetaMaskOverlay(page);
823
+ await this.closeAccountDetailsAfterRead(page);
798
824
  if (pasted && FULL_ADDRESS_PATTERN.test(pasted)) {
799
825
  return pasted;
800
826
  }
@@ -804,11 +830,13 @@ class MetaMaskRealWallet {
804
830
  // "Account details". 13.x multichain UI: account picker -> the selected
805
831
  // account row's address menu -> "Addresses" (the "Account details" item
806
832
  // there is the export-keys view, not the address).
807
- async openAccountDetailsModal(page) {
833
+ async openAccountDetailsModal(page, timeout = 90_000) {
834
+ const deadline = Date.now() + timeout;
835
+ const remaining = (max = DEFAULT_TIMEOUT_MS) => Math.max(Math.min(max, deadline - Date.now()), 1);
808
836
  if (this.generation === '12x') {
809
- const menuOpened = await clickFirstVisible([page.locator(testId('account-options-menu-button'))], SHORT_TIMEOUT_MS);
837
+ const menuOpened = await clickFirstVisible([page.locator(testId('account-options-menu-button'))], remaining(SHORT_TIMEOUT_MS));
810
838
  const detailsOpened = menuOpened &&
811
- (await clickFirstVisible([page.locator(testId('account-list-menu-details'))], DEFAULT_TIMEOUT_MS));
839
+ (await clickFirstVisible([page.locator(testId('account-list-menu-details'))], remaining()));
812
840
  if (!detailsOpened)
813
841
  throw new Error('Unable to open the MetaMask account details view.');
814
842
  return;
@@ -816,33 +844,52 @@ class MetaMaskRealWallet {
816
844
  // 13.x multichain path. Prefer the home header's active-account address
817
845
  // menu (default-address-menu-button) so we read the *selected* account
818
846
  // 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));
847
+ // accounts whose picker order does not start at the active one. The menu
848
+ // render can race account-tree refreshes after imports; retry from a clean
849
+ // home render before declaring the address view unavailable.
850
+ const home = page.url().split('#')[0];
851
+ let addressMenuOpened = false;
852
+ for (let attempt = 0; attempt < 3 && Date.now() < deadline; attempt += 1) {
853
+ if (attempt > 0) {
854
+ await page.keyboard.press('Escape').catch(() => undefined);
855
+ await closeMetaMaskOverlay(page);
856
+ await page.goto(`${home}#/`);
857
+ await waitForMetaMaskHome(page, remaining());
858
+ }
859
+ addressMenuOpened = Boolean(await clickFirstVisible([page.locator(testId('default-address-menu-button'))], remaining(attempt === 0 ? SHORT_TIMEOUT_MS : DEFAULT_TIMEOUT_MS), { requireEnabled: false }));
860
+ if (!addressMenuOpened) {
861
+ await this.openAccountPicker(page, remaining(60_000));
862
+ const expected = this.expectedAddress?.toLowerCase();
863
+ const selectedRow = expected
864
+ ? page
865
+ .locator('[data-testid^="multichain-account-cell"]')
866
+ .filter({ hasText: new RegExp(`${expected.slice(0, 6)}|${shortAddress(expected)}`, 'i') })
867
+ .first()
868
+ : page.locator('[data-testid^="multichain-account-cell"]').first();
869
+ addressMenuOpened = Boolean((await clickFirstVisible([selectedRow.locator(testId('multichain-account-cell-end-accessory'))], remaining(), { requireEnabled: false })) ??
870
+ (await clickFirstVisible([page.getByRole('button', { name: 'Open multichain account address menu' }).first()], remaining(SHORT_TIMEOUT_MS), { requireEnabled: false })));
871
+ }
872
+ if (!addressMenuOpened)
873
+ continue;
874
+ const detailsOpened = await clickFirstVisible([
875
+ page.locator(testId('multichain-account-menu-item-addresses')),
876
+ page.getByRole('menuitem', { name: /^Addresses$/i }),
877
+ page.getByText('Addresses', { exact: true }),
878
+ page.locator(testId('multichain-account-menu-item-accountDetails')),
879
+ ], remaining(attempt === 0 ? DEFAULT_TIMEOUT_MS : SHORT_TIMEOUT_MS), { requireEnabled: false });
880
+ if (detailsOpened)
881
+ return;
835
882
  }
836
883
  if (!addressMenuOpened)
837
884
  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.');
885
+ throw new Error('Unable to open the MetaMask account address view.');
886
+ }
887
+ async closeAccountDetailsAfterRead(page) {
888
+ if (this.generation === '13x') {
889
+ await page.close().catch(() => undefined);
890
+ return;
891
+ }
892
+ await closeMetaMaskOverlay(page);
846
893
  }
847
894
  async readClipboardViaPaste() {
848
895
  const page = await this.context.newPage();
@@ -862,77 +909,100 @@ class MetaMaskRealWallet {
862
909
  }
863
910
  }
864
911
  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.');
912
+ let page = await this.home();
913
+ const addOnce = async () => {
914
+ const home = page.url().split('#')[0];
915
+ await waitForMetaMaskHome(page);
916
+ await closeMetaMaskOverlay(page);
917
+ const pickerOpened = await clickFirstVisible(metaMaskNetworkPickerLocators(page), DEFAULT_TIMEOUT_MS);
918
+ if (!pickerOpened)
919
+ throw new Error('Unable to open the MetaMask network picker.');
920
+ // 13.x splits the picker into "Default"/popular and "Custom" tabs; the
921
+ // custom RPC form lives behind the Custom tab. Force the tab click because
922
+ // its underline animation can keep the element "unstable" long after it
923
+ // is visibly clickable.
924
+ if (this.generation === '13x') {
925
+ const customTab = await clickFirstVisible([page.getByRole('tab', { name: /^Custom$/i })], DEFAULT_TIMEOUT_MS, { force: true });
926
+ if (!customTab)
927
+ throw new Error('Unable to open the MetaMask custom-network tab.');
928
+ await wait(250);
929
+ }
930
+ const addStarted = await clickFirstVisible([
931
+ page.locator(testId('network-list-menu-add-button')),
932
+ page.getByRole('button', { name: /Add a custom network|Add custom network/i }),
933
+ page.getByText(/Add a custom network/i),
934
+ page.getByRole('button', { name: /^Add network$/i }),
935
+ ], DEFAULT_TIMEOUT_MS);
936
+ if (!addStarted)
937
+ throw new Error('Unable to start the MetaMask add-network flow.');
938
+ // Versions that list popular networks first need one more hop.
939
+ await clickFirstVisible([page.locator(testId('add-network-manually')), page.getByText(/Add a network manually/i)], SHORT_TIMEOUT_MS);
940
+ const nameFilled = await fillFirstVisible([page.locator(testId('network-form-network-name')), page.locator('input[name="networkName"]')], network.name);
941
+ if (!nameFilled)
942
+ throw new Error('Unable to fill the MetaMask network name field.');
943
+ // RPC URL: newer MetaMask uses a dropdown with a dedicated add-RPC form;
944
+ // older versions render a plain input.
945
+ const rpcDirect = await fillFirstVisible([page.locator(testId('network-form-rpc-url')), page.locator('input[name="rpcUrl"]')], network.rpcUrl, SHORT_TIMEOUT_MS);
946
+ if (!rpcDirect) {
947
+ const dropdownOpened = await clickFirstVisible([page.locator(testId('test-add-rpc-drop-down')), page.getByText(/Add RPC URL/i)], SHORT_TIMEOUT_MS);
948
+ if (!dropdownOpened)
949
+ throw new Error('Unable to find the MetaMask RPC URL input.');
950
+ await clickFirstVisible([page.getByRole('button', { name: /Add RPC URL/i })], SHORT_TIMEOUT_MS);
951
+ const rpcFilled = await fillFirstVisible([page.locator(testId('rpc-url-input-test')), page.locator('input[name="rpcUrl"]')], network.rpcUrl);
952
+ if (!rpcFilled)
953
+ throw new Error('Unable to fill the MetaMask RPC URL field.');
954
+ const rpcConfirmed = await clickFirstVisible([page.getByRole('button', { name: /^Add URL$/i })], SHORT_TIMEOUT_MS);
955
+ if (!rpcConfirmed)
956
+ throw new Error('Unable to confirm the MetaMask RPC URL.');
957
+ }
958
+ const chainFilled = await fillFirstVisible([page.locator(testId('network-form-chain-id')), page.locator('input[name="chainId"]')], String(network.chainId));
959
+ if (!chainFilled)
960
+ throw new Error('Unable to fill the MetaMask chain id field.');
961
+ const symbolFilled = await fillFirstVisible([page.locator(testId('network-form-ticker-input')), page.locator('input[name="symbol"]')], network.symbol);
962
+ if (!symbolFilled)
963
+ throw new Error('Unable to fill the MetaMask currency symbol field.');
964
+ if (network.blockExplorerUrl) {
965
+ await fillFirstVisible([
966
+ page.locator(testId('network-form-block-explorer-url')),
967
+ page.locator('input[name="blockExplorerUrl"]'),
968
+ ], network.blockExplorerUrl, SHORT_TIMEOUT_MS);
969
+ }
970
+ const saved = await clickFirstVisible([page.locator(testId('network-form-save')), page.getByRole('button', { name: /^Save$/i })], DEFAULT_TIMEOUT_MS);
971
+ if (!saved) {
972
+ throw new Error('Unable to save the MetaMask network. Note that 13.x refuses custom networks under a ' +
973
+ 'known chain id ("edit the original network") — add those via a dapp ' +
974
+ 'wallet_addEthereumChain request plus approveNewNetwork() instead.');
975
+ }
976
+ await waitForMetaMaskReady(page);
977
+ await clickMetaMaskPromptAction(page, SHORT_TIMEOUT_MS);
978
+ await closeMetaMaskOverlay(page);
979
+ };
980
+ for (let attempt = 0; attempt < 3; attempt += 1) {
981
+ try {
982
+ await addOnce();
983
+ return;
984
+ }
985
+ catch (error) {
986
+ if (attempt === 2)
987
+ throw error;
988
+ if (!page.isClosed() && !isRecoverablePageNavigationError(error)) {
989
+ await page.keyboard.press('Escape').catch(() => undefined);
990
+ await closeMetaMaskOverlay(page);
991
+ }
992
+ if (!page.isClosed()) {
993
+ await page.goto(`${page.url().split('#')[0]}#/`, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
994
+ await waitForMetaMaskReady(page);
995
+ }
996
+ else {
997
+ page = await this.home();
998
+ }
999
+ await wait(1_000 + attempt * 1_000);
1000
+ }
924
1001
  }
925
- await waitForMetaMaskReady(page);
926
- await clickMetaMaskPromptAction(page, SHORT_TIMEOUT_MS);
927
- await closeMetaMaskOverlay(page);
928
1002
  }
929
1003
  async switchNetwork(name, options = {}) {
930
1004
  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.');
1005
+ const home = page.url().split('#')[0];
936
1006
  const candidates = () => [
937
1007
  // 13.x multichain rows are keyed by CAIP-2 chain id.
938
1008
  ...(options.chainId !== undefined
@@ -946,20 +1016,36 @@ class MetaMaskRealWallet {
946
1016
  page.getByText(name, { exact: true }),
947
1017
  ];
948
1018
  // 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);
1019
+ // (default) tab first, then the Custom tab. The 13.x list can lag just
1020
+ // after addNetwork(), so retry from a clean home render before failing.
1021
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1022
+ if (attempt > 0) {
1023
+ await page.keyboard.press('Escape').catch(() => undefined);
1024
+ await closeMetaMaskOverlay(page);
1025
+ await page.goto(`${home}#/`).catch(() => undefined);
1026
+ await wait(1_000 + attempt * 1_000);
955
1027
  }
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.`);
1028
+ await waitForMetaMaskHome(page);
1029
+ await closeMetaMaskOverlay(page);
1030
+ const pickerOpened = await clickFirstVisible(metaMaskNetworkPickerLocators(page), DEFAULT_TIMEOUT_MS);
1031
+ if (!pickerOpened)
1032
+ continue;
1033
+ let selected = await clickFirstVisible(candidates(), SHORT_TIMEOUT_MS);
1034
+ if (!selected) {
1035
+ if (this.generation === '13x') {
1036
+ await clickFirstVisible([page.getByRole('tab', { name: /^Custom$/i })], SHORT_TIMEOUT_MS);
1037
+ }
1038
+ selected = await clickFirstVisible(candidates(), DEFAULT_TIMEOUT_MS);
1039
+ }
1040
+ if (selected) {
1041
+ await waitForMetaMaskReady(page);
1042
+ await closeMetaMaskOverlay(page);
1043
+ return;
1044
+ }
1045
+ await page.keyboard.press('Escape').catch(() => undefined);
1046
+ await closeMetaMaskOverlay(page);
960
1047
  }
961
- await waitForMetaMaskReady(page);
962
- await closeMetaMaskOverlay(page);
1048
+ throw new Error(`Unable to select MetaMask network "${name}". Add it first with addNetwork(), and check "Show test networks" if it is a testnet.`);
963
1049
  }
964
1050
  async approveNewNetwork() {
965
1051
  const page = await this.notificationPage();
@@ -1009,9 +1095,12 @@ class MetaMaskRealWallet {
1009
1095
  await this.rejectFooterAction(page);
1010
1096
  }
1011
1097
  async importWalletFromPrivateKey(privateKey) {
1098
+ const methodDeadline = Date.now() + 220_000;
1099
+ const remaining = () => Math.max(methodDeadline - Date.now(), 1);
1100
+ const timeoutFor = (max = DEFAULT_TIMEOUT_MS) => Math.min(max, remaining());
1012
1101
  const normalized = normalizePrivateKey(privateKey);
1013
1102
  const page = await this.preparedHome();
1014
- await this.openAccountPicker(page);
1103
+ await this.openAccountPicker(page, Math.min(90_000, remaining()));
1015
1104
  let importOpened = false;
1016
1105
  if (this.generation === '12x') {
1017
1106
  // 12.x: action button → "Import account".
@@ -1029,30 +1118,30 @@ class MetaMaskRealWallet {
1029
1118
  for (let attempt = 0; attempt < 3 && !importOpened; attempt += 1) {
1030
1119
  if (attempt > 0) {
1031
1120
  await page.keyboard.press('Escape').catch(() => undefined);
1032
- await this.openAccountPicker(page);
1121
+ await this.openAccountPicker(page, Math.min(60_000, remaining()));
1033
1122
  }
1034
1123
  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)) {
1124
+ await addWallet.scrollIntoViewIfNeeded({ timeout: timeoutFor(SHORT_TIMEOUT_MS) }).catch(() => undefined);
1125
+ if (await clickFirstVisible([addWallet], timeoutFor())) {
1037
1126
  importOpened = Boolean(await clickFirstVisible([
1038
1127
  page.locator(testId('choose-wallet-type-import-account')),
1039
1128
  page.getByText('Import an account', { exact: true }),
1040
- ], DEFAULT_TIMEOUT_MS));
1129
+ ], timeoutFor()));
1041
1130
  }
1042
1131
  }
1043
1132
  }
1044
1133
  if (!importOpened) {
1045
1134
  throw new Error('Unable to open the MetaMask import-account flow — update web3-tester for this MetaMask version.');
1046
1135
  }
1047
- const keyFilled = await fillFirstVisible([page.locator('#private-key-box')], normalized);
1136
+ const keyFilled = await fillFirstVisible([page.locator('#private-key-box')], normalized, timeoutFor());
1048
1137
  if (!keyFilled)
1049
1138
  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);
1139
+ const confirmed = await clickFirstVisible([page.locator(testId('import-account-confirm-button'))], timeoutFor());
1051
1140
  if (!confirmed)
1052
1141
  throw new Error('Unable to confirm the MetaMask private key import.');
1053
1142
  // Success closes the dialog (the keyring import can take a moment);
1054
1143
  // failure keeps it open with inline help text.
1055
- const dialogClosed = await isHidden(page.locator('#private-key-box'), DEFAULT_TIMEOUT_MS).catch(() => false);
1144
+ const dialogClosed = await isHidden(page.locator('#private-key-box'), timeoutFor()).catch(() => false);
1056
1145
  if (!dialogClosed) {
1057
1146
  const helpText = (await page.locator('.mm-help-text').first().textContent({ timeout: SHORT_TIMEOUT_MS }).catch(() => null))?.trim();
1058
1147
  throw new Error(`MetaMask rejected the private key import${helpText ? `: ${helpText}` : '.'}`);
@@ -1065,22 +1154,25 @@ class MetaMaskRealWallet {
1065
1154
  await waitForMetaMaskReady(page);
1066
1155
  }
1067
1156
  this.expectedAddress = undefined;
1157
+ this.trustedSelectedAddress = undefined;
1068
1158
  await closeMetaMaskOverlay(page);
1069
1159
  }
1070
1160
  async addNewAccount(name) {
1071
- const page = await this.preparedHome();
1161
+ const methodDeadline = Date.now() + 220_000;
1162
+ const remaining = () => Math.max(methodDeadline - Date.now(), 1);
1163
+ let page = await realWalletTiming('addNewAccount.home', () => this.home());
1072
1164
  if (this.generation === '12x') {
1073
- await this.openAccountPicker(page);
1074
- // 12.x: action button → Add account → optional name submit.
1165
+ await this.openAccountPicker(page, Math.min(90_000, remaining()));
1166
+ // 12.x: action button → Add account → submit. The creation dialog's
1167
+ // optional name field can mark valid custom names as duplicates; create
1168
+ // the derived account first, then rename the newly active account by
1169
+ // address through account details.
1075
1170
  const actionOpened = await clickFirstVisible([page.locator(testId('multichain-account-menu-popover-action-button'))], SHORT_TIMEOUT_MS);
1076
1171
  const addOpened = actionOpened &&
1077
1172
  (await clickFirstVisible([page.locator(testId('multichain-account-menu-popover-add-account'))], SHORT_TIMEOUT_MS));
1078
1173
  if (!addOpened) {
1079
1174
  throw new Error('Unable to find the MetaMask add-account control — update web3-tester for this MetaMask version.');
1080
1175
  }
1081
- if (name) {
1082
- await fillFirstVisible([page.locator('#account-name'), page.locator(testId('account-name-input'))], name, SHORT_TIMEOUT_MS);
1083
- }
1084
1176
  const submitted = await clickFirstVisible([
1085
1177
  page.locator(testId('submit-add-account-with-name')),
1086
1178
  page.getByRole('button', { name: /^(Add account|Create)$/i }),
@@ -1088,7 +1180,11 @@ class MetaMaskRealWallet {
1088
1180
  if (!submitted)
1089
1181
  throw new Error('Unable to submit the MetaMask add-account dialog.');
1090
1182
  this.expectedAddress = undefined;
1183
+ this.trustedSelectedAddress = undefined;
1091
1184
  await closeMetaMaskOverlay(page);
1185
+ if (name) {
1186
+ await this.renameActiveAccountFromDetails(page, name);
1187
+ }
1092
1188
  return;
1093
1189
  }
1094
1190
  // 13.x multichain: the "add account" control is NOT in the account picker
@@ -1102,62 +1198,91 @@ class MetaMaskRealWallet {
1102
1198
  // navigations. LavaMoat blocks bulk text reads, so everything is
1103
1199
  // count()/getAttribute()/testid based.
1104
1200
  const home = page.url().split('#')[0];
1105
- await this.openAccountPicker(page);
1106
- const walletId = await this.activeSrpWalletId(page);
1201
+ await realWalletTiming('addNewAccount.openAccountPicker', () => this.openAccountPicker(page, Math.min(90_000, remaining())));
1202
+ const walletId = await realWalletTiming('addNewAccount.activeSrpWalletId', () => this.activeSrpWalletId(page));
1107
1203
  await page.keyboard.press('Escape').catch(() => undefined);
1108
- await wait(ACCOUNT_TREE_SETTLE_MS);
1204
+ await realWalletTiming('addNewAccount.initialTreeSettle', () => wait(ACCOUNT_TREE_SETTLE_MS));
1109
1205
  const walletCellPrefix = `[data-testid^="multichain-account-cell-${walletId}/"]`;
1110
- const createAttempts = 8;
1206
+ const createAttempts = 5;
1207
+ const createDeadline = methodDeadline;
1111
1208
  let newIndex = -1;
1112
- for (let attempt = 0; attempt < createAttempts && newIndex < 0; attempt += 1) {
1209
+ for (let attempt = 0; attempt < createAttempts && newIndex < 0 && Date.now() < createDeadline; attempt += 1) {
1113
1210
  // Between attempts, let the account-tree backup-and-sync (which drops
1114
1211
  // the create when it overlaps, and retries on its own backoff) calm
1115
1212
  // before re-navigating — back off a little further each time.
1116
1213
  if (attempt > 0)
1117
1214
  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.');
1215
+ const remaining = () => Math.max(createDeadline - Date.now(), 1);
1216
+ try {
1217
+ await realWalletTiming(`addNewAccount.gotoWalletDetails.attempt${attempt + 1}`, async () => {
1218
+ await page.goto(`${home}#/multichain-wallet-details-page?id=${encodeURIComponent(walletId)}`, {
1219
+ waitUntil: 'domcontentloaded',
1220
+ });
1221
+ await waitForMetaMaskReady(page);
1222
+ });
1223
+ const addButton = page.locator(testId('add-multichain-account-button')).first();
1224
+ const addButtonVisible = await realWalletTiming(`addNewAccount.waitAddButtonVisible.attempt${attempt + 1}`, () => addButton
1225
+ .waitFor({ state: 'visible', timeout: Math.min(DEFAULT_TIMEOUT_MS, remaining()) })
1226
+ .then(() => true)
1227
+ .catch(() => false));
1228
+ if (!addButtonVisible) {
1229
+ throw new Error('Unable to find the MetaMask add-account control on the wallet details page — update web3-tester for this MetaMask version.');
1230
+ }
1231
+ await realWalletTiming(`addNewAccount.waitAddAccountReady.attempt${attempt + 1}`, () => this.waitForAddAccountReady(addButton, Math.min(DEFAULT_TIMEOUT_MS, remaining())));
1232
+ // The wallet's cells are index-ordered and contiguous; the new account
1233
+ // is one past the current maximum. Read the last cell's id with a single
1234
+ // call (enumerating all cells perturbs timing and flakes detection).
1235
+ const lastTestId = await realWalletTiming(`addNewAccount.readLastIndex.attempt${attempt + 1}`, () => page.locator(walletCellPrefix).last().getAttribute('data-testid').catch(() => null));
1236
+ const maxIndex = Number.parseInt(lastTestId?.split('/').pop() ?? '', 10);
1237
+ if (Number.isNaN(maxIndex)) {
1238
+ throw new Error('Unable to read the MetaMask wallet account indices to add an account.');
1239
+ }
1240
+ const candidate = maxIndex + 1;
1241
+ const created = await realWalletTiming(`addNewAccount.clickAndWaitCreated.attempt${attempt + 1}`, async () => {
1242
+ await addButton.click();
1243
+ return page
1244
+ .locator(testId(`multichain-account-cell-${walletId}/${candidate}`))
1245
+ .waitFor({ state: 'attached', timeout: Math.min(15_000, remaining()) })
1246
+ .then(() => true)
1247
+ .catch(() => false);
1248
+ });
1249
+ if (created)
1250
+ newIndex = candidate;
1123
1251
  }
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.');
1252
+ catch (error) {
1253
+ if (!isRecoverablePageNavigationError(error))
1254
+ throw error;
1255
+ page = await this.home();
1132
1256
  }
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
1257
  }
1143
1258
  if (newIndex < 0) {
1144
1259
  throw new Error(`MetaMask did not create a new account after ${createAttempts} attempts (account-tree sync ` +
1145
1260
  'race) — retry the operation.');
1146
1261
  }
1147
1262
  this.expectedAddress = undefined;
1263
+ this.trustedSelectedAddress = undefined;
1148
1264
  if (name) {
1149
1265
  // Rename via the account-details route: the new high-index cell's row
1150
1266
  // 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);
1267
+ await realWalletTiming('addNewAccount.gotoNewAccountDetails', () => page
1268
+ .goto(`${home}#/multichain-account-details?accountGroupId=${encodeURIComponent(`${walletId}/${newIndex}`)}`, { waitUntil: 'domcontentloaded' })
1269
+ .catch(async (error) => {
1270
+ if (!isRecoverablePageNavigationError(error))
1271
+ throw error;
1272
+ page = await this.home();
1273
+ await page.goto(`${home}#/multichain-account-details?accountGroupId=${encodeURIComponent(`${walletId}/${newIndex}`)}`, { waitUntil: 'domcontentloaded' });
1274
+ }));
1275
+ await realWalletTiming('addNewAccount.waitNewAccountDetailsReady', () => waitForMetaMaskReady(page));
1153
1276
  const editOpened = await clickFirstVisible([page.locator(testId('account-name-action'))], DEFAULT_TIMEOUT_MS);
1154
1277
  if (!editOpened)
1155
1278
  throw new Error('Unable to open the MetaMask account name editor.');
1156
- await this.fillAccountNameAndSave(page, name);
1279
+ await realWalletTiming('addNewAccount.renameNewAccount', () => this.fillAccountNameAndSave(page, name));
1157
1280
  }
1158
- await page.goto(home);
1159
- await waitForMetaMaskReady(page);
1160
- await closeMetaMaskOverlay(page);
1281
+ await realWalletTiming('addNewAccount.returnHome', async () => {
1282
+ await page.goto(`${home}#/`);
1283
+ await waitForMetaMaskReady(page);
1284
+ await closeMetaMaskOverlay(page);
1285
+ });
1161
1286
  }
1162
1287
  // The SRP (entropy) wallet id, parsed from the first multichain account
1163
1288
  // cell's test id (form: multichain-account-cell-<entropy:UID>/<index>).
@@ -1175,18 +1300,21 @@ class MetaMaskRealWallet {
1175
1300
  return match[1];
1176
1301
  }
1177
1302
  async switchAccount(nameOrAddress) {
1178
- const page = await this.preparedHome();
1179
- await this.openAccountPicker(page);
1303
+ const page = await realWalletTiming('switchAccount.home', () => this.home());
1304
+ await realWalletTiming('switchAccount.openAccountPicker', () => this.openAccountPicker(page));
1180
1305
  if (this.generation === '13x') {
1181
- const cell = await this.find13xAccountCell(page, nameOrAddress);
1306
+ const cell = await realWalletTiming('switchAccount.find13xAccountCell', () => this.find13xAccountCell(page, nameOrAddress));
1182
1307
  if (!cell) {
1183
1308
  throw new Error(`Unable to find MetaMask account "${nameOrAddress}" in the picker (searched the ` +
1184
1309
  'virtualized account list by name/address).');
1185
1310
  }
1186
- await cell.click();
1187
- await waitForMetaMaskReady(page);
1311
+ await realWalletTiming('switchAccount.clickCell', async () => {
1312
+ await cell.click();
1313
+ await waitForMetaMaskReady(page);
1314
+ });
1188
1315
  this.expectedAddress = FULL_ADDRESS_PATTERN.test(nameOrAddress) ? nameOrAddress : undefined;
1189
- await closeMetaMaskOverlay(page);
1316
+ this.trustedSelectedAddress = this.expectedAddress;
1317
+ await realWalletTiming('switchAccount.closeOverlay', () => closeMetaMaskOverlay(page));
1190
1318
  return;
1191
1319
  }
1192
1320
  const row = accountRowLocator(page).filter({ hasText: accountRowMatcher(nameOrAddress) }).first();
@@ -1197,6 +1325,7 @@ class MetaMaskRealWallet {
1197
1325
  }
1198
1326
  await waitForMetaMaskReady(page);
1199
1327
  this.expectedAddress = FULL_ADDRESS_PATTERN.test(nameOrAddress) ? nameOrAddress : undefined;
1328
+ this.trustedSelectedAddress = undefined;
1200
1329
  await closeMetaMaskOverlay(page);
1201
1330
  }
1202
1331
  async renameAccount(currentName, newName) {
@@ -1220,14 +1349,21 @@ class MetaMaskRealWallet {
1220
1349
  await closeMetaMaskOverlay(page);
1221
1350
  }
1222
1351
  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.');
1352
+ let lastError;
1353
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1354
+ const page = await this.preparedHome();
1355
+ await this.openGlobalMenu(page);
1356
+ const locked = await clickFirstVisible([page.locator(testId('global-menu-lock')), page.getByText(/^Lock( MetaMask)?$/i)], DEFAULT_TIMEOUT_MS, { force: attempt > 0 });
1357
+ if (!locked)
1358
+ throw new Error('Unable to find the MetaMask lock action in the global menu.');
1359
+ const lockScreen = await this.waitForLockedState(page, DEFAULT_TIMEOUT_MS);
1360
+ if (lockScreen)
1361
+ return;
1362
+ lastError = new Error('MetaMask did not show the unlock screen after locking.');
1363
+ await page.keyboard.press('Escape').catch(() => undefined);
1364
+ await wait(1_000 + attempt * 1_000);
1365
+ }
1366
+ throw lastError instanceof Error ? lastError : new Error('MetaMask did not show the unlock screen after locking.');
1231
1367
  }
1232
1368
  async unlock(password) {
1233
1369
  const target = password ?? this.walletPassword;
@@ -1239,6 +1375,15 @@ class MetaMaskRealWallet {
1239
1375
  return;
1240
1376
  await unlockMetaMask(page, target);
1241
1377
  }
1378
+ async waitForUnlocked() {
1379
+ const page = await openExtensionHome(this.context, this.extensionId);
1380
+ await unlockMetaMaskIfNeeded(page, this.walletPassword);
1381
+ await waitForMetaMaskReady(page);
1382
+ if (await isMetaMaskUnlockVisible(page)) {
1383
+ throw new Error('MetaMask did not leave the locked screen.');
1384
+ }
1385
+ await closeMetaMaskOverlay(page);
1386
+ }
1242
1387
  async resetAccount() {
1243
1388
  const page = await this.preparedHome();
1244
1389
  await this.openGlobalMenu(page);
@@ -1268,16 +1413,17 @@ class MetaMaskRealWallet {
1268
1413
  }
1269
1414
  }
1270
1415
  }
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)) {
1416
+ else {
1417
+ // 13.x: Settings -> Developer tools -> "Delete activity and nonce data".
1418
+ // Route directly because the grouped settings sidebar can be slow to
1419
+ // render/click under long smoke-suite load.
1420
+ const home = page.url().split('#')[0];
1421
+ const openedDeveloperTools = (await this.open13xSettingsRoute(page, home, '/settings/developer-tools')) ||
1422
+ (await clickFirstVisible([
1423
+ page.locator(testId('settings-tab-item-developer-tools')),
1424
+ page.getByText(/^Developer tools$/i),
1425
+ ], SHORT_TIMEOUT_MS, { requireEnabled: false }));
1426
+ if (openedDeveloperTools && (await this.clickResetAccountControl(page, DEFAULT_TIMEOUT_MS))) {
1281
1427
  await this.confirmResetModal(page);
1282
1428
  await this.leaveSettings(page);
1283
1429
  return;
@@ -1286,13 +1432,9 @@ class MetaMaskRealWallet {
1286
1432
  // Fallback: the settings search (the Developer tools tab can be
1287
1433
  // feature-flag hidden in production profiles).
1288
1434
  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);
1435
+ await fillFirstVisible([page.locator(testId('settings-header-search-input')), page.locator('input[type="search"]')], this.generation === '13x' ? 'Delete activity' : 'Clear activity', SHORT_TIMEOUT_MS);
1290
1436
  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);
1437
+ const cleared = await clickFirstVisible(metaMaskResetAccountControlLocators(page), DEFAULT_TIMEOUT_MS);
1296
1438
  if (cleared) {
1297
1439
  await this.confirmResetModal(page);
1298
1440
  await this.leaveSettings(page);
@@ -1304,7 +1446,7 @@ class MetaMaskRealWallet {
1304
1446
  'nonce data) — update web3-tester for this MetaMask version.');
1305
1447
  }
1306
1448
  async toggleShowTestNetworks(on) {
1307
- const page = await this.preparedHome();
1449
+ let page = await this.preparedHome();
1308
1450
  if (this.generation === '13x') {
1309
1451
  // 13.x hosts the toggle on the standalone #/networks page (the network
1310
1452
  // picker popover only renders it once non-default testnets already
@@ -1313,22 +1455,31 @@ class MetaMaskRealWallet {
1313
1455
  // property), so Playwright's check()/uncheck() — which assert the
1314
1456
  // never-updated `checked` — throw "did not change its state"; instead
1315
1457
  // 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
1458
  // The first navigation to #/networks can race the React render; renavigate
1319
1459
  // until the toggle attaches.
1320
1460
  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);
1461
+ let toggle = page.locator(testId('networks-page-show-test-networks')).first();
1462
+ for (let attempt = 0; attempt < 5 && !toggleReady; attempt += 1) {
1463
+ try {
1464
+ const home = page.url().split('#')[0];
1465
+ await page.goto(`${home}#/networks`);
1466
+ // Reaching #/networks as a hash-only change from the home route leaves
1467
+ // the toggle rendered but non-interactive (its click never fires);
1468
+ // force a full document load so its handlers bind.
1469
+ await page.reload().catch(() => undefined);
1470
+ await waitForMetaMaskReady(page);
1471
+ toggle = page.locator(testId('networks-page-show-test-networks')).first();
1472
+ toggleReady = await toggle
1473
+ .waitFor({ state: 'attached', timeout: 10_000 })
1474
+ .then(() => true)
1475
+ .catch(() => false);
1476
+ }
1477
+ catch (error) {
1478
+ if (!isRecoverablePageNavigationError(error) || attempt === 4)
1479
+ throw error;
1480
+ page = await this.home();
1481
+ toggle = page.locator(testId('networks-page-show-test-networks')).first();
1482
+ }
1332
1483
  }
1333
1484
  if (!toggleReady) {
1334
1485
  throw new Error('Unable to find the MetaMask "Show test networks" toggle on the networks page.');
@@ -1349,7 +1500,7 @@ class MetaMaskRealWallet {
1349
1500
  throw new Error('Unable to flip the MetaMask "Show test networks" toggle on the networks page.');
1350
1501
  }
1351
1502
  await waitForMetaMaskReady(page);
1352
- await page.goto(home);
1503
+ await page.goto(page.url().split('#')[0]).catch(() => undefined);
1353
1504
  await waitForMetaMaskReady(page);
1354
1505
  await closeMetaMaskOverlay(page);
1355
1506
  return;
@@ -1522,13 +1673,13 @@ class MetaMaskRealWallet {
1522
1673
  await isHidden(page.locator(testId('import-tokens-modal-custom-address')), DEFAULT_TIMEOUT_MS).catch(() => undefined);
1523
1674
  }
1524
1675
  async confirmTransactionAndWaitForMining(options = {}) {
1525
- await this.confirmTransaction({ gasSetting: options.gasSetting });
1676
+ await realWalletTiming('confirmTransactionAndWaitForMining.confirmTransaction', () => this.confirmTransaction({ gasSetting: options.gasSetting }));
1526
1677
  const timeoutMs = options.timeoutMs ?? 60_000;
1527
- const page = await this.preparedHome();
1528
- const activityOpened = await clickFirstVisible([
1678
+ const page = await realWalletTiming('confirmTransactionAndWaitForMining.preparedHome', () => this.preparedHome());
1679
+ const activityOpened = await realWalletTiming('confirmTransactionAndWaitForMining.openActivity', () => clickFirstVisible([
1529
1680
  page.locator(testId('account-overview__activity-tab')),
1530
1681
  page.getByRole('button', { name: /^Activity$/i }),
1531
- ], DEFAULT_TIMEOUT_MS);
1682
+ ], DEFAULT_TIMEOUT_MS));
1532
1683
  if (!activityOpened)
1533
1684
  throw new Error('Unable to open the MetaMask activity tab.');
1534
1685
  const row = page
@@ -1537,44 +1688,48 @@ class MetaMaskRealWallet {
1537
1688
  const statusVisible = (status) => isVisible(page.locator(testId(`transaction-status-label--${status}`)).first(), LOCATOR_PROBE_MS).catch(() => false);
1538
1689
  const deadline = Date.now() + timeoutMs;
1539
1690
  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;
1691
+ await realWalletTiming('confirmTransactionAndWaitForMining.waitActivityConfirmed', async () => {
1692
+ while (Date.now() < deadline) {
1693
+ if ((await statusVisible('failed')) || (await statusVisible('dropped'))) {
1694
+ throw new Error('The newest MetaMask activity entry failed or was dropped.');
1695
+ }
1696
+ if (await statusVisible('confirmed')) {
1697
+ confirmed = true;
1698
+ break;
1699
+ }
1700
+ // Instant-mining nodes may never render a pending state: a visible row
1701
+ // with no pending/queued label counts as confirmed.
1702
+ if ((await isVisible(row, LOCATOR_PROBE_MS).catch(() => false)) &&
1703
+ !(await statusVisible('pending')) &&
1704
+ !(await statusVisible('queued'))) {
1705
+ confirmed = true;
1706
+ break;
1707
+ }
1708
+ await wait(LOCATOR_PROBE_MS);
1555
1709
  }
1556
- await wait(LOCATOR_PROBE_MS);
1557
- }
1710
+ });
1558
1711
  if (!confirmed) {
1559
1712
  throw new Error(`Timed out after ${timeoutMs}ms waiting for the transaction to confirm in the activity tab.`);
1560
1713
  }
1561
1714
  // Best-effort hash read — never throws; the mining wait already passed.
1562
1715
  let txHash;
1563
1716
  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;
1717
+ await realWalletTiming('confirmTransactionAndWaitForMining.readTxHash', async () => {
1718
+ if (await clickFirstVisible([row], SHORT_TIMEOUT_MS, { requireEnabled: false })) {
1719
+ await waitForMetaMaskReady(page);
1720
+ const copied = await clickFirstVisible([
1721
+ page.getByRole('button', { name: /Copy transaction ID/i }),
1722
+ page.getByText(/Copy transaction ID/i),
1723
+ ], SHORT_TIMEOUT_MS, { requireEnabled: false });
1724
+ if (copied) {
1725
+ const pasted = await this.readClipboardViaPaste();
1726
+ if (isFullTxHash(pasted))
1727
+ txHash = pasted;
1728
+ }
1729
+ await closeMetaMaskOverlay(page);
1730
+ await page.keyboard.press('Escape').catch(() => undefined);
1574
1731
  }
1575
- await closeMetaMaskOverlay(page);
1576
- await page.keyboard.press('Escape').catch(() => undefined);
1577
- }
1732
+ });
1578
1733
  }
1579
1734
  catch {
1580
1735
  // txHash stays undefined.
@@ -1582,30 +1737,107 @@ class MetaMaskRealWallet {
1582
1737
  return { txHash };
1583
1738
  }
1584
1739
  // ── shared private helpers for the account/settings surface ─────────────
1740
+ async waitForLockedState(page, timeout = DEFAULT_TIMEOUT_MS) {
1741
+ const startedAt = Date.now();
1742
+ const deadline = startedAt + timeout;
1743
+ let openedHome = false;
1744
+ while (Date.now() < deadline) {
1745
+ if (!page.isClosed() && (await isMetaMaskUnlockVisible(page)))
1746
+ return page;
1747
+ const lockedPage = await findMetaMaskLockedPage(this.context, this.extensionId);
1748
+ if (lockedPage)
1749
+ return lockedPage;
1750
+ if (!openedHome && Date.now() - startedAt >= 1_500) {
1751
+ openedHome = true;
1752
+ const homePage = await openExtensionHome(this.context, this.extensionId).catch(() => undefined);
1753
+ if (homePage && (await isMetaMaskUnlockVisible(homePage)))
1754
+ return homePage;
1755
+ }
1756
+ await wait(Math.min(LOCATOR_PROBE_MS, Math.max(deadline - Date.now(), 0)));
1757
+ }
1758
+ return undefined;
1759
+ }
1760
+ async open13xSettingsRoute(page, home, route) {
1761
+ await page.goto(`${home}#${route}`);
1762
+ await waitForMetaMaskReady(page);
1763
+ return Boolean(await findVisibleLocator([
1764
+ page.locator(testId('settings-tab-bar-grouped')),
1765
+ page.getByText(/^Developer tools$/i),
1766
+ page.locator(testId('developer-options-delete-activity-and-nonce-data')),
1767
+ ], DEFAULT_TIMEOUT_MS, { requireEnabled: false }));
1768
+ }
1769
+ async open13xNetworkFormRoute(page, home) {
1770
+ const form = page.locator(testId('network-form-network-name')).first();
1771
+ for (const route of ['#/networks/form', '#/networks/add-network']) {
1772
+ await page.goto(`${home}${route}`);
1773
+ await waitForMetaMaskReady(page);
1774
+ if (await isVisible(form, SHORT_TIMEOUT_MS).catch(() => false))
1775
+ return true;
1776
+ await clickFirstVisible([
1777
+ page.locator(testId('add-network-manually')),
1778
+ page.locator(testId('network-list-menu-add-button')),
1779
+ page.getByRole('button', { name: /Add a custom network|Add custom network|Add a network manually/i }),
1780
+ page.getByText(/Add a custom network|Add a network manually/i),
1781
+ ], SHORT_TIMEOUT_MS);
1782
+ await waitForMetaMaskReady(page);
1783
+ if (await isVisible(form, SHORT_TIMEOUT_MS).catch(() => false))
1784
+ return true;
1785
+ }
1786
+ return false;
1787
+ }
1788
+ async clickResetAccountControl(page, timeout = DEFAULT_TIMEOUT_MS) {
1789
+ const clicked = await clickFirstVisible(metaMaskResetAccountControlLocators(page), timeout);
1790
+ if (!clicked)
1791
+ return false;
1792
+ await waitForMetaMaskReady(page);
1793
+ return true;
1794
+ }
1585
1795
  async preparedHome() {
1586
1796
  const page = await this.home();
1587
1797
  await waitForMetaMaskHome(page);
1588
1798
  await closeMetaMaskOverlay(page);
1589
1799
  return page;
1590
1800
  }
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.)
1801
+ async openAccountPicker(page, timeout = 90_000) {
1802
+ // 13.x exposes the account list as a route. Prefer it over the header click
1803
+ // because the header can disappear briefly during account-tree refreshes.
1804
+ // Older UIs still use the popover trigger.
1805
+ const deadline = Date.now() + timeout;
1806
+ const remaining = (max = DEFAULT_TIMEOUT_MS) => Math.max(Math.min(max, deadline - Date.now()), 1);
1595
1807
  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);
1808
+ for (let attempt = 0; attempt < 5 && Date.now() < deadline; attempt += 1) {
1809
+ await realWalletTiming('openAccountPicker.escape', () => page.keyboard.press('Escape').catch(() => undefined));
1810
+ await realWalletTiming('openAccountPicker.closeOverlay', () => closeMetaMaskOverlay(page));
1811
+ if (this.generation === '13x') {
1812
+ await realWalletTiming('openAccountPicker.gotoAccountList', async () => {
1813
+ await page.goto(`${home}#/account-list`);
1814
+ await waitForMetaMaskReady(page);
1815
+ });
1816
+ if (await realWalletTiming('openAccountPicker.findContent', () => findVisibleLocator(accountPickerContentLocators(page), remaining(), {
1817
+ requireEnabled: false,
1818
+ }))) {
1819
+ await realWalletTiming('openAccountPicker.settleAccountList', () => this.settleAccountList(page));
1820
+ return;
1821
+ }
1601
1822
  }
1602
- opened = await clickFirstVisible([page.locator(testId('account-menu-icon'))], DEFAULT_TIMEOUT_MS, {
1603
- requireEnabled: false,
1823
+ await realWalletTiming('openAccountPicker.gotoHome', async () => {
1824
+ await page.goto(`${home}#/`);
1825
+ await waitForMetaMaskHome(page, remaining());
1604
1826
  });
1827
+ await realWalletTiming('openAccountPicker.closeOverlayAfterHome', () => closeMetaMaskOverlay(page));
1828
+ const clicked = await realWalletTiming('openAccountPicker.clickTrigger', () => clickFirstVisible(metaMaskAccountPickerTriggerLocators(page), remaining(), {
1829
+ requireEnabled: false,
1830
+ }));
1831
+ if (!clicked)
1832
+ continue;
1833
+ if (await realWalletTiming('openAccountPicker.findPopoverContent', () => findVisibleLocator(accountPickerContentLocators(page), remaining(SHORT_TIMEOUT_MS), {
1834
+ requireEnabled: false,
1835
+ }))) {
1836
+ await realWalletTiming('openAccountPicker.settlePopoverAccountList', () => this.settleAccountList(page));
1837
+ return;
1838
+ }
1605
1839
  }
1606
- if (!opened)
1607
- throw new Error('Unable to open the MetaMask account picker.');
1608
- await this.settleAccountList(page);
1840
+ throw new Error('Unable to open the MetaMask account picker.');
1609
1841
  }
1610
1842
  // Let the (virtualized) account list settle: wait for the first row, then
1611
1843
  // for the row count to stop growing across two probe ticks.
@@ -1634,14 +1866,14 @@ class MetaMaskRealWallet {
1634
1866
  // Fill the picker search box to narrow the list to `term`. The testid is on
1635
1867
  // a wrapper; the editable input is nested. No-op (returns false) when absent.
1636
1868
  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;
1869
+ return realWalletTiming('searchAccountPicker', async () => {
1870
+ const input = page.locator(`${testId('multichain-account-list-search')} input`).first();
1871
+ if (!(await isVisible(input, SHORT_TIMEOUT_MS).catch(() => false)))
1872
+ return false;
1873
+ await input.fill('');
1874
+ await input.fill(term);
1875
+ return true;
1876
+ });
1645
1877
  }
1646
1878
  // The account cell whose display name (its first <p>) exactly equals `name`,
1647
1879
  // after narrowing by search. Search is a contains filter, so the exact
@@ -1650,13 +1882,17 @@ class MetaMaskRealWallet {
1650
1882
  async findAccountCellByName(page, name) {
1651
1883
  await this.searchAccountPicker(page, name);
1652
1884
  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
- }
1885
+ const deadline = Date.now() + 1_500;
1886
+ do {
1887
+ const count = await cells.count();
1888
+ for (let index = 0; index < count; index += 1) {
1889
+ const cell = cells.nth(index);
1890
+ const cellName = (await cell.locator('p').first().textContent({ timeout: LOCATOR_PROBE_MS }).catch(() => null))?.trim();
1891
+ if (cellName === name)
1892
+ return cell;
1893
+ }
1894
+ await wait(LOCATOR_PROBE_MS);
1895
+ } while (Date.now() < deadline);
1660
1896
  return undefined;
1661
1897
  }
1662
1898
  // Resolves a 13.x account cell by exact display name, or — for an address —
@@ -1676,15 +1912,19 @@ class MetaMaskRealWallet {
1676
1912
  return imported;
1677
1913
  const short = shortAddress(nameOrAddress);
1678
1914
  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
- }
1915
+ const deadline = Date.now() + 1_500;
1916
+ do {
1917
+ const count = await cells.count();
1918
+ for (let index = 0; index < count; index += 1) {
1919
+ const cell = cells.nth(index);
1920
+ const text = ((await cell.textContent({ timeout: LOCATOR_PROBE_MS }).catch(() => '')) ?? '')
1921
+ .replace(/\s+/g, '')
1922
+ .toLowerCase();
1923
+ if (text.includes(addressLc) || text.includes(short))
1924
+ return cell;
1925
+ }
1926
+ await wait(LOCATOR_PROBE_MS);
1927
+ } while (Date.now() < deadline);
1688
1928
  return undefined;
1689
1929
  }
1690
1930
  // 13.x rename via a surfaced (searched/visible) account cell's row menu.
@@ -1705,15 +1945,15 @@ class MetaMaskRealWallet {
1705
1945
  // Polls the wallet-details "Add account" button until it reads exactly
1706
1946
  // "Add account" (not "Syncing…"/"Adding account…") for two consecutive
1707
1947
  // reads — only then is the create dispatch not silently dropped.
1708
- async waitForAddAccountReady(addButton) {
1709
- const deadline = Date.now() + DEFAULT_TIMEOUT_MS;
1948
+ async waitForAddAccountReady(addButton, timeout = DEFAULT_TIMEOUT_MS) {
1949
+ const deadline = Date.now() + timeout;
1710
1950
  let stable = 0;
1711
1951
  while (Date.now() < deadline) {
1712
- const label = (await addButton.textContent({ timeout: SHORT_TIMEOUT_MS }).catch(() => ''))?.trim();
1952
+ const label = (await addButton.textContent({ timeout: LOCATOR_PROBE_MS }).catch(() => ''))?.trim();
1713
1953
  if (label === 'Add account') {
1714
1954
  stable += 1;
1715
1955
  if (stable >= 2) {
1716
- await wait(1_500);
1956
+ await wait(LOCATOR_PROBE_MS);
1717
1957
  return;
1718
1958
  }
1719
1959
  }
@@ -1730,6 +1970,17 @@ class MetaMaskRealWallet {
1730
1970
  }
1731
1971
  // 12.x rename: row options menu → Account details → editable label. (13.x
1732
1972
  // renames through rename13xCellViaMenu / the account-details route.)
1973
+ async renameActiveAccountFromDetails(page, newName) {
1974
+ await waitForMetaMaskHome(page);
1975
+ await closeMetaMaskOverlay(page);
1976
+ await this.openAccountDetailsModal(page, DEFAULT_TIMEOUT_MS);
1977
+ const editOpened = await clickFirstVisible([page.locator(testId('editable-label-button')), page.locator(testId('account-name-action'))], DEFAULT_TIMEOUT_MS);
1978
+ if (!editOpened)
1979
+ throw new Error('Unable to open the MetaMask account name editor.');
1980
+ await this.fillAccountNameAndSave(page, newName);
1981
+ await page.keyboard.press('Escape').catch(() => undefined);
1982
+ await closeMetaMaskOverlay(page);
1983
+ }
1733
1984
  async renameAccountRow(page, row, newName, label) {
1734
1985
  const menuOpened = await clickFirstVisible([
1735
1986
  row.locator(testId('account-list-item-menu-button')),
@@ -1748,7 +1999,7 @@ class MetaMaskRealWallet {
1748
1999
  await closeMetaMaskOverlay(page);
1749
2000
  }
1750
2001
  async fillAccountNameAndSave(page, newName) {
1751
- const filled = await fillFirstVisible([
2002
+ const filled = await fillFirstVisibleStableValue([
1752
2003
  page.locator(`${testId('account-name-input')} input`),
1753
2004
  page.locator(testId('account-name-input')),
1754
2005
  page.locator(`${testId('editable-input')} input`),
@@ -1959,48 +2210,29 @@ async function prepareMetaMask({ expectedAddress, page, setup, wallet, }) {
1959
2210
  }
1960
2211
  export async function launchRealWallet(options) {
1961
2212
  const extensionName = options.extensionName ?? DEFAULT_EXTENSION_NAME;
1962
- if (!fs.existsSync(options.extensionPath)) {
1963
- throw new Error(`MetaMask extension path does not exist: ${options.extensionPath}`);
1964
- }
1965
2213
  const generation = options.generation ?? detectWalletGeneration(options.extensionPath);
1966
- const headless = resolveRealWalletHeadless(options.headless);
1967
- const profile = resolveRealWalletProfile(options.profileDir);
1968
- const context = await chromium
1969
- .launchPersistentContext(profile.userDataDir, {
1970
- args: [
1971
- ...(profile.profileDirectory ? [`--profile-directory=${profile.profileDirectory}`] : []),
1972
- `--disable-extensions-except=${options.extensionPath}`,
1973
- `--load-extension=${options.extensionPath}`,
1974
- ],
2214
+ const extensionSession = await launchRealWalletExtension({
1975
2215
  baseURL: options.baseURL,
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.
2216
+ extensionName,
2217
+ extensionPath: options.extensionPath,
2218
+ headless: options.headless,
2219
+ initialPage: 'home.html',
1982
2220
  locale: 'en-US',
2221
+ profileDir: options.profileDir,
1983
2222
  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;
1994
2223
  });
1995
- const extensionId = await getExtensionId(context, extensionName);
1996
- const page = await openExtensionHome(context, extensionId);
1997
- const wallet = new MetaMaskRealWallet(context, page, extensionId, options.expectedAddress, passwordForSetup(options.setup), generation);
2224
+ const { context, extensionId } = extensionSession;
2225
+ const page = extensionSession.page ?? (await openExtensionHome(context, extensionId));
2226
+ const walletPassword = passwordForSetup(options.setup);
2227
+ const wallet = new MetaMaskRealWallet(context, page, extensionId, options.expectedAddress, walletPassword, generation);
1998
2228
  await prepareMetaMask({
1999
2229
  expectedAddress: options.expectedAddress,
2000
2230
  page,
2001
2231
  setup: options.setup,
2002
2232
  wallet,
2003
2233
  });
2234
+ await routeMetaMaskTabToHome(page);
2235
+ await unlockMetaMaskIfNeeded(page, walletPassword);
2004
2236
  return {
2005
2237
  addNetwork: (network) => wallet.addNetwork(network),
2006
2238
  addNewAccount: (name) => wallet.addNewAccount(name),
@@ -2009,7 +2241,7 @@ export async function launchRealWallet(options) {
2009
2241
  approveNewNetwork: () => wallet.approveNewNetwork(),
2010
2242
  approveSwitchNetwork: () => wallet.approveSwitchNetwork(),
2011
2243
  approveTokenPermission: (approvalOptions) => wallet.approveTokenPermission(approvalOptions),
2012
- close: () => context.close(),
2244
+ close: () => extensionSession.close(),
2013
2245
  confirmSignature: () => wallet.confirmSignature(),
2014
2246
  confirmTransaction: (confirmationOptions) => wallet.confirmTransaction(confirmationOptions),
2015
2247
  confirmTransactionAndWaitForMining: (miningOptions) => wallet.confirmTransactionAndWaitForMining(miningOptions),
@@ -2032,6 +2264,7 @@ export async function launchRealWallet(options) {
2032
2264
  switchNetwork: (name, switchOptions) => wallet.switchNetwork(name, switchOptions),
2033
2265
  toggleShowTestNetworks: (on) => wallet.toggleShowTestNetworks(on),
2034
2266
  unlock: (password) => wallet.unlock(password),
2267
+ waitForUnlocked: () => wallet.waitForUnlocked(),
2035
2268
  wallet,
2036
2269
  };
2037
2270
  }