@marigoldlabs/web3-tester 0.1.1 → 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-setup.d.ts +4 -0
- package/dist/real-wallet-setup.d.ts.map +1 -0
- package/dist/real-wallet-setup.js +5 -0
- package/dist/real-wallet-setup.js.map +1 -0
- package/dist/real-wallet.d.ts +124 -15
- package/dist/real-wallet.d.ts.map +1 -1
- package/dist/real-wallet.js +1682 -138
- 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 +21 -2
- package/examples/local-wallet.spec.ts +1 -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,10 +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';
|
|
5
|
+
import { passwordForSetup } from './real-wallet-setup.js';
|
|
4
6
|
const DEFAULT_EXTENSION_NAME = 'MetaMask';
|
|
5
7
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
6
8
|
const SHORT_TIMEOUT_MS = 2_000;
|
|
9
|
+
const LOCATOR_PROBE_MS = 250;
|
|
10
|
+
// 13.x derives many accounts from a heavily-used SRP; the account-tree
|
|
11
|
+
// backup-and-sync races createNextMultichainAccountGroup and silently drops
|
|
12
|
+
// ~1/3 of add clicks until it settles. Dwell before the first create.
|
|
13
|
+
const ACCOUNT_TREE_SETTLE_MS = 12_000;
|
|
14
|
+
const FULL_ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/;
|
|
7
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
|
+
}
|
|
8
70
|
function extensionUrl(extensionId, page = 'home.html') {
|
|
9
71
|
return `chrome-extension://${extensionId}/${page}`;
|
|
10
72
|
}
|
|
@@ -29,36 +91,195 @@ async function isHidden(locator, timeout = SHORT_TIMEOUT_MS) {
|
|
|
29
91
|
await locator.first().waitFor({ state: 'hidden', timeout });
|
|
30
92
|
return true;
|
|
31
93
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
94
|
+
function wait(ms) {
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
setTimeout(resolve, ms);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
async function findVisibleLocator(locators, timeout = SHORT_TIMEOUT_MS, options = {}) {
|
|
100
|
+
const deadline = Date.now() + timeout;
|
|
101
|
+
do {
|
|
102
|
+
const remaining = Math.max(deadline - Date.now(), 1);
|
|
103
|
+
for (const locator of locators) {
|
|
104
|
+
const target = locator.first();
|
|
105
|
+
const probeTimeout = Math.min(LOCATOR_PROBE_MS, remaining);
|
|
106
|
+
if (!(await isVisible(target, probeTimeout).catch(() => false)))
|
|
107
|
+
continue;
|
|
108
|
+
if (options.requireEnabled && !(await target.isEnabled({ timeout: 0 }).catch(() => false)))
|
|
109
|
+
continue;
|
|
110
|
+
return target;
|
|
111
|
+
}
|
|
112
|
+
const delay = Math.min(LOCATOR_PROBE_MS, Math.max(deadline - Date.now(), 0));
|
|
113
|
+
if (delay > 0)
|
|
114
|
+
await wait(delay);
|
|
115
|
+
} while (Date.now() < deadline);
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
async function clickFirstVisible(locators, timeout = SHORT_TIMEOUT_MS, options = { requireEnabled: true }) {
|
|
119
|
+
const target = await findVisibleLocator(locators, timeout, {
|
|
120
|
+
requireEnabled: options.requireEnabled ?? true,
|
|
121
|
+
});
|
|
122
|
+
if (!target)
|
|
123
|
+
return undefined;
|
|
124
|
+
await target.click({ force: options.force, timeout });
|
|
125
|
+
return target;
|
|
41
126
|
}
|
|
42
127
|
async function fillFirstVisible(locators, value, timeout = DEFAULT_TIMEOUT_MS) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
128
|
+
const target = await findVisibleLocator(locators, timeout);
|
|
129
|
+
if (!target)
|
|
130
|
+
return false;
|
|
131
|
+
await target.fill(value);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
async function startSeedPhraseWordGrid(target, firstWord) {
|
|
135
|
+
await target.fill(firstWord);
|
|
136
|
+
await wait(250);
|
|
137
|
+
await target.press('Space');
|
|
138
|
+
}
|
|
139
|
+
// MetaMask 12.x routes are home.html#onboarding/..., 13.x uses #/onboarding/...
|
|
140
|
+
const isOnboardingRoute = (url) => /home\.html#\/?onboarding/.test(url);
|
|
141
|
+
function metaMaskOnboardingLocators(page) {
|
|
142
|
+
return [
|
|
143
|
+
page.locator(testId('onboarding-get-started-button')),
|
|
144
|
+
page.locator(testId('onboarding-welcome-banner-title')),
|
|
145
|
+
page.locator(testId('onboarding-import-wallet')),
|
|
146
|
+
page.locator(testId('onboarding-import-with-srp-button')),
|
|
147
|
+
page.locator(testId('onboarding-create-wallet')),
|
|
148
|
+
page.locator(testId('onboarding-terms-checkbox')),
|
|
149
|
+
page.locator(testId('srp-input-import__srp-note')),
|
|
150
|
+
page.locator(testId('import-srp-confirm')),
|
|
151
|
+
page.locator(testId('create-password-new-input')),
|
|
152
|
+
page.locator(testId('create-password-confirm-input')),
|
|
153
|
+
page.getByRole('button', { name: 'Get started' }),
|
|
154
|
+
page.getByRole('button', { name: 'I have an existing wallet' }),
|
|
155
|
+
page.getByRole('button', { name: 'Create a new wallet' }),
|
|
156
|
+
page.getByRole('button', { name: 'Import using Secret Recovery Phrase' }),
|
|
157
|
+
page.getByRole('heading', { name: 'Import a wallet' }),
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
async function isMetaMaskOnboardingVisible(page) {
|
|
161
|
+
return isOnboardingRoute(page.url()) || Boolean(await findVisibleLocator(metaMaskOnboardingLocators(page), SHORT_TIMEOUT_MS));
|
|
162
|
+
}
|
|
163
|
+
function metaMaskSeedPhraseInputLocators(page) {
|
|
164
|
+
return [
|
|
165
|
+
page.locator(testId('srp-input-import__srp-note')),
|
|
166
|
+
page.locator('#first-word-input-text-area'),
|
|
167
|
+
page.locator('textarea[name="seedPhrase"]'),
|
|
168
|
+
page.locator('textarea').first(),
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
function metaMaskSeedPhraseImportLocators(page) {
|
|
172
|
+
return [
|
|
173
|
+
...metaMaskSeedPhraseInputLocators(page),
|
|
174
|
+
page.getByRole('heading', { name: 'Import a wallet' }),
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
function metaMaskSeedPhraseWordInputs(page) {
|
|
178
|
+
return page.locator('.srp-input-import__words-list input, input[id^="import-srp__srp-word-"], input[data-testid^="import-srp__srp-word-"], input[name^="srp-word-"]');
|
|
179
|
+
}
|
|
180
|
+
async function isSeedPhraseConfirmEnabled(page, timeout = SHORT_TIMEOUT_MS) {
|
|
181
|
+
return Boolean(await findVisibleLocator([page.locator(testId('import-srp-confirm'))], timeout, {
|
|
182
|
+
requireEnabled: true,
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
async function fillMetaMaskSeedPhraseWordGrid(page, words, startIndex = 0) {
|
|
186
|
+
const wordInputs = metaMaskSeedPhraseWordInputs(page);
|
|
187
|
+
for (let index = startIndex; index < words.length; index += 1) {
|
|
188
|
+
const input = wordInputs.nth(index);
|
|
189
|
+
await input.waitFor({ state: 'visible', timeout: DEFAULT_TIMEOUT_MS });
|
|
190
|
+
await input.fill(words[index]);
|
|
191
|
+
if (index < words.length - 1) {
|
|
192
|
+
await input.press('Space');
|
|
193
|
+
await wait(100);
|
|
194
|
+
}
|
|
49
195
|
}
|
|
50
|
-
|
|
196
|
+
}
|
|
197
|
+
async function isMetaMaskSeedPhraseImportVisible(page) {
|
|
198
|
+
return (/#\/?onboarding\/import-with-recovery-phrase/.test(page.url()) ||
|
|
199
|
+
Boolean(await findVisibleLocator(metaMaskSeedPhraseImportLocators(page), SHORT_TIMEOUT_MS)));
|
|
200
|
+
}
|
|
201
|
+
async function isMetaMaskCreatePasswordVisible(page) {
|
|
202
|
+
return (/#\/?onboarding\/create-password/.test(page.url()) ||
|
|
203
|
+
Boolean(await findVisibleLocator([page.locator(testId('create-password-new-input')), page.locator('input[type="password"]').nth(0)], SHORT_TIMEOUT_MS)));
|
|
51
204
|
}
|
|
52
205
|
async function waitForMetaMaskReady(page) {
|
|
53
206
|
await page.waitForLoadState('domcontentloaded', { timeout: DEFAULT_TIMEOUT_MS }).catch(() => undefined);
|
|
54
207
|
await isHidden(page.locator('.spinner, .loading-overlay, [data-testid="loading-overlay"]'), DEFAULT_TIMEOUT_MS).catch(() => undefined);
|
|
55
208
|
}
|
|
209
|
+
function metaMaskUnlockPasswordLocators(page) {
|
|
210
|
+
return [page.locator(testId('unlock-password')), page.locator('input[type="password"]').first()];
|
|
211
|
+
}
|
|
212
|
+
function metaMaskUnlockSubmitLocators(page) {
|
|
213
|
+
return [page.locator(testId('unlock-submit')), page.getByRole('button', { name: 'Unlock' })];
|
|
214
|
+
}
|
|
215
|
+
async function isMetaMaskUnlockVisible(page) {
|
|
216
|
+
return Boolean(await findVisibleLocator(metaMaskUnlockPasswordLocators(page), SHORT_TIMEOUT_MS));
|
|
217
|
+
}
|
|
56
218
|
async function closeMetaMaskOverlay(page) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
];
|
|
239
|
+
}
|
|
240
|
+
function metaMaskAccountMenuLocators(page) {
|
|
241
|
+
return [
|
|
242
|
+
page.locator(testId('account-options-menu-button')),
|
|
243
|
+
page.locator(testId('account-menu-icon')),
|
|
244
|
+
page.getByRole('button', { name: /Account options|Account menu/i }),
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
function metaMaskPromptActions(page) {
|
|
248
|
+
return [
|
|
249
|
+
page.locator(testId('onboarding-complete-done')),
|
|
250
|
+
page.locator(testId('metametrics-no-thanks')),
|
|
251
|
+
page.getByRole('button', { name: 'No thanks' }),
|
|
252
|
+
page.getByRole('button', { name: 'Done' }),
|
|
253
|
+
page.getByRole('button', { name: 'Skip' }),
|
|
254
|
+
page.getByRole('button', { name: 'Continue' }),
|
|
255
|
+
page.getByRole('button', { name: 'Open wallet' }),
|
|
256
|
+
page.getByRole('button', { name: 'Maybe later' }),
|
|
257
|
+
page.getByRole('link', { name: 'Maybe later' }),
|
|
258
|
+
];
|
|
259
|
+
}
|
|
260
|
+
function metaMaskTextPromptActions(page) {
|
|
261
|
+
return [
|
|
262
|
+
page.getByText('Maybe later', { exact: true }),
|
|
263
|
+
page.getByText('No thanks', { exact: true }),
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
async function clickMetaMaskPromptAction(page, timeout = SHORT_TIMEOUT_MS) {
|
|
267
|
+
return ((await clickFirstVisible(metaMaskPromptActions(page), timeout)) ||
|
|
268
|
+
(await clickFirstVisible(metaMaskTextPromptActions(page), timeout, { requireEnabled: false })));
|
|
269
|
+
}
|
|
270
|
+
async function waitForMetaMaskHome(page, timeout = DEFAULT_TIMEOUT_MS) {
|
|
271
|
+
const deadline = Date.now() + timeout;
|
|
272
|
+
do {
|
|
273
|
+
await waitForMetaMaskReady(page);
|
|
274
|
+
if (await findVisibleLocator(metaMaskAccountMenuLocators(page), SHORT_TIMEOUT_MS))
|
|
275
|
+
return true;
|
|
276
|
+
if (await clickMetaMaskPromptAction(page, SHORT_TIMEOUT_MS))
|
|
277
|
+
continue;
|
|
278
|
+
const delay = Math.min(LOCATOR_PROBE_MS, Math.max(deadline - Date.now(), 0));
|
|
279
|
+
if (delay > 0)
|
|
280
|
+
await wait(delay);
|
|
281
|
+
} while (Date.now() < deadline);
|
|
282
|
+
return Boolean(await findVisibleLocator(metaMaskAccountMenuLocators(page), SHORT_TIMEOUT_MS));
|
|
62
283
|
}
|
|
63
284
|
async function discoverExtensionIdFromRuntime(context) {
|
|
64
285
|
const workerId = context.serviceWorkers().map((worker) => extensionIdFromUrl(worker.url())).find(Boolean);
|
|
@@ -113,7 +334,7 @@ async function openExtensionHome(context, extensionId) {
|
|
|
113
334
|
const homeUrl = extensionUrl(extensionId);
|
|
114
335
|
const existing = context.pages().find((page) => page.url().startsWith(homeUrl));
|
|
115
336
|
const page = existing ?? (await context.newPage());
|
|
116
|
-
if (
|
|
337
|
+
if (page.url() !== homeUrl)
|
|
117
338
|
await page.goto(homeUrl);
|
|
118
339
|
await waitForMetaMaskReady(page);
|
|
119
340
|
return page;
|
|
@@ -121,37 +342,105 @@ async function openExtensionHome(context, extensionId) {
|
|
|
121
342
|
function notificationUrlPrefix(extensionId) {
|
|
122
343
|
return extensionUrl(extensionId, 'notification.html');
|
|
123
344
|
}
|
|
345
|
+
function extensionPageUrlPrefix(extensionId) {
|
|
346
|
+
return `chrome-extension://${extensionId}/`;
|
|
347
|
+
}
|
|
348
|
+
function metaMaskActionContentLocators(page) {
|
|
349
|
+
return [
|
|
350
|
+
page.getByRole('heading', {
|
|
351
|
+
name: /Spending cap request|Transaction request|Signature request|Sign-in request|Permission request|Add suggested tokens?/i,
|
|
352
|
+
}),
|
|
353
|
+
page.getByText(/Spending cap request|Transaction request|Signature request|Sign-in request|Permission request|This site wants permission|Add suggested tokens?/i),
|
|
354
|
+
];
|
|
355
|
+
}
|
|
356
|
+
function metaMaskActionControlLocators(page) {
|
|
357
|
+
return [
|
|
358
|
+
page.locator(testId('confirm-footer-button')),
|
|
359
|
+
page.locator(testId('confirmation-submit-button')),
|
|
360
|
+
page.locator(testId('page-container-footer-next')),
|
|
361
|
+
page.locator(testId('request-signature__sign')),
|
|
362
|
+
page.locator(testId('signature-sign-button')),
|
|
363
|
+
page.locator(testId('custom-spending-cap-input')),
|
|
364
|
+
page.locator('.set-approval-for-all-warning__footer__approve-button'),
|
|
365
|
+
page.getByRole('button', { name: /^(Confirm|Connect|Next|Approve|Sign)$/i }),
|
|
366
|
+
];
|
|
367
|
+
}
|
|
368
|
+
function metaMaskActionLocators(page) {
|
|
369
|
+
return [...metaMaskActionContentLocators(page), ...metaMaskActionControlLocators(page)];
|
|
370
|
+
}
|
|
371
|
+
async function findMetaMaskActionPage(context, extensionId) {
|
|
372
|
+
const extensionPrefix = extensionPageUrlPrefix(extensionId);
|
|
373
|
+
let controlOnlyPage;
|
|
374
|
+
for (const page of [...context.pages()].reverse()) {
|
|
375
|
+
if (page.isClosed() || !page.url().startsWith(extensionPrefix))
|
|
376
|
+
continue;
|
|
377
|
+
await page.waitForLoadState('domcontentloaded', { timeout: LOCATOR_PROBE_MS }).catch(() => undefined);
|
|
378
|
+
if (await findVisibleLocator(metaMaskActionContentLocators(page), LOCATOR_PROBE_MS, { requireEnabled: false })) {
|
|
379
|
+
return page;
|
|
380
|
+
}
|
|
381
|
+
if (!controlOnlyPage &&
|
|
382
|
+
(await findVisibleLocator(metaMaskActionControlLocators(page), LOCATOR_PROBE_MS, { requireEnabled: false }))) {
|
|
383
|
+
controlOnlyPage = page;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return controlOnlyPage;
|
|
387
|
+
}
|
|
388
|
+
async function findMetaMaskLockedPage(context, extensionId) {
|
|
389
|
+
const extensionPrefix = extensionPageUrlPrefix(extensionId);
|
|
390
|
+
for (const page of [...context.pages()].reverse()) {
|
|
391
|
+
if (page.isClosed() || !page.url().startsWith(extensionPrefix))
|
|
392
|
+
continue;
|
|
393
|
+
await page.waitForLoadState('domcontentloaded', { timeout: LOCATOR_PROBE_MS }).catch(() => undefined);
|
|
394
|
+
if (await isMetaMaskUnlockVisible(page))
|
|
395
|
+
return page;
|
|
396
|
+
}
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
function findMetaMaskNotificationPage(context, extensionId) {
|
|
400
|
+
const notificationPrefix = notificationUrlPrefix(extensionId);
|
|
401
|
+
return [...context.pages()]
|
|
402
|
+
.reverse()
|
|
403
|
+
.find((page) => !page.isClosed() && page.url().startsWith(notificationPrefix));
|
|
404
|
+
}
|
|
124
405
|
async function getNotificationPage(context, extensionId, timeout = DEFAULT_TIMEOUT_MS) {
|
|
125
406
|
const prefix = notificationUrlPrefix(extensionId);
|
|
407
|
+
const extensionPrefix = extensionPageUrlPrefix(extensionId);
|
|
126
408
|
const startedAt = Date.now();
|
|
127
|
-
|
|
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;
|
|
414
|
+
let page = await findMetaMaskActionPage(context, extensionId);
|
|
128
415
|
while (!page && Date.now() - startedAt < timeout) {
|
|
129
|
-
const pendingPages = context.pages().filter((candidate) => !candidate.isClosed());
|
|
130
|
-
for (const pendingPage of pendingPages) {
|
|
131
|
-
await pendingPage
|
|
132
|
-
.waitForURL((url) => url.href.startsWith(prefix), { timeout: 250 })
|
|
133
|
-
.catch(() => undefined);
|
|
134
|
-
if (pendingPage.url().startsWith(prefix)) {
|
|
135
|
-
page = pendingPage;
|
|
136
|
-
break;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
if (page)
|
|
140
|
-
break;
|
|
141
416
|
const remaining = Math.max(timeout - (Date.now() - startedAt), 1_000);
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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);
|
|
146
421
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
+
}
|
|
431
|
+
}
|
|
432
|
+
page = await findMetaMaskActionPage(context, extensionId);
|
|
152
433
|
}
|
|
153
|
-
|
|
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);
|
|
154
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
|
+
}
|
|
155
444
|
await waitForMetaMaskReady(page);
|
|
156
445
|
await page.bringToFront().catch(() => undefined);
|
|
157
446
|
return page;
|
|
@@ -159,62 +448,129 @@ async function getNotificationPage(context, extensionId, timeout = DEFAULT_TIMEO
|
|
|
159
448
|
function shortAddress(address) {
|
|
160
449
|
return `${address.slice(0, 6)}...${address.slice(-4)}`.toLowerCase();
|
|
161
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
|
+
}
|
|
479
|
+
async function pageContainsAddress(page, address) {
|
|
480
|
+
const normalizedAddress = address.toLowerCase();
|
|
481
|
+
const text = ((await page.locator('body').textContent({ timeout: SHORT_TIMEOUT_MS }).catch(() => '')) ?? '')
|
|
482
|
+
.replace(/\s+/g, '')
|
|
483
|
+
.toLowerCase();
|
|
484
|
+
return (text.includes(normalizedAddress) ||
|
|
485
|
+
(text.includes(normalizedAddress.slice(0, 6)) && text.includes(normalizedAddress.slice(-4))) ||
|
|
486
|
+
(text.includes(normalizedAddress.slice(0, 7)) && text.includes(normalizedAddress.slice(-5))));
|
|
487
|
+
}
|
|
162
488
|
async function fillSeedPhrase(page, seedPhrase) {
|
|
163
489
|
const words = seedPhrase.trim().split(/\s+/);
|
|
164
490
|
if (words.length < 12) {
|
|
165
491
|
throw new Error('setup.seedPhrase must contain at least 12 words.');
|
|
166
492
|
}
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
page
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
493
|
+
const singleInput = await findVisibleLocator(metaMaskSeedPhraseInputLocators(page), DEFAULT_TIMEOUT_MS);
|
|
494
|
+
if (singleInput) {
|
|
495
|
+
await startSeedPhraseWordGrid(singleInput, words[0]);
|
|
496
|
+
await fillMetaMaskSeedPhraseWordGrid(page, words, 1);
|
|
497
|
+
if (await isSeedPhraseConfirmEnabled(page)) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const wordInputs = metaMaskSeedPhraseWordInputs(page);
|
|
175
502
|
if ((await wordInputs.count()) >= words.length) {
|
|
503
|
+
await fillMetaMaskSeedPhraseWordGrid(page, words);
|
|
504
|
+
if (await isSeedPhraseConfirmEnabled(page))
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const textboxes = page.getByRole('textbox');
|
|
508
|
+
if ((await textboxes.count()) >= words.length) {
|
|
176
509
|
for (const [index, word] of words.entries()) {
|
|
177
|
-
await
|
|
510
|
+
await textboxes.nth(index).fill(word);
|
|
178
511
|
}
|
|
179
512
|
return;
|
|
180
513
|
}
|
|
181
514
|
throw new Error('Unable to find MetaMask seed phrase input fields.');
|
|
182
515
|
}
|
|
183
516
|
async function importMetaMaskWallet(page, setup) {
|
|
184
|
-
|
|
185
|
-
|
|
517
|
+
const password = passwordForSetup(setup);
|
|
518
|
+
if (!password || !setup.seedPhrase) {
|
|
519
|
+
throw new Error('MetaMask is on onboarding. Provide setup.seedPhrase to import a wallet through web3-tester, or use a preconfigured persistent profile.');
|
|
186
520
|
}
|
|
187
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
|
+
}
|
|
188
534
|
const terms = page.locator(testId('onboarding-terms-checkbox'));
|
|
189
535
|
if (await isVisible(terms, SHORT_TIMEOUT_MS).catch(() => false)) {
|
|
190
536
|
await terms.check();
|
|
191
537
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
page
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
538
|
+
if (!(await isMetaMaskSeedPhraseImportVisible(page)) && !(await isMetaMaskCreatePasswordVisible(page))) {
|
|
539
|
+
const importStarted = await clickFirstVisible([
|
|
540
|
+
page.locator(testId('onboarding-import-wallet')),
|
|
541
|
+
page.locator(testId('onboarding-import-with-srp-button')),
|
|
542
|
+
page.getByRole('button', { name: 'I have an existing wallet' }),
|
|
543
|
+
page.getByRole('button', { name: 'Import an existing wallet' }),
|
|
544
|
+
page.getByRole('button', { name: 'Import wallet' }),
|
|
545
|
+
], DEFAULT_TIMEOUT_MS);
|
|
546
|
+
if (!importStarted)
|
|
547
|
+
throw new Error('Unable to start MetaMask wallet import flow.');
|
|
548
|
+
await waitForMetaMaskReady(page);
|
|
549
|
+
await clickFirstVisible([
|
|
550
|
+
page.locator(testId('onboarding-import-with-srp-button')),
|
|
551
|
+
page.getByRole('button', { name: 'Import using Secret Recovery Phrase' }),
|
|
552
|
+
], 5_000);
|
|
553
|
+
await clickFirstVisible([
|
|
554
|
+
page.locator(testId('metametrics-no-thanks')),
|
|
555
|
+
page.locator(testId('metametrics-i-agree')),
|
|
556
|
+
page.getByRole('button', { name: 'No thanks' }),
|
|
557
|
+
page.getByRole('button', { name: 'I agree' }),
|
|
558
|
+
], SHORT_TIMEOUT_MS);
|
|
559
|
+
}
|
|
560
|
+
if (!(await isMetaMaskCreatePasswordVisible(page))) {
|
|
561
|
+
await fillSeedPhrase(page, setup.seedPhrase);
|
|
562
|
+
const seedConfirmed = await clickFirstVisible([
|
|
563
|
+
page.locator(testId('import-srp-confirm')),
|
|
564
|
+
page.getByRole('button', { name: 'Confirm Secret Recovery Phrase' }),
|
|
565
|
+
page.getByRole('button', { name: 'Confirm' }),
|
|
566
|
+
], DEFAULT_TIMEOUT_MS);
|
|
567
|
+
if (!seedConfirmed)
|
|
568
|
+
throw new Error('Unable to confirm MetaMask seed phrase import.');
|
|
569
|
+
}
|
|
570
|
+
const passwordFilled = await fillFirstVisible([page.locator(testId('create-password-new-input')), page.locator('input[type="password"]').nth(0)], password);
|
|
215
571
|
if (!passwordFilled)
|
|
216
572
|
throw new Error('Unable to find MetaMask password input.');
|
|
217
|
-
const confirmationPasswordFilled = await fillFirstVisible([page.locator(testId('create-password-confirm-input')), page.locator('input[type="password"]').nth(1)],
|
|
573
|
+
const confirmationPasswordFilled = await fillFirstVisible([page.locator(testId('create-password-confirm-input')), page.locator('input[type="password"]').nth(1)], password);
|
|
218
574
|
if (!confirmationPasswordFilled)
|
|
219
575
|
throw new Error('Unable to find MetaMask password confirmation input.');
|
|
220
576
|
const passwordTerms = page.locator(testId('create-password-terms'));
|
|
@@ -224,38 +580,102 @@ async function importMetaMaskWallet(page, setup) {
|
|
|
224
580
|
const passwordSubmitted = await clickFirstVisible([page.locator(testId('create-password-submit')), page.getByRole('button', { name: 'Import my wallet' })], DEFAULT_TIMEOUT_MS);
|
|
225
581
|
if (!passwordSubmitted)
|
|
226
582
|
throw new Error('Unable to submit MetaMask import password.');
|
|
227
|
-
await
|
|
228
|
-
page.locator(testId('onboarding-complete-done')),
|
|
229
|
-
page.getByRole('button', { name: 'Done' }),
|
|
230
|
-
page.getByRole('button', { name: 'Skip' }),
|
|
231
|
-
], DEFAULT_TIMEOUT_MS);
|
|
232
|
-
await waitForMetaMaskReady(page);
|
|
583
|
+
await finishMetaMaskOnboarding(page, password);
|
|
233
584
|
}
|
|
234
585
|
async function unlockMetaMask(page, password) {
|
|
235
|
-
const passwordFilled = await fillFirstVisible(
|
|
586
|
+
const passwordFilled = await fillFirstVisible(metaMaskUnlockPasswordLocators(page), password, DEFAULT_TIMEOUT_MS);
|
|
236
587
|
if (!passwordFilled)
|
|
237
588
|
throw new Error('Unable to find MetaMask unlock password input.');
|
|
238
|
-
const unlocked = await clickFirstVisible(
|
|
589
|
+
const unlocked = await clickFirstVisible(metaMaskUnlockSubmitLocators(page), DEFAULT_TIMEOUT_MS);
|
|
239
590
|
if (!unlocked)
|
|
240
591
|
throw new Error('Unable to submit MetaMask unlock form.');
|
|
241
592
|
await waitForMetaMaskReady(page);
|
|
242
593
|
}
|
|
594
|
+
async function unlockMetaMaskIfNeeded(page, password) {
|
|
595
|
+
if (!(await isMetaMaskUnlockVisible(page)))
|
|
596
|
+
return false;
|
|
597
|
+
if (!password) {
|
|
598
|
+
throw new Error('MetaMask profile is locked. Provide setup.password to unlock through web3-tester, or unlock the persistent profile before running.');
|
|
599
|
+
}
|
|
600
|
+
await page.bringToFront().catch(() => undefined);
|
|
601
|
+
await unlockMetaMask(page, password);
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
async function finishMetaMaskOnboarding(page, password) {
|
|
605
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
606
|
+
await waitForMetaMaskReady(page);
|
|
607
|
+
const actions = [...metaMaskPromptActions(page), ...metaMaskTextPromptActions(page)];
|
|
608
|
+
const onSetupScreen = isOnboardingRoute(page.url()) ||
|
|
609
|
+
Boolean(await findVisibleLocator(actions, SHORT_TIMEOUT_MS));
|
|
610
|
+
if (!onSetupScreen)
|
|
611
|
+
break;
|
|
612
|
+
const advanced = await clickMetaMaskPromptAction(page, 5_000);
|
|
613
|
+
if (!advanced)
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
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))) {
|
|
628
|
+
throw new Error('MetaMask wallet import did not complete onboarding.');
|
|
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);
|
|
638
|
+
}
|
|
243
639
|
class MetaMaskRealWallet {
|
|
244
640
|
context;
|
|
245
641
|
homePage;
|
|
246
642
|
extensionId;
|
|
247
|
-
|
|
643
|
+
expectedAddress;
|
|
644
|
+
walletPassword;
|
|
645
|
+
generation;
|
|
646
|
+
constructor(context, homePage, extensionId,
|
|
647
|
+
// Mutable: account mutations (switchAccount, imports, new accounts) must
|
|
648
|
+
// invalidate it, or getAccountAddress's fast-path returns stale results.
|
|
649
|
+
expectedAddress, walletPassword, generation = '13x') {
|
|
248
650
|
this.context = context;
|
|
249
651
|
this.homePage = homePage;
|
|
250
652
|
this.extensionId = extensionId;
|
|
653
|
+
this.expectedAddress = expectedAddress;
|
|
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);
|
|
251
665
|
}
|
|
252
666
|
async approveTokenPermission(options) {
|
|
253
667
|
const page = await this.notificationPage();
|
|
254
668
|
if (options?.spendLimit === 'max') {
|
|
255
|
-
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
|
+
}
|
|
256
673
|
}
|
|
257
674
|
else if (typeof options?.spendLimit === 'number') {
|
|
258
|
-
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
|
+
}
|
|
259
679
|
}
|
|
260
680
|
await this.confirmFooterAction(page);
|
|
261
681
|
if (!page.isClosed()) {
|
|
@@ -276,9 +696,12 @@ class MetaMaskRealWallet {
|
|
|
276
696
|
await scrollButton.click();
|
|
277
697
|
}
|
|
278
698
|
const signed = await clickFirstVisible([
|
|
699
|
+
page.locator(testId('confirm-footer-button')),
|
|
700
|
+
page.locator(testId('confirmation-submit-button')),
|
|
279
701
|
page.locator(testId('request-signature__sign')),
|
|
280
702
|
structuredSignButton,
|
|
281
703
|
page.locator(testId('page-container-footer-next')),
|
|
704
|
+
page.getByRole('button', { name: 'Confirm' }),
|
|
282
705
|
page.getByRole('button', { name: 'Sign' }),
|
|
283
706
|
], DEFAULT_TIMEOUT_MS);
|
|
284
707
|
if (!signed)
|
|
@@ -286,10 +709,43 @@ class MetaMaskRealWallet {
|
|
|
286
709
|
await clickFirstVisible([page.locator(testId('signature-warning-sign-button'))], SHORT_TIMEOUT_MS);
|
|
287
710
|
}
|
|
288
711
|
async confirmTransaction(options) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
712
|
+
let page = await this.notificationPage();
|
|
713
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
714
|
+
await this.applyGasSetting(page, options?.gasSetting);
|
|
715
|
+
await clickFirstVisible([page.locator('.set-approval-for-all-warning__footer__approve-button')], SHORT_TIMEOUT_MS);
|
|
716
|
+
const clicked = await this.confirmFooterAction(page);
|
|
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
|
+
}
|
|
742
|
+
if (!nextPage)
|
|
743
|
+
return;
|
|
744
|
+
page = nextPage;
|
|
745
|
+
await waitForMetaMaskReady(page);
|
|
746
|
+
await page.bringToFront().catch(() => undefined);
|
|
747
|
+
}
|
|
748
|
+
throw new Error('MetaMask transaction confirmation did not settle after multiple confirmation steps.');
|
|
293
749
|
}
|
|
294
750
|
async connectToDapp(accounts) {
|
|
295
751
|
const page = await this.notificationPage();
|
|
@@ -302,20 +758,229 @@ class MetaMaskRealWallet {
|
|
|
302
758
|
}
|
|
303
759
|
async getAccountAddress() {
|
|
304
760
|
const page = await this.home();
|
|
761
|
+
await waitForMetaMaskHome(page);
|
|
305
762
|
await closeMetaMaskOverlay(page);
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const
|
|
763
|
+
if (this.expectedAddress && await pageContainsAddress(page, this.expectedAddress)) {
|
|
764
|
+
return this.expectedAddress;
|
|
765
|
+
}
|
|
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();
|
|
315
797
|
await closeMetaMaskOverlay(page);
|
|
316
|
-
if (
|
|
317
|
-
|
|
318
|
-
|
|
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);
|
|
927
|
+
await closeMetaMaskOverlay(page);
|
|
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);
|
|
319
984
|
}
|
|
320
985
|
async rejectSignature() {
|
|
321
986
|
const page = await this.notificationPage();
|
|
@@ -328,16 +993,843 @@ class MetaMaskRealWallet {
|
|
|
328
993
|
const page = await this.notificationPage();
|
|
329
994
|
await this.rejectFooterAction(page);
|
|
330
995
|
}
|
|
331
|
-
async
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
+
}
|
|
336
1302
|
}
|
|
337
|
-
|
|
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
|
+
}
|
|
1810
|
+
async home() {
|
|
1811
|
+
const page = await openExtensionHome(this.context, this.extensionId);
|
|
1812
|
+
await unlockMetaMaskIfNeeded(page, this.walletPassword);
|
|
1813
|
+
return page;
|
|
338
1814
|
}
|
|
339
1815
|
async notificationPage() {
|
|
340
|
-
|
|
1816
|
+
await this.unlockVisibleMetaMask();
|
|
1817
|
+
let page = await getNotificationPage(this.context, this.extensionId).catch(async (error) => {
|
|
1818
|
+
if (await this.unlockVisibleMetaMask()) {
|
|
1819
|
+
return getNotificationPage(this.context, this.extensionId);
|
|
1820
|
+
}
|
|
1821
|
+
throw error;
|
|
1822
|
+
});
|
|
1823
|
+
if (await unlockMetaMaskIfNeeded(page, this.walletPassword)) {
|
|
1824
|
+
page = await getNotificationPage(this.context, this.extensionId);
|
|
1825
|
+
}
|
|
1826
|
+
return page;
|
|
1827
|
+
}
|
|
1828
|
+
async unlockVisibleMetaMask() {
|
|
1829
|
+
const page = await findMetaMaskLockedPage(this.context, this.extensionId);
|
|
1830
|
+
if (!page)
|
|
1831
|
+
return false;
|
|
1832
|
+
return unlockMetaMaskIfNeeded(page, this.walletPassword);
|
|
341
1833
|
}
|
|
342
1834
|
async confirmFooterAction(page, timeout = DEFAULT_TIMEOUT_MS) {
|
|
343
1835
|
const confirmed = await clickFirstVisible([
|
|
@@ -351,6 +1843,7 @@ class MetaMaskRealWallet {
|
|
|
351
1843
|
], timeout);
|
|
352
1844
|
if (!confirmed)
|
|
353
1845
|
throw new Error('Unable to confirm MetaMask notification.');
|
|
1846
|
+
return confirmed;
|
|
354
1847
|
}
|
|
355
1848
|
async rejectFooterAction(page, extraLocators = []) {
|
|
356
1849
|
const rejected = await clickFirstVisible([
|
|
@@ -366,16 +1859,38 @@ class MetaMaskRealWallet {
|
|
|
366
1859
|
async selectAccounts(page, accounts) {
|
|
367
1860
|
if (!accounts?.length)
|
|
368
1861
|
return;
|
|
369
|
-
|
|
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
|
+
}
|
|
370
1885
|
const rowCount = await rows.count();
|
|
371
|
-
if (!rowCount)
|
|
372
|
-
return;
|
|
373
1886
|
const expected = accounts.map((account) => account.toLowerCase());
|
|
374
1887
|
const expectedShort = accounts.map(shortAddress);
|
|
1888
|
+
let matched = false;
|
|
375
1889
|
for (let index = 0; index < rowCount; index += 1) {
|
|
376
1890
|
const row = rows.nth(index);
|
|
377
1891
|
const text = ((await row.textContent()) ?? '').toLowerCase();
|
|
378
|
-
const matches = expected.some((account) => text.includes(account)) ||
|
|
1892
|
+
const matches = expected.some((account) => text.includes(account)) ||
|
|
1893
|
+
expectedShort.some((account) => text.includes(account));
|
|
379
1894
|
if (!matches)
|
|
380
1895
|
continue;
|
|
381
1896
|
const checkbox = row.locator('input[type="checkbox"]').first();
|
|
@@ -383,9 +1898,10 @@ class MetaMaskRealWallet {
|
|
|
383
1898
|
await checkbox.check();
|
|
384
1899
|
else
|
|
385
1900
|
await row.click();
|
|
386
|
-
|
|
1901
|
+
matched = true;
|
|
1902
|
+
break;
|
|
387
1903
|
}
|
|
388
|
-
if (
|
|
1904
|
+
if (!matched) {
|
|
389
1905
|
throw new Error(`Unable to find requested MetaMask account in connect prompt: ${accounts.join(', ')}.`);
|
|
390
1906
|
}
|
|
391
1907
|
}
|
|
@@ -427,24 +1943,13 @@ class MetaMaskRealWallet {
|
|
|
427
1943
|
}
|
|
428
1944
|
}
|
|
429
1945
|
async function prepareMetaMask({ expectedAddress, page, setup, wallet, }) {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
if ((await isVisible(onboardingImport, SHORT_TIMEOUT_MS).catch(() => false)) ||
|
|
434
|
-
(await isVisible(onboardingCreate, SHORT_TIMEOUT_MS).catch(() => false)) ||
|
|
435
|
-
(await isVisible(onboardingTerms, SHORT_TIMEOUT_MS).catch(() => false))) {
|
|
436
|
-
if (!setup?.password || !setup.seedPhrase) {
|
|
437
|
-
throw new Error('MetaMask is on onboarding. Provide setup.password and setup.seedPhrase to import a wallet through web3-tester, or use a preconfigured persistent profile.');
|
|
1946
|
+
if (await isMetaMaskOnboardingVisible(page)) {
|
|
1947
|
+
if (!setup?.seedPhrase) {
|
|
1948
|
+
throw new Error('MetaMask is on onboarding. Provide setup.seedPhrase to import a wallet through web3-tester, or use a preconfigured persistent profile.');
|
|
438
1949
|
}
|
|
439
1950
|
await importMetaMaskWallet(page, setup);
|
|
440
1951
|
}
|
|
441
|
-
|
|
442
|
-
if (await isVisible(unlockPassword, SHORT_TIMEOUT_MS).catch(() => false)) {
|
|
443
|
-
if (!setup?.password) {
|
|
444
|
-
throw new Error('MetaMask profile is locked. Provide setup.password to unlock through web3-tester, or unlock the persistent profile before running.');
|
|
445
|
-
}
|
|
446
|
-
await unlockMetaMask(page, setup.password);
|
|
447
|
-
}
|
|
1952
|
+
await unlockMetaMaskIfNeeded(page, passwordForSetup(setup));
|
|
448
1953
|
if (!expectedAddress)
|
|
449
1954
|
return;
|
|
450
1955
|
const address = await wallet.getAccountAddress();
|
|
@@ -457,20 +1962,39 @@ export async function launchRealWallet(options) {
|
|
|
457
1962
|
if (!fs.existsSync(options.extensionPath)) {
|
|
458
1963
|
throw new Error(`MetaMask extension path does not exist: ${options.extensionPath}`);
|
|
459
1964
|
}
|
|
1965
|
+
const generation = options.generation ?? detectWalletGeneration(options.extensionPath);
|
|
1966
|
+
const headless = resolveRealWalletHeadless(options.headless);
|
|
460
1967
|
const profile = resolveRealWalletProfile(options.profileDir);
|
|
461
|
-
const context = await chromium
|
|
1968
|
+
const context = await chromium
|
|
1969
|
+
.launchPersistentContext(profile.userDataDir, {
|
|
462
1970
|
args: [
|
|
463
1971
|
...(profile.profileDirectory ? [`--profile-directory=${profile.profileDirectory}`] : []),
|
|
464
1972
|
`--disable-extensions-except=${options.extensionPath}`,
|
|
465
1973
|
`--load-extension=${options.extensionPath}`,
|
|
466
1974
|
],
|
|
467
1975
|
baseURL: options.baseURL,
|
|
468
|
-
|
|
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',
|
|
469
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;
|
|
470
1994
|
});
|
|
471
1995
|
const extensionId = await getExtensionId(context, extensionName);
|
|
472
1996
|
const page = await openExtensionHome(context, extensionId);
|
|
473
|
-
const wallet = new MetaMaskRealWallet(context, page, extensionId);
|
|
1997
|
+
const wallet = new MetaMaskRealWallet(context, page, extensionId, options.expectedAddress, passwordForSetup(options.setup), generation);
|
|
474
1998
|
await prepareMetaMask({
|
|
475
1999
|
expectedAddress: options.expectedAddress,
|
|
476
2000
|
page,
|
|
@@ -478,16 +2002,36 @@ export async function launchRealWallet(options) {
|
|
|
478
2002
|
wallet,
|
|
479
2003
|
});
|
|
480
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(),
|
|
481
2011
|
approveTokenPermission: (approvalOptions) => wallet.approveTokenPermission(approvalOptions),
|
|
482
2012
|
close: () => context.close(),
|
|
483
2013
|
confirmSignature: () => wallet.confirmSignature(),
|
|
484
2014
|
confirmTransaction: (confirmationOptions) => wallet.confirmTransaction(confirmationOptions),
|
|
2015
|
+
confirmTransactionAndWaitForMining: (miningOptions) => wallet.confirmTransactionAndWaitForMining(miningOptions),
|
|
485
2016
|
connectToDapp: (accounts) => wallet.connectToDapp(accounts),
|
|
486
2017
|
context,
|
|
487
2018
|
extensionId,
|
|
488
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(),
|
|
489
2025
|
rejectSignature: () => wallet.rejectSignature(),
|
|
2026
|
+
rejectSwitchNetwork: () => wallet.rejectSwitchNetwork(),
|
|
2027
|
+
rejectTokenPermission: () => wallet.rejectTokenPermission(),
|
|
490
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),
|
|
491
2035
|
wallet,
|
|
492
2036
|
};
|
|
493
2037
|
}
|