dappress 0.4.0 → 0.5.0

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