dappress 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -7
- package/package.json +3 -1
- package/src/index.js +10 -2
- package/src/metamask-pages.js +26 -2
- package/src/metamask.js +74 -13
- package/src/page-helpers.js +90 -13
- package/src/profile.js +25 -2
- package/types/index.d.ts +1 -1
package/README.md
CHANGED
|
@@ -95,12 +95,14 @@ Add the command types to `tsconfig.json`:
|
|
|
95
95
|
|
|
96
96
|
JavaScript projects use the same files with a `.js` extension and `module.exports`.
|
|
97
97
|
|
|
98
|
-
Run the tests in a
|
|
98
|
+
Run the tests in a browser that supports extensions:
|
|
99
99
|
|
|
100
100
|
```bash
|
|
101
101
|
npx cypress run --browser chrome-for-testing --headed
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
+
Without `--headed`, the run is headless and works the same, except for the wallet cache, which needs a headed browser.
|
|
105
|
+
|
|
104
106
|
## Usage
|
|
105
107
|
|
|
106
108
|
```ts
|
|
@@ -157,7 +159,7 @@ Settings normally live in `cypress/wallet.setup.ts`. Secrets go in `cypress.env.
|
|
|
157
159
|
| `network` | | None. The dapp stays on the network MetaMask starts on, Ethereum mainnet. |
|
|
158
160
|
| `backupAndSync` | | `false`. Dappress turns off MetaMask's backup and sync while importing the wallet, so that an account a test adds is not restored by the next import of the same seed phrase. With `true`, MetaMask keeps saving and restoring the accounts and contacts of that phrase. |
|
|
159
161
|
| `autoSetup` | | `true`. Dappress imports or unlocks the wallet before the first test of each spec. Set to `false` to call `cy.setupMetaMask()` yourself. |
|
|
160
|
-
| `cache` | | `false`. The wallet is imported in every run, about fifteen seconds. With `true`, and a seed phrase of your own, it is imported once, in a browser Dappress opens before the run, and the resulting profile is reused by later runs, which then start by unlocking the wallet. |
|
|
162
|
+
| `cache` | | `false`. The wallet is imported in every run, about fifteen seconds. With `true`, and a seed phrase of your own, it is imported once, in a browser Dappress opens before the run, and the resulting profile is reused by later runs, which then start by unlocking the wallet. Headed runs only: a headless run imports the wallet as usual. |
|
|
161
163
|
| `timeout` | | `20000` ms. The time allowed for MetaMask to display a request before a command fails. |
|
|
162
164
|
|
|
163
165
|
### Security
|
|
@@ -171,11 +173,15 @@ With `cache: true`, the profile under `~/.cache/dappress/profiles` holds the wal
|
|
|
171
173
|
The suite runs one test per command against [MetaMask's test dapp](https://metamask.github.io/test-dapp/). Each run makes a wallet nobody has used, with Foundry's `cast`, and starts a local [Anvil](https://getfoundry.sh) node that funds it. It requires Chrome for Testing and Foundry.
|
|
172
174
|
|
|
173
175
|
```bash
|
|
174
|
-
npm run conformance
|
|
175
|
-
npm run conformance -- 13.51.0
|
|
176
|
+
npm run conformance # default MetaMask version, side panel
|
|
177
|
+
npm run conformance -- 13.51.0 # a specific release
|
|
178
|
+
DAPPRESS_MODE=headless npm run conformance # without a browser window
|
|
179
|
+
DAPPRESS_MODE=popup npm run conformance # wallet from the profile cache: requests in the popup
|
|
176
180
|
```
|
|
177
181
|
|
|
178
|
-
Each run writes `reports/metamask-<version>.json
|
|
182
|
+
A mode is where MetaMask shows the dapp's requests: `sidepanel` (the default), `headless`, or `popup`. Each run writes `reports/metamask-<version>-<mode>.json`, and `npm run matrix -- reports .` turns the reports into `MATRIX.md` and `badge.json`, a [shields.io endpoint](https://shields.io/badges/endpoint-badge) for the latest version.
|
|
183
|
+
|
|
184
|
+
The GitHub workflow runs the suite daily, in the three modes, against the latest MetaMask release that has no report yet, and publishes the reports, the matrix and the badge on the [`conformance-reports`](https://github.com/blassaut/dappress/tree/conformance-reports) branch: [MATRIX.md](https://github.com/blassaut/dappress/blob/conformance-reports/MATRIX.md).
|
|
179
185
|
|
|
180
186
|
### Updating to a new MetaMask release
|
|
181
187
|
|
|
@@ -187,7 +193,9 @@ Each run writes `reports/metamask-<version>.json`. The GitHub workflow runs the
|
|
|
187
193
|
|
|
188
194
|
Cypress executes tests inside the dapp's tab and has no access to the extension. For each command, Dappress connects Puppeteer to the browser Cypress launched, through the debugging URL Cypress provides, locates the MetaMask page displaying the request and interacts with it.
|
|
189
195
|
|
|
190
|
-
MetaMask displays requests in its side panel when the panel is open, and in its popup window otherwise. Without the cache, the import of the wallet ends by opening the side panel, so requests appear there. With the cache, the wallet was imported in another browser, the panel is closed, and requests appear in the popup. Dappress handles both, and the conformance suite
|
|
196
|
+
MetaMask displays requests in its side panel when the panel is open, and in its popup window otherwise. Without the cache, the import of the wallet ends by opening the side panel, so requests appear there. With the cache, the wallet was imported in another browser, the panel is closed, and requests appear in the popup. Dappress handles both, and the conformance suite runs in both: its `popup` mode turns the cache on.
|
|
197
|
+
|
|
198
|
+
Cypress leaves extensions out of a headless launch, so Dappress passes MetaMask to Chrome itself. Headless Chrome displays the side panel but does not open the popup window, which is why the cache, whose requests appear in the popup, is limited to headed runs.
|
|
191
199
|
|
|
192
200
|
With `cache: true`, Dappress imports the wallet once before the run, in a browser of its own, and keeps the profile; MetaMask's storage is copied from it into the profile Cypress is about to launch, so each run starts from a wallet that has never seen the dapp.
|
|
193
201
|
|
|
@@ -217,7 +225,7 @@ Selectors are MetaMask's `data-testid` attributes, each with the button's Englis
|
|
|
217
225
|
|
|
218
226
|
## Status
|
|
219
227
|
|
|
220
|
-
Verified with MetaMask 13.49.0 and 13.50.0, Cypress 16 and Chrome for Testing 154
|
|
228
|
+
Verified with MetaMask 13.49.0 and 13.50.0, Cypress 16 and Chrome for Testing 154: in headed mode on macOS and on GitHub's Linux runners, and in headless mode on macOS.
|
|
221
229
|
|
|
222
230
|
## License
|
|
223
231
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dappress",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "MetaMask automation for Cypress: load the real extension, import a test wallet, answer dapp requests with cy.* commands. Tracks the latest MetaMask release.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Benjamin Lassaut",
|
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
53
|
"conformance": "node scripts/conformance.js",
|
|
54
|
+
"matrix": "node scripts/matrix.js",
|
|
55
|
+
"test": "node --test scripts/*.test.js",
|
|
54
56
|
"check-types": "tsc -p tsconfig.json"
|
|
55
57
|
},
|
|
56
58
|
"peerDependencies": {
|
package/src/index.js
CHANGED
|
@@ -20,9 +20,12 @@ function configureDappress(on, config, userOptions = {}) {
|
|
|
20
20
|
console.warn('[dappress] chromeWebSecurity is off: MetaMask cannot start its snaps, so adding or importing an account will hang');
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// A cached wallet gets its requests in MetaMask's popup window, which headless Chrome doesn't open
|
|
24
|
+
const usesCache = (browser) => options.cache && !browser.isHeadless;
|
|
25
|
+
|
|
23
26
|
// Build the wallet profile before the run, out of the time Cypress allows a browser to come up
|
|
24
27
|
on('before:run', async ({ browser }) => {
|
|
25
|
-
if (
|
|
28
|
+
if (browser && usesCache(browser)) await prepareProfile({ browserPath: browser.path, extensionDir: await prepareExtension(options), options });
|
|
26
29
|
});
|
|
27
30
|
|
|
28
31
|
on('before:browser:launch', async (browser, launchOptions) => {
|
|
@@ -31,7 +34,12 @@ function configureDappress(on, config, userOptions = {}) {
|
|
|
31
34
|
}
|
|
32
35
|
const extensionDir = await prepareExtension(options);
|
|
33
36
|
launchOptions.extensions.push(extensionDir);
|
|
34
|
-
if (
|
|
37
|
+
if (browser.isHeadless) {
|
|
38
|
+
// Cypress leaves extensions out of a headless launch, so ask Chrome directly
|
|
39
|
+
launchOptions.args.push(`--load-extension=${extensionDir}`);
|
|
40
|
+
if (options.cache) console.warn('[dappress] The wallet cache needs a headed browser: importing the wallet in this run instead');
|
|
41
|
+
}
|
|
42
|
+
if (usesCache(browser)) {
|
|
35
43
|
const profileDir = await prepareProfile({ browserPath: browser.path, extensionDir, options });
|
|
36
44
|
await installProfile(profileDir, browser, config.isTextTerminal);
|
|
37
45
|
}
|
package/src/metamask-pages.js
CHANGED
|
@@ -74,7 +74,7 @@ async function getConfirmationPage(browser, extensionId, timeout) {
|
|
|
74
74
|
while (Date.now() < deadline) {
|
|
75
75
|
for (const pathname of CONFIRMATION_PATHS) {
|
|
76
76
|
const [target] = pagesOf(browser, extensionId, pathname).filter((candidate) => !isHomeRoute(candidate.url()));
|
|
77
|
-
if (target) return target.page();
|
|
77
|
+
if (target) return pathname === '/notification.html' ? withUsableWindow(await target.page()) : target.page();
|
|
78
78
|
}
|
|
79
79
|
await sleep(250);
|
|
80
80
|
}
|
|
@@ -88,4 +88,28 @@ async function getConfirmationPage(browser, extensionId, timeout) {
|
|
|
88
88
|
);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
// MetaMask asks Chrome for a 400x620 popup. Under Xvfb on a GitHub runner the
|
|
92
|
+
// window it gets is 1x1: nothing is laid out, the pane of a confirmation that
|
|
93
|
+
// must be read to the end has no height, so it can never be scrolled there and
|
|
94
|
+
// its Confirm button stays disabled. The popup is given its size back first.
|
|
95
|
+
const POPUP = { width: 400, height: 620 };
|
|
96
|
+
|
|
97
|
+
async function withUsableWindow(page) {
|
|
98
|
+
const session = await page.target().createCDPSession();
|
|
99
|
+
try {
|
|
100
|
+
const { windowId, bounds } = await session.send('Browser.getWindowForTarget');
|
|
101
|
+
if (bounds.windowState === 'normal' && bounds.width >= 200 && bounds.height >= 200) return page;
|
|
102
|
+
console.log(`[dappress] MetaMask's popup is ${bounds.width}x${bounds.height} (${bounds.windowState}); resizing it to ${POPUP.width}x${POPUP.height}`);
|
|
103
|
+
if (bounds.windowState !== 'normal') await session.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });
|
|
104
|
+
await session.send('Browser.setWindowBounds', { windowId, bounds: { ...POPUP } });
|
|
105
|
+
// Let the page lay itself out at its new size before anything is looked for in it
|
|
106
|
+
await sleep(300);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.warn(`[dappress] Could not check the popup window's size: ${error.message}`);
|
|
109
|
+
} finally {
|
|
110
|
+
await session.detach().catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
return page;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { findExtensionId, getHomePage, getConfirmationPage, withUsableWindow };
|
package/src/metamask.js
CHANGED
|
@@ -66,7 +66,10 @@ const selectors = {
|
|
|
66
66
|
confirmation: {
|
|
67
67
|
confirm: [testId('confirm-footer-button'), buttonText('Confirm'), buttonText('Approve')],
|
|
68
68
|
cancel: [testId('confirm-footer-cancel-button'), buttonText('Cancel')],
|
|
69
|
-
|
|
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"]',
|
|
70
73
|
},
|
|
71
74
|
// The older footer, still used by the permission update a network switch
|
|
72
75
|
// asks for, by "Add suggested tokens", and by template confirmations
|
|
@@ -83,6 +86,8 @@ const selectors = {
|
|
|
83
86
|
// sends many requests in a row, and it swallows the clicks
|
|
84
87
|
modal: {
|
|
85
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"]',
|
|
86
91
|
lastButton: 'xpath/(.//div[contains(@class, "mm-modal-content")]//button)[last()]',
|
|
87
92
|
},
|
|
88
93
|
};
|
|
@@ -220,7 +225,7 @@ async function reloadHome(page) {
|
|
|
220
225
|
* is pressed again when no account shows up.
|
|
221
226
|
*/
|
|
222
227
|
async function addAccount(page) {
|
|
223
|
-
await
|
|
228
|
+
await openAccountList(page);
|
|
224
229
|
const before = await accountNames(page);
|
|
225
230
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
226
231
|
const added = (await newAccount(page, before, 0)) || (await pressAddAccount(page, before));
|
|
@@ -255,19 +260,42 @@ async function accountNames(page) {
|
|
|
255
260
|
|
|
256
261
|
/** Make `name` the selected account of the wallet. */
|
|
257
262
|
async function switchAccount(page, name) {
|
|
258
|
-
await
|
|
263
|
+
await openAccountList(page);
|
|
259
264
|
await selectAccount(page, name);
|
|
260
265
|
}
|
|
261
266
|
|
|
262
|
-
//
|
|
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.
|
|
263
284
|
async function selectAccount(page, name) {
|
|
264
|
-
|
|
265
|
-
|
|
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;
|
|
266
294
|
while (Date.now() < deadline) {
|
|
267
|
-
if ((await selectedAccount(page)) === name) return;
|
|
295
|
+
if ((await selectedAccount(page)) === name) return true;
|
|
268
296
|
await sleep(250);
|
|
269
297
|
}
|
|
270
|
-
|
|
298
|
+
return false;
|
|
271
299
|
}
|
|
272
300
|
|
|
273
301
|
async function selectedAccount(page) {
|
|
@@ -315,13 +343,10 @@ function approveNetworkChange(page, timeout) {
|
|
|
315
343
|
async function pressAndWaitForDismissal(page, button, timeout) {
|
|
316
344
|
await waitFor(page, button, { timeout });
|
|
317
345
|
await dismissModal(page);
|
|
318
|
-
if (await isVisible(page, selectors.confirmation.scrollToBottom, 500)) {
|
|
319
|
-
await click(page, selectors.confirmation.scrollToBottom);
|
|
320
|
-
}
|
|
321
346
|
const logged = recordErrors(page);
|
|
322
347
|
const request = page.url();
|
|
323
348
|
try {
|
|
324
|
-
await clickWhenEnabled(page, button);
|
|
349
|
+
await clickWhenEnabled(page, button, { whileDisabled: () => scrollContentToEnd(page) });
|
|
325
350
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
326
351
|
if (await isVisible(page, selectors.alert.acknowledge, 500)) await click(page, selectors.alert.acknowledge);
|
|
327
352
|
if (await isGone(page, button, 3000)) return;
|
|
@@ -355,11 +380,47 @@ function recordErrors(page) {
|
|
|
355
380
|
};
|
|
356
381
|
}
|
|
357
382
|
|
|
358
|
-
/**
|
|
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
|
+
*/
|
|
359
415
|
async function dismissModal(page) {
|
|
360
416
|
if (!(await isVisible(page, selectors.modal.content, 300))) return;
|
|
361
417
|
await page.keyboard.press('Escape');
|
|
362
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
|
+
}
|
|
363
424
|
await dispatchClick(page, selectors.modal.lastButton);
|
|
364
425
|
}
|
|
365
426
|
|
package/src/page-helpers.js
CHANGED
|
@@ -55,21 +55,80 @@ async function isGone(page, selector, timeout = 1500) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
async function click(page, selector, options) {
|
|
58
|
-
|
|
59
|
-
await waitForStill(element);
|
|
60
|
-
await element.click();
|
|
58
|
+
await clickFresh(page, selector, options);
|
|
61
59
|
}
|
|
62
60
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
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 } = {}) {
|
|
66
67
|
const deadline = Date.now() + timeout;
|
|
67
|
-
|
|
68
|
-
|
|
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
|
+
}
|
|
69
80
|
await sleep(100);
|
|
70
81
|
}
|
|
71
|
-
await
|
|
72
|
-
|
|
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
|
+
}
|
|
73
132
|
}
|
|
74
133
|
|
|
75
134
|
/** Click by dispatching the event on the element itself, for buttons something may cover. */
|
|
@@ -97,16 +156,34 @@ async function fill(page, selector, text, options) {
|
|
|
97
156
|
await element.type(text);
|
|
98
157
|
}
|
|
99
158
|
|
|
100
|
-
/**
|
|
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
|
+
*/
|
|
101
163
|
async function failure(page, message) {
|
|
102
164
|
const prefix = `[dappress] ${message} on ${page.url()}`;
|
|
165
|
+
const shown = await pageText(page);
|
|
103
166
|
try {
|
|
104
167
|
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
|
105
168
|
const file = path.join(SCREENSHOTS_DIR, `${Date.now()}.png`);
|
|
106
169
|
await page.screenshot({ path: file });
|
|
107
|
-
return new Error(`${prefix}. Screenshot: ${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}". ` : '';
|
|
108
185
|
} catch {
|
|
109
|
-
return
|
|
186
|
+
return '';
|
|
110
187
|
}
|
|
111
188
|
}
|
|
112
189
|
|
package/src/profile.js
CHANGED
|
@@ -31,7 +31,14 @@ async function buildProfile(dir, { browserPath, extensionDir, options }) {
|
|
|
31
31
|
executablePath: browserPath,
|
|
32
32
|
headless: false,
|
|
33
33
|
userDataDir: dir,
|
|
34
|
-
args: [
|
|
34
|
+
args: [
|
|
35
|
+
`--load-extension=${extensionDir}`,
|
|
36
|
+
`--disable-extensions-except=${extensionDir}`,
|
|
37
|
+
'--no-first-run',
|
|
38
|
+
// No sandbox on Linux: Ubuntu 23.10+ (GitHub's ubuntu-latest) blocks the
|
|
39
|
+
// user namespaces it needs, and this browser only imports a test wallet
|
|
40
|
+
...(process.platform === 'linux' ? ['--no-sandbox'] : []),
|
|
41
|
+
],
|
|
35
42
|
});
|
|
36
43
|
|
|
37
44
|
await withMetaMask(launch, (home) => metamask.onboard(home, options));
|
|
@@ -49,13 +56,29 @@ async function buildProfile(dir, { browserPath, extensionDir, options }) {
|
|
|
49
56
|
async function withMetaMask(launch, action) {
|
|
50
57
|
const browser = await launch();
|
|
51
58
|
try {
|
|
52
|
-
|
|
59
|
+
const home = await getHomePage(browser, await findExtensionId(browser));
|
|
60
|
+
await keepInFront(browser, home);
|
|
61
|
+
await action(home);
|
|
53
62
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
54
63
|
} finally {
|
|
55
64
|
await browser.close();
|
|
56
65
|
}
|
|
57
66
|
}
|
|
58
67
|
|
|
68
|
+
// Chrome stops rendering a tab that isn't the active one, and Puppeteer waits
|
|
69
|
+
// on rendering to find an element or to click it: a hidden tab never answers.
|
|
70
|
+
// So the home page is made the active tab, and again whenever MetaMask opens
|
|
71
|
+
// a tab of its own, as it does for its onboarding right after the install.
|
|
72
|
+
async function keepInFront(browser, home) {
|
|
73
|
+
const toFront = () => home.bringToFront().catch(() => {});
|
|
74
|
+
await toFront();
|
|
75
|
+
browser.on('targetcreated', (target) => {
|
|
76
|
+
if (target.type() !== 'page') return;
|
|
77
|
+
toFront();
|
|
78
|
+
setTimeout(toFront, 500);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
59
82
|
// Where Cypress is about to create the browser profile:
|
|
60
83
|
// <app data>/Cypress/cy/<env>/browsers/<name>-<channel>/run-<pid of the Cypress process>,
|
|
61
84
|
// or .../interactive in open mode. The pid is our parent's: Cypress runs
|
package/types/index.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export interface DappressOptions extends WalletSetup {
|
|
|
33
33
|
backupAndSync?: boolean;
|
|
34
34
|
/** Run cy.setupMetaMask() before the first test of each spec. Default: true. */
|
|
35
35
|
autoSetup?: boolean;
|
|
36
|
-
/** Import the wallet once and reuse the profile across runs. Keeps the vault under cacheDir. Default: false. */
|
|
36
|
+
/** Import the wallet once and reuse the profile across runs. Keeps the vault under cacheDir. Headed runs only. Default: false. */
|
|
37
37
|
cache?: boolean;
|
|
38
38
|
/** Time allowed for MetaMask to display a request, in ms. Default: 20000. */
|
|
39
39
|
timeout?: number;
|