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