@marigoldlabs/web3-tester 0.1.2 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.env.example +26 -17
  2. package/LICENSE +21 -0
  3. package/README.md +480 -48
  4. package/dist/anvil.d.ts +90 -2
  5. package/dist/anvil.d.ts.map +1 -1
  6. package/dist/anvil.js +215 -13
  7. package/dist/anvil.js.map +1 -1
  8. package/dist/benchmark.d.ts +55 -0
  9. package/dist/benchmark.d.ts.map +1 -0
  10. package/dist/benchmark.js +168 -0
  11. package/dist/benchmark.js.map +1 -0
  12. package/dist/contracts/test-erc20.d.ts +227 -0
  13. package/dist/contracts/test-erc20.d.ts.map +1 -0
  14. package/dist/contracts/test-erc20.js +8 -0
  15. package/dist/contracts/test-erc20.js.map +1 -0
  16. package/dist/erc20.d.ts +38 -0
  17. package/dist/erc20.d.ts.map +1 -0
  18. package/dist/erc20.js +229 -0
  19. package/dist/erc20.js.map +1 -0
  20. package/dist/fixtures.d.ts +44 -2
  21. package/dist/fixtures.d.ts.map +1 -1
  22. package/dist/fixtures.js +162 -17
  23. package/dist/fixtures.js.map +1 -1
  24. package/dist/index.d.ts +26 -5
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +11 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/injected-provider.d.ts.map +1 -1
  29. package/dist/injected-provider.js +863 -77
  30. package/dist/injected-provider.js.map +1 -1
  31. package/dist/live-fixtures.d.ts +32 -3
  32. package/dist/live-fixtures.d.ts.map +1 -1
  33. package/dist/live-fixtures.js +64 -27
  34. package/dist/live-fixtures.js.map +1 -1
  35. package/dist/matchers.d.ts +90 -0
  36. package/dist/matchers.d.ts.map +1 -0
  37. package/dist/matchers.js +268 -0
  38. package/dist/matchers.js.map +1 -0
  39. package/dist/metamask-extension.d.ts +34 -0
  40. package/dist/metamask-extension.d.ts.map +1 -0
  41. package/dist/metamask-extension.js +97 -0
  42. package/dist/metamask-extension.js.map +1 -0
  43. package/dist/mock-wallet-controller.d.ts +354 -4
  44. package/dist/mock-wallet-controller.d.ts.map +1 -1
  45. package/dist/mock-wallet-controller.js +1444 -58
  46. package/dist/mock-wallet-controller.js.map +1 -1
  47. package/dist/private-key-rpc-client.d.ts +1744 -2
  48. package/dist/private-key-rpc-client.d.ts.map +1 -1
  49. package/dist/private-key-rpc-client.js +245 -30
  50. package/dist/private-key-rpc-client.js.map +1 -1
  51. package/dist/real-wallet-cache.d.ts +103 -0
  52. package/dist/real-wallet-cache.d.ts.map +1 -0
  53. package/dist/real-wallet-cache.js +331 -0
  54. package/dist/real-wallet-cache.js.map +1 -0
  55. package/dist/real-wallet-extension-fixtures.d.ts +35 -0
  56. package/dist/real-wallet-extension-fixtures.d.ts.map +1 -0
  57. package/dist/real-wallet-extension-fixtures.js +96 -0
  58. package/dist/real-wallet-extension-fixtures.js.map +1 -0
  59. package/dist/real-wallet-extension.d.ts +86 -0
  60. package/dist/real-wallet-extension.d.ts.map +1 -0
  61. package/dist/real-wallet-extension.js +245 -0
  62. package/dist/real-wallet-extension.js.map +1 -0
  63. package/dist/real-wallet-fixtures.d.ts +52 -0
  64. package/dist/real-wallet-fixtures.d.ts.map +1 -0
  65. package/dist/real-wallet-fixtures.js +88 -0
  66. package/dist/real-wallet-fixtures.js.map +1 -0
  67. package/dist/real-wallet.d.ts +119 -19
  68. package/dist/real-wallet.d.ts.map +1 -1
  69. package/dist/real-wallet.js +1656 -144
  70. package/dist/real-wallet.js.map +1 -1
  71. package/dist/safe.d.ts +311 -0
  72. package/dist/safe.d.ts.map +1 -0
  73. package/dist/safe.js +743 -0
  74. package/dist/safe.js.map +1 -0
  75. package/dist/transactions.d.ts +118 -0
  76. package/dist/transactions.d.ts.map +1 -0
  77. package/dist/transactions.js +207 -0
  78. package/dist/transactions.js.map +1 -0
  79. package/dist/types.d.ts +36 -1
  80. package/dist/types.d.ts.map +1 -1
  81. package/dist/wallet-personas.d.ts +99 -0
  82. package/dist/wallet-personas.d.ts.map +1 -0
  83. package/dist/wallet-personas.js +666 -0
  84. package/dist/wallet-personas.js.map +1 -0
  85. package/dist/walletconnect.d.ts +373 -0
  86. package/dist/walletconnect.d.ts.map +1 -0
  87. package/dist/walletconnect.js +799 -0
  88. package/dist/walletconnect.js.map +1 -0
  89. package/examples/live-sepolia.spec.ts +20 -1
  90. package/examples/playwright.config.ts +8 -0
  91. package/package.json +90 -8
  92. package/docs/API.md +0 -223
  93. package/docs/ARCHITECTURE.md +0 -81
  94. package/docs/CONSUMING_FROM_FJORD.md +0 -123
  95. package/docs/FJORD_LIVE_QA.md +0 -87
  96. package/docs/RELEASE_CHECKLIST.md +0 -55
@@ -1,27 +1,51 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { chromium } from '@playwright/test';
1
+ import { extensionManifestVersion } from './metamask-extension.js';
2
+ import { extensionPageUrl, launchRealWalletExtension, } from './real-wallet-extension.js';
4
3
  import { passwordForSetup } from './real-wallet-setup.js';
4
+ export { resolveRealWalletHeadless, resolveRealWalletProfile } from './real-wallet-extension.js';
5
5
  const DEFAULT_EXTENSION_NAME = 'MetaMask';
6
6
  const DEFAULT_TIMEOUT_MS = 30_000;
7
7
  const SHORT_TIMEOUT_MS = 2_000;
8
- const LOCATOR_PROBE_MS = 250;
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;
13
+ const FULL_ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/;
9
14
  const testId = (id) => `[data-testid="${id}"]`;
