dappress 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/metamask.js DELETED
@@ -1,808 +0,0 @@
1
- // MetaMask's screens: what to click, and in which order.
2
- //
3
- // Selectors come from MetaMask's own end-to-end page objects
4
- // (test/e2e/page-objects in the metamask-extension repository), which MetaMask
5
- // has to keep working for its own test suite. Each entry lists the data-testid
6
- // first, then a fallback on the button's English text. When a MetaMask release
7
- // moves something, this is the file to fix.
8
-
9
- const { describe, waitFor, isVisible, isGone, click, clickWhenEnabled, dispatchClick, fill, failure, sleep } = require('./page-helpers');
10
-
11
- const testId = (id) => `[data-testid="${id}"]`;
12
- // XPath rather than Puppeteer's ::-p-text(): the latter needs MutationObserver,
13
- // which MetaMask's sandbox (LavaMoat) blocks in its pages.
14
- const buttonText = (text) => `xpath/.//button[normalize-space(.)="${text}"]`;
15
- const linkText = (text) => `xpath/.//a[normalize-space(.)="${text}"]`;
16
- const passwordInput = (position) => `xpath/(.//input[@type="password"])[${position}]`;
17
-
18
- const selectors = {
19
- onboarding: {
20
- importWallet: [testId('onboarding-import-wallet'), buttonText('I have an existing wallet')],
21
- importWithSrp: [testId('onboarding-import-with-srp-button'), buttonText('Import using Secret Recovery Phrase')],
22
- srpInput: testId('srp-input-import__srp-note'),
23
- srpWord: (index) => testId(`import-srp__srp-word-${index}`),
24
- srpConfirm: [testId('import-srp-confirm'), buttonText('Continue')],
25
- newPassword: [testId('create-password-new-input'), passwordInput(1)],
26
- confirmPassword: [testId('create-password-confirm-input'), passwordInput(2)],
27
- passwordTerms: testId('create-password-terms'),
28
- passwordSubmit: [testId('create-password-submit'), buttonText('Import my wallet')],
29
- passkeyMaybeLater: [testId('passkey-maybe-later-button'), buttonText('Maybe later')],
30
- metricsCheckbox: testId('metametrics-checkbox'),
31
- metricsContinue: [testId('metametrics-i-agree'), buttonText('Continue')],
32
- downloadAppContinue: testId('download-app-continue'),
33
- done: [testId('onboarding-complete-done'), buttonText('Done')],
34
- // "Manage default settings", offered next to "Done"
35
- manageDefaultSettings: testId('manage-default-settings'),
36
- // The first category, "General": its testid carries the translated label
37
- generalSettings: 'xpath/(.//*[starts-with(@data-testid, "category-item-")])[1]',
38
- backupAndSyncToggle: testId('backup-and-sync-toggle-button'),
39
- categoryBack: testId('category-back-button'),
40
- settingsBack: testId('privacy-settings-back-button'),
41
- },
42
- unlock: {
43
- password: [testId('unlock-password'), passwordInput(1)],
44
- submit: [testId('unlock-submit'), buttonText('Unlock')],
45
- },
46
- home: {
47
- header: testId('parent-selector-header-navbar'),
48
- accountMenu: testId('account-menu-icon'),
49
- },
50
- // The menu of the home header, a drawer: settings, permissions, lock
51
- menu: {
52
- open: [testId('account-options-menu-button'), 'button[aria-label="Account options"]'],
53
- lock: [testId('global-menu-lock'), buttonText('Lock')],
54
- permissions: [testId('global-menu-connected-sites'), linkText('Permissions')],
55
- },
56
- // "Permissions": the sites connected to the wallet, then the page of one site
57
- permissions: {
58
- list: testId('parent-selector-permission-list'),
59
- // Shown first when the wallet also holds token permissions; "Connections" leads to the list
60
- hub: testId('parent-selector-gator-permissions'),
61
- connections: 'xpath/.//p[normalize-space(.)="Connections"]',
62
- // A site is listed by its host, "localhost:3000"
63
- site: (host) => [
64
- `xpath/.//*[@data-testid="connection-list-item"][.//p[normalize-space(.)="${host}"]]`,
65
- `xpath/.//p[normalize-space(.)="${host}"]`,
66
- ],
67
- disconnect: [testId('disconnect-button'), 'button[aria-label="Disconnect"]'],
68
- // "Disconnect", in the modal that asks to confirm
69
- confirmDisconnect: [testId('disconnect-all'), `xpath/.//*[@data-testid="disconnect-all-modal"]//button[normalize-space(.)="Disconnect"]`],
70
- },
71
- // The account list, opened from the home header
72
- accounts: {
73
- name: '[data-testid^="multichain-account-cell-name-"]',
74
- cell: (name) => `xpath/.//*[contains(@class, "multichain-account-cell")][.//*[@data-testid="multichain-account-cell-name-${name}"]]`,
75
- add: 'xpath/(.//*[@data-testid="add-multichain-account-button"])[1]',
76
- addWallet: testId('account-list-add-wallet-button'),
77
- importAccount: testId('choose-wallet-type-import-account'),
78
- privateKey: '#private-key-box',
79
- importConfirm: [testId('import-account-confirm-button'), buttonText('Import')],
80
- back: testId('account-list-page-back-button'),
81
- },
82
- // "Connect this website with MetaMask"
83
- connect: {
84
- confirm: [testId('confirm-btn'), buttonText('Connect')],
85
- cancel: [testId('cancel-btn'), buttonText('Cancel')],
86
- },
87
- // The accounts of a connection: the request names the one it suggests, and
88
- // pressing it opens the list of the wallet's accounts, a checkbox on each
89
- connectAccounts: {
90
- // The suggested account, or their count when there are several
91
- edit: [testId('account-selection-section'), '[data-testid="parent-selector-connect-page"] .multichain-account-cell, [data-testid^="accounts-count-"]'],
92
- checkbox: (name) => `xpath/.//*[contains(@class, "multichain-account-cell")][.//*[@data-testid="multichain-account-cell-name-${name}"]]//input[@type="checkbox"]`,
93
- save: [testId('connect-more-accounts-button'), buttonText('Save')],
94
- // What the request shows once the list is saved: the account alone, or how many there are
95
- chosen: (names) =>
96
- names.length === 1
97
- ? `[data-testid="parent-selector-connect-page"] [data-testid="multichain-account-cell-name-${names[0]}"]`
98
- : testId(`accounts-count-${names.length}`),
99
- },
100
- // Signatures, transactions and "Add network" share the same confirmation footer
101
- confirmation: {
102
- confirm: [testId('confirm-footer-button'), buttonText('Confirm'), buttonText('Approve')],
103
- cancel: [testId('confirm-footer-cancel-button'), buttonText('Cancel')],
104
- // The arrow of the newer confirmations has a class and no test id
105
- scrollToBottom: `${testId('confirm-scroll-to-bottom')}, .confirm-scroll-to-bottom__button`,
106
- // The pane the content scrolls in, by its inline style: it has no test id
107
- scrollPane: '[style*="overflow: auto"]',
108
- },
109
- // What a transaction lets its reader change before confirming it: the
110
- // spending cap of an ERC-20 approval, and the network fee
111
- transaction: {
112
- // The pencil of the "Spending cap" row, before the amount it edits
113
- editSpendingCap: [testId('edit-spending-cap-icon'), 'xpath/.//button[@aria-label="Edit"][following::*[@data-testid="simulation-token-value"]]'],
114
- spendingCap: testId('simulation-token-value'),
115
- spendingCapInput: [testId('custom-spending-cap-input'), '.edit-spending-cap-modal input'],
116
- // "Save", the last button of the modal's footer: it has no test id
117
- spendingCapSave: ['.edit-spending-cap-modal .mm-modal-footer__button:last-child', 'xpath/.//*[contains(@class, "edit-spending-cap-modal")]//button[normalize-space(.)="Save"]'],
118
- // The pencil of the "Network fee" row opens the fee editor: a list of
119
- // estimates, whose last entry, "Advanced", leads to a form
120
- editGas: [testId('edit-gas-fee-icon'), `${testId('gas-fee-section')} button[aria-label="Edit"]`],
121
- gasEstimates: testId('gas-fee-estimates-modal'),
122
- gasOption: (key, label) => [testId(`gas-option-${key}`), `xpath/.//*[@data-testid="gas-fee-estimates-modal"]//p[normalize-space(.)="${label}"]`],
123
- gasForm: testId('gas-fee-advanced-eip1559-modal'),
124
- gasField: (id) => [`#${id}`, `${testId(id)} input`],
125
- gasSave: [testId('gas-fee-modal-save-button'), buttonText('Save')],
126
- },
127
- // The older footer, still used by the permission update a network switch
128
- // asks for, by "Add suggested tokens", and by template confirmations
129
- pageContainer: {
130
- confirm: [testId('page-container-footer-next'), testId('confirmation-submit-button'), buttonText('Confirm')],
131
- cancel: [testId('page-container-footer-cancel'), testId('confirmation-cancel-button'), buttonText('Cancel')],
132
- },
133
- // "Switching network will cancel N pending transactions": a warning MetaMask
134
- // raises after the confirmation is accepted; "Got it" carries on
135
- alert: {
136
- acknowledge: [testId('alert-modal-button'), buttonText('Got it')],
137
- },
138
- // "We've noticed multiple requests": shown over the confirmation when a dapp
139
- // sends many requests in a row, and it swallows the clicks
140
- modal: {
141
- content: '.mm-modal-content',
142
- // The cross of the modal's header, when it has one
143
- close: '.mm-modal-content button[aria-label="Close"]',
144
- lastButton: 'xpath/(.//div[contains(@class, "mm-modal-content")]//button)[last()]',
145
- },
146
- };
147
-
148
- // Which button each command presses on the confirmation
149
- const decisions = {
150
- connectToDapp: selectors.connect.confirm,
151
- rejectConnection: selectors.connect.cancel,
152
- approveNewNetwork: selectors.confirmation.confirm,
153
- rejectNewNetwork: selectors.confirmation.cancel,
154
- // A switch to a network the dapp is allowed on is silent; otherwise MetaMask asks for the permission
155
- approveSwitchNetwork: selectors.pageContainer.confirm,
156
- rejectSwitchNetwork: selectors.pageContainer.cancel,
157
- // wallet_watchAsset
158
- approveAddToken: selectors.pageContainer.confirm,
159
- rejectAddToken: selectors.pageContainer.cancel,
160
- confirmSignature: selectors.confirmation.confirm,
161
- rejectSignature: selectors.confirmation.cancel,
162
- confirmTransaction: selectors.confirmation.confirm,
163
- rejectTransaction: selectors.confirmation.cancel,
164
- };
165
-
166
- /** Which screen the extension home page shows, once MetaMask has started. */
167
- async function walletState(page) {
168
- const deadline = Date.now() + 20000;
169
- while (Date.now() < deadline) {
170
- if (await isVisible(page, selectors.onboarding.importWallet, 500)) return 'onboarding';
171
- if (await isVisible(page, selectors.unlock.password, 500)) return 'locked';
172
- if (await isVisible(page, selectors.home.header, 500)) return 'unlocked';
173
- }
174
- return 'unknown';
175
- }
176
-
177
- /** Import a wallet from its seed phrase on a fresh MetaMask install. */
178
- async function onboard(page, { seedPhrase, password, backupAndSync }) {
179
- const s = selectors.onboarding;
180
- await click(page, s.importWallet, { timeout: 30000 });
181
- await click(page, s.importWithSrp);
182
-
183
- await fillSeedPhrase(page, seedPhrase);
184
- await clickWhenEnabled(page, s.srpConfirm);
185
-
186
- await fill(page, s.newPassword, password);
187
- await fill(page, s.confirmPassword, password);
188
- await click(page, s.passwordTerms);
189
- await clickWhenEnabled(page, s.passwordSubmit);
190
-
191
- await reachHome(page, { password, backupAndSync });
192
- }
193
-
194
- // MetaMask's sandbox (LavaMoat) blocks a scripted paste, so the phrase is
195
- // typed word by word: a space after a word moves the cursor to the next field.
196
- async function fillSeedPhrase(page, seedPhrase) {
197
- const words = seedPhrase.trim().split(/\s+/);
198
- await fill(page, selectors.onboarding.srpInput, words[0]);
199
- await page.keyboard.press('Space');
200
- for (let index = 1; index < words.length; index++) {
201
- await fill(page, selectors.onboarding.srpWord(index), words[index]);
202
- if (index < words.length - 1) await page.keyboard.press('Space');
203
- }
204
- }
205
-
206
- async function unlock(page, { password }) {
207
- await submitPassword(page, password);
208
- await reachHome(page, { password });
209
- }
210
-
211
- async function submitPassword(page, password) {
212
- await fill(page, selectors.unlock.password, password);
213
- await click(page, selectors.unlock.submit);
214
- }
215
-
216
- /**
217
- * A page that was showing the unlock form when the wallet got unlocked from
218
- * another one keeps showing it, and the dapp's requests wait behind it. The
219
- * password is given there too, as a user would; what follows is the wallet
220
- * or a request, so only the form is watched.
221
- */
222
- async function leaveUnlockForm(page, { password }) {
223
- for (let attempt = 0; attempt < 3; attempt++) {
224
- if (!(await isVisible(page, selectors.unlock.password, 500))) return;
225
- await submitPassword(page, password);
226
- if (await isGone(page, selectors.unlock.password, 10000)) return;
227
- }
228
- throw await failure(page, 'MetaMask kept showing its unlock form');
229
- }
230
-
231
- /**
232
- * Go through whatever MetaMask shows until the wallet's home: the screens
233
- * that may follow the password or an unlock (passkey, analytics, "download
234
- * the app", "Done"), and the unlock form if MetaMask locked meanwhile. After
235
- * "Done", MetaMask takes a moment to record the onboarding as complete and
236
- * redirects to it until then, so the home page is reloaded.
237
- */
238
- async function reachHome(page, { password, backupAndSync }) {
239
- const s = selectors.onboarding;
240
- const deadline = Date.now() + 90000;
241
- let syncTurnedOff = false;
242
- while (Date.now() < deadline) {
243
- if (await isVisible(page, selectors.home.header, 500)) return;
244
- if (await isVisible(page, s.passkeyMaybeLater, 500)) await click(page, s.passkeyMaybeLater);
245
- if (await isVisible(page, s.metricsCheckbox, 500)) {
246
- await optOutOfMetrics(page);
247
- await click(page, s.metricsContinue);
248
- }
249
- if (await isVisible(page, s.downloadAppContinue, 500)) await click(page, s.downloadAppContinue);
250
- if (backupAndSync === false && !syncTurnedOff && (await isVisible(page, s.manageDefaultSettings, 500))) {
251
- await turnOffBackupAndSync(page);
252
- syncTurnedOff = true;
253
- }
254
- if (await isVisible(page, s.done, 500)) {
255
- await click(page, s.done);
256
- await sleep(1000);
257
- await reloadHome(page);
258
- }
259
- if (await isVisible(page, selectors.unlock.password, 500)) await submitPassword(page, password);
260
- }
261
- throw await failure(page, 'MetaMask did not show the wallet');
262
- }
263
-
264
- // Backup and sync restores, for a seed phrase, the accounts and contacts saved
265
- // from other installs. Off, every import starts from the same state.
266
- async function turnOffBackupAndSync(page) {
267
- const s = selectors.onboarding;
268
- await click(page, s.manageDefaultSettings);
269
- await click(page, s.generalSettings);
270
- const toggle = await waitFor(page, s.backupAndSyncToggle);
271
- const on = await toggle.evaluate((el) => el.closest('.toggle-button')?.classList.contains('toggle-button--on') ?? el.className.includes('--on'));
272
- if (on) await toggle.click();
273
- await click(page, s.categoryBack);
274
- await click(page, s.settingsBack);
275
- }
276
-
277
- async function optOutOfMetrics(page) {
278
- const checkbox = await waitFor(page, selectors.onboarding.metricsCheckbox);
279
- const checked = await checkbox.evaluate((el) => el.getAttribute('data-checked') === 'true');
280
- if (checked) await checkbox.click();
281
- }
282
-
283
- // Through about:blank: dropping only the "#" part of the URL would be a
284
- // same-document navigation, which goto() waits on forever
285
- async function reloadHome(page) {
286
- const homeUrl = page.url().split('#')[0];
287
- await page.goto('about:blank');
288
- await page.goto(homeUrl);
289
- }
290
-
291
- /**
292
- * Add an account to the wallet and select it. Yields its name, "Account N".
293
- * A click that lands while the list is still settling is lost, so the button
294
- * is pressed again when no account shows up.
295
- */
296
- async function addAccount(page) {
297
- await openAccountList(page);
298
- const before = await accountNames(page);
299
- for (let attempt = 0; attempt < 3; attempt++) {
300
- const added = (await newAccount(page, before, 0)) || (await pressAddAccount(page, before));
301
- if (added) {
302
- await selectAccount(page, added);
303
- return added;
304
- }
305
- }
306
- throw await failure(page, 'MetaMask added no account');
307
- }
308
-
309
- async function pressAddAccount(page, before) {
310
- await clickWhenEnabled(page, selectors.accounts.add, { timeout: 30000 });
311
- return newAccount(page, before, 15000);
312
- }
313
-
314
- /** The name of an account that wasn't in `before`, within `timeout` ms. */
315
- async function newAccount(page, before, timeout) {
316
- const deadline = Date.now() + timeout;
317
- do {
318
- const [added] = (await accountNames(page)).filter((name) => !before.includes(name));
319
- if (added) return added;
320
- await sleep(250);
321
- } while (Date.now() < deadline);
322
- return null;
323
- }
324
-
325
- async function accountNames(page) {
326
- await waitFor(page, selectors.accounts.name);
327
- return page.$$eval(selectors.accounts.name, (cells) => cells.map((cell) => cell.textContent.trim()));
328
- }
329
-
330
- /** Make `name` the selected account of the wallet. */
331
- async function switchAccount(page, name) {
332
- await openAccountList(page);
333
- await selectAccount(page, name);
334
- }
335
-
336
- // A click on the menu is lost while the home screen settles, as with the
337
- // buttons in the list, or swallowed by a modal MetaMask shows over the home
338
- // screen after some activity (the Transaction Shield offer, for one): any
339
- // modal is dismissed first, and the menu is pressed again until the list shows.
340
- async function openAccountList(page) {
341
- for (let attempt = 0; attempt < 3; attempt++) {
342
- // Back in front: a tab something opened meanwhile would hide this page and stall its rendering
343
- await page.bringToFront().catch(() => {});
344
- await dismissModal(page);
345
- await click(page, selectors.home.accountMenu);
346
- if (await isVisible(page, selectors.accounts.name, 10000)) return;
347
- }
348
- throw await failure(page, 'MetaMask did not open the account list');
349
- }
350
-
351
- // Picking an account closes the list and shows it in the home header. As with
352
- // adding one, a lost click leaves the list open, so the account is pressed again.
353
- async function selectAccount(page, name) {
354
- for (let attempt = 0; attempt < 3; attempt++) {
355
- await click(page, selectors.accounts.cell(name));
356
- if (await isSelected(page, name, 5000)) return;
357
- }
358
- throw await failure(page, `MetaMask did not select "${name}"`);
359
- }
360
-
361
- async function isSelected(page, name, timeout) {
362
- const deadline = Date.now() + timeout;
363
- while (Date.now() < deadline) {
364
- if ((await selectedAccount(page)) === name) return true;
365
- await sleep(250);
366
- }
367
- return false;
368
- }
369
-
370
- async function selectedAccount(page) {
371
- const menu = await page.$(selectors.home.accountMenu);
372
- return menu ? menu.evaluate((el) => el.textContent.trim()) : null;
373
- }
374
-
375
- /** Import an account from its private key and select it. Yields its name. */
376
- async function importAccount(page, privateKey) {
377
- await waitFor(page, selectors.home.accountMenu);
378
- const before = await selectedAccount(page);
379
- // Straight to the form the account list's "Add wallet" menu leads to
380
- const homeUrl = page.url().split('#')[0];
381
- await page.goto('about:blank');
382
- await page.goto(`${homeUrl}#/add-wallet-page`);
383
- await fill(page, selectors.accounts.privateKey, privateKey, { timeout: 30000 });
384
- await clickWhenEnabled(page, selectors.accounts.importConfirm);
385
- const deadline = Date.now() + 30000;
386
- while (Date.now() < deadline) {
387
- const selected = await selectedAccount(page);
388
- if (selected && selected !== before) return selected;
389
- await sleep(250);
390
- }
391
- throw await failure(page, 'MetaMask did not import the account');
392
- }
393
-
394
- /** Lock the wallet from the menu of its home page. Nothing to do on a wallet already locked. */
395
- async function lock(page) {
396
- if ((await walletState(page)) === 'locked') return;
397
- for (let attempt = 0; attempt < 3; attempt++) {
398
- await openMenu(page);
399
- await click(page, selectors.menu.lock);
400
- if (await isVisible(page, selectors.unlock.password, 10000)) return;
401
- }
402
- throw await failure(page, 'MetaMask did not lock');
403
- }
404
-
405
- // As with the account list, a click on the menu is lost while the home screen
406
- // settles or swallowed by a modal, so it is pressed again until the menu shows.
407
- async function openMenu(page) {
408
- for (let attempt = 0; attempt < 3; attempt++) {
409
- await page.bringToFront().catch(() => {});
410
- await dismissModal(page);
411
- await click(page, selectors.menu.open);
412
- if (await isVisible(page, selectors.menu.lock, 5000)) return;
413
- }
414
- throw await failure(page, 'MetaMask did not open its menu');
415
- }
416
-
417
- /**
418
- * Disconnect the site at `origin` from the wallet, on its page of the
419
- * "Permissions" screen. The dapp gets an empty accountsChanged.
420
- */
421
- async function disconnectSite(page, origin) {
422
- const s = selectors.permissions;
423
- const { host } = new URL(origin);
424
- await openConnections(page);
425
- if (!(await isVisible(page, s.site(host), 5000))) throw await failure(page, `MetaMask lists no connection to "${host}"`);
426
- await openSite(page, host);
427
- // Disconnecting goes back to the list, without the site. A lost click
428
- // leaves the site's page or the modal showing, and is pressed again.
429
- for (let attempt = 0; attempt < 3; attempt++) {
430
- if (!(await isVisible(page, s.confirmDisconnect, 500))) await click(page, s.disconnect);
431
- if (!(await isVisible(page, s.confirmDisconnect, 5000))) continue;
432
- await click(page, s.confirmDisconnect);
433
- if ((await isGone(page, [...s.confirmDisconnect, ...s.disconnect], 5000)) && (await isGone(page, s.site(host), 5000))) return;
434
- }
435
- throw await failure(page, `MetaMask did not disconnect "${host}"`);
436
- }
437
-
438
- /** The list of connected sites, from the menu's "Permissions". */
439
- async function openConnections(page) {
440
- const s = selectors.permissions;
441
- for (let attempt = 0; attempt < 3; attempt++) {
442
- await openMenu(page);
443
- await click(page, selectors.menu.permissions);
444
- if (!(await isVisible(page, [s.list, s.hub], 10000))) continue;
445
- if (await isVisible(page, s.list, 500)) return;
446
- // The hub goes on to the list by itself when there is nothing else to show
447
- if (await isVisible(page, s.connections, 3000)) await click(page, s.connections);
448
- if (await isVisible(page, s.list, 10000)) return;
449
- }
450
- throw await failure(page, 'MetaMask did not open its permissions');
451
- }
452
-
453
- async function openSite(page, host) {
454
- for (let attempt = 0; attempt < 3; attempt++) {
455
- await click(page, selectors.permissions.site(host));
456
- if (await isVisible(page, selectors.permissions.disconnect, 5000)) return;
457
- }
458
- throw await failure(page, `MetaMask did not open the permissions of "${host}"`);
459
- }
460
-
461
- // What a command sets on its confirmation before pressing the button, when the
462
- // test passed it options: adjustments[command](page, options, timeout)
463
- const adjustments = {
464
- connectToDapp: chooseAccounts,
465
- confirmTransaction: adjustTransaction,
466
- };
467
-
468
- /**
469
- * Connect the dapp with the accounts named in `accounts` and no other, in the
470
- * list the connection request opens: the boxes of the others are unticked,
471
- * theirs ticked, and the list saved, which goes back to the request.
472
- */
473
- async function chooseAccounts(page, { accounts }) {
474
- if (accounts === undefined) return;
475
- if (!Array.isArray(accounts) || accounts.length === 0) throw new Error('[dappress] connectToDapp({ accounts }) needs the name of at least one account');
476
- const wanted = [...new Set(accounts)];
477
- await openConnectAccounts(page);
478
- const listed = await accountNames(page);
479
- const unknown = wanted.filter((name) => !listed.includes(name));
480
- if (unknown.length) {
481
- const quoted = (names) => names.map((name) => `"${name}"`).join(', ');
482
- throw await failure(page, `MetaMask lists no account named ${quoted(unknown)} to connect: it lists ${quoted(listed)}`);
483
- }
484
- for (const name of listed) await tickAccount(page, name, wanted.includes(name));
485
- await saveConnectAccounts(page, wanted);
486
- }
487
-
488
- // As elsewhere, a click that lands while the request settles is lost: pressed again until the list shows
489
- async function openConnectAccounts(page) {
490
- const s = selectors.connectAccounts;
491
- for (let attempt = 0; attempt < 3; attempt++) {
492
- if (!(await isVisible(page, s.save, 500))) await click(page, s.edit);
493
- if (await isVisible(page, s.save, 5000)) return;
494
- }
495
- throw await failure(page, 'MetaMask did not open the accounts of the connection');
496
- }
497
-
498
- // A click on an account's row ticks or unticks its box. The box is read
499
- // before each click: a second click on a row that took the first undoes it.
500
- async function tickAccount(page, name, ticked) {
501
- for (let attempt = 0; attempt < 3; attempt++) {
502
- if (await isTicked(page, name, ticked, 500)) return;
503
- await click(page, selectors.accounts.cell(name));
504
- if (await isTicked(page, name, ticked, 3000)) return;
505
- }
506
- throw await failure(page, `MetaMask did not ${ticked ? 'tick' : 'untick'} "${name}"`);
507
- }
508
-
509
- async function isTicked(page, name, ticked, timeout) {
510
- const deadline = Date.now() + timeout;
511
- do {
512
- const checkbox = await page.$(selectors.connectAccounts.checkbox(name));
513
- const state = checkbox ? await checkbox.evaluate((el) => el.checked).catch(() => null) : null;
514
- if (state === ticked) return true;
515
- await sleep(250);
516
- } while (Date.now() < deadline);
517
- return false;
518
- }
519
-
520
- // Saving goes back to the request, which then shows what was chosen
521
- async function saveConnectAccounts(page, names) {
522
- const s = selectors.connectAccounts;
523
- for (let attempt = 0; attempt < 3; attempt++) {
524
- if (await isVisible(page, s.save, 500)) await clickWhenEnabled(page, s.save);
525
- if (!(await isGone(page, s.save, 5000))) continue;
526
- if (await isVisible(page, s.chosen(names), 5000)) return;
527
- break;
528
- }
529
- throw await failure(page, `MetaMask did not keep ${names.map((name) => `"${name}"`).join(', ')} as the accounts to connect`);
530
- }
531
-
532
- /** Press the button of `decision` on the confirmation shown on `page`, once it is set as `options` ask. */
533
- async function decide(decision, page, timeout, options) {
534
- if (options) {
535
- const adjust = adjustments[decision];
536
- if (!adjust) throw new Error(`[dappress] ${decision} takes no options`);
537
- await waitFor(page, decisions[decision], { timeout });
538
- await adjust(page, options, timeout);
539
- }
540
- return pressAndWaitForDismissal(page, decisions[decision], timeout);
541
- }
542
-
543
- /** wallet_addEthereumChain on a network MetaMask knows behaves like a switch: either prompt may show. */
544
- function approveNetworkChange(page, timeout) {
545
- return pressAndWaitForDismissal(page, [...selectors.confirmation.confirm, ...selectors.pageContainer.confirm], timeout);
546
- }
547
-
548
- // The estimates of the fee editor, by the name MetaMask shows: its test id and its English label
549
- const gasEstimates = { low: ['low', 'Low'], market: ['medium', 'Market'], aggressive: ['high', 'Aggressive'], networkSuggested: ['gasPrice', 'Network suggested'] };
550
- // The fields of the fee editor's advanced form, by the name MetaMask labels them with
551
- const gasFields = { maxBaseFee: 'max-base-fee-input', priorityFee: 'priority-fee-input', gasLimit: 'gas-input' };
552
-
553
- /**
554
- * Set what the test asked of a transaction before it is confirmed. The cap
555
- * goes first: saving it makes MetaMask estimate the gas limit again.
556
- */
557
- async function adjustTransaction(page, options) {
558
- const { spendingCap, gas, ...unknown } = options;
559
- const [stray] = Object.keys(unknown);
560
- if (stray) throw new Error(`[dappress] confirmTransaction takes spendingCap and gas, not "${stray}"`);
561
- const cap = spendingCap === undefined ? undefined : amount(spendingCap, 'spendingCap');
562
- const fee = gas === undefined ? undefined : gasChoice(gas);
563
- await dismissModal(page);
564
- if (cap !== undefined) await setSpendingCap(page, cap);
565
- if (fee !== undefined) await setGas(page, fee);
566
- }
567
-
568
- // An amount as a user types it, "5" or "2.5", from a number or a string
569
- function amount(value, name) {
570
- const text = String(value).trim();
571
- if (!/^\d+(\.\d+)?$/.test(text)) throw new Error(`[dappress] ${name} is an amount such as 5 or '2.5', not ${JSON.stringify(value)}`);
572
- return text;
573
- }
574
-
575
- // The estimate to pick, or the fields of the advanced form to fill with what
576
- function gasChoice(gas) {
577
- if (typeof gas === 'string') {
578
- if (!gasEstimates[gas]) throw new Error(`[dappress] gas is ${Object.keys(gasEstimates).join(', ')} or custom values, not "${gas}"`);
579
- return gas;
580
- }
581
- const fields = Object.keys(gas || {});
582
- const [stray] = fields.filter((field) => !gasFields[field]);
583
- if (stray || !fields.length) throw new Error(`[dappress] Custom gas takes ${Object.keys(gasFields).join(', ')}, not ${stray ? `"${stray}"` : 'nothing'}`);
584
- return Object.fromEntries(fields.map((field) => [field, amount(gas[field], `gas.${field}`)]));
585
- }
586
-
587
- /**
588
- * Set the amount an ERC-20 approval lets the spender use, in tokens, from the
589
- * pencil of its "Spending cap" row. The row shows once MetaMask has read the
590
- * token, and only an ERC-20 approval has one.
591
- */
592
- async function setSpendingCap(page, cap) {
593
- const s = selectors.transaction;
594
- if (!(await isVisible(page, s.editSpendingCap, 10000))) throw await failure(page, 'MetaMask shows no spending cap to edit: spendingCap is for an ERC-20 approval');
595
- const shown = await textOf(page, s.spendingCap);
596
- const asked = await openSpendingCap(page);
597
- await type(page, s.spendingCapInput, cap, 'spending cap');
598
- if (!(await pressToClose(page, s.spendingCapSave, s.spendingCapInput))) throw await failure(page, `MetaMask did not save the spending cap of ${cap}`);
599
- // The modal closes before the confirmation has the new cap, and confirming
600
- // meanwhile approves the old one. Two caps may read the same once MetaMask
601
- // has rounded them, so a row that stays as it was is not a failure.
602
- const deadline = Date.now() + (Number(asked) === Number(cap) ? 0 : 5000);
603
- while (Date.now() < deadline && (await textOf(page, s.spendingCap)) === shown) await sleep(250);
604
- }
605
-
606
- /** Open the spending cap's modal. Yields the cap the dapp asked for, which its field starts with. */
607
- async function openSpendingCap(page) {
608
- const s = selectors.transaction;
609
- for (let attempt = 0; attempt < 3; attempt++) {
610
- if (!(await isVisible(page, s.spendingCapInput, 500))) await click(page, s.editSpendingCap);
611
- if (await isVisible(page, s.spendingCapInput, 5000)) return valueOf(page, s.spendingCapInput);
612
- }
613
- throw await failure(page, 'MetaMask did not open the spending cap');
614
- }
615
-
616
- /**
617
- * Set the network fee in the fee editor, opened from the pencil of the
618
- * "Network fee" row: one of its estimates, or the values of its advanced form.
619
- */
620
- async function setGas(page, gas) {
621
- const s = selectors.transaction;
622
- if (!(await isVisible(page, s.editGas, 10000))) throw await failure(page, 'MetaMask shows no network fee to edit on this confirmation');
623
- await openFeeEditor(page);
624
- if (typeof gas === 'string') await pickGasEstimate(page, gas);
625
- else await fillGasForm(page, gas);
626
- }
627
-
628
- async function openFeeEditor(page) {
629
- const s = selectors.transaction;
630
- for (let attempt = 0; attempt < 3; attempt++) {
631
- if (!(await isVisible(page, s.gasEstimates, 500))) await click(page, s.editGas);
632
- if (await isVisible(page, s.gasEstimates, 5000)) return;
633
- }
634
- throw await failure(page, 'MetaMask did not open its fee editor');
635
- }
636
-
637
- // Picking an estimate closes the editor. MetaMask lists the estimates it has
638
- // for the network: Low, Market and Aggressive where it has fee estimates,
639
- // "Network suggested" where it only has a gas price, as on a local node.
640
- async function pickGasEstimate(page, name) {
641
- const s = selectors.transaction;
642
- const option = s.gasOption(...gasEstimates[name]);
643
- if (!(await isVisible(page, option, 5000))) throw await failure(page, `MetaMask offers no "${name}" fee for this transaction`);
644
- for (let attempt = 0; attempt < 3; attempt++) {
645
- await click(page, option);
646
- if (await isGone(page, s.gasEstimates, 10000)) return;
647
- }
648
- throw await failure(page, `MetaMask did not take the "${name}" fee`);
649
- }
650
-
651
- async function fillGasForm(page, values) {
652
- const s = selectors.transaction;
653
- await openGasForm(page);
654
- // The form checks each fee against the other as it stood, and keeps the old
655
- // value of a fee it refused: the max base fee is typed again once the
656
- // priority fee is in, for two fees that both move below or above the old ones.
657
- const fields = Object.keys(gasFields).filter((field) => values[field] !== undefined);
658
- if (values.maxBaseFee !== undefined && values.priorityFee !== undefined) fields.push('maxBaseFee');
659
- for (const field of fields) await type(page, s.gasField(gasFields[field]), values[field], field);
660
- // "Save" stays disabled on a value the form refuses, and the error quotes what the form says of it
661
- if (!(await pressToClose(page, s.gasSave, s.gasForm))) throw await failure(page, 'MetaMask did not save the network fee');
662
- }
663
-
664
- // "Advanced", in the list of estimates, replaces the list with the form. A
665
- // transaction with a gas price gets another form, which has none of these fields.
666
- async function openGasForm(page) {
667
- const s = selectors.transaction;
668
- for (let attempt = 0; attempt < 3; attempt++) {
669
- if (!(await isVisible(page, s.gasForm, 500))) await click(page, s.gasOption('advanced', 'Advanced'));
670
- if (await isVisible(page, s.gasForm, 5000)) return;
671
- }
672
- throw await failure(page, 'MetaMask did not open the advanced fee form of an EIP-1559 transaction');
673
- }
674
-
675
- // Type into a field and read it back: keys pressed while MetaMask re-renders are lost
676
- async function type(page, selector, text, what) {
677
- for (let attempt = 0; attempt < 3; attempt++) {
678
- await fill(page, selector, text);
679
- if (Number(await valueOf(page, selector)) === Number(text)) return;
680
- }
681
- throw await failure(page, `MetaMask's field for the ${what} did not take "${text}"`);
682
- }
683
-
684
- // Press the button that saves a modal until the modal closes: a lost click leaves it open
685
- async function pressToClose(page, button, content) {
686
- for (let attempt = 0; attempt < 3; attempt++) {
687
- await clickWhenEnabled(page, button);
688
- if (await isGone(page, content, 10000)) return true;
689
- }
690
- return false;
691
- }
692
-
693
- async function valueOf(page, selector) {
694
- const field = await waitFor(page, selector);
695
- return field.evaluate((el) => el.value);
696
- }
697
-
698
- async function textOf(page, selector) {
699
- const element = await page.$(selector);
700
- return element ? element.evaluate((el) => el.textContent.trim()).catch(() => null) : null;
701
- }
702
-
703
- /**
704
- * Press a footer button, then wait for the confirmation to go away: the
705
- * popup closes, the side panel goes back to the home screen. A click that
706
- * lands while the confirmation is still settling is lost, so the click is
707
- * repeated while the button stays. If it stays anyway, the error says
708
- * whether it is the same request or a new one the dapp sent meanwhile, and
709
- * what MetaMask logged.
710
- */
711
- async function pressAndWaitForDismissal(page, button, timeout) {
712
- await waitFor(page, button, { timeout });
713
- await dismissModal(page);
714
- const logged = recordErrors(page);
715
- const request = page.url();
716
- try {
717
- await clickWhenEnabled(page, button, { whileDisabled: () => scrollContentToEnd(page) });
718
- for (let attempt = 0; attempt < 3; attempt++) {
719
- if (await isVisible(page, selectors.alert.acknowledge, 500)) await click(page, selectors.alert.acknowledge);
720
- if (await isGone(page, button, 3000)) return;
721
- await dismissModal(page);
722
- await dispatchClick(page, button, { timeout: 3000 }).catch(() => {});
723
- }
724
- const what = page.url() === request ? 'MetaMask kept the same request open' : 'MetaMask shows a new request: the dapp asked again';
725
- const distinct = [...new Set(logged.errors)].slice(-3);
726
- const errors = distinct.length ? `MetaMask logged: ${distinct.join(' | ')}` : 'MetaMask logged no error';
727
- throw await failure(page, `"${describe(button)}" is still showing after being clicked. ${what}. ${errors}`);
728
- } finally {
729
- logged.stop();
730
- }
731
- }
732
-
733
- /** Collect the errors MetaMask's page logs to its console, until stop(). */
734
- function recordErrors(page) {
735
- const errors = [];
736
- const onConsole = (message) => {
737
- if (message.type() === 'error') errors.push(message.text().slice(0, 300));
738
- };
739
- const onPageError = (error) => errors.push(String(error.message || error).slice(0, 300));
740
- page.on('console', onConsole);
741
- page.on('pageerror', onPageError);
742
- return {
743
- errors,
744
- stop() {
745
- page.off('console', onConsole);
746
- page.off('pageerror', onPageError);
747
- },
748
- };
749
- }
750
-
751
- /**
752
- * MetaMask keeps the button of a confirmation disabled until its content was
753
- * read to the end: scrolled to the bottom, or not scrollable at all. Some
754
- * screens offer a button for that, most don't, so the content is scrolled
755
- * with the wheel, as a reader would. The popup, being small, needs it often.
756
- */
757
- async function scrollContentToEnd(page) {
758
- const scrollButton = await page.$(selectors.confirmation.scrollToBottom);
759
- if (scrollButton) {
760
- await scrollButton.click().catch(() => {});
761
- return;
762
- }
763
- // The pane itself, to its end: that fires the scroll event MetaMask listens to
764
- await page
765
- .$$eval(selectors.confirmation.scrollPane, (panes) => {
766
- for (const pane of panes) {
767
- if (pane.scrollHeight > pane.clientHeight) pane.scrollTop = pane.scrollHeight;
768
- }
769
- })
770
- .catch(() => {});
771
- // And the wheel over the content, for a pane styled otherwise
772
- const [width, height] = await page.evaluate(() => [document.documentElement.clientWidth, document.documentElement.clientHeight]);
773
- await page.mouse.move(Math.round(width / 2), Math.round(height / 2));
774
- await page.mouse.wheel({ deltaY: 10000 });
775
- }
776
-
777
- /**
778
- * Close a modal shown over the screen: Escape, then the cross of its header,
779
- * then its last button, which is "Cancel" on the "multiple requests" modal.
780
- * The last button is the last resort: on an offer such as Transaction
781
- * Shield's, it is a call to action that opens a page, which hides this one.
782
- */
783
- async function dismissModal(page) {
784
- if (!(await isVisible(page, selectors.modal.content, 300))) return;
785
- await page.keyboard.press('Escape');
786
- if (await isGone(page, selectors.modal.content, 1000)) return;
787
- const close = await page.$(selectors.modal.close);
788
- if (close) {
789
- await close.click().catch(() => {});
790
- if (await isGone(page, selectors.modal.content, 1000)) return;
791
- }
792
- await dispatchClick(page, selectors.modal.lastButton);
793
- }
794
-
795
- module.exports = {
796
- decisions,
797
- walletState,
798
- onboard,
799
- unlock,
800
- leaveUnlockForm,
801
- lock,
802
- addAccount,
803
- switchAccount,
804
- importAccount,
805
- disconnectSite,
806
- decide,
807
- approveNetworkChange,
808
- };