dappress 0.3.3 → 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.
package/src/metamask.js DELETED
@@ -1,427 +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 passwordInput = (position) => `xpath/(.//input[@type="password"])[${position}]`;
16
-
17
- const selectors = {
18
- onboarding: {
19
- importWallet: [testId('onboarding-import-wallet'), buttonText('I have an existing wallet')],
20
- importWithSrp: [testId('onboarding-import-with-srp-button'), buttonText('Import using Secret Recovery Phrase')],
21
- srpInput: testId('srp-input-import__srp-note'),
22
- srpWord: (index) => testId(`import-srp__srp-word-${index}`),
23
- srpConfirm: [testId('import-srp-confirm'), buttonText('Continue')],
24
- newPassword: [testId('create-password-new-input'), passwordInput(1)],
25
- confirmPassword: [testId('create-password-confirm-input'), passwordInput(2)],
26
- passwordTerms: testId('create-password-terms'),
27
- passwordSubmit: [testId('create-password-submit'), buttonText('Import my wallet')],
28
- passkeyMaybeLater: [testId('passkey-maybe-later-button'), buttonText('Maybe later')],
29
- metricsCheckbox: testId('metametrics-checkbox'),
30
- metricsContinue: [testId('metametrics-i-agree'), buttonText('Continue')],
31
- downloadAppContinue: testId('download-app-continue'),
32
- done: [testId('onboarding-complete-done'), buttonText('Done')],
33
- // "Manage default settings", offered next to "Done"
34
- manageDefaultSettings: testId('manage-default-settings'),
35
- // The first category, "General": its testid carries the translated label
36
- generalSettings: 'xpath/(.//*[starts-with(@data-testid, "category-item-")])[1]',
37
- backupAndSyncToggle: testId('backup-and-sync-toggle-button'),
38
- categoryBack: testId('category-back-button'),
39
- settingsBack: testId('privacy-settings-back-button'),
40
- },
41
- unlock: {
42
- password: [testId('unlock-password'), passwordInput(1)],
43
- submit: [testId('unlock-submit'), buttonText('Unlock')],
44
- },
45
- home: {
46
- header: testId('parent-selector-header-navbar'),
47
- accountMenu: testId('account-menu-icon'),
48
- },
49
- // The account list, opened from the home header
50
- accounts: {
51
- name: '[data-testid^="multichain-account-cell-name-"]',
52
- cell: (name) => `xpath/.//*[contains(@class, "multichain-account-cell")][.//*[@data-testid="multichain-account-cell-name-${name}"]]`,
53
- add: 'xpath/(.//*[@data-testid="add-multichain-account-button"])[1]',
54
- addWallet: testId('account-list-add-wallet-button'),
55
- importAccount: testId('choose-wallet-type-import-account'),
56
- privateKey: '#private-key-box',
57
- importConfirm: [testId('import-account-confirm-button'), buttonText('Import')],
58
- back: testId('account-list-page-back-button'),
59
- },
60
- // "Connect this website with MetaMask"
61
- connect: {
62
- confirm: [testId('confirm-btn'), buttonText('Connect')],
63
- cancel: [testId('cancel-btn'), buttonText('Cancel')],
64
- },
65
- // Signatures, transactions and "Add network" share the same confirmation footer
66
- confirmation: {
67
- confirm: [testId('confirm-footer-button'), buttonText('Confirm'), buttonText('Approve')],
68
- cancel: [testId('confirm-footer-cancel-button'), buttonText('Cancel')],
69
- // The arrow of the newer confirmations has a class and no test id
70
- scrollToBottom: `${testId('confirm-scroll-to-bottom')}, .confirm-scroll-to-bottom__button`,
71
- // The pane the content scrolls in, by its inline style: it has no test id
72
- scrollPane: '[style*="overflow: auto"]',
73
- },
74
- // The older footer, still used by the permission update a network switch
75
- // asks for, by "Add suggested tokens", and by template confirmations
76
- pageContainer: {
77
- confirm: [testId('page-container-footer-next'), testId('confirmation-submit-button'), buttonText('Confirm')],
78
- cancel: [testId('page-container-footer-cancel'), testId('confirmation-cancel-button'), buttonText('Cancel')],
79
- },
80
- // "Switching network will cancel N pending transactions": a warning MetaMask
81
- // raises after the confirmation is accepted; "Got it" carries on
82
- alert: {
83
- acknowledge: [testId('alert-modal-button'), buttonText('Got it')],
84
- },
85
- // "We've noticed multiple requests": shown over the confirmation when a dapp
86
- // sends many requests in a row, and it swallows the clicks
87
- modal: {
88
- content: '.mm-modal-content',
89
- // The cross of the modal's header, when it has one
90
- close: '.mm-modal-content button[aria-label="Close"]',
91
- lastButton: 'xpath/(.//div[contains(@class, "mm-modal-content")]//button)[last()]',
92
- },
93
- };
94
-
95
- // Which button each command presses on the confirmation
96
- const decisions = {
97
- connectToDapp: selectors.connect.confirm,
98
- rejectConnection: selectors.connect.cancel,
99
- approveNewNetwork: selectors.confirmation.confirm,
100
- rejectNewNetwork: selectors.confirmation.cancel,
101
- // A switch to a network the dapp is allowed on is silent; otherwise MetaMask asks for the permission
102
- approveSwitchNetwork: selectors.pageContainer.confirm,
103
- rejectSwitchNetwork: selectors.pageContainer.cancel,
104
- // wallet_watchAsset
105
- approveAddToken: selectors.pageContainer.confirm,
106
- rejectAddToken: selectors.pageContainer.cancel,
107
- confirmSignature: selectors.confirmation.confirm,
108
- rejectSignature: selectors.confirmation.cancel,
109
- confirmTransaction: selectors.confirmation.confirm,
110
- rejectTransaction: selectors.confirmation.cancel,
111
- };
112
-
113
- /** Which screen the extension home page shows, once MetaMask has started. */
114
- async function walletState(page) {
115
- const deadline = Date.now() + 20000;
116
- while (Date.now() < deadline) {
117
- if (await isVisible(page, selectors.onboarding.importWallet, 500)) return 'onboarding';
118
- if (await isVisible(page, selectors.unlock.password, 500)) return 'locked';
119
- if (await isVisible(page, selectors.home.header, 500)) return 'unlocked';
120
- }
121
- return 'unknown';
122
- }
123
-
124
- /** Import a wallet from its seed phrase on a fresh MetaMask install. */
125
- async function onboard(page, { seedPhrase, password, backupAndSync }) {
126
- const s = selectors.onboarding;
127
- await click(page, s.importWallet, { timeout: 30000 });
128
- await click(page, s.importWithSrp);
129
-
130
- await fillSeedPhrase(page, seedPhrase);
131
- await clickWhenEnabled(page, s.srpConfirm);
132
-
133
- await fill(page, s.newPassword, password);
134
- await fill(page, s.confirmPassword, password);
135
- await click(page, s.passwordTerms);
136
- await clickWhenEnabled(page, s.passwordSubmit);
137
-
138
- await reachHome(page, { password, backupAndSync });
139
- }
140
-
141
- // MetaMask's sandbox (LavaMoat) blocks a scripted paste, so the phrase is
142
- // typed word by word: a space after a word moves the cursor to the next field.
143
- async function fillSeedPhrase(page, seedPhrase) {
144
- const words = seedPhrase.trim().split(/\s+/);
145
- await fill(page, selectors.onboarding.srpInput, words[0]);
146
- await page.keyboard.press('Space');
147
- for (let index = 1; index < words.length; index++) {
148
- await fill(page, selectors.onboarding.srpWord(index), words[index]);
149
- if (index < words.length - 1) await page.keyboard.press('Space');
150
- }
151
- }
152
-
153
- async function unlock(page, { password }) {
154
- await fill(page, selectors.unlock.password, password);
155
- await click(page, selectors.unlock.submit);
156
- await reachHome(page, { password });
157
- }
158
-
159
- /**
160
- * Go through whatever MetaMask shows until the wallet's home: the screens
161
- * that may follow the password or an unlock (passkey, analytics, "download
162
- * the app", "Done"), and the unlock form if MetaMask locked meanwhile. After
163
- * "Done", MetaMask takes a moment to record the onboarding as complete and
164
- * redirects to it until then, so the home page is reloaded.
165
- */
166
- async function reachHome(page, { password, backupAndSync }) {
167
- const s = selectors.onboarding;
168
- const deadline = Date.now() + 90000;
169
- let syncTurnedOff = false;
170
- while (Date.now() < deadline) {
171
- if (await isVisible(page, selectors.home.header, 500)) return;
172
- if (await isVisible(page, s.passkeyMaybeLater, 500)) await click(page, s.passkeyMaybeLater);
173
- if (await isVisible(page, s.metricsCheckbox, 500)) {
174
- await optOutOfMetrics(page);
175
- await click(page, s.metricsContinue);
176
- }
177
- if (await isVisible(page, s.downloadAppContinue, 500)) await click(page, s.downloadAppContinue);
178
- if (backupAndSync === false && !syncTurnedOff && (await isVisible(page, s.manageDefaultSettings, 500))) {
179
- await turnOffBackupAndSync(page);
180
- syncTurnedOff = true;
181
- }
182
- if (await isVisible(page, s.done, 500)) {
183
- await click(page, s.done);
184
- await sleep(1000);
185
- await reloadHome(page);
186
- }
187
- if (await isVisible(page, selectors.unlock.password, 500)) {
188
- await fill(page, selectors.unlock.password, password);
189
- await click(page, selectors.unlock.submit);
190
- }
191
- }
192
- throw await failure(page, 'MetaMask did not show the wallet');
193
- }
194
-
195
- // Backup and sync restores, for a seed phrase, the accounts and contacts saved
196
- // from other installs. Off, every import starts from the same state.
197
- async function turnOffBackupAndSync(page) {
198
- const s = selectors.onboarding;
199
- await click(page, s.manageDefaultSettings);
200
- await click(page, s.generalSettings);
201
- const toggle = await waitFor(page, s.backupAndSyncToggle);
202
- const on = await toggle.evaluate((el) => el.closest('.toggle-button')?.classList.contains('toggle-button--on') ?? el.className.includes('--on'));
203
- if (on) await toggle.click();
204
- await click(page, s.categoryBack);
205
- await click(page, s.settingsBack);
206
- }
207
-
208
- async function optOutOfMetrics(page) {
209
- const checkbox = await waitFor(page, selectors.onboarding.metricsCheckbox);
210
- const checked = await checkbox.evaluate((el) => el.getAttribute('data-checked') === 'true');
211
- if (checked) await checkbox.click();
212
- }
213
-
214
- // Through about:blank: dropping only the "#" part of the URL would be a
215
- // same-document navigation, which goto() waits on forever
216
- async function reloadHome(page) {
217
- const homeUrl = page.url().split('#')[0];
218
- await page.goto('about:blank');
219
- await page.goto(homeUrl);
220
- }
221
-
222
- /**
223
- * Add an account to the wallet and select it. Yields its name, "Account N".
224
- * A click that lands while the list is still settling is lost, so the button
225
- * is pressed again when no account shows up.
226
- */
227
- async function addAccount(page) {
228
- await openAccountList(page);
229
- const before = await accountNames(page);
230
- for (let attempt = 0; attempt < 3; attempt++) {
231
- const added = (await newAccount(page, before, 0)) || (await pressAddAccount(page, before));
232
- if (added) {
233
- await selectAccount(page, added);
234
- return added;
235
- }
236
- }
237
- throw await failure(page, 'MetaMask added no account');
238
- }
239
-
240
- async function pressAddAccount(page, before) {
241
- await clickWhenEnabled(page, selectors.accounts.add, { timeout: 30000 });
242
- return newAccount(page, before, 15000);
243
- }
244
-
245
- /** The name of an account that wasn't in `before`, within `timeout` ms. */
246
- async function newAccount(page, before, timeout) {
247
- const deadline = Date.now() + timeout;
248
- do {
249
- const [added] = (await accountNames(page)).filter((name) => !before.includes(name));
250
- if (added) return added;
251
- await sleep(250);
252
- } while (Date.now() < deadline);
253
- return null;
254
- }
255
-
256
- async function accountNames(page) {
257
- await waitFor(page, selectors.accounts.name);
258
- return page.$$eval(selectors.accounts.name, (cells) => cells.map((cell) => cell.textContent.trim()));
259
- }
260
-
261
- /** Make `name` the selected account of the wallet. */
262
- async function switchAccount(page, name) {
263
- await openAccountList(page);
264
- await selectAccount(page, name);
265
- }
266
-
267
- // A click on the menu is lost while the home screen settles, as with the
268
- // buttons in the list, or swallowed by a modal MetaMask shows over the home
269
- // screen after some activity (the Transaction Shield offer, for one): any
270
- // modal is dismissed first, and the menu is pressed again until the list shows.
271
- async function openAccountList(page) {
272
- for (let attempt = 0; attempt < 3; attempt++) {
273
- // Back in front: a tab something opened meanwhile would hide this page and stall its rendering
274
- await page.bringToFront().catch(() => {});
275
- await dismissModal(page);
276
- await click(page, selectors.home.accountMenu);
277
- if (await isVisible(page, selectors.accounts.name, 10000)) return;
278
- }
279
- throw await failure(page, 'MetaMask did not open the account list');
280
- }
281
-
282
- // Picking an account closes the list and shows it in the home header. As with
283
- // adding one, a lost click leaves the list open, so the account is pressed again.
284
- async function selectAccount(page, name) {
285
- for (let attempt = 0; attempt < 3; attempt++) {
286
- await click(page, selectors.accounts.cell(name));
287
- if (await isSelected(page, name, 5000)) return;
288
- }
289
- throw await failure(page, `MetaMask did not select "${name}"`);
290
- }
291
-
292
- async function isSelected(page, name, timeout) {
293
- const deadline = Date.now() + timeout;
294
- while (Date.now() < deadline) {
295
- if ((await selectedAccount(page)) === name) return true;
296
- await sleep(250);
297
- }
298
- return false;
299
- }
300
-
301
- async function selectedAccount(page) {
302
- const menu = await page.$(selectors.home.accountMenu);
303
- return menu ? menu.evaluate((el) => el.textContent.trim()) : null;
304
- }
305
-
306
- /** Import an account from its private key and select it. Yields its name. */
307
- async function importAccount(page, privateKey) {
308
- await waitFor(page, selectors.home.accountMenu);
309
- const before = await selectedAccount(page);
310
- // Straight to the form the account list's "Add wallet" menu leads to
311
- const homeUrl = page.url().split('#')[0];
312
- await page.goto('about:blank');
313
- await page.goto(`${homeUrl}#/add-wallet-page`);
314
- await fill(page, selectors.accounts.privateKey, privateKey, { timeout: 30000 });
315
- await clickWhenEnabled(page, selectors.accounts.importConfirm);
316
- const deadline = Date.now() + 30000;
317
- while (Date.now() < deadline) {
318
- const selected = await selectedAccount(page);
319
- if (selected && selected !== before) return selected;
320
- await sleep(250);
321
- }
322
- throw await failure(page, 'MetaMask did not import the account');
323
- }
324
-
325
- /** Press the button of `decision` on the confirmation shown on `page`. */
326
- function decide(decision, page, timeout) {
327
- return pressAndWaitForDismissal(page, decisions[decision], timeout);
328
- }
329
-
330
- /** wallet_addEthereumChain on a network MetaMask knows behaves like a switch: either prompt may show. */
331
- function approveNetworkChange(page, timeout) {
332
- return pressAndWaitForDismissal(page, [...selectors.confirmation.confirm, ...selectors.pageContainer.confirm], timeout);
333
- }
334
-
335
- /**
336
- * Press a footer button, then wait for the confirmation to go away: the
337
- * popup closes, the side panel goes back to the home screen. A click that
338
- * lands while the confirmation is still settling is lost, so the click is
339
- * repeated while the button stays. If it stays anyway, the error says
340
- * whether it is the same request or a new one the dapp sent meanwhile, and
341
- * what MetaMask logged.
342
- */
343
- async function pressAndWaitForDismissal(page, button, timeout) {
344
- await waitFor(page, button, { timeout });
345
- await dismissModal(page);
346
- const logged = recordErrors(page);
347
- const request = page.url();
348
- try {
349
- await clickWhenEnabled(page, button, { whileDisabled: () => scrollContentToEnd(page) });
350
- for (let attempt = 0; attempt < 3; attempt++) {
351
- if (await isVisible(page, selectors.alert.acknowledge, 500)) await click(page, selectors.alert.acknowledge);
352
- if (await isGone(page, button, 3000)) return;
353
- await dismissModal(page);
354
- await dispatchClick(page, button, { timeout: 3000 }).catch(() => {});
355
- }
356
- const what = page.url() === request ? 'MetaMask kept the same request open' : 'MetaMask shows a new request: the dapp asked again';
357
- const distinct = [...new Set(logged.errors)].slice(-3);
358
- const errors = distinct.length ? `MetaMask logged: ${distinct.join(' | ')}` : 'MetaMask logged no error';
359
- throw await failure(page, `"${describe(button)}" is still showing after being clicked. ${what}. ${errors}`);
360
- } finally {
361
- logged.stop();
362
- }
363
- }
364
-
365
- /** Collect the errors MetaMask's page logs to its console, until stop(). */
366
- function recordErrors(page) {
367
- const errors = [];
368
- const onConsole = (message) => {
369
- if (message.type() === 'error') errors.push(message.text().slice(0, 300));
370
- };
371
- const onPageError = (error) => errors.push(String(error.message || error).slice(0, 300));
372
- page.on('console', onConsole);
373
- page.on('pageerror', onPageError);
374
- return {
375
- errors,
376
- stop() {
377
- page.off('console', onConsole);
378
- page.off('pageerror', onPageError);
379
- },
380
- };
381
- }
382
-
383
- /**
384
- * MetaMask keeps the button of a confirmation disabled until its content was
385
- * read to the end: scrolled to the bottom, or not scrollable at all. Some
386
- * screens offer a button for that, most don't, so the content is scrolled
387
- * with the wheel, as a reader would. The popup, being small, needs it often.
388
- */
389
- async function scrollContentToEnd(page) {
390
- const scrollButton = await page.$(selectors.confirmation.scrollToBottom);
391
- if (scrollButton) {
392
- await scrollButton.click().catch(() => {});
393
- return;
394
- }
395
- // The pane itself, to its end: that fires the scroll event MetaMask listens to
396
- await page
397
- .$$eval(selectors.confirmation.scrollPane, (panes) => {
398
- for (const pane of panes) {
399
- if (pane.scrollHeight > pane.clientHeight) pane.scrollTop = pane.scrollHeight;
400
- }
401
- })
402
- .catch(() => {});
403
- // And the wheel over the content, for a pane styled otherwise
404
- const [width, height] = await page.evaluate(() => [document.documentElement.clientWidth, document.documentElement.clientHeight]);
405
- await page.mouse.move(Math.round(width / 2), Math.round(height / 2));
406
- await page.mouse.wheel({ deltaY: 10000 });
407
- }
408
-
409
- /**
410
- * Close a modal shown over the screen: Escape, then the cross of its header,
411
- * then its last button, which is "Cancel" on the "multiple requests" modal.
412
- * The last button is the last resort: on an offer such as Transaction
413
- * Shield's, it is a call to action that opens a page, which hides this one.
414
- */
415
- async function dismissModal(page) {
416
- if (!(await isVisible(page, selectors.modal.content, 300))) return;
417
- await page.keyboard.press('Escape');
418
- if (await isGone(page, selectors.modal.content, 1000)) return;
419
- const close = await page.$(selectors.modal.close);
420
- if (close) {
421
- await close.click().catch(() => {});
422
- if (await isGone(page, selectors.modal.content, 1000)) return;
423
- }
424
- await dispatchClick(page, selectors.modal.lastButton);
425
- }
426
-
427
- module.exports = { decisions, walletState, onboard, unlock, addAccount, switchAccount, importAccount, decide, approveNetworkChange };
@@ -1,194 +0,0 @@
1
- // Small helpers on top of Puppeteer's Page API. They give every interaction
2
- // the same waiting and error reporting, and stay within what MetaMask's
3
- // sandbox (LavaMoat) allows: no script injected into the page.
4
- //
5
- // A "selector" is a CSS or XPath selector, or a list of them tried together:
6
- // the first one to show up wins.
7
-
8
- const fs = require('node:fs');
9
- const os = require('node:os');
10
- const path = require('node:path');
11
-
12
- const DEFAULT_TIMEOUT = 15000;
13
- const SCREENSHOTS_DIR = path.join(os.tmpdir(), 'dappress');
14
-
15
- const candidates = (selector) => [].concat(selector);
16
-
17
- /** A readable name for a selector, for error messages. */
18
- function describe(selector) {
19
- return candidates(selector)
20
- .map((candidate) => candidate.replace(/^\[data-testid="(.+)"\]$/, '$1').replace(/^xpath\/.*="(.+)"\]$/, 'button "$1"'))
21
- .join(' | ');
22
- }
23
-
24
- /** Wait for the first candidate to be visible and return its element handle. */
25
- async function waitFor(page, selector, { timeout = DEFAULT_TIMEOUT } = {}) {
26
- const attempts = candidates(selector).map((candidate) => page.waitForSelector(candidate, { visible: true, timeout }));
27
- try {
28
- return await Promise.any(attempts);
29
- } catch {
30
- throw await failure(page, `Timed out waiting for "${describe(selector)}"`);
31
- } finally {
32
- // Let the losing attempts fail quietly when they time out
33
- attempts.forEach((attempt) => attempt.catch(() => {}));
34
- }
35
- }
36
-
37
- /** Returns true when `selector` is visible within `timeout` ms, false otherwise. */
38
- async function isVisible(page, selector, timeout = 1500) {
39
- try {
40
- await waitFor(page, selector, { timeout });
41
- return true;
42
- } catch {
43
- return false;
44
- }
45
- }
46
-
47
- /** Returns true once every candidate has left the page, or if the page itself closes. */
48
- async function isGone(page, selector, timeout = 1500) {
49
- try {
50
- await Promise.all(candidates(selector).map((candidate) => page.waitForSelector(candidate, { hidden: true, timeout })));
51
- return true;
52
- } catch {
53
- return page.isClosed();
54
- }
55
- }
56
-
57
- async function click(page, selector, options) {
58
- await clickFresh(page, selector, options);
59
- }
60
-
61
- /**
62
- * Wait until the element is both visible and enabled, then click it. The
63
- * element is taken again at each turn: MetaMask re-renders its screens, and
64
- * a handle on a replaced element reads its last state forever.
65
- */
66
- async function clickWhenEnabled(page, selector, { timeout = DEFAULT_TIMEOUT, whileDisabled } = {}) {
67
- const deadline = Date.now() + timeout;
68
- let nudged = 0;
69
- for (;;) {
70
- const element = await waitFor(page, selector, { timeout });
71
- if (!(await element.evaluate((el) => el.disabled))) break;
72
- if (Date.now() > deadline) {
73
- throw await failure(page, `"${describe(selector)}" stayed disabled for ${timeout}ms. ${await describeDisabled(page, element)}`);
74
- }
75
- // Something the page wants done before it enables the button, every half second
76
- if (whileDisabled && Date.now() - nudged > 500) {
77
- await whileDisabled();
78
- nudged = Date.now();
79
- }
80
- await sleep(100);
81
- }
82
- await clickFresh(page, selector, { timeout });
83
- }
84
-
85
- // What may keep a button disabled: the button itself (a spinner, a class),
86
- // a pane left to scroll, the page not being the focused one
87
- async function describeDisabled(page, element) {
88
- try {
89
- const state = await page.evaluate((button) => {
90
- const panes = [...document.querySelectorAll('[style*="overflow"]')]
91
- .filter((pane) => pane.scrollHeight > pane.clientHeight)
92
- .map((pane) => `${pane.scrollTop}+${pane.clientHeight}/${pane.scrollHeight}`);
93
- return {
94
- button: button.outerHTML.replace(/\s+/g, ' ').slice(0, 300),
95
- panesToScroll: panes,
96
- viewport: `${document.documentElement.clientWidth}x${document.documentElement.clientHeight}`,
97
- focused: document.hasFocus(),
98
- visibility: document.visibilityState,
99
- };
100
- }, element);
101
- state.window = await windowBounds(page);
102
- return `Button: ${JSON.stringify(state)}.`;
103
- } catch {
104
- return '';
105
- }
106
- }
107
-
108
- // The size and state of the window the page is in, as "400x620 normal"
109
- async function windowBounds(page) {
110
- const session = await page.target().createCDPSession();
111
- try {
112
- const { bounds } = await session.send('Browser.getWindowForTarget');
113
- return `${bounds.width}x${bounds.height} ${bounds.windowState}`;
114
- } catch {
115
- return 'unknown';
116
- } finally {
117
- await session.detach().catch(() => {});
118
- }
119
- }
120
-
121
- // Find the element and click it; once more if a re-render replaced it between the two
122
- async function clickFresh(page, selector, options) {
123
- for (let attempt = 0; ; attempt++) {
124
- const element = await waitFor(page, selector, options);
125
- await waitForStill(element);
126
- try {
127
- return await element.click();
128
- } catch (error) {
129
- if (attempt > 0 || !/detached/.test(error.message)) throw error;
130
- }
131
- }
132
- }
133
-
134
- /** Click by dispatching the event on the element itself, for buttons something may cover. */
135
- async function dispatchClick(page, selector, options) {
136
- const element = await waitFor(page, selector, options);
137
- await element.evaluate((el) => el.click());
138
- }
139
-
140
- /** MetaMask animates its menus and confirmations in; a click during the animation lands elsewhere. */
141
- async function waitForStill(element, timeout = 2000) {
142
- const deadline = Date.now() + timeout;
143
- let previous = JSON.stringify(await element.boundingBox());
144
- while (Date.now() < deadline) {
145
- await sleep(100);
146
- const current = JSON.stringify(await element.boundingBox());
147
- if (current === previous) return;
148
- previous = current;
149
- }
150
- }
151
-
152
- /** Replace the content of a field with `text`. */
153
- async function fill(page, selector, text, options) {
154
- const element = await waitFor(page, selector, options);
155
- await element.click({ clickCount: 3 });
156
- await element.type(text);
157
- }
158
-
159
- /**
160
- * The error for something that went wrong on `page`: what the page shows, in
161
- * words for the log and as a screenshot to look at.
162
- */
163
- async function failure(page, message) {
164
- const prefix = `[dappress] ${message} on ${page.url()}`;
165
- const shown = await pageText(page);
166
- try {
167
- fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
168
- const file = path.join(SCREENSHOTS_DIR, `${Date.now()}.png`);
169
- await page.screenshot({ path: file });
170
- return new Error(`${prefix}. ${shown}Screenshot: ${file}`);
171
- } catch {
172
- return new Error(`${prefix}. ${shown}`.trim());
173
- }
174
- }
175
-
176
- // The visible text of the page, shortened: enough to tell which MetaMask
177
- // screen it is from the log alone, when the screenshot is out of reach
178
- async function pageText(page) {
179
- try {
180
- const text = await Promise.race([
181
- page.evaluate(() => document.body.innerText.replace(/\s+/g, ' ').trim()),
182
- sleep(2000).then(() => null),
183
- ]);
184
- return text ? `The page shows: "${text.length > 500 ? `${text.slice(0, 500)}…` : text}". ` : '';
185
- } catch {
186
- return '';
187
- }
188
- }
189
-
190
- function sleep(ms) {
191
- return new Promise((resolve) => setTimeout(resolve, ms));
192
- }
193
-
194
- module.exports = { describe, waitFor, isVisible, isGone, click, clickWhenEnabled, dispatchClick, fill, failure, sleep };