10
- function extensionUrl(extensionId, page = 'home.html') {
11
- return `chrome-extension://${extensionId}/${page}`;
15
+ /**
16
+ * @internal Resolves a generation-annotated selector stack against the
17
+ * configured generation: tagged entries of the other generation are dropped,
18
+ * bare entries pass through, and relative order is preserved.
19
+ */
20
+ export function resolveGenLocators(locators, generation) {
21
+ const resolved = [];
22
+ for (const entry of locators) {
23
+ if ('gen' in entry) {
24
+ if (entry.gen === generation)
25
+ resolved.push(entry.loc);
26
+ }
27
+ else {
28
+ resolved.push(entry);
29
+ }
30
+ }
31
+ return resolved;
12
32
  }
13
- function extensionIdFromUrl(url) {
14
- return /^chrome-extension:\/\/([^/]+)\//.exec(url)?.[1];
33
+ /** @internal Maps an extension manifest version to the UI generation it ships. */
34
+ export function walletGenerationForVersion(version) {
35
+ const major = Number.parseInt(version, 10);
36
+ return Number.isNaN(major) || major >= 13 ? '13x' : '12x';
15
37
  }
16
- export function resolveRealWalletProfile(profileDir) {
17
- const resolved = path.resolve(profileDir);
18
- const profileDirectory = path.basename(resolved);
19
- const userDataDir = path.dirname(resolved);
20
- const looksLikeChromeProfile = /^(?:Default|Profile \d+)$/.test(profileDirectory);
21
- if (looksLikeChromeProfile && fs.existsSync(path.join(userDataDir, 'Local State'))) {
22
- return { profileDirectory, userDataDir };
38
+ function detectWalletGeneration(extensionPath) {
39
+ try {
40
+ return walletGenerationForVersion(extensionManifestVersion(extensionPath));
23
41
  }
24
- return { userDataDir: resolved };
42
+ catch {
43
+ // No readable manifest version: assume the pinned default generation.
44
+ return '13x';
45
+ }
46
+ }
47
+ function extensionUrl(extensionId, page = 'home.html') {
48
+ return extensionPageUrl(extensionId, page);
25
49
  }
26
50
  async function isVisible(locator, timeout = SHORT_TIMEOUT_MS) {
27
51
  await locator.first().waitFor({ state: 'visible', timeout });
@@ -36,6 +60,24 @@ function wait(ms) {
36
60
  setTimeout(resolve, ms);
37
61
  });
38
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
+ }
39
81
  async function findVisibleLocator(locators, timeout = SHORT_TIMEOUT_MS, options = {}) {
40
82
  const deadline = Date.now() + timeout;
41
83
  do {
@@ -60,9 +102,9 @@ async function clickFirstVisible(locators, timeout = SHORT_TIMEOUT_MS, options =
60
102
  requireEnabled: options.requireEnabled ?? true,
61
103
  });
62
104
  if (!target)
63
- return false;
105
+ return undefined;
64
106
  await target.click({ force: options.force, timeout });
65
- return true;
107
+ return target;
66
108
  }
67
109
  async function fillFirstVisible(locators, value, timeout = DEFAULT_TIMEOUT_MS) {
68
110
  const target = await findVisibleLocator(locators, timeout);
@@ -71,13 +113,37 @@ async function fillFirstVisible(locators, value, timeout = DEFAULT_TIMEOUT_MS) {
71
113
  await target.fill(value);
72
114
  return true;
73
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
+ }
74
136
  async function startSeedPhraseWordGrid(target, firstWord) {
75
137
  await target.fill(firstWord);
76
138
  await wait(250);
77
139
  await target.press('Space');
78
140
  }
141
+ // MetaMask 12.x routes are home.html#onboarding/..., 13.x uses #/onboarding/...
142
+ const isOnboardingRoute = (url) => /home\.html#\/?onboarding/.test(url);
79
143
  function metaMaskOnboardingLocators(page) {
80
144
  return [
145
+ page.locator(testId('onboarding-get-started-button')),
146
+ page.locator(testId('onboarding-welcome-banner-title')),
81
147
  page.locator(testId('onboarding-import-wallet')),
82
148
  page.locator(testId('onboarding-import-with-srp-button')),
83
149
  page.locator(testId('onboarding-create-wallet')),
@@ -86,6 +152,7 @@ function metaMaskOnboardingLocators(page) {
86
152
  page.locator(testId('import-srp-confirm')),
87
153
  page.locator(testId('create-password-new-input')),
88
154
  page.locator(testId('create-password-confirm-input')),
155
+ page.getByRole('button', { name: 'Get started' }),
89
156
  page.getByRole('button', { name: 'I have an existing wallet' }),
90
157
  page.getByRole('button', { name: 'Create a new wallet' }),
91
158
  page.getByRole('button', { name: 'Import using Secret Recovery Phrase' }),
@@ -93,7 +160,7 @@ function metaMaskOnboardingLocators(page) {
93
160
  ];
94
161
  }
95
162
  async function isMetaMaskOnboardingVisible(page) {
96
- return page.url().includes('/home.html#/onboarding') || Boolean(await findVisibleLocator(metaMaskOnboardingLocators(page), SHORT_TIMEOUT_MS));
163
+ return isOnboardingRoute(page.url()) || Boolean(await findVisibleLocator(metaMaskOnboardingLocators(page), SHORT_TIMEOUT_MS));
97
164
  }
98
165
  function metaMaskSeedPhraseInputLocators(page) {
99
166
  return [
@@ -130,11 +197,11 @@ async function fillMetaMaskSeedPhraseWordGrid(page, words, startIndex = 0) {
130
197
  }
131
198
  }
132
199
  async function isMetaMaskSeedPhraseImportVisible(page) {
133
- return (page.url().includes('/onboarding/import-with-recovery-phrase') ||
200
+ return (/#\/?onboarding\/import-with-recovery-phrase/.test(page.url()) ||
134
201
  Boolean(await findVisibleLocator(metaMaskSeedPhraseImportLocators(page), SHORT_TIMEOUT_MS)));
135
202
  }
136
203
  async function isMetaMaskCreatePasswordVisible(page) {
137
- return (page.url().includes('/onboarding/create-password') ||
204
+ return (/#\/?onboarding\/create-password/.test(page.url()) ||
138
205
  Boolean(await findVisibleLocator([page.locator(testId('create-password-new-input')), page.locator('input[type="password"]').nth(0)], SHORT_TIMEOUT_MS)));
139
206
  }
140
207
  async function waitForMetaMaskReady(page) {
@@ -147,14 +214,50 @@ function metaMaskUnlockPasswordLocators(page) {
147
214
  function metaMaskUnlockSubmitLocators(page) {
148
215
  return [page.locator(testId('unlock-submit')), page.getByRole('button', { name: 'Unlock' })];
149
216
  }
150
- async function isMetaMaskUnlockVisible(page) {
151
- 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));
220
+ }
221
+ async function closeMetaMaskOverlay(page, timeout = LOCATOR_PROBE_MS) {
222
+ // Dismiss stacked overlays ("what's new" modals, popovers) that intercept
223
+ // pointer events over the whole home screen.
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;
230
+ const closed = await clickFirstVisible([
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 }),
236
+ page.locator('.mm-modal-content button[aria-label="Close"]'),
237
+ page.locator('.mm-modal-content .mm-modal-header button').first(),
238
+ ], timeout, { force: true }).catch(() => undefined);
239
+ if (!closed)
240
+ return;
241
+ await wait(250);
242
+ }
243
+ }
244
+ function metaMaskNetworkPickerLocators(page) {
245
+ return [
246
+ page.locator(testId('network-display')),
247
+ page.locator(testId('sort-by-networks')),
248
+ page.getByRole('button', { name: /select a network|network menu/i }),
249
+ ];
152
250
  }
153
- 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);
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
+ ];
158
261
  }
159
262
  function metaMaskAccountMenuLocators(page) {
160
263
  return [
@@ -163,6 +266,13 @@ function metaMaskAccountMenuLocators(page) {
163
266
  page.getByRole('button', { name: /Account options|Account menu/i }),
164
267
  ];
165
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
+ }
166
276
  function metaMaskPromptActions(page) {
167
277
  return [
168
278
  page.locator(testId('onboarding-complete-done')),
@@ -194,67 +304,34 @@ async function waitForMetaMaskHome(page, timeout = DEFAULT_TIMEOUT_MS) {
194
304
  return true;
195
305
  if (await clickMetaMaskPromptAction(page, SHORT_TIMEOUT_MS))
196
306
  continue;
307
+ if (isOnboardingRoute(page.url())) {
308
+ await routeMetaMaskTabToHome(page);
309
+ continue;
310
+ }
197
311
  const delay = Math.min(LOCATOR_PROBE_MS, Math.max(deadline - Date.now(), 0));
198
312
  if (delay > 0)
199
313
  await wait(delay);
200
314
  } while (Date.now() < deadline);
201
315
  return Boolean(await findVisibleLocator(metaMaskAccountMenuLocators(page), SHORT_TIMEOUT_MS));
202
316
  }
203
- async function discoverExtensionIdFromRuntime(context) {
204
- const workerId = context.serviceWorkers().map((worker) => extensionIdFromUrl(worker.url())).find(Boolean);
205
- if (workerId)
206
- return workerId;
207
- const pageId = context.pages().map((page) => extensionIdFromUrl(page.url())).find(Boolean);
208
- if (pageId)
209
- return pageId;
210
- const worker = await context.waitForEvent('serviceworker', { timeout: 10_000 }).catch(() => undefined);
211
- if (worker) {
212
- const extensionId = extensionIdFromUrl(worker.url());
213
- if (extensionId)
214
- return extensionId;
215
- }
216
- return undefined;
217
- }
218
- async function discoverExtensionIdFromManagementApi(context, extensionName) {
219
- const page = await context.newPage();
220
- try {
221
- await page.goto('chrome://extensions', { waitUntil: 'domcontentloaded' });
222
- const extensions = await page.evaluate(() => {
223
- const chromeApi = globalThis;
224
- return new Promise((resolve, reject) => {
225
- if (!chromeApi.chrome?.management?.getAll) {
226
- reject(new Error('chrome.management.getAll is not available on chrome://extensions.'));
227
- return;
228
- }
229
- chromeApi.chrome.management.getAll((items) => {
230
- const error = chromeApi.chrome?.runtime?.lastError;
231
- if (error)
232
- reject(new Error(error.message ?? 'Unable to enumerate Chrome extensions.'));
233
- else
234
- resolve(items);
235
- });
236
- });
237
- });
238
- const exact = extensions.find((extension) => extension.name.toLowerCase() === extensionName.toLowerCase());
239
- if (exact)
240
- return exact.id;
241
- const available = extensions.map((extension) => extension.name).sort().join(', ');
242
- throw new Error(`Unable to find extension "${extensionName}". Installed extensions: ${available || 'none'}.`);
243
- }
244
- finally {
245
- await page.close().catch(() => undefined);
246
- }
247
- }
248
- async function getExtensionId(context, extensionName) {
249
- return ((await discoverExtensionIdFromRuntime(context)) ??
250
- (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);
251
321
  }
252
322
  async function openExtensionHome(context, extensionId) {
253
- const homeUrl = extensionUrl(extensionId);
254
- const existing = context.pages().find((page) => page.url().startsWith(homeUrl));
255
- const page = existing ?? (await context.newPage());
256
- if (page.url() !== homeUrl)
257
- 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
+ }
258
335
  await waitForMetaMaskReady(page);
259
336
  return page;
260
337
  }
@@ -267,9 +344,9 @@ function extensionPageUrlPrefix(extensionId) {
267
344
  function metaMaskActionContentLocators(page) {
268
345
  return [
269
346
  page.getByRole('heading', {
270
- name: /Spending cap request|Transaction request|Signature request|Sign-in request|Permission request/i,
347
+ name: /Spending cap request|Transaction request|Signature request|Sign-in request|Permission request|Add suggested tokens?/i,
271
348
  }),
272
- page.getByText(/Spending cap request|Transaction request|Signature request|Sign-in request|Permission request|This site wants permission/i),
349
+ page.getByText(/Spending cap request|Transaction request|Signature request|Sign-in request|Permission request|This site wants permission|Add suggested tokens?/i),
273
350
  ];
274
351
  }
275
352
  function metaMaskActionControlLocators(page) {
@@ -325,24 +402,44 @@ async function getNotificationPage(context, extensionId, timeout = DEFAULT_TIMEO
325
402
  const prefix = notificationUrlPrefix(extensionId);
326
403
  const extensionPrefix = extensionPageUrlPrefix(extensionId);
327
404
  const startedAt = Date.now();
405
+ // MetaMask suppresses its popup window when extension tabs are already
406
+ // open; after a short grace period for a spontaneous popup, open
407
+ // notification.html ourselves — pending confirmations render there.
408
+ const forceAt = startedAt;
409
+ let forcedPage;
328
410
  let page = await findMetaMaskActionPage(context, extensionId);
329
- let notificationPage = findMetaMaskNotificationPage(context, extensionId);
330
411
  while (!page && Date.now() - startedAt < timeout) {
331
412
  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
- })
413
+ if (!forcedPage && Date.now() >= forceAt) {
414
+ forcedPage = await context.newPage();
415
+ await forcedPage.goto(extensionUrl(extensionId, 'notification.html')).catch(() => undefined);
416
+ await waitForMetaMaskReady(forcedPage);
417
+ }
418
+ else {
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) })
338
422
  .catch(() => undefined);
423
+ if (candidate) {
424
+ await candidate
425
+ .waitForURL((url) => url.href.startsWith(prefix) || url.href.startsWith(extensionPrefix), {
426
+ timeout: Math.min(remaining, 5_000),
427
+ })
428
+ .catch(() => undefined);
429
+ }
339
430
  }
340
431
  page = await findMetaMaskActionPage(context, extensionId);
341
- notificationPage = findMetaMaskNotificationPage(context, extensionId) ?? notificationPage;
342
432
  }
343
- page ??= notificationPage;
344
- if (!page)
433
+ // Last resort: any notification.html page (including the forced one) even
434
+ // if the action-content matchers did not recognize the confirmation copy.
435
+ page ??= findMetaMaskNotificationPage(context, extensionId);
436
+ if (!page) {
437
+ await forcedPage?.close().catch(() => undefined);
345
438
  throw new Error('Timed out waiting for MetaMask notification window.');
439
+ }
440
+ if (forcedPage && forcedPage !== page && !forcedPage.isClosed()) {
441
+ await forcedPage.close().catch(() => undefined);
442
+ }
346
443
  await waitForMetaMaskReady(page);
347
444
  await page.bringToFront().catch(() => undefined);
348
445
  return page;
@@ -350,6 +447,42 @@ async function getNotificationPage(context, extensionId, timeout = DEFAULT_TIMEO
350
447
  function shortAddress(address) {
351
448
  return `${address.slice(0, 6)}...${address.slice(-4)}`.toLowerCase();
352
449
  }
450
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
451
+ /** @internal Validates and trims a 32-byte hex private key (0x optional). */
452
+ export function normalizePrivateKey(privateKey) {
453
+ const trimmed = privateKey.trim();
454
+ if (!/^(0x)?[0-9a-fA-F]{64}$/.test(trimmed)) {
455
+ throw new Error('importWalletFromPrivateKey requires a 32-byte hex private key (with or without 0x).');
456
+ }
457
+ return trimmed;
458
+ }
459
+ /** @internal */
460
+ export function isFullTxHash(value) {
461
+ return typeof value === 'string' && /^0x[0-9a-fA-F]{64}$/.test(value);
462
+ }
463
+ /**
464
+ * @internal Matches an account picker row by display name, or by full /
465
+ * shortened address when the identifier is an address.
466
+ */
467
+ export function accountRowMatcher(identifier) {
468
+ if (FULL_ADDRESS_PATTERN.test(identifier)) {
469
+ return new RegExp(`${escapeRegExp(identifier)}|${escapeRegExp(shortAddress(identifier))}`, 'i');
470
+ }
471
+ return new RegExp(escapeRegExp(identifier), 'i');
472
+ }
473
+ // Account picker rows across UI generations: 12.x popover items, 13.x
474
+ // multichain account cells.
475
+ function accountRowLocator(page) {
476
+ return page.locator('.multichain-account-menu-popover__list--menu-item, .multichain-account-cell, .multichain-account-list-item');
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
+ }
353
486
  async function pageContainsAddress(page, address) {
354
487
  const normalizedAddress = address.toLowerCase();
355
488
  const text = ((await page.locator('body').textContent({ timeout: SHORT_TIMEOUT_MS }).catch(() => '')) ?? '')
@@ -393,6 +526,18 @@ async function importMetaMaskWallet(page, setup) {
393
526
  throw new Error('MetaMask is on onboarding. Provide setup.seedPhrase to import a wallet through web3-tester, or use a preconfigured persistent profile.');
394
527
  }
395
528
  await page.bringToFront().catch(() => undefined);
529
+ // 12.23+ shows a welcome interstitial followed by a Terms of Use dialog
530
+ // (scroll to bottom, check, agree) before the create/import choice.
531
+ await clickFirstVisible([page.locator(testId('onboarding-get-started-button'))], SHORT_TIMEOUT_MS);
532
+ const termsOfUseCheckbox = page.locator(testId('terms-of-use-checkbox'));
533
+ if (await isVisible(termsOfUseCheckbox, SHORT_TIMEOUT_MS).catch(() => false)) {
534
+ await clickFirstVisible([page.locator(testId('terms-of-use-scroll-button'))], SHORT_TIMEOUT_MS);
535
+ await termsOfUseCheckbox.click();
536
+ const agreed = await clickFirstVisible([page.locator(testId('terms-of-use-agree-button'))], DEFAULT_TIMEOUT_MS);
537
+ if (!agreed)
538
+ throw new Error('Unable to accept the MetaMask Terms of Use.');
539
+ await waitForMetaMaskReady(page);
540
+ }
396
541
  const terms = page.locator(testId('onboarding-terms-checkbox'));
397
542
  if (await isVisible(terms, SHORT_TIMEOUT_MS).catch(() => false)) {
398
543
  await terms.check();
@@ -442,7 +587,7 @@ async function importMetaMaskWallet(page, setup) {
442
587
  const passwordSubmitted = await clickFirstVisible([page.locator(testId('create-password-submit')), page.getByRole('button', { name: 'Import my wallet' })], DEFAULT_TIMEOUT_MS);
443
588
  if (!passwordSubmitted)
444
589
  throw new Error('Unable to submit MetaMask import password.');
445
- await finishMetaMaskOnboarding(page);
590
+ await finishMetaMaskOnboarding(page, password);
446
591
  }
447
592
  async function unlockMetaMask(page, password) {
448
593
  const passwordFilled = await fillFirstVisible(metaMaskUnlockPasswordLocators(page), password, DEFAULT_TIMEOUT_MS);
@@ -463,24 +608,38 @@ async function unlockMetaMaskIfNeeded(page, password) {
463
608
  await unlockMetaMask(page, password);
464
609
  return true;
465
610
  }
466
- async function finishMetaMaskOnboarding(page) {
611
+ async function finishMetaMaskOnboarding(page, password) {
467
612
  for (let attempt = 0; attempt < 6; attempt += 1) {
468
613
  await waitForMetaMaskReady(page);
469
614
  const actions = [...metaMaskPromptActions(page), ...metaMaskTextPromptActions(page)];
470
- const onSetupScreen = page.url().includes('/home.html#/onboarding') ||
615
+ const onSetupScreen = isOnboardingRoute(page.url()) ||
471
616
  Boolean(await findVisibleLocator(actions, SHORT_TIMEOUT_MS));
472
617
  if (!onSetupScreen)
473
- return;
618
+ break;
474
619
  const advanced = await clickMetaMaskPromptAction(page, 5_000);
475
620
  if (!advanced)
476
621
  break;
477
622
  }
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))) {
623
+ // MetaMask 13.x defaults to opening the wallet in Chrome's side panel, so
624
+ // clicking "Open wallet" disables the button and leaves the onboarding tab
625
+ // parked on #/onboarding/completion (Playwright can't drive the side
626
+ // panel). Route the tab to the wallet home ourselves.
627
+ if (isOnboardingRoute(page.url())) {
628
+ await routeMetaMaskTabToHome(page);
629
+ // Navigating away from completion can re-lock the freshly created vault.
630
+ await unlockMetaMaskIfNeeded(page, password);
631
+ }
632
+ if (!(await waitForMetaMaskHome(page, DEFAULT_TIMEOUT_MS))) {
482
633
  throw new Error('MetaMask wallet import did not complete onboarding.');
483
634
  }
635
+ // MetaMask 13.x persists extension state through a debounced write
636
+ // (app/scripts/lib/safe-reload.ts OperationSafener — a lodash
637
+ // trailing-edge debounce, wait 1000ms, no maxWait — wrapping
638
+ // persistenceManager.set()). Give the UI a short beat to enqueue the
639
+ // post-onboarding write; the real flush guarantee lives in
640
+ // buildWalletProfile, which polls the profile's storage until the write
641
+ // lands and goes quiet before closing.
642
+ await page.waitForTimeout(500);
484
643
  }
485
644
  class MetaMaskRealWallet {
486
645
  context;
@@ -488,20 +647,44 @@ class MetaMaskRealWallet {
488
647
  extensionId;
489
648
  expectedAddress;
490
649
  walletPassword;
491
- constructor(context, homePage, extensionId, expectedAddress, walletPassword) {
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;
655
+ constructor(context, homePage, extensionId,
656
+ // Mutable: account mutations (switchAccount, imports, new accounts) must
657
+ // invalidate it, or later address checks may target stale UI text.
658
+ expectedAddress, walletPassword, generation = '13x') {
492
659
  this.context = context;
493
660
  this.homePage = homePage;
494
661
  this.extensionId = extensionId;
495
662
  this.expectedAddress = expectedAddress;
496
663
  this.walletPassword = walletPassword;
664
+ this.generation = generation;
665
+ }
666
+ // Generation-aware variant of clickFirstVisible: the annotated stack is
667
+ // resolved against the configured generation first, and an empty resolution
668
+ // short-circuits without probing the page at all.
669
+ async clickFirst(locators, timeout, options) {
670
+ const resolved = resolveGenLocators(locators, this.generation);
671
+ if (!resolved.length)
672
+ return undefined;
673
+ return clickFirstVisible(resolved, timeout, options);
497
674
  }
498
675
  async approveTokenPermission(options) {
499
676
  const page = await this.notificationPage();
500
677
  if (options?.spendLimit === 'max') {
501
- await clickFirstVisible([page.locator(testId('custom-spending-cap-max-button'))], SHORT_TIMEOUT_MS);
678
+ const clicked = await clickFirstVisible([page.locator(testId('custom-spending-cap-max-button'))], SHORT_TIMEOUT_MS);
679
+ if (!clicked) {
680
+ throw new Error('A max spendLimit was requested but the MetaMask spending-cap "Max" control was not found.');
681
+ }
502
682
  }
503
683
  else if (typeof options?.spendLimit === 'number') {
504
- await fillFirstVisible([page.locator(testId('custom-spending-cap-input'))], String(options.spendLimit), SHORT_TIMEOUT_MS);
684
+ const filled = await fillFirstVisible([page.locator(testId('custom-spending-cap-input'))], String(options.spendLimit), SHORT_TIMEOUT_MS);
685
+ if (!filled) {
686
+ throw new Error('A numeric spendLimit was requested but the MetaMask spending-cap input was not found.');
687
+ }
505
688
  }
506
689
  await this.confirmFooterAction(page);
507
690
  if (!page.isClosed()) {
@@ -533,19 +716,56 @@ class MetaMaskRealWallet {
533
716
  if (!signed)
534
717
  throw new Error('Unable to confirm MetaMask signature request.');
535
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);
536
725
  }
537
726
  async confirmTransaction(options) {
538
- let page = await this.notificationPage();
727
+ let page = await realWalletTiming('confirmTransaction.notificationPage', () => this.notificationPage());
539
728
  for (let attempt = 0; attempt < 3; attempt += 1) {
540
- await this.applyGasSetting(page, options?.gasSetting);
541
- 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);
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));
732
+ // The click registered once the confirmed control leaves the view or
733
+ // the popup closes no fixed sleep deciding "settled".
734
+ await realWalletTiming(`confirmTransaction.waitClickSettled.attempt${attempt + 1}`, () => Promise.race([
735
+ clicked.waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => undefined),
736
+ page.waitForEvent('close', { timeout: 10_000 }).catch(() => undefined),
737
+ ]));
738
+ if (!page.isClosed()) {
739
+ // Same window advanced to another confirmation step.
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 }));
749
+ if (!nextStep)
750
+ return;
751
+ await realWalletTiming(`confirmTransaction.waitNextStepReady.attempt${attempt + 1}`, () => waitForMetaMaskReady(page));
752
+ continue;
753
+ }
754
+ // Popup closed: give a follow-up notification window (approve + action
755
+ // flows) a moment to appear.
756
+ const deadline = Date.now() + 1_500;
757
+ let nextPage;
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
+ });
545
765
  if (!nextPage)
546
766
  return;
547
767
  page = nextPage;
548
- await waitForMetaMaskReady(page);
768
+ await realWalletTiming(`confirmTransaction.waitFollowupReady.attempt${attempt + 1}`, () => waitForMetaMaskReady(page));
549
769
  await page.bringToFront().catch(() => undefined);
550
770
  }
551
771
  throw new Error('MetaMask transaction confirmation did not settle after multiple confirmation steps.');
@@ -560,25 +780,293 @@ class MetaMaskRealWallet {
560
780
  }
561
781
  }
562
782
  async getAccountAddress() {
783
+ if (this.generation === '13x' && this.trustedSelectedAddress) {
784
+ return this.trustedSelectedAddress;
785
+ }
563
786
  const page = await this.home();
564
787
  await waitForMetaMaskHome(page);
565
788
  await closeMetaMaskOverlay(page);
566
789
  if (this.expectedAddress && await pageContainsAddress(page, this.expectedAddress)) {
567
790
  return this.expectedAddress;
568
791
  }
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();
792
+ // 12.x exposes a header copy button (an extension copy control that uses
793
+ // document.execCommand('copy')) that puts the full address on the
794
+ // clipboard; reading it via a synthetic paste avoids the account-details
795
+ // modal entirely (extension pages block evaluate via LavaMoat, normal
796
+ // pages do not). 13.x has no header copy control — the tag skips it.
797
+ const headerCopied = await this.clickFirst([{ gen: '12x', loc: page.locator(testId('app-header-copy-button')) }], SHORT_TIMEOUT_MS);
798
+ if (headerCopied) {
799
+ const pasted = await this.readClipboardViaPaste();
800
+ if (pasted && FULL_ADDRESS_PATTERN.test(pasted))
801
+ return pasted;
802
+ }
803
+ await this.openAccountDetailsModal(page, 90_000);
804
+ // Read the full address from whichever copy affordance the modal exposes
805
+ // (12.x: address-copy-button-text; 13.x addresses view:
806
+ // multichain-address-row-copy-button). The visible text may be shortened,
807
+ // so prefer clicking the copy control and reading the clipboard.
808
+ const addressCopy = await findVisibleLocator([
809
+ page.locator(testId('address-copy-button-text')),
810
+ page.locator(testId('multichain-address-row-copy-button')),
811
+ page.locator(testId('address-qr-code-modal-copy-button')),
812
+ ], DEFAULT_TIMEOUT_MS, { requireEnabled: false });
813
+ if (!addressCopy) {
814
+ throw new Error('Unable to find the MetaMask account address in the details view.');
815
+ }
816
+ const elementText = (await addressCopy.textContent())?.trim();
817
+ if (elementText && FULL_ADDRESS_PATTERN.test(elementText)) {
818
+ await this.closeAccountDetailsAfterRead(page);
819
+ return elementText;
820
+ }
821
+ await addressCopy.click().catch(() => undefined);
822
+ const pasted = await this.readClipboardViaPaste();
823
+ await this.closeAccountDetailsAfterRead(page);
824
+ if (pasted && FULL_ADDRESS_PATTERN.test(pasted)) {
825
+ return pasted;
826
+ }
827
+ throw new Error('Unable to read the selected MetaMask account address from the UI.');
828
+ }
829
+ // Opens the account address view. 12.x: account-options (3-dot) menu ->
830
+ // "Account details". 13.x multichain UI: account picker -> the selected
831
+ // account row's address menu -> "Addresses" (the "Account details" item
832
+ // there is the export-keys view, not the address).
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);
836
+ if (this.generation === '12x') {
837
+ const menuOpened = await clickFirstVisible([page.locator(testId('account-options-menu-button'))], remaining(SHORT_TIMEOUT_MS));
838
+ const detailsOpened = menuOpened &&
839
+ (await clickFirstVisible([page.locator(testId('account-list-menu-details'))], remaining()));
840
+ if (!detailsOpened)
841
+ throw new Error('Unable to open the MetaMask account details view.');
842
+ return;
843
+ }
844
+ // 13.x multichain path. Prefer the home header's active-account address
845
+ // menu (default-address-menu-button) so we read the *selected* account
846
+ // rather than an arbitrary cell — a single SRP import derives many
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;
882
+ }
883
+ if (!addressMenuOpened)
884
+ throw new Error('Unable to open the MetaMask account address menu.');
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
+ }
578
892
  await closeMetaMaskOverlay(page);
