dappress 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,7 +66,7 @@ const wallet: WalletSetup = {
66
66
  export default wallet;
67
67
  ```
68
68
 
69
- Keep the seed phrase out of the repository, in `cypress.env.json` (git-ignored):
69
+ Without a seed phrase, Dappress makes a new wallet for each run: enough to connect and sign. To test with a wallet of yours, one that holds test funds for instance, give its seed phrase and keep it out of the repository, in `cypress.env.json` (git-ignored):
70
70
 
71
71
  ```json
72
72
  {
@@ -82,7 +82,6 @@ env:
82
82
  DAPPRESS_SEED_PHRASE: ${{ secrets.DAPPRESS_SEED_PHRASE }}
83
83
  ```
84
84
 
85
- When neither is provided, Dappress uses the public Hardhat / Anvil development wallet. Give your test suite a seed phrase of its own: that one is shared with everybody.
86
85
 
87
86
  Add the command types to `tsconfig.json`:
88
87
 
@@ -96,12 +95,14 @@ Add the command types to `tsconfig.json`:
96
95
 
97
96
  JavaScript projects use the same files with a `.js` extension and `module.exports`.
98
97
 
99
- Run the tests in a headed browser that supports extensions:
98
+ Run the tests in a browser that supports extensions:
100
99
 
101
100
  ```bash
102
101
  npx cypress run --browser chrome-for-testing --headed
103
102
  ```
104
103
 
104
+ Without `--headed`, the run is headless and works the same, except for the wallet cache, which needs a headed browser.
105
+
105
106
  ## Usage
106
107
 
107
108
  ```ts
@@ -109,7 +110,7 @@ it('connects the wallet and signs in', () => {
109
110
  cy.visit('/');
110
111
  cy.contains('button', 'Connect wallet').click();
111
112
  cy.connectToDapp();
112
- cy.getAccountAddress().should('eq', '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266');
113
+ cy.getAccountAddress().should('match', /^0x[0-9a-f]{40}$/);
113
114
 
114
115
  cy.contains('button', 'Sign in').click();
115
116
  cy.confirmSignature();
@@ -153,17 +154,17 @@ Settings normally live in `cypress/wallet.setup.ts`. Secrets go in `cypress.env.
153
154
  | Option | Environment variable | Default |
154
155
  |---|---|---|
155
156
  | `metamaskVersion` | `DAPPRESS_METAMASK_VERSION` | `13.50.0`. The build is downloaded from MetaMask's GitHub releases on first run and cached in `~/.cache/dappress`. |
156
- | `seedPhrase` | `DAPPRESS_SEED_PHRASE` | `test test test test test test test test test test test junk`, the Hardhat / Anvil development wallet. Its first account is `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266`. |
157
+ | `seedPhrase` | `DAPPRESS_SEED_PHRASE` | None. Dappress makes a new wallet for each run, which nobody else knows and which is never written anywhere. |
157
158
  | `password` | `DAPPRESS_PASSWORD` | `Tester@1234`. It only protects the throwaway browser profile Cypress creates for each run. |
158
159
  | `network` | | None. The dapp stays on the network MetaMask starts on, Ethereum mainnet. |
159
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. |
160
161
  | `autoSetup` | | `true`. Dappress imports or unlocks the wallet before the first test of each spec. Set to `false` to call `cy.setupMetaMask()` yourself. |
161
- | `cache` | | `false`. The wallet is imported in every run, about fifteen seconds. With `true`, 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. |
162
163
  | `timeout` | | `20000` ms. The time allowed for MetaMask to display a request before a command fails. |
163
164
 
164
165
  ### Security
165
166
 
166
- Use a wallet dedicated to testing, funded on test networks only. The default seed phrase is public: anyone can spend from it, and its accounts can carry activity nobody controls. It is fine for a first run, not for a test suite you rely on. The seed phrase and password remain on the Node.js side: they are never exposed to the browser nor written to the Cypress command log. The wallet setup file contains no secret and can be committed.
167
+ When you give a seed phrase, make it one dedicated to testing, funded on test networks only. Avoid a phrase other people know, such as the Hardhat or Anvil development mnemonic: MetaMask restores the accounts others saved for it, so the wallet is not the one you expect. The seed phrase and password remain on the Node.js side: they are never exposed to the browser nor written to the Cypress command log. The wallet setup file contains no secret and can be committed.
167
168
 
168
169
  With `cache: true`, the profile under `~/.cache/dappress/profiles` holds the wallet's vault, encrypted by MetaMask with the password. Treat that directory like the seed phrase: keep it on the machine, and do not store it in a CI cache that other people or workflows can restore.
169
170
 
@@ -172,8 +173,9 @@ With `cache: true`, the profile under `~/.cache/dappress/profiles` holds the wal
172
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.
173
174
 
174
175
  ```bash
175
- npm run conformance # default MetaMask version
176
- npm run conformance -- 13.51.0 # a specific release
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
177
179
  ```
178
180
 
179
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.
@@ -190,6 +192,8 @@ Cypress executes tests inside the dapp's tab and has no access to the extension.
190
192
 
191
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.
192
194
 
195
+ 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
+
193
197
  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.
194
198
 
195
199
  ```
@@ -214,11 +218,11 @@ Selectors are MetaMask's `data-testid` attributes, each with the button's Englis
214
218
  - **Cypress exits immediately with `MODULE_NOT_FOUND`.** The terminal sets `ELECTRON_RUN_AS_NODE=1`, as some IDEs do. Run `env -u ELECTRON_RUN_AS_NODE npx cypress run …`.
215
219
  - **"MetaMask showed no confirmation".** The dapp sent no request, or sent it on a network whose RPC endpoint is unreachable.
216
220
  - **Adding or importing an account never finishes.** `chromeWebSecurity: false` is set in the Cypress config. MetaMask then cannot start its snaps, which its account screens wait for. Leave Chrome's web security on, the Cypress default.
217
- - **The wallet shows accounts you did not create.** The seed phrase is used elsewhere, and MetaMask restored what its cloud holds for it. This happens with the public default phrase. Use a phrase made for your tests.
221
+ - **The wallet shows accounts you did not create.** The seed phrase is used elsewhere, and MetaMask restored what its cloud holds for it. This happens with well-known development mnemonics. Use a phrase made for your tests, or none.
218
222
 
219
223
  ## Status
220
224
 
221
- 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. Headless execution is not validated yet.
225
+ 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.
222
226
 
223
227
  ## License
224
228
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dappress",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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",
@@ -57,6 +57,7 @@
57
57
  "cypress": ">=13.6.0"
58
58
  },
59
59
  "dependencies": {
60
+ "@scure/bip39": "^1.6.0",
60
61
  "extract-zip": "^2.0.1",
61
62
  "puppeteer-core": "^24.43.1"
62
63
  },
package/src/config.js CHANGED
@@ -1,11 +1,14 @@
1
1
  const fs = require('node:fs');
2
2
  const os = require('node:os');
3
3
  const path = require('node:path');
4
+ const { generateMnemonic } = require('@scure/bip39');
5
+ const { wordlist } = require('@scure/bip39/wordlists/english');
4
6
 
5
7
  const DEFAULTS = {
6
8
  metamaskVersion: '13.50.0',
7
- // The Hardhat / Anvil development mnemonic. Account 0 is 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266.
8
- seedPhrase: 'test test test test test test test test test test test junk',
9
+ // None: a new wallet is made for each run. A phrase known to others is not
10
+ // blank, since MetaMask restores the accounts saved for it elsewhere.
11
+ seedPhrase: null,
9
12
  password: 'Tester@1234',
10
13
  // The network cy.connectToDapp() moves the dapp onto, as a wallet_addEthereumChain parameter; none by default
11
14
  network: null,
@@ -42,9 +45,20 @@ function resolveOptions(userOptions = {}, cypressConfig = {}) {
42
45
  for (const [key, value] of Object.entries(fromEnv)) {
43
46
  if (value) options[key] = value;
44
47
  }
48
+ if (!options.seedPhrase) useNewWallet(options);
45
49
  return options;
46
50
  }
47
51
 
52
+ /** No seed phrase was given: make one for this run. It is never written anywhere. */
53
+ function useNewWallet(options) {
54
+ options.seedPhrase = generateMnemonic(wordlist);
55
+ console.log('[dappress] No seed phrase configured: using a new wallet for this run');
56
+ if (options.cache) {
57
+ console.warn('[dappress] The profile cache needs a seed phrase of your own: importing the wallet in this run instead');
58
+ options.cache = false;
59
+ }
60
+ }
61
+
48
62
  /** The project's wallet setup file: { seedPhrase?, network? }. */
49
63
  function loadWalletSetup(projectRoot = process.cwd()) {
50
64
  const file = WALLET_SETUP_FILES.map((name) => path.join(projectRoot, name)).find((candidate) => fs.existsSync(candidate));
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 (options.cache && browser) await prepareProfile({ browserPath: browser.path, extensionDir: await prepareExtension(options), options });
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 (options.cache) {
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.js CHANGED
@@ -214,23 +214,40 @@ async function reloadHome(page) {
214
214
  await page.goto(homeUrl);
215
215
  }
216
216
 
217
- /** Add an account to the wallet and select it. Yields its name, "Account N". */
217
+ /**
218
+ * Add an account to the wallet and select it. Yields its name, "Account N".
219
+ * A click that lands while the list is still settling is lost, so the button
220
+ * is pressed again when no account shows up.
221
+ */
218
222
  async function addAccount(page) {
219
223
  await click(page, selectors.home.accountMenu);
220
224
  const before = await accountNames(page);
221
- await clickWhenEnabled(page, selectors.accounts.add, { timeout: 30000 });
222
- const deadline = Date.now() + 30000;
223
- while (Date.now() < deadline) {
224
- const [added] = (await accountNames(page)).filter((name) => !before.includes(name));
225
+ for (let attempt = 0; attempt < 3; attempt++) {
226
+ const added = (await newAccount(page, before, 0)) || (await pressAddAccount(page, before));
225
227
  if (added) {
226
228
  await selectAccount(page, added);
227
229
  return added;
228
230
  }
229
- await sleep(250);
230
231
  }
231
232
  throw await failure(page, 'MetaMask added no account');
232
233
  }
233
234
 
235
+ async function pressAddAccount(page, before) {
236
+ await clickWhenEnabled(page, selectors.accounts.add, { timeout: 30000 });
237
+ return newAccount(page, before, 15000);
238
+ }
239
+
240
+ /** The name of an account that wasn't in `before`, within `timeout` ms. */
241
+ async function newAccount(page, before, timeout) {
242
+ const deadline = Date.now() + timeout;
243
+ do {
244
+ const [added] = (await accountNames(page)).filter((name) => !before.includes(name));
245
+ if (added) return added;
246
+ await sleep(250);
247
+ } while (Date.now() < deadline);
248
+ return null;
249
+ }
250
+
234
251
  async function accountNames(page) {
235
252
  await waitFor(page, selectors.accounts.name);
236
253
  return page.$$eval(selectors.accounts.name, (cells) => cells.map((cell) => cell.textContent.trim()));
@@ -242,15 +259,23 @@ async function switchAccount(page, name) {
242
259
  await selectAccount(page, name);
243
260
  }
244
261
 
245
- // Picking an account closes the list and shows it in the home header
262
+ // Picking an account closes the list and shows it in the home header. As with
263
+ // adding one, a lost click leaves the list open, so the account is pressed again.
246
264
  async function selectAccount(page, name) {
247
- await click(page, selectors.accounts.cell(name));
248
- const deadline = Date.now() + 15000;
265
+ for (let attempt = 0; attempt < 3; attempt++) {
266
+ await click(page, selectors.accounts.cell(name));
267
+ if (await isSelected(page, name, 5000)) return;
268
+ }
269
+ throw await failure(page, `MetaMask did not select "${name}"`);
270
+ }
271
+
272
+ async function isSelected(page, name, timeout) {
273
+ const deadline = Date.now() + timeout;
249
274
  while (Date.now() < deadline) {
250
- if ((await selectedAccount(page)) === name) return;
275
+ if ((await selectedAccount(page)) === name) return true;
251
276
  await sleep(250);
252
277
  }
253
- throw await failure(page, `MetaMask did not select "${name}"`);
278
+ return false;
254
279
  }
255
280
 
256
281
  async function selectedAccount(page) {
package/types/index.d.ts CHANGED
@@ -12,7 +12,10 @@ export interface Network {
12
12
 
13
13
  /** What cypress/wallet.setup.ts exports. */
14
14
  export interface WalletSetup {
15
- /** Prefer DAPPRESS_SEED_PHRASE in cypress.env.json or the environment: this file is usually committed. */
15
+ /**
16
+ * The wallet to import. Without one, Dappress makes a new wallet for each run.
17
+ * Prefer DAPPRESS_SEED_PHRASE in cypress.env.json or the environment: this file is usually committed.
18
+ */
16
19
  seedPhrase?: string;
17
20
  /** The network the dapp is moved onto by cy.connectToDapp(). */
18
21
  network?: Network;
@@ -30,7 +33,7 @@ export interface DappressOptions extends WalletSetup {
30
33
  backupAndSync?: boolean;
31
34
  /** Run cy.setupMetaMask() before the first test of each spec. Default: true. */
32
35
  autoSetup?: boolean;
33
- /** 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. */
34
37
  cache?: boolean;
35
38
  /** Time allowed for MetaMask to display a request, in ms. Default: 20000. */
36
39
  timeout?: number;