dappress 0.5.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ What changes for the users of Dappress, version by version. The format follows [
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.0] - 2026-10-05
8
+
9
+ ### Fixed
10
+
11
+ - The peer dependency on Cypress asks for 15.10 or later, the first version with `Cypress.expose()`, which the commands read their options with. Earlier versions were accepted at install, then failed as soon as the support file loaded.
12
+ - A command sent right after another was answered in the popup no longer fails on "Timed out waiting for confirm-footer-button". The popup closes a moment after its button goes, and the next command could find it, still listed on the request just answered, then lose it. A decision now waits for the popup to close, and a confirmation that closes before it is acted on is looked for again.
13
+
14
+ ## [0.5.1] - 2026-10-05
15
+
16
+ ### Changed
17
+
18
+ - The README is shorter and keeps to what using Dappress needs. What contributors need moves to `CONTRIBUTING.md`.
19
+
7
20
  ## [0.5.0] - 2026-10-03
8
21
 
9
22
  ### Changed
@@ -79,7 +92,9 @@ What changes for the users of Dappress, version by version. The format follows [
79
92
  - The wallet setup file, `cypress/wallet.setup.ts`, and the opt-in profile cache.
80
93
  - TypeScript declarations.
81
94
 
82
- [Unreleased]: https://github.com/blassaut/dappress/compare/v0.5.0...HEAD
95
+ [Unreleased]: https://github.com/blassaut/dappress/compare/v0.6.0...HEAD
96
+ [0.6.0]: https://github.com/blassaut/dappress/compare/v0.5.1...v0.6.0
97
+ [0.5.1]: https://github.com/blassaut/dappress/compare/v0.5.0...v0.5.1
83
98
  [0.5.0]: https://github.com/blassaut/dappress/compare/v0.4.0...v0.5.0
84
99
  [0.4.0]: https://github.com/blassaut/dappress/compare/v0.3.3...v0.4.0
85
100
  [0.3.3]: https://github.com/blassaut/dappress/compare/v0.3.2...v0.3.3
package/README.md CHANGED
@@ -4,27 +4,41 @@
4
4
 
5
5
  [![MetaMask conformance](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/blassaut/dappress/conformance-reports/badge.json)](https://github.com/blassaut/dappress/blob/conformance-reports/MATRIX.md)
6
6
 
7
- Dappress loads the MetaMask browser extension into the browser Cypress launches, imports a test wallet, and exposes `cy.*` commands that answer the requests a dapp sends to the wallet: connection, signatures, transactions, network changes.
7
+ Your dapp asks MetaMask to connect, sign or send a transaction, and a real user clicks a button in the wallet. Dappress clicks it for you. It loads the real MetaMask extension into the browser Cypress launches, imports a test wallet, and gives you `cy.*` commands such as `cy.connectToDapp()` or `cy.confirmTransaction()`.
8
8
 
9
- - **Current MetaMask.** The extension version is a configuration value. Each release is verified by a conformance suite, with one test per command.
10
- - **Cypress native.** No second browser, no proxy. Dappress drives MetaMask through Puppeteer, connected to the browser Cypress already runs. Typed commands for TypeScript projects.
11
- - **Resilient selectors.** Every selector comes from MetaMask's own end-to-end test suite and carries a fallback.
9
+ - **The latest MetaMask.** You choose the extension version. Every MetaMask release is tested daily, one test per command: see the [conformance matrix](https://github.com/blassaut/dappress/blob/conformance-reports/MATRIX.md).
10
+ - **Plain Cypress.** No second browser, no proxy. Commands are typed for TypeScript.
11
+ - **Stable selectors.** They come from MetaMask's own end-to-end tests, each with a fallback.
12
+
13
+ ## Contents
14
+
15
+ - [Requirements](#requirements)
16
+ - [Quick start](#quick-start)
17
+ - [Commands](#commands)
18
+ - [Configuration](#configuration)
19
+ - [Security](#security)
20
+ - [Troubleshooting](#troubleshooting)
21
+ - [Contributing](#contributing)
12
22
 
13
23
  ## Requirements
14
24
 
15
- | Dependency | Version |
16
- |---|---|
17
- | Cypress | 13.6 or later |
18
- | Node.js | 20 or later |
19
- | Browser | Chrome for Testing, Chromium or Electron. Google Chrome 137 and later cannot load extensions. |
25
+ | Dependency | Version |
26
+ | ---------- | ----------------------------------------------------------------------------- |
27
+ | Cypress | 15.10 or later |
28
+ | Node.js | 20 or later |
29
+ | Browser | Chrome for Testing, Chromium or Electron. Not Google Chrome: since version 137 it no longer loads extensions. |
30
+
31
+ Tested on macOS and on GitHub's Linux runners.
20
32
 
21
- ## Installation
33
+ ## Quick start
22
34
 
23
- ```bash
35
+ ### 1. Install
36
+
37
+ ```sh
24
38
  npm install --save-dev dappress
25
39
  ```
26
40
 
27
- Register the plugin in the Cypress configuration:
41
+ ### 2. Register the plugin
28
42
 
29
43
  ```ts
30
44
  // cypress.config.ts
@@ -34,7 +48,7 @@ import { configureDappress } from 'dappress';
34
48
  export default defineConfig({
35
49
  e2e: {
36
50
  baseUrl: 'http://localhost:3000',
37
- testIsolation: false, // the wallet state is shared across tests
51
+ testIsolation: false,
38
52
  setupNodeEvents(on, config) {
39
53
  return configureDappress(on, config);
40
54
  },
@@ -42,21 +56,36 @@ export default defineConfig({
42
56
  });
43
57
  ```
44
58
 
45
- Load the commands in the support file:
59
+ `testIsolation: false` is required. The tests of a spec share one wallet, and Cypress must not reset the page between them. Each test starts where the previous one ended, so write the tests of a spec to run in order.
60
+
61
+ ### 3. Load the commands
46
62
 
47
63
  ```ts
48
64
  // cypress/support/e2e.ts
49
65
  import 'dappress/support';
50
66
  ```
51
67
 
52
- Describe the test wallet:
68
+ ```json
69
+ // tsconfig.json
70
+ {
71
+ "compilerOptions": {
72
+ "types": ["cypress", "dappress/support"]
73
+ }
74
+ }
75
+ ```
76
+
77
+ JavaScript projects use the same files with a `.js` extension and `module.exports`, and skip `tsconfig.json`.
78
+
79
+ ### 4. Choose the network
80
+
81
+ Dappress reads `cypress/wallet.setup.ts` (or `.js`) by itself. It holds no secret, so commit it.
53
82
 
54
83
  ```ts
55
84
  // cypress/wallet.setup.ts
56
85
  import type { WalletSetup } from 'dappress';
57
86
 
58
87
  const wallet: WalletSetup = {
59
- // The network the dapp is moved onto when it connects
88
+ // The network cy.connectToDapp() moves the dapp onto
60
89
  network: {
61
90
  chainId: '0x88bb0',
62
91
  chainName: 'Hoodi',
@@ -68,46 +97,20 @@ const wallet: WalletSetup = {
68
97
  export default wallet;
69
98
  ```
70
99
 
71
- 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):
100
+ This step is optional. Without a network, the dapp stays on Ethereum mainnet, where MetaMask starts.
72
101
 
73
- ```json
74
- {
75
- "DAPPRESS_SEED_PHRASE": "word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12"
76
- }
77
- ```
78
-
79
- In CI, provide it as an encrypted secret of the repository, exposed to the job as an environment variable:
80
-
81
- ```yaml
82
- # GitHub Actions
83
- env:
84
- DAPPRESS_SEED_PHRASE: ${{ secrets.DAPPRESS_SEED_PHRASE }}
85
- ```
102
+ ### 5. Get a browser that loads extensions
86
103
 
87
-
88
- Add the command types to `tsconfig.json`:
89
-
90
- ```json
91
- {
92
- "compilerOptions": {
93
- "types": ["cypress", "dappress/support"]
94
- }
95
- }
104
+ ```sh
105
+ npx @puppeteer/browsers install chrome@stable
96
106
  ```
97
107
 
98
- JavaScript projects use the same files with a `.js` extension and `module.exports`.
108
+ The command downloads Chrome for Testing and prints the path of its executable. `npx cypress info` lists the browsers Cypress finds by itself.
99
109
 
100
- Run the tests in a browser that supports extensions:
101
-
102
- ```bash
103
- npx cypress run --browser chrome-for-testing --headed
104
- ```
105
-
106
- Without `--headed`, the run is headless and works the same, except for the wallet cache, which needs a headed browser.
107
-
108
- ## Usage
110
+ ### 6. Write a test and run it
109
111
 
110
112
  ```ts
113
+ // cypress/e2e/wallet.cy.ts
111
114
  it('connects the wallet and signs in', () => {
112
115
  cy.visit('/');
113
116
  cy.contains('button', 'Connect wallet').click();
@@ -119,161 +122,147 @@ it('connects the wallet and signs in', () => {
119
122
  });
120
123
  ```
121
124
 
122
- The wallet is imported once, before the first test of each spec. Each command waits for MetaMask to display the request, answers it, and waits for the request to be dismissed.
123
-
124
- ### Commands
125
-
126
- | Command | What it does |
127
- |---|---|
128
- | `cy.connectToDapp()` | Accepts the connection request, then moves the dapp onto the network of the wallet setup |
129
- | `cy.connectToDapp({ accounts })` | The same, with the accounts named in `accounts` and no other, such as `['Account 1', 'Account 2']`, in place of the wallet's selected account |
130
- | `cy.rejectConnection()` | Rejects the connection request |
131
- | `cy.disconnectFromDapp()` | Disconnects the dapp from MetaMask's permissions screen. The dapp receives an empty `accountsChanged`. |
132
- | `cy.confirmSignature()` | Signs the message (`personal_sign`, `eth_signTypedData_*`) |
133
- | `cy.rejectSignature()` | Rejects the signature request |
134
- | `cy.confirmTransaction(options?)` | Sends the transaction, including ERC-20 approvals. With options, sets its spending cap or its network fee first: see [Transaction options](#transaction-options). |
135
- | `cy.rejectTransaction()` | Rejects the transaction |
136
- | `cy.approveNewNetwork()` | Adds the network requested by `wallet_addEthereumChain` |
137
- | `cy.rejectNewNetwork()` | Rejects the network |
138
- | `cy.approveSwitchNetwork()` | Grants the permission asked by `wallet_switchEthereumChain` for a network the dapp is not yet allowed on |
139
- | `cy.rejectSwitchNetwork()` | Denies that permission |
140
- | `cy.approveAddToken()` | Adds the token requested by `wallet_watchAsset` |
141
- | `cy.rejectAddToken()` | Rejects the token |
142
- | `cy.addAccount()` | Adds an account to the wallet and selects it. Yields its name, such as `Account 2`. |
143
- | `cy.importAccount(privateKey)` | Imports an account from its private key and selects it. Yields its name. The key stays out of the command log. |
144
- | `cy.switchAccount(name)` | Selects an account of the wallet by its name |
145
- | `cy.lockWallet()` | Locks the wallet from MetaMask's menu. Requests wait behind its unlock screen until it is unlocked. |
146
- | `cy.unlockWallet()` | Unlocks the wallet with the configured password. Yields how it was found: `locked`, or `unlocked` when there was nothing to do. |
147
- | `cy.useNetwork(network?)` | Moves the dapp onto a network, adding it to MetaMask when needed. Defaults to the network of the wallet setup. |
148
- | `cy.getAccountAddress()` | Yields the address the dapp is connected with |
149
- | `cy.setupMetaMask()` | Imports the wallet, or unlocks it. Called automatically before each spec. |
125
+ ```sh
126
+ npx cypress run --browser /path/to/chrome-for-testing
127
+ ```
150
128
 
151
- ### Transaction options
129
+ Pass `--browser chrome-for-testing` instead when `npx cypress info` lists it. Add `--headed` to watch MetaMask at work.
152
130
 
153
- `cy.confirmTransaction()` takes what a user may change on the confirmation before confirming it:
131
+ Before the first test of each spec, Dappress imports the wallet. Each command then waits for MetaMask to show the request, answers it, and waits for the request to close.
154
132
 
155
- ```ts
156
- // An ERC-20 approval: let the spender use 2.5 tokens, whatever the dapp asked for
157
- cy.confirmTransaction({ spendingCap: '2.5' });
133
+ ### Testing with funds
158
134
 
159
- // The network fee, from the advanced form of MetaMask's fee editor
160
- cy.confirmTransaction({ gas: { maxBaseFee: 30, priorityFee: 2, gasLimit: 100000 } });
135
+ By default, Dappress creates a new, empty wallet for each run. That is enough to connect and sign. To send transactions, use a wallet of yours that holds test funds, and give its seed phrase outside the repository.
161
136
 
162
- // Or one of the estimates the fee editor lists
163
- cy.confirmTransaction({ gas: 'aggressive' });
164
- ```
137
+ On your machine, in `cypress.env.json` (add it to `.gitignore`):
165
138
 
166
- | Option | Value |
167
- |---|---|
168
- | `spendingCap` | The amount the spender may use, in tokens, as typed in MetaMask: `5` or `'2.5'`. The command fails on a transaction that is not an ERC-20 approval. |
169
- | `gas` | `'low'`, `'market'` or `'aggressive'` on a network MetaMask has fee estimates for; `'networkSuggested'` on one it only has a gas price for, such as a local node. The command fails when MetaMask does not list the estimate. |
170
- | `gas` | Or custom values, named as MetaMask labels them: `maxBaseFee` and `priorityFee` in GWEI, which become the transaction's `maxFeePerGas` and `maxPriorityFeePerGas`, and `gasLimit`. A field left out keeps MetaMask's value. EIP-1559 transactions only. |
171
-
172
- Both options can be given together. MetaMask remembers the fee chosen for an account on a network, and starts the next transactions from it.
139
+ ```json
140
+ {
141
+ "DAPPRESS_SEED_PHRASE": "word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12"
142
+ }
143
+ ```
173
144
 
174
- ### Configuration
145
+ In CI, as an encrypted secret:
175
146
 
176
- Settings normally live in `cypress/wallet.setup.ts`. Secrets go in `cypress.env.json` or in environment variables, which take precedence. The full order, first one found wins:
147
+ ```yaml
148
+ # GitHub Actions
149
+ env:
150
+ DAPPRESS_SEED_PHRASE: ${{ secrets.DAPPRESS_SEED_PHRASE }}
151
+ ```
177
152
 
178
- 1. Environment variables, for secrets in CI.
179
- 2. `cypress.env.json`, for secrets on a development machine, such as the seed phrase.
180
- 3. `cypress/wallet.setup.ts`, for everything else.
181
- 4. The third argument of `configureDappress(on, config, options)`.
153
+ Read [Security](#security) before you pick that seed phrase.
182
154
 
183
- | Option | Environment variable | Default |
184
- |---|---|---|
185
- | `metamaskVersion` | `DAPPRESS_METAMASK_VERSION` | `13.50.0`. The build is downloaded from MetaMask's GitHub releases on first run and cached in `~/.cache/dappress`. |
186
- | `seedPhrase` | `DAPPRESS_SEED_PHRASE` | None. Dappress makes a new wallet for each run, which nobody else knows and which is never written anywhere. |
187
- | `password` | `DAPPRESS_PASSWORD` | `Tester@1234`. It only protects the throwaway browser profile Cypress creates for each run. |
188
- | `network` | | None. The dapp stays on the network MetaMask starts on, Ethereum mainnet. |
189
- | `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. |
190
- | `autoSetup` | | `true`. Dappress imports or unlocks the wallet before the first test of each spec. Set to `false` to call `cy.setupMetaMask()` yourself. |
191
- | `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. |
192
- | `timeout` | | `20000` ms. The time allowed for MetaMask to display a request before a command fails. |
155
+ ## Commands
193
156
 
194
- ### Security
157
+ ### Connection
195
158
 
196
- 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.
159
+ | Command | What it does |
160
+ | -------------------------------- | -------------------------------------------------------------------------------------------- |
161
+ | `cy.connectToDapp()` | Accepts the connection request, then switches the dapp to the network of your wallet setup. |
162
+ | `cy.connectToDapp({ accounts })` | Same, but connects only the listed accounts, for example `['Account 1', 'Account 2']`. |
163
+ | `cy.rejectConnection()` | Rejects the connection request. |
164
+ | `cy.disconnectFromDapp()` | Disconnects the dapp in MetaMask. The dapp receives an empty `accountsChanged`. |
165
+ | `cy.getAccountAddress()` | Yields the address the dapp is connected with. |
197
166
 
198
- 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.
167
+ ### Signatures and transactions
199
168
 
200
- ## Conformance suite
169
+ | Command | What it does |
170
+ | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
171
+ | `cy.confirmSignature()` | Signs the message (`personal_sign`, `eth_signTypedData_*`). |
172
+ | `cy.rejectSignature()` | Rejects the signature request. |
173
+ | `cy.confirmTransaction(options?)` | Confirms the transaction, ERC-20 approvals included. Options set the spending cap or the gas fee first: see below. |
174
+ | `cy.rejectTransaction()` | Rejects the transaction. |
201
175
 
202
- 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.
176
+ ### Networks and tokens
203
177
 
204
- ```bash
205
- npm run conformance # default MetaMask version, side panel
206
- npm run conformance -- 13.51.0 # a specific release
207
- DAPPRESS_MODE=headless npm run conformance # without a browser window
208
- DAPPRESS_MODE=popup npm run conformance # wallet from the profile cache: requests in the popup
209
- ```
178
+ | Command | What it does |
179
+ | --------------------------- | -------------------------------------------------------------------------------------------------- |
180
+ | `cy.useNetwork(network?)` | Switches the dapp to a network, and adds it to MetaMask if needed. Defaults to your wallet setup. |
181
+ | `cy.approveNewNetwork()` | Accepts a `wallet_addEthereumChain` request. |
182
+ | `cy.rejectNewNetwork()` | Rejects it. |
183
+ | `cy.approveSwitchNetwork()` | Accepts a `wallet_switchEthereumChain` request to a network the dapp has no permission on yet. |
184
+ | `cy.rejectSwitchNetwork()` | Rejects it. |
185
+ | `cy.approveAddToken()` | Accepts a `wallet_watchAsset` request. |
186
+ | `cy.rejectAddToken()` | Rejects it. |
210
187
 
211
- 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.
188
+ ### Accounts and wallet
212
189
 
213
- 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).
190
+ | Command | What it does |
191
+ | ------------------------------ | ------------------------------------------------------------------------------------------------------ |
192
+ | `cy.addAccount()` | Creates an account and selects it. Yields its name, for example `Account 2`. |
193
+ | `cy.importAccount(privateKey)` | Imports an account from a private key and selects it. Yields its name. The key stays out of the log. |
194
+ | `cy.switchAccount(name)` | Selects an account by its name. |
195
+ | `cy.lockWallet()` | Locks the wallet. Requests then wait until it is unlocked. |
196
+ | `cy.unlockWallet()` | Unlocks the wallet. Yields `locked` if it was locked, `unlocked` if there was nothing to do. |
197
+ | `cy.setupMetaMask()` | Imports or unlocks the wallet. Runs by itself before each spec, unless `autoSetup` is `false`. |
214
198
 
215
- ### Updating to a new MetaMask release
199
+ ### Transaction options
216
200
 
217
- 1. Run the suite. Each failure names a screenshot of the MetaMask screen at that moment.
218
- 2. Locate the new selector in MetaMask's page objects: `github.com/MetaMask/metamask-extension/tree/v<version>/test/e2e/page-objects/pages`.
219
- 3. Update `src/metamask.ts`, run the suite against the new and the previous release, bump the default version in `src/config.ts`, and commit the report.
201
+ ```ts
202
+ // ERC-20 approval: allow the spender 2.5 tokens, whatever the dapp asked for
203
+ cy.confirmTransaction({ spendingCap: '2.5' });
220
204
 
221
- ## Development
205
+ // One of the fee levels MetaMask offers
206
+ cy.confirmTransaction({ gas: 'aggressive' });
222
207
 
223
- ```bash
224
- npm run lint # ESLint
225
- npm run format # Prettier, on everything but the Markdown files
226
- npm run check-types # TypeScript, on the sources, the scripts and the suite
227
- npm test # unit tests
228
- npm run build # the package, compiled into dist
208
+ // Custom fees, in GWEI, as in MetaMask's advanced fee form
209
+ cy.confirmTransaction({ gas: { maxBaseFee: 30, priorityFee: 2, gasLimit: 100000 } });
229
210
  ```
230
211
 
231
- The CI workflow runs these on every pull request, with `format:check` in place of `format`.
212
+ | Option | Value |
213
+ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
214
+ | `spendingCap` | The amount the spender may use, in tokens: `5` or `'2.5'`. Use a string to avoid rounding. Fails if the transaction is not an ERC-20 approval. |
215
+ | `gas` | A fee level: `'low'`, `'market'` or `'aggressive'`. On a network with no fee estimates, such as a local node, use `'networkSuggested'`. Fails if MetaMask does not offer that level. |
216
+ | `gas` | Or custom values: `maxBaseFee` and `priorityFee` in GWEI, and `gasLimit`. A missing field keeps MetaMask's value. EIP-1559 transactions only. |
232
217
 
233
- ### Releasing
218
+ Both options can be combined. MetaMask remembers the fee chosen for an account on a network and starts the next transaction from it.
234
219
 
235
- 1. Move what `CHANGELOG.md` lists under "Unreleased" to a section for the new version, and set the version in `package.json` (`npm version <version> --no-git-tag-version`).
236
- 2. Commit, then tag the commit `v<version>` and push the tag.
220
+ ## Configuration
237
221
 
238
- The release workflow checks the tag against `package.json`, publishes the package to npm and makes a GitHub release from the version's section of the changelog. npm trusts the workflow itself ([trusted publishing](https://docs.npmjs.com/trusted-publishers)): no npm token is stored.
222
+ Settings come from three places:
239
223
 
240
- ## Architecture
224
+ - **`cypress/wallet.setup.ts`** for the network. It accepts `network` and `seedPhrase` only, and any other key is an error. Keep the seed phrase out of it, since this file is committed.
225
+ - **Environment variables or `cypress.env.json`** for secrets and the MetaMask version.
226
+ - **The third argument of `configureDappress()`** for everything else:
241
227
 
242
- 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.
243
-
244
- 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.
228
+ ```ts
229
+ setupNodeEvents(on, config) {
230
+ return configureDappress(on, config, { cache: true, timeout: 30000 });
231
+ }
232
+ ```
245
233
 
246
- 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.
234
+ When a setting appears in several places, the first one found wins: environment variable, then `cypress.env.json`, then `wallet.setup.ts`, then `configureDappress()`.
247
235
 
248
- 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.
236
+ | Option | Environment variable | Default and meaning |
237
+ | ----------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
238
+ | `network` | | None. The network `cy.connectToDapp()` and `cy.useNetwork()` switch to. |
239
+ | `seedPhrase` | `DAPPRESS_SEED_PHRASE` | None. A new wallet is created for each run. It is never written to disk. |
240
+ | `password` | `DAPPRESS_PASSWORD` | `Tester@1234`. It only protects the throwaway browser profile of the run. |
241
+ | `metamaskVersion` | `DAPPRESS_METAMASK_VERSION` | `13.50.0`. Downloaded from MetaMask's GitHub releases on first use, then cached. |
242
+ | `timeout` | | `20000` ms. How long a command waits for MetaMask to show the request. |
243
+ | `autoSetup` | | `true`. Set to `false` to call `cy.setupMetaMask()` yourself. |
244
+ | `cache` | | `false`. Importing the wallet takes about fifteen seconds per run. With `true`, Dappress imports it once and reuses the browser profile. Needs your own seed phrase and a headed run. |
245
+ | `backupAndSync` | | `false`. Dappress turns off MetaMask's backup and sync, so accounts added by a test do not come back on the next run. |
246
+ | `cacheDir` | | `~/.cache/dappress`. Where MetaMask builds and cached profiles are kept. |
249
247
 
250
- ```
251
- src/index.ts configureDappress(): plugin entry point
252
- src/config.ts options and wallet setup file
253
- src/download.ts download and cache of MetaMask builds
254
- src/browser.ts Puppeteer connection to the Cypress browser
255
- src/profile.ts wallet profile built once and reused across runs
256
- src/metamask-pages.ts discovery of MetaMask's pages
257
- src/metamask.ts MetaMask screens: selectors and flows
258
- src/page-helpers.ts wait, click and fill primitives with fallback selectors
259
- src/actions.ts Cypress tasks behind the commands
260
- src/support.ts cy.* commands
261
- src/types.ts types of the public API
262
- conformance/ conformance suite
263
- ```
248
+ ## Security
264
249
 
265
- Selectors are MetaMask's `data-testid` attributes, each with the button's English label as a fallback. MetaMask's pages run under LavaMoat, which rejects injected scripts; the helpers therefore rely only on what Puppeteer can do from outside the page.
250
+ - **Use a seed phrase made for testing**, funded on test networks only.
251
+ - **Avoid well-known phrases**, such as the Hardhat or Anvil development mnemonic. MetaMask restores the accounts other people saved for them, and your wallet will not be the one you expect.
252
+ - **Secrets stay in Node.js.** The seed phrase and the password never reach the browser or the Cypress log.
253
+ - **With `cache: true`**, `~/.cache/dappress/profiles` holds the wallet's encrypted vault. Treat it like the seed phrase: keep it on the machine, and keep it out of any CI cache that other people or workflows can restore.
266
254
 
267
255
  ## Troubleshooting
268
256
 
269
- - **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 …`.
270
- - **"MetaMask showed no confirmation".** The dapp sent no request, or sent it on a network whose RPC endpoint is unreachable.
271
- - **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.
272
- - **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.
257
+ - **Cypress exits immediately with `MODULE_NOT_FOUND`.** Your terminal sets `ELECTRON_RUN_AS_NODE=1`, as some IDEs do. Run `env -u ELECTRON_RUN_AS_NODE npx cypress run …`.
258
+ - **"MetaMask showed no confirmation".** The dapp sent no request, or the network's RPC endpoint is unreachable.
259
+ - **Adding or importing an account never finishes.** `chromeWebSecurity: false` is set in your Cypress config. MetaMask then cannot start the snaps its account screens wait for. Remove that setting.
260
+ - **The wallet shows accounts you did not create.** The seed phrase is used elsewhere, and MetaMask restored what it saved for it. Use a phrase made for your tests, or none.
261
+ - **"MetaMask extension not found in the browser".** The browser did not load the extension. Most often it is Google Chrome 137 or later, which no longer can. Use Chrome for Testing: see [step 5](#5-get-a-browser-that-loads-extensions).
273
262
 
274
- ## Status
263
+ ## Contributing
275
264
 
276
- 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.
265
+ How Dappress works, how to run the conformance suite, update to a new MetaMask release and publish a version: see [CONTRIBUTING.md](CONTRIBUTING.md).
277
266
 
278
267
  ## License
279
268
 
package/dist/actions.js CHANGED
@@ -86,16 +86,27 @@ function createTasks(options) {
86
86
  await metamask.leaveUnlockForm(page, options);
87
87
  return state;
88
88
  }
89
- /** Approve the prompt a network change raised: "Add network" or the permission to switch. */
90
- async function approveNetworkChange(browser) {
91
- const page = await (0, metamask_pages_1.getConfirmationPage)(browser, await metamaskId(browser), options.timeout);
92
- await metamask.approveNetworkChange(page, options.timeout);
89
+ /**
90
+ * Find the confirmation and act on it. One that closes before it is acted
91
+ * on was the popup of the request before, found as it closed: the request's
92
+ * own is looked for once more.
93
+ */
94
+ async function onConfirmation(browser, act) {
95
+ for (let attempt = 0;; attempt++) {
96
+ const page = await (0, metamask_pages_1.getConfirmationPage)(browser, await metamaskId(browser), options.timeout);
97
+ try {
98
+ return await act(page);
99
+ }
100
+ catch (error) {
101
+ if (attempt > 0 || !(error instanceof metamask.ConfirmationClosed))
102
+ throw error;
103
+ }
104
+ }
93
105
  }
106
+ /** Approve the prompt a network change raised: "Add network" or the permission to switch. */
107
+ const approveNetworkChange = (browser) => onConfirmation(browser, (page) => metamask.approveNetworkChange(page, options.timeout));
94
108
  /** The task for a decision: find the confirmation, press its button. */
95
- const decide = (decision) => async (browser, argument) => {
96
- const page = await (0, metamask_pages_1.getConfirmationPage)(browser, await metamaskId(browser), options.timeout);
97
- await metamask.decide(decision, page, options.timeout, argument);
98
- };
109
+ const decide = (decision) => (browser, argument) => onConfirmation(browser, (page) => metamask.decide(decision, page, options.timeout, argument));
99
110
  const actions = {
100
111
  setupWallet,
101
112
  approveNetworkChange,
package/dist/browser.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  // Connects Puppeteer to the browser Cypress launched, through the debugging
3
- // URL Cypress hands out in after:browser:launch (Cypress 13.6+).
3
+ // URL Cypress hands out in after:browser:launch (Cypress 15.10+).
4
4
  var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  return (mod && mod.__esModule) ? mod : { "default": mod };
6
6
  };
@@ -20,7 +20,7 @@ function captureDebuggerUrl(on) {
20
20
  */
21
21
  async function withBrowser(action) {
22
22
  if (!debuggerUrl) {
23
- throw new Error('[dappress] No browser to connect to. Is configureDappress() called in setupNodeEvents, with Cypress 13.6 or later?');
23
+ throw new Error('[dappress] No browser to connect to. Is configureDappress() called in setupNodeEvents, with Cypress 15.10 or later?');
24
24
  }
25
25
  const browser = await puppeteer_core_1.default.connect({ browserWSEndpoint: debuggerUrl, defaultViewport: null });
26
26
  try {
@@ -12,6 +12,16 @@ export declare function getRequestPages(browser: Browser, extensionId: string):
12
12
  * The page a dapp request is shown on. Waits for the popup to open, or takes
13
13
  * the side panel. A popup still on its home route is one closing after the
14
14
  * previous request, or one that hasn't routed to the request yet: skipped.
15
+ * The route is read from the page, which follows a change of hash at once,
16
+ * where the target can still name the request just answered.
15
17
  */
16
18
  export declare function getConfirmationPage(browser: Browser, extensionId: string, timeout: number): Promise<Page>;
19
+ /**
20
+ * Once the request shown on a popup is answered, wait for the popup to close,
21
+ * at most `timeout` ms. MetaMask closes it a moment after its button goes, and
22
+ * left to close on its own, the next command could find it, still listed on
23
+ * `request`, and act on a page that vanishes under it. A popup that goes on
24
+ * to another request instead is left to it; so is the side panel, which stays.
25
+ */
26
+ export declare function waitForDismissal(page: Page, request: string, timeout: number): Promise<void>;
17
27
  export declare function withUsableWindow(page: Page): Promise<Page>;
@@ -7,6 +7,7 @@ exports.findExtensionId = findExtensionId;
7
7
  exports.getHomePage = getHomePage;
8
8
  exports.getRequestPages = getRequestPages;
9
9
  exports.getConfirmationPage = getConfirmationPage;
10
+ exports.waitForDismissal = waitForDismissal;
10
11
  exports.withUsableWindow = withUsableWindow;
11
12
  const page_helpers_1 = require("./page-helpers");
12
13
  const HOME_PATH = '/home.html';
@@ -97,14 +98,20 @@ async function getRequestPages(browser, extensionId) {
97
98
  * The page a dapp request is shown on. Waits for the popup to open, or takes
98
99
  * the side panel. A popup still on its home route is one closing after the
99
100
  * previous request, or one that hasn't routed to the request yet: skipped.
101
+ * The route is read from the page, which follows a change of hash at once,
102
+ * where the target can still name the request just answered.
100
103
  */
101
104
  async function getConfirmationPage(browser, extensionId, timeout) {
102
105
  const deadline = Date.now() + timeout;
103
106
  while (Date.now() < deadline) {
104
107
  for (const pathname of CONFIRMATION_PATHS) {
105
- const [target] = pagesOf(browser, extensionId, pathname).filter((candidate) => !isHomeRoute(candidate.url()));
106
- if (target)
107
- return pathname === '/notification.html' ? withUsableWindow(await pageOf(target)) : pageOf(target);
108
+ for (const target of pagesOf(browser, extensionId, pathname)) {
109
+ // A target closing while its page is taken has none: not a confirmation either
110
+ const page = await target.page().catch(() => null);
111
+ if (!page || page.isClosed() || isHomeRoute(page.url()))
112
+ continue;
113
+ return pathname === '/notification.html' ? withUsableWindow(page) : page;
114
+ }
108
115
  }
109
116
  await (0, page_helpers_1.sleep)(250);
110
117
  }
@@ -115,6 +122,26 @@ async function getConfirmationPage(browser, extensionId, timeout) {
115
122
  .join(', ');
116
123
  throw new Error(`[dappress] MetaMask showed no confirmation within ${timeout}ms. Did the dapp send a request? MetaMask pages seen: ${seen || 'none'}`);
117
124
  }
125
+ /**
126
+ * Once the request shown on a popup is answered, wait for the popup to close,
127
+ * at most `timeout` ms. MetaMask closes it a moment after its button goes, and
128
+ * left to close on its own, the next command could find it, still listed on
129
+ * `request`, and act on a page that vanishes under it. A popup that goes on
130
+ * to another request instead is left to it; so is the side panel, which stays.
131
+ */
132
+ async function waitForDismissal(page, request, timeout) {
133
+ if (!request.includes('/notification.html'))
134
+ return;
135
+ const deadline = Date.now() + timeout;
136
+ while (Date.now() < deadline) {
137
+ if (page.isClosed())
138
+ return;
139
+ const url = page.url();
140
+ if (url !== request && !isHomeRoute(url))
141
+ return;
142
+ await (0, page_helpers_1.sleep)(100);
143
+ }
144
+ }
118
145
  // MetaMask asks Chrome for a 400x620 popup. Under Xvfb on a GitHub runner the
119
146
  // window it gets is 1x1: nothing is laid out, the pane of a confirmation that
120
147
  // must be read to the end has no height, so it can never be scrolled there and
@@ -48,6 +48,13 @@ export declare function disconnectSite(page: Page, origin: string): Promise<void
48
48
  type CommandOptions = ConnectOptions & TransactionOptions;
49
49
  /** Press the button of `decision` on the confirmation shown on `page`, once it is set as `options` ask. */
50
50
  export declare function decide(decision: Decision, page: Page, timeout: number, options?: CommandOptions): Promise<void>;
51
+ /**
52
+ * Thrown when the confirmation page closed before its button was pressed: it
53
+ * was the popup of the request before, found as it closed. The request's own
54
+ * popup is there to be looked for.
55
+ */
56
+ export declare class ConfirmationClosed extends Error {
57
+ }
51
58
  /** wallet_addEthereumChain on a network MetaMask knows behaves like a switch: either prompt may show. */
52
59
  export declare function approveNetworkChange(page: Page, timeout: number): Promise<void>;
53
60
  export {};
package/dist/metamask.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // first, then a fallback on the button's English text. When a MetaMask release
8
8
  // moves something, this is the file to fix.
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.decisions = void 0;
10
+ exports.ConfirmationClosed = exports.decisions = void 0;
11
11
  exports.walletState = walletState;
12
12
  exports.onboard = onboard;
13
13
  exports.unlock = unlock;
@@ -20,6 +20,7 @@ exports.disconnectSite = disconnectSite;
20
20
  exports.decide = decide;
21
21
  exports.approveNetworkChange = approveNetworkChange;
22
22
  const page_helpers_1 = require("./page-helpers");
23
+ const metamask_pages_1 = require("./metamask-pages");
23
24
  const testId = (id) => `[data-testid="${id}"]`;
24
25
  // XPath rather than Puppeteer's ::-p-text(): the latter needs MutationObserver,
25
26
  // which MetaMask's sandbox (LavaMoat) blocks in its pages.
@@ -553,11 +554,30 @@ async function decide(decision, page, timeout, options) {
553
554
  const adjust = adjustments[decision];
554
555
  if (!adjust)
555
556
  throw new Error(`[dappress] ${decision} takes no options`);
556
- await (0, page_helpers_1.waitFor)(page, exports.decisions[decision], { timeout });
557
+ await waitForButton(page, exports.decisions[decision], timeout);
557
558
  await adjust(page, options, timeout);
558
559
  }
559
560
  return pressAndWaitForDismissal(page, exports.decisions[decision], timeout);
560
561
  }
562
+ /**
563
+ * Thrown when the confirmation page closed before its button was pressed: it
564
+ * was the popup of the request before, found as it closed. The request's own
565
+ * popup is there to be looked for.
566
+ */
567
+ class ConfirmationClosed extends Error {
568
+ }
569
+ exports.ConfirmationClosed = ConfirmationClosed;
570
+ // The button of a confirmation, or ConfirmationClosed if the page went meanwhile
571
+ async function waitForButton(page, button, timeout) {
572
+ try {
573
+ await (0, page_helpers_1.waitFor)(page, button, { timeout });
574
+ }
575
+ catch (error) {
576
+ if (page.isClosed())
577
+ throw new ConfirmationClosed(`[dappress] The confirmation closed before "${(0, page_helpers_1.describe)(button)}" was pressed`);
578
+ throw error;
579
+ }
580
+ }
561
581
  /** wallet_addEthereumChain on a network MetaMask knows behaves like a switch: either prompt may show. */
562
582
  function approveNetworkChange(page, timeout) {
563
583
  return pressAndWaitForDismissal(page, [...selectors.confirmation.confirm, ...selectors.pageContainer.confirm], timeout);
@@ -741,7 +761,7 @@ async function textOf(page, selector) {
741
761
  * what MetaMask logged.
742
762
  */
743
763
  async function pressAndWaitForDismissal(page, button, timeout) {
744
- await (0, page_helpers_1.waitFor)(page, button, { timeout });
764
+ await waitForButton(page, button, timeout);
745
765
  await dismissModal(page);
746
766
  const logged = recordErrors(page);
747
767
  const request = page.url();
@@ -751,7 +771,7 @@ async function pressAndWaitForDismissal(page, button, timeout) {
751
771
  if (await (0, page_helpers_1.isVisible)(page, selectors.alert.acknowledge, 500))
752
772
  await (0, page_helpers_1.click)(page, selectors.alert.acknowledge);
753
773
  if (await (0, page_helpers_1.isGone)(page, button, 3000))
754
- return;
774
+ return (0, metamask_pages_1.waitForDismissal)(page, request, 3000);
755
775
  await dismissModal(page);
756
776
  await (0, page_helpers_1.dispatchClick)(page, button, { timeout: 3000 }).catch(() => { });
757
777
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dappress",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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",
@@ -61,7 +61,7 @@
61
61
  "format:check": "prettier --check ."
62
62
  },
63
63
  "peerDependencies": {
64
- "cypress": ">=13.6.0"
64
+ "cypress": ">=15.10.0"
65
65
  },
66
66
  "dependencies": {
67
67
  "@scure/bip39": "^1.6.0",