579
- if (!address?.startsWith('0x'))
580
- throw new Error('Unable to read selected MetaMask account address.');
581
- return address;
893
+ }
894
+ async readClipboardViaPaste() {
895
+ const page = await this.context.newPage();
896
+ try {
897
+ await page.goto('data:text/html,<input id="paste-target" autofocus>');
898
+ const input = page.locator('#paste-target');
899
+ await input.click();
900
+ await page.keyboard.press('ControlOrMeta+v');
901
+ const value = (await input.inputValue()).trim();
902
+ return value || undefined;
903
+ }
904
+ catch {
905
+ return undefined;
906
+ }
907
+ finally {
908
+ await page.close().catch(() => undefined);
909
+ }
910
+ }
911
+ async addNetwork(network) {
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
+ }
1001
+ }
1002
+ }
1003
+ async switchNetwork(name, options = {}) {
1004
+ const page = await this.home();
1005
+ const home = page.url().split('#')[0];
1006
+ const candidates = () => [
1007
+ // 13.x multichain rows are keyed by CAIP-2 chain id.
1008
+ ...(options.chainId !== undefined
1009
+ ? [page.locator(testId(`network-list-item-eip155:${options.chainId}`))]
1010
+ : []),
1011
+ // 12.x rows have historically used the network name as test id.
1012
+ page.locator(testId(name)),
1013
+ page.locator('[data-testid="network-list-item"]').filter({ hasText: name }),
1014
+ page.locator('[data-testid^="network-list-item"]').filter({ hasText: name }),
1015
+ page.locator('.multichain-network-list-item').filter({ hasText: name }),
1016
+ page.getByText(name, { exact: true }),
1017
+ ];
1018
+ // Custom RPC networks live under the 13.x "Custom" tab; try the current
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);
1027
+ }
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);
1047
+ }
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.`);
1049
+ }
1050
+ async approveNewNetwork() {
1051
+ const page = await this.notificationPage();
1052
+ await this.confirmFooterAction(page);
1053
+ // MetaMask follows up with a "switch to this network" step.
1054
+ if (!page.isClosed()) {
1055
+ await waitForMetaMaskReady(page);
1056
+ await this.confirmFooterAction(page, SHORT_TIMEOUT_MS).catch(() => undefined);
1057
+ }
1058
+ }
1059
+ async rejectNewNetwork() {
1060
+ const page = await this.notificationPage();
1061
+ await this.rejectFooterAction(page);
1062
+ }
1063
+ async approveSwitchNetwork() {
1064
+ const page = await this.notificationPage();
1065
+ await this.confirmFooterAction(page);
1066
+ }
1067
+ async rejectSwitchNetwork() {
1068
+ const page = await this.notificationPage();
1069
+ await this.rejectFooterAction(page);
582
1070
  }
583
1071
  async rejectSignature() {
584
1072
  const page = await this.notificationPage();
@@ -591,6 +1079,985 @@ class MetaMaskRealWallet {
591
1079
  const page = await this.notificationPage();
592
1080
  await this.rejectFooterAction(page);
593
1081
  }
1082
+ async rejectTokenPermission() {
1083
+ const page = await this.notificationPage();
1084
+ await this.rejectFooterAction(page);
1085
+ }
1086
+ async approveAddToken() {
1087
+ const page = await this.notificationPage();
1088
+ await this.confirmFooterAction(page);
1089
+ }
1090
+ async addNewToken() {
1091
+ await this.approveAddToken();
1092
+ }
1093
+ async rejectAddToken() {
1094
+ const page = await this.notificationPage();
1095
+ await this.rejectFooterAction(page);
1096
+ }
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());
1101
+ const normalized = normalizePrivateKey(privateKey);
1102
+ const page = await this.preparedHome();
1103
+ await this.openAccountPicker(page, Math.min(90_000, remaining()));
1104
+ let importOpened = false;
1105
+ if (this.generation === '12x') {
1106
+ // 12.x: action button → "Import account".
1107
+ if (await clickFirstVisible([page.locator(testId('multichain-account-menu-popover-action-button'))], SHORT_TIMEOUT_MS)) {
1108
+ importOpened = Boolean(await clickFirstVisible([page.locator(testId('multichain-account-menu-popover-add-imported-account'))], SHORT_TIMEOUT_MS));
1109
+ }
1110
+ }
1111
+ else {
1112
+ // 13.x: the add-wallet button sits below the (possibly long) account
1113
+ // list, so scroll it into view first, then pick "Import an account"
1114
+ // (runtime testid choose-wallet-type-import-account). The import page
1115
+ // defaults to the Private key tab, so no further wallet-type choice is
1116
+ // needed. Retry the picker → add-wallet → choose-type hop (it can race
1117
+ // the account-list render under load).
1118
+ for (let attempt = 0; attempt < 3 && !importOpened; attempt += 1) {
1119
+ if (attempt > 0) {
1120
+ await page.keyboard.press('Escape').catch(() => undefined);
1121
+ await this.openAccountPicker(page, Math.min(60_000, remaining()));
1122
+ }
1123
+ const addWallet = page.locator(testId('account-list-add-wallet-button')).first();
1124
+ await addWallet.scrollIntoViewIfNeeded({ timeout: timeoutFor(SHORT_TIMEOUT_MS) }).catch(() => undefined);
1125
+ if (await clickFirstVisible([addWallet], timeoutFor())) {
1126
+ importOpened = Boolean(await clickFirstVisible([
1127
+ page.locator(testId('choose-wallet-type-import-account')),
1128
+ page.getByText('Import an account', { exact: true }),
1129
+ ], timeoutFor()));
1130
+ }
1131
+ }
1132
+ }
1133
+ if (!importOpened) {
1134
+ throw new Error('Unable to open the MetaMask import-account flow — update web3-tester for this MetaMask version.');
1135
+ }
1136
+ const keyFilled = await fillFirstVisible([page.locator('#private-key-box')], normalized, timeoutFor());
1137
+ if (!keyFilled)
1138
+ throw new Error('Unable to find the MetaMask private key input.');
1139
+ const confirmed = await clickFirstVisible([page.locator(testId('import-account-confirm-button'))], timeoutFor());
1140
+ if (!confirmed)
1141
+ throw new Error('Unable to confirm the MetaMask private key import.');
1142
+ // Success closes the dialog (the keyring import can take a moment);
1143
+ // failure keeps it open with inline help text.
1144
+ const dialogClosed = await isHidden(page.locator('#private-key-box'), timeoutFor()).catch(() => false);
1145
+ if (!dialogClosed) {
1146
+ const helpText = (await page.locator('.mm-help-text').first().textContent({ timeout: SHORT_TIMEOUT_MS }).catch(() => null))?.trim();
1147
+ throw new Error(`MetaMask rejected the private key import${helpText ? `: ${helpText}` : '.'}`);
1148
+ }
1149
+ // The imported account becomes active. 13.x leaves the SPA parked on
1150
+ // #/choose-new-wallet-type, so route back to the wallet home or later
1151
+ // home-screen lookups (account-menu-icon, etc.) time out.
1152
+ if (this.generation === '13x') {
1153
+ await page.goto(`${page.url().split('#')[0]}#/`).catch(() => undefined);
1154
+ await waitForMetaMaskReady(page);
1155
+ }
1156
+ this.expectedAddress = undefined;
1157
+ this.trustedSelectedAddress = undefined;
1158
+ await closeMetaMaskOverlay(page);
1159
+ }
1160
+ async addNewAccount(name) {
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());
1164
+ if (this.generation === '12x') {
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.
1170
+ const actionOpened = await clickFirstVisible([page.locator(testId('multichain-account-menu-popover-action-button'))], SHORT_TIMEOUT_MS);
1171
+ const addOpened = actionOpened &&
1172
+ (await clickFirstVisible([page.locator(testId('multichain-account-menu-popover-add-account'))], SHORT_TIMEOUT_MS));
1173
+ if (!addOpened) {
1174
+ throw new Error('Unable to find the MetaMask add-account control — update web3-tester for this MetaMask version.');
1175
+ }
1176
+ const submitted = await clickFirstVisible([
1177
+ page.locator(testId('submit-add-account-with-name')),
1178
+ page.getByRole('button', { name: /^(Add account|Create)$/i }),
1179
+ ], DEFAULT_TIMEOUT_MS);
1180
+ if (!submitted)
1181
+ throw new Error('Unable to submit the MetaMask add-account dialog.');
1182
+ this.expectedAddress = undefined;
1183
+ this.trustedSelectedAddress = undefined;
1184
+ await closeMetaMaskOverlay(page);
1185
+ if (name) {
1186
+ await this.renameActiveAccountFromDetails(page, name);
1187
+ }
1188
+ return;
1189
+ }
1190
+ // 13.x multichain: the "add account" control is NOT in the account picker
1191
+ // (its only add affordance, account-list-add-wallet-button, opens the
1192
+ // import/hardware "Add a wallet" flow). Adding a derived account lives on
1193
+ // the SRP wallet's details page. The create is a background dispatch that
1194
+ // the account-tree sync silently drops ~1/3 of the time (no toast, the new
1195
+ // account is not auto-selected), so the reliable approach is: settle the
1196
+ // tree, gate on a non-syncing button, click, then wait for the specific
1197
+ // new cell (the wallet's max index + 1) to attach — retrying across fresh
1198
+ // navigations. LavaMoat blocks bulk text reads, so everything is
1199
+ // count()/getAttribute()/testid based.
1200
+ const home = page.url().split('#')[0];
1201
+ await realWalletTiming('addNewAccount.openAccountPicker', () => this.openAccountPicker(page, Math.min(90_000, remaining())));
1202
+ const walletId = await realWalletTiming('addNewAccount.activeSrpWalletId', () => this.activeSrpWalletId(page));
1203
+ await page.keyboard.press('Escape').catch(() => undefined);
1204
+ await realWalletTiming('addNewAccount.initialTreeSettle', () => wait(ACCOUNT_TREE_SETTLE_MS));
1205
+ const walletCellPrefix = `[data-testid^="multichain-account-cell-${walletId}/"]`;
1206
+ const createAttempts = 5;
1207
+ const createDeadline = methodDeadline;
1208
+ let newIndex = -1;
1209
+ for (let attempt = 0; attempt < createAttempts && newIndex < 0 && Date.now() < createDeadline; attempt += 1) {
1210
+ // Between attempts, let the account-tree backup-and-sync (which drops
1211
+ // the create when it overlaps, and retries on its own backoff) calm
1212
+ // before re-navigating — back off a little further each time.
1213
+ if (attempt > 0)
1214
+ await wait(2_000 + attempt * 1_500);
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;
1251
+ }
1252
+ catch (error) {
1253
+ if (!isRecoverablePageNavigationError(error))
1254
+ throw error;
1255
+ page = await this.home();
1256
+ }
1257
+ }
1258
+ if (newIndex < 0) {
1259
+ throw new Error(`MetaMask did not create a new account after ${createAttempts} attempts (account-tree sync ` +
1260
+ 'race) — retry the operation.');
1261
+ }
1262
+ this.expectedAddress = undefined;
1263
+ this.trustedSelectedAddress = undefined;
1264
+ if (name) {
1265
+ // Rename via the account-details route: the new high-index cell's row
1266
+ // menu is unclickable at the foot of a long (un-virtualized) wallet list.
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));
1276
+ const editOpened = await clickFirstVisible([page.locator(testId('account-name-action'))], DEFAULT_TIMEOUT_MS);
1277
+ if (!editOpened)
1278
+ throw new Error('Unable to open the MetaMask account name editor.');
1279
+ await realWalletTiming('addNewAccount.renameNewAccount', () => this.fillAccountNameAndSave(page, name));
1280
+ }
1281
+ await realWalletTiming('addNewAccount.returnHome', async () => {
1282
+ await page.goto(`${home}#/`);
1283
+ await waitForMetaMaskReady(page);
1284
+ await closeMetaMaskOverlay(page);
1285
+ });
1286
+ }
1287
+ // The SRP (entropy) wallet id, parsed from the first multichain account
1288
+ // cell's test id (form: multichain-account-cell-<entropy:UID>/<index>).
1289
+ // Needed to address the wallet details page where account creation lives.
1290
+ async activeSrpWalletId(page) {
1291
+ const testIdAttr = await page
1292
+ .locator('[data-testid^="multichain-account-cell-entropy:"]')
1293
+ .first()
1294
+ .getAttribute('data-testid')
1295
+ .catch(() => null);
1296
+ const match = testIdAttr?.match(/^multichain-account-cell-(entropy:[^/]+)/);
1297
+ if (!match) {
1298
+ throw new Error('Unable to determine the MetaMask SRP wallet id from the account list — update web3-tester for this MetaMask version.');
1299
+ }
1300
+ return match[1];
1301
+ }
1302
+ async switchAccount(nameOrAddress) {
1303
+ const page = await realWalletTiming('switchAccount.home', () => this.home());
1304
+ await realWalletTiming('switchAccount.openAccountPicker', () => this.openAccountPicker(page));
1305
+ if (this.generation === '13x') {
1306
+ const cell = await realWalletTiming('switchAccount.find13xAccountCell', () => this.find13xAccountCell(page, nameOrAddress));
1307
+ if (!cell) {
1308
+ throw new Error(`Unable to find MetaMask account "${nameOrAddress}" in the picker (searched the ` +
1309
+ 'virtualized account list by name/address).');
1310
+ }
1311
+ await realWalletTiming('switchAccount.clickCell', async () => {
1312
+ await cell.click();
1313
+ await waitForMetaMaskReady(page);
1314
+ });
1315
+ this.expectedAddress = FULL_ADDRESS_PATTERN.test(nameOrAddress) ? nameOrAddress : undefined;
1316
+ this.trustedSelectedAddress = this.expectedAddress;
1317
+ await realWalletTiming('switchAccount.closeOverlay', () => closeMetaMaskOverlay(page));
1318
+ return;
1319
+ }
1320
+ const row = accountRowLocator(page).filter({ hasText: accountRowMatcher(nameOrAddress) }).first();
1321
+ const clicked = await clickFirstVisible([row], DEFAULT_TIMEOUT_MS, { requireEnabled: false });
1322
+ if (!clicked) {
1323
+ throw new Error(`Unable to find MetaMask account "${nameOrAddress}" in the picker. On 13.x the rows show ` +
1324
+ 'names, so address matching is best-effort — prefer account names.');
1325
+ }
1326
+ await waitForMetaMaskReady(page);
1327
+ this.expectedAddress = FULL_ADDRESS_PATTERN.test(nameOrAddress) ? nameOrAddress : undefined;
1328
+ this.trustedSelectedAddress = undefined;
1329
+ await closeMetaMaskOverlay(page);
1330
+ }
1331
+ async renameAccount(currentName, newName) {
1332
+ const page = await this.preparedHome();
1333
+ await this.openAccountPicker(page);
1334
+ if (this.generation === '13x') {
1335
+ const cell = await this.findAccountCellByName(page, currentName);
1336
+ if (!cell)
1337
+ throw new Error(`Unable to find MetaMask account "${currentName}" to rename.`);
1338
+ await this.rename13xCellViaMenu(page, cell, newName);
1339
+ await page.keyboard.press('Escape').catch(() => undefined);
1340
+ await closeMetaMaskOverlay(page);
1341
+ return;
1342
+ }
1343
+ const row = accountRowLocator(page).filter({ hasText: accountRowMatcher(currentName) }).first();
1344
+ if (!(await isVisible(row, DEFAULT_TIMEOUT_MS).catch(() => false))) {
1345
+ throw new Error(`Unable to find MetaMask account "${currentName}" to rename.`);
1346
+ }
1347
+ await this.renameAccountRow(page, row, newName, currentName);
1348
+ await page.keyboard.press('Escape').catch(() => undefined);
1349
+ await closeMetaMaskOverlay(page);
1350
+ }
1351
+ async lock() {
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.');
1367
+ }
1368
+ async unlock(password) {
1369
+ const target = password ?? this.walletPassword;
1370
+ if (!target) {
1371
+ throw new Error('unlock() needs a password — pass one or provide setup.password at launch.');
1372
+ }
1373
+ const page = await openExtensionHome(this.context, this.extensionId);
1374
+ if (!(await isMetaMaskUnlockVisible(page)))
1375
+ return;
1376
+ await unlockMetaMask(page, target);
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
+ }
1387
+ async resetAccount() {
1388
+ const page = await this.preparedHome();
1389
+ await this.openGlobalMenu(page);
1390
+ const settingsOpened = await clickFirstVisible([page.locator(testId('global-menu-settings')), page.getByText(/^Settings$/i)], DEFAULT_TIMEOUT_MS);
1391
+ if (!settingsOpened)
1392
+ throw new Error('Unable to open MetaMask settings.');
1393
+ await waitForMetaMaskReady(page);
1394
+ if (this.generation === '12x') {
1395
+ // 12.x: "Clear activity tab data" lives on the Advanced tab (or is
1396
+ // already visible when settings opens straight onto it).
1397
+ if (await clickFirstVisible([page.locator(testId('advanced-setting-reset-account')).getByRole('button')], SHORT_TIMEOUT_MS)) {
1398
+ await this.confirmResetModal(page);
1399
+ await this.leaveSettings(page);
1400
+ return;
1401
+ }
1402
+ if (await clickFirstVisible([
1403
+ page.getByRole('tab', { name: /^Advanced$/i }),
1404
+ page.locator('.tab-bar__tab').filter({ hasText: /^Advanced$/ }),
1405
+ ], SHORT_TIMEOUT_MS)) {
1406
+ if (await clickFirstVisible([
1407
+ page.locator(testId('advanced-setting-reset-account')).getByRole('button'),
1408
+ page.getByRole('button', { name: /Clear activity( tab)? data/i }),
1409
+ ], SHORT_TIMEOUT_MS)) {
1410
+ await this.confirmResetModal(page);
1411
+ await this.leaveSettings(page);
1412
+ return;
1413
+ }
1414
+ }
1415
+ }
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))) {
1427
+ await this.confirmResetModal(page);
1428
+ await this.leaveSettings(page);
1429
+ return;
1430
+ }
1431
+ }
1432
+ // Fallback: the settings search (the Developer tools tab can be
1433
+ // feature-flag hidden in production profiles).
1434
+ if (await clickFirstVisible([page.locator(testId('settings-header-search-button'))], 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);
1436
+ if (await clickFirstVisible([page.locator(testId('settings-search-result-item')).first()], SHORT_TIMEOUT_MS)) {
1437
+ const cleared = await clickFirstVisible(metaMaskResetAccountControlLocators(page), DEFAULT_TIMEOUT_MS);
1438
+ if (cleared) {
1439
+ await this.confirmResetModal(page);
1440
+ await this.leaveSettings(page);
1441
+ return;
1442
+ }
1443
+ }
1444
+ }
1445
+ throw new Error('Unable to find the MetaMask reset-account control (Clear activity / Delete activity and ' +
1446
+ 'nonce data) — update web3-tester for this MetaMask version.');
1447
+ }
1448
+ async toggleShowTestNetworks(on) {
1449
+ let page = await this.preparedHome();
1450
+ if (this.generation === '13x') {
1451
+ // 13.x hosts the toggle on the standalone #/networks page (the network
1452
+ // picker popover only renders it once non-default testnets already
1453
+ // exist). It is a hidden <input type=checkbox> under a styled overlay.
1454
+ // react-toggle-button binds its `value` attribute (not the `checked`
1455
+ // property), so Playwright's check()/uncheck() — which assert the
1456
+ // never-updated `checked` — throw "did not change its state"; instead
1457
+ // force-click and confirm the `value` attribute flipped.
1458
+ // The first navigation to #/networks can race the React render; renavigate
1459
+ // until the toggle attaches.
1460
+ let toggleReady = 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
+ }
1483
+ }
1484
+ if (!toggleReady) {
1485
+ throw new Error('Unable to find the MetaMask "Show test networks" toggle on the networks page.');
1486
+ }
1487
+ // The testid sits on a hidden 1×1 screen-reader <input> bound by `value`
1488
+ // (not `checked`); force-clicking that tiny input is unreliable. Click the
1489
+ // wrapping label.toggle-button instead — the visible, hit-testable control
1490
+ // — and confirm the input's `value` attribute flipped (give the
1491
+ // controlled re-render a beat so a re-click can't flip it back).
1492
+ const toggleLabel = toggle.locator('xpath=ancestor-or-self::label[contains(@class,"toggle-button")][1]');
1493
+ const readOn = async () => (await toggle.getAttribute('value').catch(() => null)) === 'true';
1494
+ const target = on ?? !(await readOn());
1495
+ for (let attempt = 0; attempt < 5 && (await readOn()) !== target; attempt += 1) {
1496
+ await toggleLabel.click({ force: true }).catch(() => undefined);
1497
+ await wait(1_000);
1498
+ }
1499
+ if ((await readOn()) !== target) {
1500
+ throw new Error('Unable to flip the MetaMask "Show test networks" toggle on the networks page.');
1501
+ }
1502
+ await waitForMetaMaskReady(page);
1503
+ await page.goto(page.url().split('#')[0]).catch(() => undefined);
1504
+ await waitForMetaMaskReady(page);
1505
+ await closeMetaMaskOverlay(page);
1506
+ return;
1507
+ }
1508
+ const pickerOpened = await clickFirstVisible(metaMaskNetworkPickerLocators(page), DEFAULT_TIMEOUT_MS);
1509
+ if (!pickerOpened)
1510
+ throw new Error('Unable to open the MetaMask network picker.');
1511
+ await waitForMetaMaskReady(page);
1512
+ // react-toggle-button renders the data-testid onto a 1×1 screen-reader
1513
+ // <input>; clicking that fails Playwright's hit-target check. Click the
1514
+ // wrapping label.toggle-button (the hit-testable track) instead.
1515
+ const toggle = await findVisibleLocator([
1516
+ page
1517
+ .getByText(/Show test networks/i)
1518
+ .locator('xpath=following::label[contains(@class,"toggle-button")][1]'),
1519
+ page.locator('label.toggle-button').first(),
1520
+ ], DEFAULT_TIMEOUT_MS, { requireEnabled: false });
1521
+ if (!toggle) {
1522
+ throw new Error('Unable to find the MetaMask "Show test networks" toggle.');
1523
+ }
1524
+ if (on === undefined) {
1525
+ await toggle.click();
1526
+ }
1527
+ else {
1528
+ const current = await this.readToggleState(toggle);
1529
+ if (current !== on) {
1530
+ await toggle.click();
1531
+ }
1532
+ }
1533
+ await waitForMetaMaskReady(page);
1534
+ await page.keyboard.press('Escape').catch(() => undefined);
1535
+ await closeMetaMaskOverlay(page);
1536
+ }
1537
+ async importToken(token) {
1538
+ if (!FULL_ADDRESS_PATTERN.test(token.address)) {
1539
+ throw new Error('importToken requires a 20-byte 0x token address.');
1540
+ }
1541
+ const page = await this.preparedHome();
1542
+ // The token list (and its control bar) lives on the Tokens tab.
1543
+ await clickFirstVisible([
1544
+ page.locator(testId('account-overview__asset-tab')),
1545
+ page.getByRole('button', { name: /^Tokens$/i }),
1546
+ ], SHORT_TIMEOUT_MS);
1547
+ const menuOpened = await clickFirstVisible([page.locator(testId('asset-list-control-bar-action-button'))], DEFAULT_TIMEOUT_MS);
1548
+ if (!menuOpened)
1549
+ throw new Error('Unable to open the MetaMask token list menu.');
1550
+ if (this.generation === '13x') {
1551
+ await this.importTokenViaTokenManagement(page, token);
1552
+ }
1553
+ else {
1554
+ await this.importTokenViaModal(page, token);
1555
+ }
1556
+ await closeMetaMaskOverlay(page);
1557
+ }
1558
+ // 13.x: control-bar menu → "Manage tokens" → token-management page → "Add a
1559
+ // custom token" → the custom-token-import page (address, then auto-loaded
1560
+ // symbol/decimals, then "Add token"). Replaces the 12.x import-tokens modal.
1561
+ async importTokenViaTokenManagement(page, token) {
1562
+ const manageOpened = await clickFirstVisible([page.locator(testId('manageTokens')), page.getByRole('button', { name: /^Manage tokens$/i })], DEFAULT_TIMEOUT_MS);
1563
+ if (!manageOpened)
1564
+ throw new Error('Unable to open the MetaMask "Manage tokens" page.');
1565
+ const addCustomOpened = await clickFirstVisible([
1566
+ page.locator(testId('token-management-add-custom-token-button')),
1567
+ page.getByRole('button', { name: /Add a custom token/i }),
1568
+ ], DEFAULT_TIMEOUT_MS);
1569
+ if (!addCustomOpened)
1570
+ throw new Error('Unable to open the MetaMask custom-token import form.');
1571
+ if (token.networkName) {
1572
+ const selectorOpened = await clickFirstVisible([page.locator(testId('network-selector')), page.getByRole('button', { name: /^Network$/i })], SHORT_TIMEOUT_MS);
1573
+ if (selectorOpened) {
1574
+ const picked = await clickFirstVisible([page.getByText(token.networkName, { exact: true })], DEFAULT_TIMEOUT_MS);
1575
+ if (!picked) {
1576
+ throw new Error(`Unable to select network "${token.networkName}" in the token import page.`);
1577
+ }
1578
+ }
1579
+ }
1580
+ const addressFilled = await fillFirstVisible([page.locator(testId('custom-token-import-address-input'))], token.address);
1581
+ if (!addressFilled)
1582
+ throw new Error('Unable to fill the token contract address.');
1583
+ // MetaMask auto-loads symbol/decimals from the contract and enables "Add
1584
+ // token". Only fill the fields if they stay EMPTY (the RPC read failed) —
1585
+ // re-filling an auto-populated field on every tick re-triggers validation
1586
+ // and keeps the submit button from ever settling enabled.
1587
+ const submit = page.locator(testId('custom-token-import-submit-button')).first();
1588
+ const deadline = Date.now() + DEFAULT_TIMEOUT_MS;
1589
+ while (Date.now() < deadline && !(await submit.isEnabled().catch(() => false))) {
1590
+ if (token.symbol) {
1591
+ await this.fillIfEmpty(page.locator(testId('custom-token-import-symbol-input')).first(), token.symbol);
1592
+ }
1593
+ if (token.decimals !== undefined) {
1594
+ await this.fillIfEmpty(page.locator(testId('custom-token-import-decimal-input')).first(), String(token.decimals));
1595
+ }
1596
+ await wait(LOCATOR_PROBE_MS);
1597
+ }
1598
+ const added = await clickFirstVisible([submit, page.getByRole('button', { name: /^Add token$/i })], DEFAULT_TIMEOUT_MS);
1599
+ if (!added) {
1600
+ throw new Error('MetaMask never enabled the custom-token "Add token" button — check the token address, ' +
1601
+ 'network, and (for contracts the RPC cannot read) pass symbol/decimals explicitly.');
1602
+ }
1603
+ // The import is confirmed by the success toast (and the form unmounting);
1604
+ // a regression that dismisses the form without importing fails here.
1605
+ const confirmed = await findVisibleLocator([
1606
+ page.locator(testId('token-management-custom-token-success-toast')),
1607
+ page.getByText(/was successfully added/i),
1608
+ ], DEFAULT_TIMEOUT_MS, { requireEnabled: false });
1609
+ if (!confirmed) {
1610
+ await isHidden(page.locator(testId('custom-token-import-address-input')), DEFAULT_TIMEOUT_MS).catch(() => undefined);
1611
+ }
1612
+ }
1613
+ // Fills a metadata field only when it is currently empty, so an
1614
+ // auto-populated value (MetaMask reading the contract) is left intact. A
1615
+ // tight loop that unconditionally re-fills an auto-filled field re-triggers
1616
+ // validation and prevents the submit/Next button from settling enabled.
1617
+ async fillIfEmpty(field, value) {
1618
+ const current = await field.inputValue({ timeout: LOCATOR_PROBE_MS }).catch(() => null);
1619
+ if (current === '')
1620
+ await field.fill(value).catch(() => undefined);
1621
+ }
1622
+ // 12.x: control-bar menu → "Import tokens" → Custom token tab → modal form.
1623
+ async importTokenViaModal(page, token) {
1624
+ const importClicked = await clickFirstVisible([page.locator(testId('importTokens')), page.getByText(/^Import tokens$/i)], DEFAULT_TIMEOUT_MS);
1625
+ if (!importClicked)
1626
+ throw new Error('Unable to open the MetaMask Import tokens dialog.');
1627
+ await clickFirstVisible([
1628
+ page.locator(testId('import-tokens-modal-custom-token-tab')),
1629
+ page.getByRole('tab', { name: /Custom token/i }),
1630
+ page.getByText('Custom token', { exact: true }),
1631
+ ], SHORT_TIMEOUT_MS);
1632
+ if (token.networkName) {
1633
+ const dropdownOpened = await clickFirstVisible([
1634
+ page.locator(testId('import-tokens-drop-down-custom-import')),
1635
+ page.locator(testId('test-import-tokens-drop-down-custom-import')),
1636
+ ], SHORT_TIMEOUT_MS);
1637
+ if (dropdownOpened) {
1638
+ const picked = await clickFirstVisible([page.getByText(token.networkName, { exact: true })], DEFAULT_TIMEOUT_MS);
1639
+ if (!picked) {
1640
+ throw new Error(`Unable to select network "${token.networkName}" in the token import dialog.`);
1641
+ }
1642
+ }
1643
+ }
1644
+ const addressFilled = await fillFirstVisible([page.locator(testId('import-tokens-modal-custom-address'))], token.address);
1645
+ if (!addressFilled)
1646
+ throw new Error('Unable to fill the token contract address.');
1647
+ // MetaMask auto-loads symbol/decimals from the contract and enables Next.
1648
+ // Only fill the fields if they stay EMPTY (the RPC read failed) — re-filling
1649
+ // an auto-populated field on every tick re-triggers validation and keeps
1650
+ // Next from ever settling enabled (observed on 12.23.1).
1651
+ const nextButton = page.locator(testId('import-tokens-button-next')).first();
1652
+ const deadline = Date.now() + DEFAULT_TIMEOUT_MS;
1653
+ while (Date.now() < deadline && !(await nextButton.isEnabled().catch(() => false))) {
1654
+ if (token.symbol) {
1655
+ await this.fillIfEmpty(page.locator(testId('import-tokens-modal-custom-symbol')).first(), token.symbol);
1656
+ }
1657
+ if (token.decimals !== undefined) {
1658
+ await this.fillIfEmpty(page.locator(testId('import-tokens-modal-custom-decimals')).first(), String(token.decimals));
1659
+ }
1660
+ await wait(LOCATOR_PROBE_MS);
1661
+ }
1662
+ const next = await clickFirstVisible([nextButton], SHORT_TIMEOUT_MS);
1663
+ if (!next) {
1664
+ throw new Error('MetaMask never enabled the Import Tokens "Next" button — check the token address, ' +
1665
+ 'network, and (for contracts the RPC cannot read) pass symbol/decimals explicitly.');
1666
+ }
1667
+ const imported = await clickFirstVisible([
1668
+ page.locator(testId('import-tokens-modal-import-button')),
1669
+ page.getByRole('button', { name: /^Import( tokens)?$/i }),
1670
+ ], DEFAULT_TIMEOUT_MS);
1671
+ if (!imported)
1672
+ throw new Error('Unable to confirm the MetaMask token import.');
1673
+ await isHidden(page.locator(testId('import-tokens-modal-custom-address')), DEFAULT_TIMEOUT_MS).catch(() => undefined);
1674
+ }
1675
+ async confirmTransactionAndWaitForMining(options = {}) {
1676
+ await realWalletTiming('confirmTransactionAndWaitForMining.confirmTransaction', () => this.confirmTransaction({ gasSetting: options.gasSetting }));
1677
+ const timeoutMs = options.timeoutMs ?? 60_000;
1678
+ const page = await realWalletTiming('confirmTransactionAndWaitForMining.preparedHome', () => this.preparedHome());
1679
+ const activityOpened = await realWalletTiming('confirmTransactionAndWaitForMining.openActivity', () => clickFirstVisible([
1680
+ page.locator(testId('account-overview__activity-tab')),
1681
+ page.getByRole('button', { name: /^Activity$/i }),
1682
+ ], DEFAULT_TIMEOUT_MS));
1683
+ if (!activityOpened)
1684
+ throw new Error('Unable to open the MetaMask activity tab.');
1685
+ const row = page
1686
+ .locator('[data-testid="transaction-list-item"], [data-testid="activity-list-item"], .transaction-list-item, .activity-list-item')
1687
+ .first();
1688
+ const statusVisible = (status) => isVisible(page.locator(testId(`transaction-status-label--${status}`)).first(), LOCATOR_PROBE_MS).catch(() => false);
1689
+ const deadline = Date.now() + timeoutMs;
1690
+ let confirmed = false;
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);
1709
+ }
1710
+ });
1711
+ if (!confirmed) {
1712
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for the transaction to confirm in the activity tab.`);
1713
+ }
1714
+ // Best-effort hash read — never throws; the mining wait already passed.
1715
+ let txHash;
1716
+ try {
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);
1731
+ }
1732
+ });
1733
+ }
1734
+ catch {
1735
+ // txHash stays undefined.
1736
+ }
1737
+ return { txHash };
1738
+ }
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
+ }
1795
+ async preparedHome() {
1796
+ const page = await this.home();
1797
+ await waitForMetaMaskHome(page);
1798
+ await closeMetaMaskOverlay(page);
1799
+ return page;
1800
+ }
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);
1807
+ const home = page.url().split('#')[0];
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
+ }
1822
+ }
1823
+ await realWalletTiming('openAccountPicker.gotoHome', async () => {
1824
+ await page.goto(`${home}#/`);
1825
+ await waitForMetaMaskHome(page, remaining());
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
+ }
1839
+ }
1840
+ throw new Error('Unable to open the MetaMask account picker.');
1841
+ }
1842
+ // Let the (virtualized) account list settle: wait for the first row, then
1843
+ // for the row count to stop growing across two probe ticks.
1844
+ async settleAccountList(page) {
1845
+ await waitForMetaMaskReady(page);
1846
+ await isVisible(accountRowLocator(page).first(), DEFAULT_TIMEOUT_MS).catch(() => undefined);
1847
+ const rows = accountRowLocator(page);
1848
+ const deadline = Date.now() + SHORT_TIMEOUT_MS;
1849
+ let lastCount = -1;
1850
+ while (Date.now() < deadline) {
1851
+ const count = await rows.count().catch(() => lastCount);
1852
+ if (count === lastCount)
1853
+ break;
1854
+ lastCount = count;
1855
+ await wait(LOCATOR_PROBE_MS);
1856
+ }
1857
+ }
1858
+ // ── 13.x virtualized-picker account helpers ─────────────────────────────
1859
+ // The 13.x picker renders only a small window of a (potentially huge)
1860
+ // account tree, so name/address lookups must narrow it via the search box
1861
+ // first. Entropy (SRP-derived) and keyring (imported) accounts both render
1862
+ // as multichain-account-cell-<groupId> cells.
1863
+ accountGroupCells(page) {
1864
+ return page.locator('[data-testid^="multichain-account-cell-entropy:"], [data-testid^="multichain-account-cell-keyring:"]');
1865
+ }
1866
+ // Fill the picker search box to narrow the list to `term`. The testid is on
1867
+ // a wrapper; the editable input is nested. No-op (returns false) when absent.
1868
+ async searchAccountPicker(page, term) {
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
+ });
1877
+ }
1878
+ // The account cell whose display name (its first <p>) exactly equals `name`,
1879
+ // after narrowing by search. Search is a contains filter, so the exact
1880
+ // match is enforced client-side (cell.textContent concatenates
1881
+ // name+address+balance, so an anchored full-text regex cannot be used).
1882
+ async findAccountCellByName(page, name) {
1883
+ await this.searchAccountPicker(page, name);
1884
+ const cells = this.accountGroupCells(page);
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);
1896
+ return undefined;
1897
+ }
1898
+ // Resolves a 13.x account cell by exact display name, or — for an address —
1899
+ // the derivable imported-keyring cell, else any cell whose text carries the
1900
+ // (full or shortened) address.
1901
+ async find13xAccountCell(page, nameOrAddress) {
1902
+ if (!FULL_ADDRESS_PATTERN.test(nameOrAddress)) {
1903
+ return this.findAccountCellByName(page, nameOrAddress);
1904
+ }
1905
+ const addressLc = nameOrAddress.toLowerCase();
1906
+ await this.searchAccountPicker(page, nameOrAddress);
1907
+ // Imported (private-key) accounts have a fully derivable cell testid.
1908
+ const imported = page
1909
+ .locator(testId(`multichain-account-cell-keyring:Simple Key Pair/${addressLc}`))
1910
+ .first();
1911
+ if (await isVisible(imported, SHORT_TIMEOUT_MS).catch(() => false))
1912
+ return imported;
1913
+ const short = shortAddress(nameOrAddress);
1914
+ const cells = this.accountGroupCells(page);
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);
1928
+ return undefined;
1929
+ }
1930
+ // 13.x rename via a surfaced (searched/visible) account cell's row menu.
1931
+ async rename13xCellViaMenu(page, cell, newName) {
1932
+ await cell.hover().catch(() => undefined);
1933
+ await wait(LOCATOR_PROBE_MS);
1934
+ const accessoryOpened = await clickFirstVisible([cell.locator(testId('multichain-account-cell-end-accessory'))], SHORT_TIMEOUT_MS);
1935
+ const renameClicked = accessoryOpened &&
1936
+ (await clickFirstVisible([
1937
+ page.locator(testId('multichain-account-menu-item-rename')),
1938
+ page.getByRole('menuitem', { name: /^Rename$/i }),
1939
+ ], SHORT_TIMEOUT_MS));
1940
+ if (!renameClicked) {
1941
+ throw new Error('Unable to open the MetaMask rename dialog from the account row menu.');
1942
+ }
1943
+ await this.fillAccountNameAndSave(page, newName);
1944
+ }
1945
+ // Polls the wallet-details "Add account" button until it reads exactly
1946
+ // "Add account" (not "Syncing…"/"Adding account…") for two consecutive
1947
+ // reads — only then is the create dispatch not silently dropped.
1948
+ async waitForAddAccountReady(addButton, timeout = DEFAULT_TIMEOUT_MS) {
1949
+ const deadline = Date.now() + timeout;
1950
+ let stable = 0;
1951
+ while (Date.now() < deadline) {
1952
+ const label = (await addButton.textContent({ timeout: LOCATOR_PROBE_MS }).catch(() => ''))?.trim();
1953
+ if (label === 'Add account') {
1954
+ stable += 1;
1955
+ if (stable >= 2) {
1956
+ await wait(LOCATOR_PROBE_MS);
1957
+ return;
1958
+ }
1959
+ }
1960
+ else {
1961
+ stable = 0;
1962
+ }
1963
+ await wait(LOCATOR_PROBE_MS);
1964
+ }
1965
+ }
1966
+ async openGlobalMenu(page) {
1967
+ const opened = await clickFirstVisible([page.locator(testId('account-options-menu-button'))], DEFAULT_TIMEOUT_MS);
1968
+ if (!opened)
1969
+ throw new Error('Unable to open the MetaMask global menu.');
1970
+ }
1971
+ // 12.x rename: row options menu → Account details → editable label. (13.x
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
+ }
1984
+ async renameAccountRow(page, row, newName, label) {
1985
+ const menuOpened = await clickFirstVisible([
1986
+ row.locator(testId('account-list-item-menu-button')),
1987
+ ...(label ? [page.getByRole('button', { name: `${label} Options` })] : []),
1988
+ ], SHORT_TIMEOUT_MS);
1989
+ if (!menuOpened) {
1990
+ throw new Error('Unable to open the MetaMask account row menu to rename.');
1991
+ }
1992
+ const detailsOpened = await clickFirstVisible([page.locator(testId('account-list-menu-details'))], DEFAULT_TIMEOUT_MS);
1993
+ if (!detailsOpened)
1994
+ throw new Error('Unable to open the MetaMask account details to rename.');
1995
+ const editOpened = await clickFirstVisible([page.locator(testId('editable-label-button'))], DEFAULT_TIMEOUT_MS);
1996
+ if (!editOpened)
1997
+ throw new Error('Unable to open the MetaMask account name editor.');
1998
+ await this.fillAccountNameAndSave(page, newName);
1999
+ await closeMetaMaskOverlay(page);
2000
+ }
2001
+ async fillAccountNameAndSave(page, newName) {
2002
+ const filled = await fillFirstVisibleStableValue([
2003
+ page.locator(`${testId('account-name-input')} input`),
2004
+ page.locator(testId('account-name-input')),
2005
+ page.locator(`${testId('editable-input')} input`),
2006
+ page.locator(testId('editable-input')),
2007
+ ], newName, DEFAULT_TIMEOUT_MS);
2008
+ if (!filled)
2009
+ throw new Error('Unable to fill the MetaMask account name input.');
2010
+ const saved = await clickFirstVisible([
2011
+ page.locator(testId('save-account-label-input')),
2012
+ page.locator('.mm-button-base[aria-label="Confirm"]'),
2013
+ page.getByRole('button', { name: /^(Confirm|Save)$/i }),
2014
+ ], DEFAULT_TIMEOUT_MS);
2015
+ if (!saved)
2016
+ throw new Error('Unable to save the MetaMask account name.');
2017
+ await waitForMetaMaskReady(page);
2018
+ }
2019
+ async confirmResetModal(page) {
2020
+ // 12.x labels the confirm "Clear"; 13.x labels it "Delete".
2021
+ const confirmed = await clickFirstVisible([
2022
+ page.locator(testId('delete-activity-and-nonce-data-button')),
2023
+ page.getByRole('button', { name: /^(Clear|Delete)$/i }),
2024
+ page.locator('.modal button.btn-danger-primary'),
2025
+ ], DEFAULT_TIMEOUT_MS);
2026
+ if (!confirmed)
2027
+ throw new Error('Unable to confirm the MetaMask reset-account dialog.');
2028
+ await waitForMetaMaskReady(page);
2029
+ }
2030
+ async leaveSettings(page) {
2031
+ await clickFirstVisible([page.locator(testId('settings-back-button')), page.locator('.settings-page__close-button')], SHORT_TIMEOUT_MS);
2032
+ await page.goto(extensionUrl(this.extensionId)).catch(() => undefined);
2033
+ await waitForMetaMaskReady(page);
2034
+ await closeMetaMaskOverlay(page);
2035
+ }
2036
+ async readToggleState(toggle) {
2037
+ // react-toggle-button's state lives in the label's toggle-button--on/off
2038
+ // class. Its inner <input type=checkbox> is bound by `value`, NOT
2039
+ // `checked` (and is never clicked directly), so isChecked() always reads
2040
+ // false — read the class first. `toggle` is the label.toggle-button.
2041
+ const ownClass = (await toggle.getAttribute('class').catch(() => null)) ?? '';
2042
+ const labelClass = /toggle-button--(on|off)/.test(ownClass)
2043
+ ? ownClass
2044
+ : (await toggle
2045
+ .locator('xpath=ancestor-or-self::label[contains(@class,"toggle-button")][1]')
2046
+ .getAttribute('class')
2047
+ .catch(() => null)) ?? ownClass;
2048
+ if (/toggle-button--on/.test(labelClass))
2049
+ return true;
2050
+ if (/toggle-button--off/.test(labelClass))
2051
+ return false;
2052
+ const ariaChecked = await toggle.getAttribute('aria-checked').catch(() => null);
2053
+ if (ariaChecked === 'true' || ariaChecked === 'false')
2054
+ return ariaChecked === 'true';
2055
+ const checkbox = toggle.locator('input[type="checkbox"]').first();
2056
+ const checked = await checkbox.isChecked({ timeout: LOCATOR_PROBE_MS }).catch(() => undefined);
2057
+ if (checked !== undefined)
2058
+ return checked;
2059
+ return undefined;
2060
+ }
594
2061
  async home() {
595
2062
  const page = await openExtensionHome(this.context, this.extensionId);
596
2063
  await unlockMetaMaskIfNeeded(page, this.walletPassword);
@@ -627,6 +2094,7 @@ class MetaMaskRealWallet {
627
2094
  ], timeout);
