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