dappress 0.3.1 → 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 CHANGED
@@ -173,12 +173,15 @@ With `cache: true`, the profile under `~/.cache/dappress/profiles` holds the wal
173
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.
174
174
 
175
175
  ```bash
176
- npm run conformance # default MetaMask version
177
- npm run conformance -- 13.51.0 # a specific release
178
- DAPPRESS_HEADLESS=1 npm run conformance # without a browser window
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
179
180
  ```
180
181
 
181
- Each run writes `reports/metamask-<version>.json`. The GitHub workflow runs the suite daily against the latest MetaMask release that has no report yet.
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).
182
185
 
183
186
  ### Updating to a new MetaMask release
184
187
 
@@ -190,7 +193,7 @@ Each run writes `reports/metamask-<version>.json`. The GitHub workflow runs the
190
193
 
191
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.
192
195
 
193
- 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 covers both.
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.
194
197
 
195
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.
196
199
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dappress",
3
- "version": "0.3.1",
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": {
@@ -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
- module.exports = { findExtensionId, getHomePage, getConfirmationPage };
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
- scrollToBottom: testId('confirm-scroll-to-bottom'),
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 click(page, selectors.home.accountMenu);
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,10 +260,25 @@ 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 click(page, selectors.home.accountMenu);
263
+ await openAccountList(page);
259
264
  await selectAccount(page, name);
260
265
  }
261
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
+
262
282
  // Picking an account closes the list and shows it in the home header. As with
263
283
  // adding one, a lost click leaves the list open, so the account is pressed again.
264
284
  async function selectAccount(page, name) {
@@ -323,13 +343,10 @@ function approveNetworkChange(page, timeout) {
323
343
  async function pressAndWaitForDismissal(page, button, timeout) {
324
344
  await waitFor(page, button, { timeout });
325
345
  await dismissModal(page);
326
- if (await isVisible(page, selectors.confirmation.scrollToBottom, 500)) {
327
- await click(page, selectors.confirmation.scrollToBottom);
328
- }
329
346
  const logged = recordErrors(page);
330
347
  const request = page.url();
331
348
  try {
332
- await clickWhenEnabled(page, button);
349
+ await clickWhenEnabled(page, button, { whileDisabled: () => scrollContentToEnd(page) });
333
350
  for (let attempt = 0; attempt < 3; attempt++) {
334
351
  if (await isVisible(page, selectors.alert.acknowledge, 500)) await click(page, selectors.alert.acknowledge);
335
352
  if (await isGone(page, button, 3000)) return;
@@ -363,11 +380,47 @@ function recordErrors(page) {
363
380
  };
364
381
  }
365
382
 
366
- /** Close a modal shown over the confirmation (Escape, or its last button: "Cancel"). */
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
+ */
367
415
  async function dismissModal(page) {
368
416
  if (!(await isVisible(page, selectors.modal.content, 300))) return;
369
417
  await page.keyboard.press('Escape');
370
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
+ }
371
424
  await dispatchClick(page, selectors.modal.lastButton);
372
425
  }
373
426
 
@@ -55,21 +55,80 @@ async function isGone(page, selector, timeout = 1500) {
55
55
  }
56
56
 
57
57
  async function click(page, selector, options) {
58
- const element = await waitFor(page, selector, options);
59
- await waitForStill(element);
60
- await element.click();
58
+ await clickFresh(page, selector, options);
61
59
  }
62
60
 
63
- /** Wait until the element is both visible and enabled, then click it. */
64
- async function clickWhenEnabled(page, selector, { timeout = DEFAULT_TIMEOUT } = {}) {
65
- const element = await waitFor(page, selector, { timeout });
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
- while (await element.evaluate((el) => el.disabled)) {
68
- if (Date.now() > deadline) throw await failure(page, `"${describe(selector)}" stayed disabled for ${timeout}ms`);
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 waitForStill(element);
72
- await element.click();
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
- /** The error for something that went wrong on `page`, with a screenshot to look at. */
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 new Error(prefix);
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: [`--load-extension=${extensionDir}`, `--disable-extensions-except=${extensionDir}`, '--no-first-run'],
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
- await action(await getHomePage(browser, await findExtensionId(browser)));
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