628
2095
  if (!confirmed)
629
2096
  throw new Error('Unable to confirm MetaMask notification.');
2097
+ return confirmed;
630
2098
  }
631
2099
  async rejectFooterAction(page, extraLocators = []) {
632
2100
  const rejected = await clickFirstVisible([
@@ -642,16 +2110,38 @@ class MetaMaskRealWallet {
642
2110
  async selectAccounts(page, accounts) {
643
2111
  if (!accounts?.length)
644
2112
  return;
645
- const rows = page.locator('.choose-account-list .choose-account-list__account');
2113
+ // Newer MetaMask shows the currently selected account with an "Edit
2114
+ // accounts" affordance; expand it so the full list renders.
2115
+ await clickFirstVisible([
2116
+ page.locator(testId('edit-accounts')),
2117
+ page.getByRole('button', { name: /Edit accounts?/i }),
2118
+ ], SHORT_TIMEOUT_MS);
2119
+ const rowLocators = [
2120
+ page.locator('.choose-account-list .choose-account-list__account'),
2121
+ page.locator(testId('choose-account-list')).locator('[role="listitem"], li'),
2122
+ page.locator('.multichain-account-list-item'),
2123
+ page.locator('[data-testid="account-list-item"]'),
2124
+ ];
2125
+ let rows;
2126
+ for (const candidate of rowLocators) {
2127
+ if ((await candidate.count()) > 0) {
2128
+ rows = candidate;
2129
+ break;
2130
+ }
2131
+ }
2132
+ if (!rows) {
2133
+ throw new Error(`Unable to locate the MetaMask account list in the connect prompt to select: ${accounts.join(', ')}. ` +
2134
+ 'Connect without the accounts argument to use the default account, or update web3-tester for this MetaMask version.');
2135
+ }
646
2136
  const rowCount = await rows.count();
647
- if (!rowCount)
648
- return;
649
2137
  const expected = accounts.map((account) => account.toLowerCase());
650
2138
  const expectedShort = accounts.map(shortAddress);
2139
+ let matched = false;
651
2140
  for (let index = 0; index < rowCount; index += 1) {
652
2141
  const row = rows.nth(index);
653
2142
  const text = ((await row.textContent()) ?? '').toLowerCase();
654
- const matches = expected.some((account) => text.includes(account)) || expectedShort.some((account) => text.includes(account));
2143
+ const matches = expected.some((account) => text.includes(account)) ||
2144
+ expectedShort.some((account) => text.includes(account));
655
2145
  if (!matches)
656
2146
  continue;
657
2147
  const checkbox = row.locator('input[type="checkbox"]').first();
@@ -659,9 +2149,10 @@ class MetaMaskRealWallet {
659
2149
  await checkbox.check();
660
2150
  else
661
2151
  await row.click();
662
- return;
2152
+ matched = true;
2153
+ break;
663
2154
  }
664
- if (rowCount > 1) {
2155
+ if (!matched) {
665
2156
  throw new Error(`Unable to find requested MetaMask account in connect prompt: ${accounts.join(', ')}.`);
666
2157
  }
667
2158
  }
@@ -719,40 +2210,61 @@ async function prepareMetaMask({ expectedAddress, page, setup, wallet, }) {
719
2210
  }
720
2211
  export async function launchRealWallet(options) {
721
2212
  const extensionName = options.extensionName ?? DEFAULT_EXTENSION_NAME;
722
- if (!fs.existsSync(options.extensionPath)) {
723
- throw new Error(`MetaMask extension path does not exist: ${options.extensionPath}`);
724
- }
725
- const profile = resolveRealWalletProfile(options.profileDir);
726
- const context = await chromium.launchPersistentContext(profile.userDataDir, {
727
- args: [
728
- ...(profile.profileDirectory ? [`--profile-directory=${profile.profileDirectory}`] : []),
729
- `--disable-extensions-except=${options.extensionPath}`,
730
- `--load-extension=${options.extensionPath}`,
731
- ],
2213
+ const generation = options.generation ?? detectWalletGeneration(options.extensionPath);
2214
+ const extensionSession = await launchRealWalletExtension({
732
2215
  baseURL: options.baseURL,
733
- headless: options.headless ?? false,
2216
+ extensionName,
2217
+ extensionPath: options.extensionPath,
2218
+ headless: options.headless,
2219
+ initialPage: 'home.html',
2220
+ locale: 'en-US',
2221
+ profileDir: options.profileDir,
734
2222
  slowMo: options.slowMo,
735
2223
  });
736
- const extensionId = await getExtensionId(context, extensionName);
737
- const page = await openExtensionHome(context, extensionId);
738
- const wallet = new MetaMaskRealWallet(context, page, extensionId, options.expectedAddress, passwordForSetup(options.setup));
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);
739
2228
  await prepareMetaMask({
740
2229
  expectedAddress: options.expectedAddress,
741
2230
  page,
742
2231
  setup: options.setup,
743
2232
  wallet,
744
2233
  });
2234
+ await routeMetaMaskTabToHome(page);
2235
+ await unlockMetaMaskIfNeeded(page, walletPassword);
745
2236
  return {
2237
+ addNetwork: (network) => wallet.addNetwork(network),
2238
+ addNewAccount: (name) => wallet.addNewAccount(name),
2239
+ addNewToken: () => wallet.addNewToken(),
2240
+ approveAddToken: () => wallet.approveAddToken(),
2241
+ approveNewNetwork: () => wallet.approveNewNetwork(),
2242
+ approveSwitchNetwork: () => wallet.approveSwitchNetwork(),
746
2243
  approveTokenPermission: (approvalOptions) => wallet.approveTokenPermission(approvalOptions),
747
- close: () => context.close(),
2244
+ close: () => extensionSession.close(),
748
2245
  confirmSignature: () => wallet.confirmSignature(),
749
2246
  confirmTransaction: (confirmationOptions) => wallet.confirmTransaction(confirmationOptions),
2247
+ confirmTransactionAndWaitForMining: (miningOptions) => wallet.confirmTransactionAndWaitForMining(miningOptions),
750
2248
  connectToDapp: (accounts) => wallet.connectToDapp(accounts),
751
2249
  context,
752
2250
  extensionId,
753
2251
  getAccountAddress: () => wallet.getAccountAddress(),
2252
+ importToken: (token) => wallet.importToken(token),
2253
+ importWalletFromPrivateKey: (privateKey) => wallet.importWalletFromPrivateKey(privateKey),
2254
+ lock: () => wallet.lock(),
2255
+ rejectAddToken: () => wallet.rejectAddToken(),
2256
+ rejectNewNetwork: () => wallet.rejectNewNetwork(),
754
2257
  rejectSignature: () => wallet.rejectSignature(),
2258
+ rejectSwitchNetwork: () => wallet.rejectSwitchNetwork(),
2259
+ rejectTokenPermission: () => wallet.rejectTokenPermission(),
755
2260
  rejectTransaction: () => wallet.rejectTransaction(),
2261
+ renameAccount: (currentName, newName) => wallet.renameAccount(currentName, newName),
2262
+ resetAccount: () => wallet.resetAccount(),
2263
+ switchAccount: (nameOrAddress) => wallet.switchAccount(nameOrAddress),
2264
+ switchNetwork: (name, switchOptions) => wallet.switchNetwork(name, switchOptions),
2265
+ toggleShowTestNetworks: (on) => wallet.toggleShowTestNetworks(on),
2266
+ unlock: (password) => wallet.unlock(password),
2267
+ waitForUnlocked: () => wallet.waitForUnlocked(),
756
2268
  wallet,
757
2269
  };
758
2270
  }