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