dappress 0.1.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/LICENSE +21 -0
- package/README.md +219 -0
- package/package.json +68 -0
- package/src/actions.js +50 -0
- package/src/browser.js +42 -0
- package/src/config.js +61 -0
- package/src/download.js +50 -0
- package/src/index.js +45 -0
- package/src/metamask-pages.js +91 -0
- package/src/metamask.js +220 -0
- package/src/page-helpers.js +117 -0
- package/src/profile.js +102 -0
- package/src/support.js +83 -0
- package/types/index.d.ts +47 -0
- package/types/support.d.ts +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Benjamin Lassaut
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# Dappress
|
|
2
|
+
|
|
3
|
+
**MetaMask automation for Cypress.**
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
- **Current MetaMask.** The extension version is a configuration value. Each release is verified by a conformance suite, with one test per command.
|
|
8
|
+
- **Cypress native.** No second browser, no proxy. Dappress drives MetaMask through Puppeteer, connected to the browser Cypress already runs. Typed commands for TypeScript projects.
|
|
9
|
+
- **Resilient selectors.** Every selector comes from MetaMask's own end-to-end test suite and carries a fallback.
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
| Dependency | Version |
|
|
14
|
+
|---|---|
|
|
15
|
+
| Cypress | 13.6 or later |
|
|
16
|
+
| Node.js | 20 or later |
|
|
17
|
+
| Browser | Chrome for Testing, Chromium or Electron. Google Chrome 137 and later cannot load extensions. |
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install --save-dev dappress
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Register the plugin in the Cypress configuration:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// cypress.config.ts
|
|
29
|
+
import { defineConfig } from 'cypress';
|
|
30
|
+
import { configureDappress } from 'dappress';
|
|
31
|
+
|
|
32
|
+
export default defineConfig({
|
|
33
|
+
e2e: {
|
|
34
|
+
baseUrl: 'http://localhost:3000',
|
|
35
|
+
testIsolation: false, // the wallet state is shared across tests
|
|
36
|
+
setupNodeEvents(on, config) {
|
|
37
|
+
return configureDappress(on, config);
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Load the commands in the support file:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// cypress/support/e2e.ts
|
|
47
|
+
import 'dappress/support';
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Describe the test wallet:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// cypress/wallet.setup.ts
|
|
54
|
+
import type { WalletSetup } from 'dappress';
|
|
55
|
+
|
|
56
|
+
const wallet: WalletSetup = {
|
|
57
|
+
// The network the dapp is moved onto when it connects
|
|
58
|
+
network: {
|
|
59
|
+
chainId: '0x88bb0',
|
|
60
|
+
chainName: 'Hoodi',
|
|
61
|
+
rpcUrls: ['https://ethereum-hoodi-rpc.publicnode.com'],
|
|
62
|
+
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export default wallet;
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Keep the seed phrase out of the repository, in `cypress.env.json` (git-ignored):
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"DAPPRESS_SEED_PHRASE": "word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12"
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
In CI, provide it as an encrypted secret of the repository, exposed to the job as an environment variable:
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
# GitHub Actions
|
|
81
|
+
env:
|
|
82
|
+
DAPPRESS_SEED_PHRASE: ${{ secrets.DAPPRESS_SEED_PHRASE }}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
When neither is provided, Dappress uses the public Hardhat / Anvil development wallet.
|
|
86
|
+
|
|
87
|
+
Add the command types to `tsconfig.json`:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"compilerOptions": {
|
|
92
|
+
"types": ["cypress", "dappress/support"]
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
JavaScript projects use the same files with a `.js` extension and `module.exports`.
|
|
98
|
+
|
|
99
|
+
Run the tests in a headed browser that supports extensions:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npx cypress run --browser chrome-for-testing --headed
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Usage
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
it('connects the wallet and signs in', () => {
|
|
109
|
+
cy.visit('/');
|
|
110
|
+
cy.contains('button', 'Connect wallet').click();
|
|
111
|
+
cy.connectToDapp();
|
|
112
|
+
cy.getAccountAddress().should('eq', '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266');
|
|
113
|
+
|
|
114
|
+
cy.contains('button', 'Sign in').click();
|
|
115
|
+
cy.confirmSignature();
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
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.
|
|
120
|
+
|
|
121
|
+
### Commands
|
|
122
|
+
|
|
123
|
+
| Command | What it does |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `cy.connectToDapp()` | Accepts the connection request, then moves the dapp onto the network of the wallet setup |
|
|
126
|
+
| `cy.rejectConnection()` | Rejects the connection request |
|
|
127
|
+
| `cy.confirmSignature()` | Signs the message (`personal_sign`, `eth_signTypedData_*`) |
|
|
128
|
+
| `cy.rejectSignature()` | Rejects the signature request |
|
|
129
|
+
| `cy.confirmTransaction()` | Sends the transaction, including ERC-20 approvals |
|
|
130
|
+
| `cy.rejectTransaction()` | Rejects the transaction |
|
|
131
|
+
| `cy.approveNewNetwork()` | Adds the network requested by `wallet_addEthereumChain` |
|
|
132
|
+
| `cy.rejectNewNetwork()` | Rejects the network |
|
|
133
|
+
| `cy.approveSwitchNetwork()` | Grants the permission asked by `wallet_switchEthereumChain` for a network the dapp is not yet allowed on |
|
|
134
|
+
| `cy.rejectSwitchNetwork()` | Denies that permission |
|
|
135
|
+
| `cy.approveAddToken()` | Adds the token requested by `wallet_watchAsset` |
|
|
136
|
+
| `cy.rejectAddToken()` | Rejects the token |
|
|
137
|
+
| `cy.useNetwork(network?)` | Moves the dapp onto a network, adding it to MetaMask when needed. Defaults to the network of the wallet setup. |
|
|
138
|
+
| `cy.getAccountAddress()` | Yields the address the dapp is connected with |
|
|
139
|
+
| `cy.setupMetaMask()` | Imports the wallet, or unlocks it. Called automatically before each spec. |
|
|
140
|
+
|
|
141
|
+
### Configuration
|
|
142
|
+
|
|
143
|
+
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:
|
|
144
|
+
|
|
145
|
+
1. Environment variables, for secrets in CI.
|
|
146
|
+
2. `cypress.env.json`, for secrets on a development machine, such as the seed phrase.
|
|
147
|
+
3. `cypress/wallet.setup.ts`, for everything else.
|
|
148
|
+
4. The third argument of `configureDappress(on, config, options)`.
|
|
149
|
+
|
|
150
|
+
| Option | Environment variable | Default |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| `metamaskVersion` | `DAPPRESS_METAMASK_VERSION` | `13.50.0`. The build is downloaded from MetaMask's GitHub releases on first run and cached in `~/.cache/dappress`. |
|
|
153
|
+
| `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`. |
|
|
154
|
+
| `password` | `DAPPRESS_PASSWORD` | `Tester@1234`. It only protects the throwaway browser profile Cypress creates for each run. |
|
|
155
|
+
| `network` | | None. The dapp stays on the network MetaMask starts on, Ethereum mainnet. |
|
|
156
|
+
| `autoSetup` | | `true`. Dappress imports or unlocks the wallet before the first test of each spec. Set to `false` to call `cy.setupMetaMask()` yourself. |
|
|
157
|
+
| `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. |
|
|
158
|
+
| `timeout` | | `20000` ms. The time allowed for MetaMask to display a request before a command fails. |
|
|
159
|
+
|
|
160
|
+
### Security
|
|
161
|
+
|
|
162
|
+
Use a wallet dedicated to testing, funded on test networks only. The default seed phrase is public. 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.
|
|
163
|
+
|
|
164
|
+
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.
|
|
165
|
+
|
|
166
|
+
## Conformance suite
|
|
167
|
+
|
|
168
|
+
The suite runs one test per command against [MetaMask's test dapp](https://metamask.github.io/test-dapp/), with a local [Anvil](https://getfoundry.sh) node for the funded transaction. It requires Chrome for Testing and Foundry.
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
npm run conformance # default MetaMask version
|
|
172
|
+
npm run conformance -- 13.51.0 # a specific release
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Each run writes `reports/metamask-<version>.json`. The GitHub workflow runs the suite daily against the latest MetaMask release that has no report yet.
|
|
176
|
+
|
|
177
|
+
### Updating to a new MetaMask release
|
|
178
|
+
|
|
179
|
+
1. Run the suite. Each failure names a screenshot of the MetaMask screen at that moment.
|
|
180
|
+
2. Locate the new selector in MetaMask's page objects: `github.com/MetaMask/metamask-extension/tree/v<version>/test/e2e/page-objects/pages`.
|
|
181
|
+
3. Update `src/metamask.js`, run the suite against the new and the previous release, bump the default version in `src/config.js`, and commit the report.
|
|
182
|
+
|
|
183
|
+
## Architecture
|
|
184
|
+
|
|
185
|
+
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.
|
|
186
|
+
|
|
187
|
+
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.
|
|
188
|
+
|
|
189
|
+
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.
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
src/index.js configureDappress(): plugin entry point
|
|
193
|
+
src/config.js options and wallet setup file
|
|
194
|
+
src/download.js download and cache of MetaMask builds
|
|
195
|
+
src/browser.js Puppeteer connection to the Cypress browser
|
|
196
|
+
src/profile.js wallet profile built once and reused across runs
|
|
197
|
+
src/metamask-pages.js discovery of MetaMask's pages
|
|
198
|
+
src/metamask.js MetaMask screens: selectors and flows
|
|
199
|
+
src/page-helpers.js wait, click and fill primitives with fallback selectors
|
|
200
|
+
src/actions.js Cypress tasks behind the commands
|
|
201
|
+
src/support.js cy.* commands
|
|
202
|
+
types/ TypeScript declarations
|
|
203
|
+
conformance/ conformance suite
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
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.
|
|
207
|
+
|
|
208
|
+
## Troubleshooting
|
|
209
|
+
|
|
210
|
+
- **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 …`.
|
|
211
|
+
- **"MetaMask showed no confirmation".** The dapp sent no request, or sent it on a network whose RPC endpoint is unreachable.
|
|
212
|
+
|
|
213
|
+
## Status
|
|
214
|
+
|
|
215
|
+
Verified with MetaMask 13.49.0 and 13.50.0, Cypress 16 and Chrome for Testing 154 on macOS, in headed mode. Headless execution and continuous integration are not validated yet.
|
|
216
|
+
|
|
217
|
+
## License
|
|
218
|
+
|
|
219
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dappress",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"author": "Benjamin Lassaut",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/blassaut/dappress.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/blassaut/dappress#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/blassaut/dappress/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"cypress",
|
|
17
|
+
"metamask",
|
|
18
|
+
"web3",
|
|
19
|
+
"e2e",
|
|
20
|
+
"testing",
|
|
21
|
+
"wallet",
|
|
22
|
+
"dapp"
|
|
23
|
+
],
|
|
24
|
+
"main": "src/index.js",
|
|
25
|
+
"types": "types/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./types/index.d.ts",
|
|
29
|
+
"default": "./src/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./support": {
|
|
32
|
+
"types": "./types/support.d.ts",
|
|
33
|
+
"default": "./src/support.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"typesVersions": {
|
|
37
|
+
"*": {
|
|
38
|
+
"support": [
|
|
39
|
+
"types/support.d.ts"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"src",
|
|
45
|
+
"types",
|
|
46
|
+
"README.md",
|
|
47
|
+
"LICENSE"
|
|
48
|
+
],
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=20"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"conformance": "node scripts/conformance.js",
|
|
54
|
+
"check-types": "tsc -p tsconfig.json"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"cypress": ">=13.6.0"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"extract-zip": "^2.0.1",
|
|
61
|
+
"puppeteer-core": "^24.43.1"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@types/node": "^20.19.43",
|
|
65
|
+
"cypress": "^16.1.1",
|
|
66
|
+
"typescript": "^5.6.0"
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/actions.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The Node side of every cy.* command, registered as Cypress tasks. Each
|
|
2
|
+
// action gets a Puppeteer browser connected to the Cypress browser, finds the
|
|
3
|
+
// MetaMask page it needs and drives it through src/metamask.js.
|
|
4
|
+
|
|
5
|
+
const { withBrowser } = require('./browser');
|
|
6
|
+
const metamask = require('./metamask');
|
|
7
|
+
const { findExtensionId, getHomePage, getConfirmationPage } = require('./metamask-pages');
|
|
8
|
+
|
|
9
|
+
function createTasks(options) {
|
|
10
|
+
// Found once: the browser is the same for the whole run
|
|
11
|
+
let extensionId;
|
|
12
|
+
|
|
13
|
+
async function metamaskId(browser) {
|
|
14
|
+
extensionId = extensionId || (await findExtensionId(browser));
|
|
15
|
+
return extensionId;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Onboard the wallet or unlock it, so the dapp can talk to it. Safe to call repeatedly. */
|
|
19
|
+
async function setupWallet(browser) {
|
|
20
|
+
const home = await getHomePage(browser, await metamaskId(browser));
|
|
21
|
+
const state = await metamask.walletState(home);
|
|
22
|
+
console.log(`[dappress] MetaMask is ${state}`);
|
|
23
|
+
if (state === 'onboarding') await metamask.onboard(home, options);
|
|
24
|
+
if (state === 'locked') await metamask.unlock(home, options);
|
|
25
|
+
if (state === 'unknown') throw new Error(`[dappress] Unexpected MetaMask screen at ${home.url()}`);
|
|
26
|
+
await home.close();
|
|
27
|
+
return state;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Approve the prompt a network change raised: "Add network" or the permission to switch. */
|
|
31
|
+
async function approveNetworkChange(browser) {
|
|
32
|
+
const page = await getConfirmationPage(browser, await metamaskId(browser), options.timeout);
|
|
33
|
+
await metamask.approveNetworkChange(page, options.timeout);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The task for a decision: find the confirmation, press its button. */
|
|
37
|
+
const decide = (decision) => async (browser) => {
|
|
38
|
+
const page = await getConfirmationPage(browser, await metamaskId(browser), options.timeout);
|
|
39
|
+
await metamask.decide(decision, page, options.timeout);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const actions = { setupWallet, approveNetworkChange };
|
|
43
|
+
for (const decision of Object.keys(metamask.decisions)) actions[decision] = decide(decision);
|
|
44
|
+
|
|
45
|
+
// cy.task() needs a value back: null when the action has nothing to say
|
|
46
|
+
const asTask = (action) => async (argument) => (await withBrowser((browser) => action(browser, argument))) ?? null;
|
|
47
|
+
return Object.fromEntries(Object.entries(actions).map(([name, action]) => [`dappress:${name}`, asTask(action)]));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { createTasks };
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Connects Puppeteer to the browser Cypress launched, through the debugging
|
|
2
|
+
// URL Cypress hands out in after:browser:launch (Cypress 13.6+).
|
|
3
|
+
|
|
4
|
+
const puppeteer = require('puppeteer-core');
|
|
5
|
+
|
|
6
|
+
let debuggerUrl;
|
|
7
|
+
|
|
8
|
+
function captureDebuggerUrl(on) {
|
|
9
|
+
on('after:browser:launch', (browser, options) => {
|
|
10
|
+
debuggerUrl = options.webSocketDebuggerUrl;
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Run `action` with a Puppeteer browser connected to Cypress's browser, then
|
|
16
|
+
* give the focus back to the Cypress tab and disconnect. The browser stays open.
|
|
17
|
+
*/
|
|
18
|
+
async function withBrowser(action) {
|
|
19
|
+
if (!debuggerUrl) {
|
|
20
|
+
throw new Error('[dappress] No browser to connect to. Is configureDappress() called in setupNodeEvents, with Cypress 13.6 or later?');
|
|
21
|
+
}
|
|
22
|
+
const browser = await puppeteer.connect({ browserWSEndpoint: debuggerUrl, defaultViewport: null });
|
|
23
|
+
try {
|
|
24
|
+
return await action(browser);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
// The browser side only gets the message, so keep the full stack in the terminal
|
|
27
|
+
console.error(error.stack || error);
|
|
28
|
+
throw error;
|
|
29
|
+
} finally {
|
|
30
|
+
await focusCypressTab(browser).catch(() => {});
|
|
31
|
+
await browser.disconnect();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// MetaMask opens its own tabs and popups; Chrome throttles the Cypress tab
|
|
36
|
+
// while it is in the background. The Cypress runner lives under /__/.
|
|
37
|
+
async function focusCypressTab(browser) {
|
|
38
|
+
const target = browser.targets().find((candidate) => candidate.type() === 'page' && candidate.url().includes('/__/'));
|
|
39
|
+
if (target) await (await target.page()).bringToFront();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { captureDebuggerUrl, withBrowser };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
const DEFAULTS = {
|
|
6
|
+
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
|
+
password: 'Tester@1234',
|
|
10
|
+
// The network cy.connectToDapp() moves the dapp onto, as a wallet_addEthereumChain parameter; none by default
|
|
11
|
+
network: null,
|
|
12
|
+
// Run cy.setupMetaMask() automatically before the first test of each spec
|
|
13
|
+
autoSetup: true,
|
|
14
|
+
// Import the wallet once and reuse the profile across runs (keeps the vault under cacheDir)
|
|
15
|
+
cache: false,
|
|
16
|
+
// How long to wait for MetaMask to show a confirmation after a dapp request (ms)
|
|
17
|
+
timeout: 20000,
|
|
18
|
+
cacheDir: path.join(os.homedir(), '.cache', 'dappress'),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// A .ts file loads when the Cypress config is itself in TypeScript
|
|
22
|
+
const WALLET_SETUP_FILES = ['cypress/wallet.setup.ts', 'cypress/wallet.setup.js'];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The plugin options, in order of precedence: environment variables, the
|
|
26
|
+
* Cypress `env` block (cypress.env.json), the wallet setup file
|
|
27
|
+
* (cypress/wallet.setup.ts or .js), the options given to configureDappress(),
|
|
28
|
+
* then the defaults above.
|
|
29
|
+
*/
|
|
30
|
+
function resolveOptions(userOptions = {}, cypressConfig = {}) {
|
|
31
|
+
const env = cypressConfig.env || {};
|
|
32
|
+
const fromEnv = {
|
|
33
|
+
metamaskVersion: process.env.DAPPRESS_METAMASK_VERSION || env.DAPPRESS_METAMASK_VERSION,
|
|
34
|
+
seedPhrase: process.env.DAPPRESS_SEED_PHRASE || env.DAPPRESS_SEED_PHRASE,
|
|
35
|
+
password: process.env.DAPPRESS_PASSWORD || env.DAPPRESS_PASSWORD,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const options = { ...DEFAULTS, ...userOptions, ...loadWalletSetup(cypressConfig.projectRoot) };
|
|
39
|
+
for (const [key, value] of Object.entries(fromEnv)) {
|
|
40
|
+
if (value) options[key] = value;
|
|
41
|
+
}
|
|
42
|
+
return options;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The project's wallet setup file: { seedPhrase?, network? }. */
|
|
46
|
+
function loadWalletSetup(projectRoot = process.cwd()) {
|
|
47
|
+
const file = WALLET_SETUP_FILES.map((name) => path.join(projectRoot, name)).find((candidate) => fs.existsSync(candidate));
|
|
48
|
+
if (!file) return {};
|
|
49
|
+
const loaded = require(file);
|
|
50
|
+
const setup = loaded.default || loaded; // `export default` or `module.exports`
|
|
51
|
+
const unknown = Object.keys(setup).filter((key) => !['seedPhrase', 'network'].includes(key));
|
|
52
|
+
if (unknown.length) throw new Error(`[dappress] Unknown keys in ${path.basename(file)}: ${unknown.join(', ')}`);
|
|
53
|
+
return setup;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The subset of options that is safe to expose to the browser side (no secrets). */
|
|
57
|
+
function publicOptions(options) {
|
|
58
|
+
return { metamaskVersion: options.metamaskVersion, autoSetup: options.autoSetup, network: options.network };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { DEFAULTS, resolveOptions, publicOptions };
|
package/src/download.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { pipeline } = require('node:stream/promises');
|
|
4
|
+
const { Readable } = require('node:stream');
|
|
5
|
+
const extractZip = require('extract-zip');
|
|
6
|
+
|
|
7
|
+
function releaseUrl(version) {
|
|
8
|
+
return `https://github.com/MetaMask/metamask-extension/releases/download/v${version}/metamask-chrome-${version}.zip`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function extensionDir(cacheDir, version) {
|
|
12
|
+
return path.join(cacheDir, 'metamask', version);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function download(url, destination) {
|
|
16
|
+
const response = await fetch(url, { redirect: 'follow' });
|
|
17
|
+
if (!response.ok || !response.body) {
|
|
18
|
+
throw new Error(`[dappress] Could not download ${url}: HTTP ${response.status}`);
|
|
19
|
+
}
|
|
20
|
+
await fs.promises.mkdir(path.dirname(destination), { recursive: true });
|
|
21
|
+
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(destination));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Download and unzip the MetaMask Chrome build for `version` into the cache.
|
|
26
|
+
* Returns the path of the unpacked extension (the folder holding manifest.json).
|
|
27
|
+
* Already cached versions are reused.
|
|
28
|
+
*/
|
|
29
|
+
async function prepareExtension({ metamaskVersion, cacheDir }) {
|
|
30
|
+
const dir = extensionDir(cacheDir, metamaskVersion);
|
|
31
|
+
const manifest = path.join(dir, 'manifest.json');
|
|
32
|
+
if (fs.existsSync(manifest)) return dir;
|
|
33
|
+
|
|
34
|
+
const zipPath = path.join(cacheDir, 'metamask', `metamask-chrome-${metamaskVersion}.zip`);
|
|
35
|
+
if (!fs.existsSync(zipPath)) {
|
|
36
|
+
console.log(`[dappress] Downloading MetaMask ${metamaskVersion}…`);
|
|
37
|
+
await download(releaseUrl(metamaskVersion), zipPath);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.log(`[dappress] Unpacking MetaMask ${metamaskVersion}…`);
|
|
41
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
42
|
+
await extractZip(zipPath, { dir });
|
|
43
|
+
|
|
44
|
+
if (!fs.existsSync(manifest)) {
|
|
45
|
+
throw new Error(`[dappress] ${zipPath} does not look like a MetaMask build: no manifest.json after extraction`);
|
|
46
|
+
}
|
|
47
|
+
return dir;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { prepareExtension };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const { resolveOptions, publicOptions } = require('./config');
|
|
2
|
+
const { captureDebuggerUrl } = require('./browser');
|
|
3
|
+
const { prepareExtension } = require('./download');
|
|
4
|
+
const { prepareProfile, installProfile } = require('./profile');
|
|
5
|
+
const { createTasks } = require('./actions');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Wire Dappress into a Cypress project. Call it from setupNodeEvents:
|
|
9
|
+
*
|
|
10
|
+
* setupNodeEvents(on, config) {
|
|
11
|
+
* return configureDappress(on, config);
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* It loads the MetaMask extension into the browser Cypress launches, and
|
|
15
|
+
* registers the tasks behind the cy.* commands from 'dappress/support'.
|
|
16
|
+
*/
|
|
17
|
+
function configureDappress(on, config, userOptions = {}) {
|
|
18
|
+
const options = resolveOptions(userOptions, config);
|
|
19
|
+
|
|
20
|
+
// Build the wallet profile before the run, out of the time Cypress allows a browser to come up
|
|
21
|
+
on('before:run', async ({ browser }) => {
|
|
22
|
+
if (options.cache && browser) await prepareProfile({ browserPath: browser.path, extensionDir: await prepareExtension(options), options });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
on('before:browser:launch', async (browser, launchOptions) => {
|
|
26
|
+
if (browser.family !== 'chromium') {
|
|
27
|
+
throw new Error(`[dappress] MetaMask is a Chromium extension; ${browser.name} is not supported`);
|
|
28
|
+
}
|
|
29
|
+
const extensionDir = await prepareExtension(options);
|
|
30
|
+
launchOptions.extensions.push(extensionDir);
|
|
31
|
+
if (options.cache) {
|
|
32
|
+
const profileDir = await prepareProfile({ browserPath: browser.path, extensionDir, options });
|
|
33
|
+
await installProfile(profileDir, browser, config.isTextTerminal);
|
|
34
|
+
}
|
|
35
|
+
return launchOptions;
|
|
36
|
+
});
|
|
37
|
+
captureDebuggerUrl(on);
|
|
38
|
+
on('task', createTasks(options));
|
|
39
|
+
|
|
40
|
+
// Non-sensitive settings the browser side reads with Cypress.expose()
|
|
41
|
+
config.expose = { ...config.expose, dappress: publicOptions(options) };
|
|
42
|
+
return config;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { configureDappress };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Find MetaMask's pages in the browser Cypress launched: the full-screen home
|
|
2
|
+
// page, and the surface where dapp requests show up. On Chrome that surface
|
|
3
|
+
// is the side panel when it is open, and the "MetaMask Dialog" popup otherwise.
|
|
4
|
+
|
|
5
|
+
const { sleep } = require('./page-helpers');
|
|
6
|
+
|
|
7
|
+
const HOME_PATH = '/home.html';
|
|
8
|
+
const CONFIRMATION_PATHS = ['/notification.html', '/sidepanel.html'];
|
|
9
|
+
|
|
10
|
+
function extensionIdOf(url) {
|
|
11
|
+
const match = /^chrome-extension:\/\/([a-z]{32})\//.exec(url);
|
|
12
|
+
return match ? match[1] : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isHomeRoute(url) {
|
|
16
|
+
return url.includes('/notification.html') && /notification\.html(#\/?)?$/.test(url);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pagesOf(browser, extensionId, pathname) {
|
|
20
|
+
return browser
|
|
21
|
+
.targets()
|
|
22
|
+
.filter((target) => target.type() === 'page' && target.url().startsWith(`chrome-extension://${extensionId}${pathname}`));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* MetaMask's extension id. Cypress loads its own extension in headed
|
|
27
|
+
* Chromium browsers, so the id is the one whose home page is titled "MetaMask".
|
|
28
|
+
*/
|
|
29
|
+
async function findExtensionId(browser, timeout = 30000) {
|
|
30
|
+
const deadline = Date.now() + timeout;
|
|
31
|
+
while (Date.now() < deadline) {
|
|
32
|
+
const ids = new Set(browser.targets().map((target) => extensionIdOf(target.url())).filter(Boolean));
|
|
33
|
+
for (const id of ids) {
|
|
34
|
+
if (await isMetaMask(browser, id)) return id;
|
|
35
|
+
}
|
|
36
|
+
await sleep(500);
|
|
37
|
+
}
|
|
38
|
+
throw new Error('[dappress] MetaMask extension not found in the browser. Is it loaded in before:browser:launch?');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function isMetaMask(browser, extensionId) {
|
|
42
|
+
const [target] = pagesOf(browser, extensionId, HOME_PATH);
|
|
43
|
+
if (target) {
|
|
44
|
+
const page = await target.page();
|
|
45
|
+
return (await page.title()).startsWith('MetaMask');
|
|
46
|
+
}
|
|
47
|
+
const page = await browser.newPage();
|
|
48
|
+
try {
|
|
49
|
+
const response = await page.goto(`chrome-extension://${extensionId}${HOME_PATH}`, { waitUntil: 'domcontentloaded' });
|
|
50
|
+
return Boolean(response && response.ok()) && (await page.title()).startsWith('MetaMask');
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
} finally {
|
|
54
|
+
await page.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The full-screen MetaMask page, opened if it isn't already. */
|
|
59
|
+
async function getHomePage(browser, extensionId) {
|
|
60
|
+
const [target] = pagesOf(browser, extensionId, HOME_PATH);
|
|
61
|
+
if (target) return target.page();
|
|
62
|
+
const page = await browser.newPage();
|
|
63
|
+
await page.goto(`chrome-extension://${extensionId}${HOME_PATH}`, { waitUntil: 'domcontentloaded' });
|
|
64
|
+
return page;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The page a dapp request is shown on. Waits for the popup to open, or takes
|
|
69
|
+
* the side panel. A popup still on its home route is one closing after the
|
|
70
|
+
* previous request, or one that hasn't routed to the request yet: skipped.
|
|
71
|
+
*/
|
|
72
|
+
async function getConfirmationPage(browser, extensionId, timeout) {
|
|
73
|
+
const deadline = Date.now() + timeout;
|
|
74
|
+
while (Date.now() < deadline) {
|
|
75
|
+
for (const pathname of CONFIRMATION_PATHS) {
|
|
76
|
+
const [target] = pagesOf(browser, extensionId, pathname).filter((candidate) => !isHomeRoute(candidate.url()));
|
|
77
|
+
if (target) return target.page();
|
|
78
|
+
}
|
|
79
|
+
await sleep(250);
|
|
80
|
+
}
|
|
81
|
+
const seen = browser
|
|
82
|
+
.targets()
|
|
83
|
+
.filter((target) => extensionIdOf(target.url()) === extensionId)
|
|
84
|
+
.map((target) => `${target.type()} ${target.url()}`)
|
|
85
|
+
.join(', ');
|
|
86
|
+
throw new Error(
|
|
87
|
+
`[dappress] MetaMask showed no confirmation within ${timeout}ms. Did the dapp send a request? MetaMask pages seen: ${seen || 'none'}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { findExtensionId, getHomePage, getConfirmationPage };
|
package/src/metamask.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// MetaMask's screens: what to click, and in which order.
|
|
2
|
+
//
|
|
3
|
+
// Selectors come from MetaMask's own end-to-end page objects
|
|
4
|
+
// (test/e2e/page-objects in the metamask-extension repository), which MetaMask
|
|
5
|
+
// has to keep working for its own test suite. Each entry lists the data-testid
|
|
6
|
+
// first, then a fallback on the button's English text. When a MetaMask release
|
|
7
|
+
// moves something, this is the file to fix.
|
|
8
|
+
|
|
9
|
+
const { describe, waitFor, isVisible, isGone, click, clickWhenEnabled, dispatchClick, fill, failure, sleep } = require('./page-helpers');
|
|
10
|
+
|
|
11
|
+
const testId = (id) => `[data-testid="${id}"]`;
|
|
12
|
+
// XPath rather than Puppeteer's ::-p-text(): the latter needs MutationObserver,
|
|
13
|
+
// which MetaMask's sandbox (LavaMoat) blocks in its pages.
|
|
14
|
+
const buttonText = (text) => `xpath/.//button[normalize-space(.)="${text}"]`;
|
|
15
|
+
const passwordInput = (position) => `xpath/(.//input[@type="password"])[${position}]`;
|
|
16
|
+
|
|
17
|
+
const selectors = {
|
|
18
|
+
onboarding: {
|
|
19
|
+
importWallet: [testId('onboarding-import-wallet'), buttonText('I have an existing wallet')],
|
|
20
|
+
importWithSrp: [testId('onboarding-import-with-srp-button'), buttonText('Import using Secret Recovery Phrase')],
|
|
21
|
+
srpInput: testId('srp-input-import__srp-note'),
|
|
22
|
+
srpWord: (index) => testId(`import-srp__srp-word-${index}`),
|
|
23
|
+
srpConfirm: [testId('import-srp-confirm'), buttonText('Continue')],
|
|
24
|
+
newPassword: [testId('create-password-new-input'), passwordInput(1)],
|
|
25
|
+
confirmPassword: [testId('create-password-confirm-input'), passwordInput(2)],
|
|
26
|
+
passwordTerms: testId('create-password-terms'),
|
|
27
|
+
passwordSubmit: [testId('create-password-submit'), buttonText('Import my wallet')],
|
|
28
|
+
passkeyMaybeLater: [testId('passkey-maybe-later-button'), buttonText('Maybe later')],
|
|
29
|
+
metricsCheckbox: testId('metametrics-checkbox'),
|
|
30
|
+
metricsContinue: [testId('metametrics-i-agree'), buttonText('Continue')],
|
|
31
|
+
downloadAppContinue: testId('download-app-continue'),
|
|
32
|
+
done: [testId('onboarding-complete-done'), buttonText('Done')],
|
|
33
|
+
},
|
|
34
|
+
unlock: {
|
|
35
|
+
password: [testId('unlock-password'), passwordInput(1)],
|
|
36
|
+
submit: [testId('unlock-submit'), buttonText('Unlock')],
|
|
37
|
+
},
|
|
38
|
+
home: {
|
|
39
|
+
header: testId('parent-selector-header-navbar'),
|
|
40
|
+
},
|
|
41
|
+
// "Connect this website with MetaMask"
|
|
42
|
+
connect: {
|
|
43
|
+
confirm: [testId('confirm-btn'), buttonText('Connect')],
|
|
44
|
+
cancel: [testId('cancel-btn'), buttonText('Cancel')],
|
|
45
|
+
},
|
|
46
|
+
// Signatures, transactions and "Add network" share the same confirmation footer
|
|
47
|
+
confirmation: {
|
|
48
|
+
confirm: [testId('confirm-footer-button'), buttonText('Confirm'), buttonText('Approve')],
|
|
49
|
+
cancel: [testId('confirm-footer-cancel-button'), buttonText('Cancel')],
|
|
50
|
+
scrollToBottom: testId('confirm-scroll-to-bottom'),
|
|
51
|
+
},
|
|
52
|
+
// The older footer, still used by the permission update a network switch
|
|
53
|
+
// asks for, by "Add suggested tokens", and by template confirmations
|
|
54
|
+
pageContainer: {
|
|
55
|
+
confirm: [testId('page-container-footer-next'), testId('confirmation-submit-button'), buttonText('Confirm')],
|
|
56
|
+
cancel: [testId('page-container-footer-cancel'), testId('confirmation-cancel-button'), buttonText('Cancel')],
|
|
57
|
+
},
|
|
58
|
+
// "Switching network will cancel N pending transactions": a warning MetaMask
|
|
59
|
+
// raises after the confirmation is accepted; "Got it" carries on
|
|
60
|
+
alert: {
|
|
61
|
+
acknowledge: [testId('alert-modal-button'), buttonText('Got it')],
|
|
62
|
+
},
|
|
63
|
+
// "We've noticed multiple requests": shown over the confirmation when a dapp
|
|
64
|
+
// sends many requests in a row, and it swallows the clicks
|
|
65
|
+
modal: {
|
|
66
|
+
content: '.mm-modal-content',
|
|
67
|
+
lastButton: 'xpath/(.//div[contains(@class, "mm-modal-content")]//button)[last()]',
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Which button each command presses on the confirmation
|
|
72
|
+
const decisions = {
|
|
73
|
+
connectToDapp: selectors.connect.confirm,
|
|
74
|
+
rejectConnection: selectors.connect.cancel,
|
|
75
|
+
approveNewNetwork: selectors.confirmation.confirm,
|
|
76
|
+
rejectNewNetwork: selectors.confirmation.cancel,
|
|
77
|
+
// A switch to a network the dapp is allowed on is silent; otherwise MetaMask asks for the permission
|
|
78
|
+
approveSwitchNetwork: selectors.pageContainer.confirm,
|
|
79
|
+
rejectSwitchNetwork: selectors.pageContainer.cancel,
|
|
80
|
+
// wallet_watchAsset
|
|
81
|
+
approveAddToken: selectors.pageContainer.confirm,
|
|
82
|
+
rejectAddToken: selectors.pageContainer.cancel,
|
|
83
|
+
confirmSignature: selectors.confirmation.confirm,
|
|
84
|
+
rejectSignature: selectors.confirmation.cancel,
|
|
85
|
+
confirmTransaction: selectors.confirmation.confirm,
|
|
86
|
+
rejectTransaction: selectors.confirmation.cancel,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Which screen the extension home page shows, once MetaMask has started. */
|
|
90
|
+
async function walletState(page) {
|
|
91
|
+
const deadline = Date.now() + 20000;
|
|
92
|
+
while (Date.now() < deadline) {
|
|
93
|
+
if (await isVisible(page, selectors.onboarding.importWallet, 500)) return 'onboarding';
|
|
94
|
+
if (await isVisible(page, selectors.unlock.password, 500)) return 'locked';
|
|
95
|
+
if (await isVisible(page, selectors.home.header, 500)) return 'unlocked';
|
|
96
|
+
}
|
|
97
|
+
return 'unknown';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Import a wallet from its seed phrase on a fresh MetaMask install. */
|
|
101
|
+
async function onboard(page, { seedPhrase, password }) {
|
|
102
|
+
const s = selectors.onboarding;
|
|
103
|
+
await click(page, s.importWallet, { timeout: 30000 });
|
|
104
|
+
await click(page, s.importWithSrp);
|
|
105
|
+
|
|
106
|
+
await fillSeedPhrase(page, seedPhrase);
|
|
107
|
+
await clickWhenEnabled(page, s.srpConfirm);
|
|
108
|
+
|
|
109
|
+
await fill(page, s.newPassword, password);
|
|
110
|
+
await fill(page, s.confirmPassword, password);
|
|
111
|
+
await click(page, s.passwordTerms);
|
|
112
|
+
await clickWhenEnabled(page, s.passwordSubmit);
|
|
113
|
+
|
|
114
|
+
await reachHome(page, { password });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// MetaMask's sandbox (LavaMoat) blocks a scripted paste, so the phrase is
|
|
118
|
+
// typed word by word: a space after a word moves the cursor to the next field.
|
|
119
|
+
async function fillSeedPhrase(page, seedPhrase) {
|
|
120
|
+
const words = seedPhrase.trim().split(/\s+/);
|
|
121
|
+
await fill(page, selectors.onboarding.srpInput, words[0]);
|
|
122
|
+
await page.keyboard.press('Space');
|
|
123
|
+
for (let index = 1; index < words.length; index++) {
|
|
124
|
+
await fill(page, selectors.onboarding.srpWord(index), words[index]);
|
|
125
|
+
if (index < words.length - 1) await page.keyboard.press('Space');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function unlock(page, { password }) {
|
|
130
|
+
await fill(page, selectors.unlock.password, password);
|
|
131
|
+
await click(page, selectors.unlock.submit);
|
|
132
|
+
await reachHome(page, { password });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Go through whatever MetaMask shows until the wallet's home: the screens
|
|
137
|
+
* that may follow the password or an unlock (passkey, analytics, "download
|
|
138
|
+
* the app", "Done"), and the unlock form if MetaMask locked meanwhile. After
|
|
139
|
+
* "Done", MetaMask takes a moment to record the onboarding as complete and
|
|
140
|
+
* redirects to it until then, so the home page is reloaded.
|
|
141
|
+
*/
|
|
142
|
+
async function reachHome(page, { password }) {
|
|
143
|
+
const s = selectors.onboarding;
|
|
144
|
+
const deadline = Date.now() + 90000;
|
|
145
|
+
while (Date.now() < deadline) {
|
|
146
|
+
if (await isVisible(page, selectors.home.header, 500)) return;
|
|
147
|
+
if (await isVisible(page, s.passkeyMaybeLater, 500)) await click(page, s.passkeyMaybeLater);
|
|
148
|
+
if (await isVisible(page, s.metricsCheckbox, 500)) {
|
|
149
|
+
await optOutOfMetrics(page);
|
|
150
|
+
await click(page, s.metricsContinue);
|
|
151
|
+
}
|
|
152
|
+
if (await isVisible(page, s.downloadAppContinue, 500)) await click(page, s.downloadAppContinue);
|
|
153
|
+
if (await isVisible(page, s.done, 500)) {
|
|
154
|
+
await click(page, s.done);
|
|
155
|
+
await sleep(1000);
|
|
156
|
+
await reloadHome(page);
|
|
157
|
+
}
|
|
158
|
+
if (await isVisible(page, selectors.unlock.password, 500)) {
|
|
159
|
+
await fill(page, selectors.unlock.password, password);
|
|
160
|
+
await click(page, selectors.unlock.submit);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
throw await failure(page, 'MetaMask did not show the wallet');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function optOutOfMetrics(page) {
|
|
167
|
+
const checkbox = await waitFor(page, selectors.onboarding.metricsCheckbox);
|
|
168
|
+
const checked = await checkbox.evaluate((el) => el.getAttribute('data-checked') === 'true');
|
|
169
|
+
if (checked) await checkbox.click();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Through about:blank: dropping only the "#" part of the URL would be a
|
|
173
|
+
// same-document navigation, which goto() waits on forever
|
|
174
|
+
async function reloadHome(page) {
|
|
175
|
+
const homeUrl = page.url().split('#')[0];
|
|
176
|
+
await page.goto('about:blank');
|
|
177
|
+
await page.goto(homeUrl);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Press the button of `decision` on the confirmation shown on `page`. */
|
|
181
|
+
function decide(decision, page, timeout) {
|
|
182
|
+
return pressAndWaitForDismissal(page, decisions[decision], timeout);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** wallet_addEthereumChain on a network MetaMask knows behaves like a switch: either prompt may show. */
|
|
186
|
+
function approveNetworkChange(page, timeout) {
|
|
187
|
+
return pressAndWaitForDismissal(page, [...selectors.confirmation.confirm, ...selectors.pageContainer.confirm], timeout);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Press a footer button, then wait for the confirmation to go away: the
|
|
192
|
+
* popup closes, the side panel goes back to the home screen. A click that
|
|
193
|
+
* lands while the confirmation is still settling is lost, so the click is
|
|
194
|
+
* repeated while the button stays.
|
|
195
|
+
*/
|
|
196
|
+
async function pressAndWaitForDismissal(page, button, timeout) {
|
|
197
|
+
await waitFor(page, button, { timeout });
|
|
198
|
+
await dismissModal(page);
|
|
199
|
+
if (await isVisible(page, selectors.confirmation.scrollToBottom, 500)) {
|
|
200
|
+
await click(page, selectors.confirmation.scrollToBottom);
|
|
201
|
+
}
|
|
202
|
+
await clickWhenEnabled(page, button);
|
|
203
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
204
|
+
if (await isVisible(page, selectors.alert.acknowledge, 500)) await click(page, selectors.alert.acknowledge);
|
|
205
|
+
if (await isGone(page, button, 3000)) return;
|
|
206
|
+
await dismissModal(page);
|
|
207
|
+
await dispatchClick(page, button, { timeout: 3000 }).catch(() => {});
|
|
208
|
+
}
|
|
209
|
+
throw await failure(page, `"${describe(button)}" is still showing after being clicked`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Close a modal shown over the confirmation (Escape, or its last button: "Cancel"). */
|
|
213
|
+
async function dismissModal(page) {
|
|
214
|
+
if (!(await isVisible(page, selectors.modal.content, 300))) return;
|
|
215
|
+
await page.keyboard.press('Escape');
|
|
216
|
+
if (await isGone(page, selectors.modal.content, 1000)) return;
|
|
217
|
+
await dispatchClick(page, selectors.modal.lastButton);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = { decisions, walletState, onboard, unlock, decide, approveNetworkChange };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Small helpers on top of Puppeteer's Page API. They give every interaction
|
|
2
|
+
// the same waiting and error reporting, and stay within what MetaMask's
|
|
3
|
+
// sandbox (LavaMoat) allows: no script injected into the page.
|
|
4
|
+
//
|
|
5
|
+
// A "selector" is a CSS or XPath selector, or a list of them tried together:
|
|
6
|
+
// the first one to show up wins.
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const os = require('node:os');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TIMEOUT = 15000;
|
|
13
|
+
const SCREENSHOTS_DIR = path.join(os.tmpdir(), 'dappress');
|
|
14
|
+
|
|
15
|
+
const candidates = (selector) => [].concat(selector);
|
|
16
|
+
|
|
17
|
+
/** A readable name for a selector, for error messages. */
|
|
18
|
+
function describe(selector) {
|
|
19
|
+
return candidates(selector)
|
|
20
|
+
.map((candidate) => candidate.replace(/^\[data-testid="(.+)"\]$/, '$1').replace(/^xpath\/.*="(.+)"\]$/, 'button "$1"'))
|
|
21
|
+
.join(' | ');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Wait for the first candidate to be visible and return its element handle. */
|
|
25
|
+
async function waitFor(page, selector, { timeout = DEFAULT_TIMEOUT } = {}) {
|
|
26
|
+
const attempts = candidates(selector).map((candidate) => page.waitForSelector(candidate, { visible: true, timeout }));
|
|
27
|
+
try {
|
|
28
|
+
return await Promise.any(attempts);
|
|
29
|
+
} catch {
|
|
30
|
+
throw await failure(page, `Timed out waiting for "${describe(selector)}"`);
|
|
31
|
+
} finally {
|
|
32
|
+
// Let the losing attempts fail quietly when they time out
|
|
33
|
+
attempts.forEach((attempt) => attempt.catch(() => {}));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Returns true when `selector` is visible within `timeout` ms, false otherwise. */
|
|
38
|
+
async function isVisible(page, selector, timeout = 1500) {
|
|
39
|
+
try {
|
|
40
|
+
await waitFor(page, selector, { timeout });
|
|
41
|
+
return true;
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Returns true once every candidate has left the page, or if the page itself closes. */
|
|
48
|
+
async function isGone(page, selector, timeout = 1500) {
|
|
49
|
+
try {
|
|
50
|
+
await Promise.all(candidates(selector).map((candidate) => page.waitForSelector(candidate, { hidden: true, timeout })));
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return page.isClosed();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function click(page, selector, options) {
|
|
58
|
+
const element = await waitFor(page, selector, options);
|
|
59
|
+
await waitForStill(element);
|
|
60
|
+
await element.click();
|
|
61
|
+
}
|
|
62
|
+
|
|
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 });
|
|
66
|
+
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`);
|
|
69
|
+
await sleep(100);
|
|
70
|
+
}
|
|
71
|
+
await waitForStill(element);
|
|
72
|
+
await element.click();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Click by dispatching the event on the element itself, for buttons something may cover. */
|
|
76
|
+
async function dispatchClick(page, selector, options) {
|
|
77
|
+
const element = await waitFor(page, selector, options);
|
|
78
|
+
await element.evaluate((el) => el.click());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** MetaMask animates its menus and confirmations in; a click during the animation lands elsewhere. */
|
|
82
|
+
async function waitForStill(element, timeout = 2000) {
|
|
83
|
+
const deadline = Date.now() + timeout;
|
|
84
|
+
let previous = JSON.stringify(await element.boundingBox());
|
|
85
|
+
while (Date.now() < deadline) {
|
|
86
|
+
await sleep(100);
|
|
87
|
+
const current = JSON.stringify(await element.boundingBox());
|
|
88
|
+
if (current === previous) return;
|
|
89
|
+
previous = current;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Replace the content of a field with `text`. */
|
|
94
|
+
async function fill(page, selector, text, options) {
|
|
95
|
+
const element = await waitFor(page, selector, options);
|
|
96
|
+
await element.click({ clickCount: 3 });
|
|
97
|
+
await element.type(text);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The error for something that went wrong on `page`, with a screenshot to look at. */
|
|
101
|
+
async function failure(page, message) {
|
|
102
|
+
const prefix = `[dappress] ${message} on ${page.url()}`;
|
|
103
|
+
try {
|
|
104
|
+
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
|
105
|
+
const file = path.join(SCREENSHOTS_DIR, `${Date.now()}.png`);
|
|
106
|
+
await page.screenshot({ path: file });
|
|
107
|
+
return new Error(`${prefix}. Screenshot: ${file}`);
|
|
108
|
+
} catch {
|
|
109
|
+
return new Error(prefix);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sleep(ms) {
|
|
114
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { describe, waitFor, isVisible, isGone, click, clickWhenEnabled, dispatchClick, fill, failure, sleep };
|
package/src/profile.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Onboard the wallet once, in a browser of our own, and keep the resulting
|
|
2
|
+
// profile. Each Cypress run then starts from a copy of it, with the wallet
|
|
3
|
+
// imported but nothing else: no connected site, no added network.
|
|
4
|
+
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const puppeteer = require('puppeteer-core');
|
|
10
|
+
const metamask = require('./metamask');
|
|
11
|
+
const { findExtensionId, getHomePage } = require('./metamask-pages');
|
|
12
|
+
|
|
13
|
+
// One build at a time: Cypress calls before:browser:launch again when the
|
|
14
|
+
// browser is slow to connect, and two builds on one profile would collide
|
|
15
|
+
const builds = new Map();
|
|
16
|
+
|
|
17
|
+
/** The cached profile for these options, built on first use. */
|
|
18
|
+
function prepareProfile({ browserPath, extensionDir, options }) {
|
|
19
|
+
const key = crypto.createHash('sha256').update([options.metamaskVersion, options.seedPhrase, options.password].join('|')).digest('hex').slice(0, 16);
|
|
20
|
+
if (!builds.has(key)) builds.set(key, buildProfile(path.join(options.cacheDir, 'profiles', key), { browserPath, extensionDir, options }));
|
|
21
|
+
return builds.get(key);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function buildProfile(dir, { browserPath, extensionDir, options }) {
|
|
25
|
+
if (fs.existsSync(path.join(dir, 'ready'))) return dir;
|
|
26
|
+
|
|
27
|
+
console.log('[dappress] Importing the wallet once, into a cached profile…');
|
|
28
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
29
|
+
const launch = () =>
|
|
30
|
+
puppeteer.launch({
|
|
31
|
+
executablePath: browserPath,
|
|
32
|
+
headless: false,
|
|
33
|
+
userDataDir: dir,
|
|
34
|
+
args: [`--load-extension=${extensionDir}`, `--disable-extensions-except=${extensionDir}`, '--no-first-run'],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
await withMetaMask(launch, (home) => metamask.onboard(home, options));
|
|
38
|
+
// Reopen the profile: the wallet must now be there, locked, with no onboarding left
|
|
39
|
+
await withMetaMask(launch, async (home) => {
|
|
40
|
+
const state = await metamask.walletState(home);
|
|
41
|
+
if (state !== 'locked') throw new Error(`[dappress] The cached profile did not keep the wallet: MetaMask is ${state} at ${home.url()}`);
|
|
42
|
+
await metamask.unlock(home, options);
|
|
43
|
+
});
|
|
44
|
+
fs.writeFileSync(path.join(dir, 'ready'), '');
|
|
45
|
+
return dir;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// MetaMask persists its state a moment after the last screen; leave it that moment before closing
|
|
49
|
+
async function withMetaMask(launch, action) {
|
|
50
|
+
const browser = await launch();
|
|
51
|
+
try {
|
|
52
|
+
await action(await getHomePage(browser, await findExtensionId(browser)));
|
|
53
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
54
|
+
} finally {
|
|
55
|
+
await browser.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Where Cypress is about to create the browser profile:
|
|
60
|
+
// <app data>/Cypress/cy/<env>/browsers/<name>-<channel>/run-<pid of the Cypress process>,
|
|
61
|
+
// or .../interactive in open mode. The pid is our parent's: Cypress runs
|
|
62
|
+
// setupNodeEvents in a child process. Returns null when it can't be found.
|
|
63
|
+
function cypressProfileDir(browser, isTextTerminal) {
|
|
64
|
+
const appData = [
|
|
65
|
+
path.join(os.homedir(), 'Library', 'Application Support'),
|
|
66
|
+
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),
|
|
67
|
+
process.env.APPDATA,
|
|
68
|
+
].filter(Boolean);
|
|
69
|
+
const cypressData = appData.map((dir) => path.join(dir, 'Cypress', 'cy')).find((dir) => fs.existsSync(dir));
|
|
70
|
+
if (!cypressData) return null;
|
|
71
|
+
const env = ['production', 'development'].find((name) => fs.existsSync(path.join(cypressData, name)));
|
|
72
|
+
if (!env) return null;
|
|
73
|
+
return path.join(cypressData, env, 'browsers', `${browser.name}-${browser.channel}`, isTextTerminal ? `run-${process.ppid}` : 'interactive');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Copy MetaMask's data from the cached profile into the profile Cypress is
|
|
78
|
+
* about to launch. Returns false when that profile can't be located; the
|
|
79
|
+
* wallet is then imported during the run instead.
|
|
80
|
+
*/
|
|
81
|
+
async function installProfile(profileDir, browser, isTextTerminal) {
|
|
82
|
+
const userDataDir = cypressProfileDir(browser, isTextTerminal);
|
|
83
|
+
if (!userDataDir) {
|
|
84
|
+
console.warn('[dappress] Could not locate the Cypress browser profile; importing the wallet in this run instead');
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
for (const entry of metamaskData(profileDir)) {
|
|
88
|
+
await fs.promises.cp(path.join(profileDir, entry), path.join(userDataDir, entry), { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Where Chrome keeps an extension's storage: chrome.storage.local and IndexedDB
|
|
94
|
+
function metamaskData(profileDir) {
|
|
95
|
+
const base = path.join(profileDir, 'Default');
|
|
96
|
+
const entries = ['Local Extension Settings', 'IndexedDB'];
|
|
97
|
+
return entries
|
|
98
|
+
.filter((entry) => fs.existsSync(path.join(base, entry)))
|
|
99
|
+
.flatMap((entry) => fs.readdirSync(path.join(base, entry)).map((name) => path.join('Default', entry, name)));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { prepareProfile, installProfile };
|
package/src/support.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Cypress commands. Import this file from your support file:
|
|
2
|
+
//
|
|
3
|
+
// import 'dappress/support';
|
|
4
|
+
//
|
|
5
|
+
// Each command asks the Node side (src/actions.js) to drive MetaMask through
|
|
6
|
+
// a Cypress task, then yields back to the test.
|
|
7
|
+
|
|
8
|
+
const options = Cypress.expose('dappress') || {};
|
|
9
|
+
|
|
10
|
+
function metamask(action, argument = null) {
|
|
11
|
+
Cypress.log({ name: 'metamask', message: argument ? `${action} ${argument}` : action });
|
|
12
|
+
return cy.task(`dappress:${action}`, argument, { log: false });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function provider(method, params) {
|
|
16
|
+
return cy.window({ log: false }).then((win) => win.ethereum.request({ method, params }));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Cypress.Commands.add('setupMetaMask', () => metamask('setupWallet'));
|
|
20
|
+
Cypress.Commands.add('rejectConnection', () => metamask('rejectConnection'));
|
|
21
|
+
Cypress.Commands.add('approveNewNetwork', () => metamask('approveNewNetwork'));
|
|
22
|
+
Cypress.Commands.add('rejectNewNetwork', () => metamask('rejectNewNetwork'));
|
|
23
|
+
Cypress.Commands.add('approveSwitchNetwork', () => metamask('approveSwitchNetwork'));
|
|
24
|
+
Cypress.Commands.add('rejectSwitchNetwork', () => metamask('rejectSwitchNetwork'));
|
|
25
|
+
Cypress.Commands.add('confirmSignature', () => metamask('confirmSignature'));
|
|
26
|
+
Cypress.Commands.add('rejectSignature', () => metamask('rejectSignature'));
|
|
27
|
+
Cypress.Commands.add('confirmTransaction', () => metamask('confirmTransaction'));
|
|
28
|
+
Cypress.Commands.add('rejectTransaction', () => metamask('rejectTransaction'));
|
|
29
|
+
Cypress.Commands.add('approveAddToken', () => metamask('approveAddToken'));
|
|
30
|
+
Cypress.Commands.add('rejectAddToken', () => metamask('rejectAddToken'));
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
// Connect, then move the dapp onto the network from the wallet setup, if there is one
|
|
34
|
+
Cypress.Commands.add('connectToDapp', () => {
|
|
35
|
+
metamask('connectToDapp');
|
|
36
|
+
if (options.network) cy.useNetwork(options.network);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Move the dapp onto `network` (a wallet_addEthereumChain parameter), adding
|
|
40
|
+
// it to MetaMask if needed. A switch to a network the dapp is already allowed
|
|
41
|
+
// on settles at once; anything else makes MetaMask ask, and we approve.
|
|
42
|
+
Cypress.Commands.add('useNetwork', (network = options.network) => {
|
|
43
|
+
if (!network) throw new Error('[dappress] cy.useNetwork() needs a network, or one in cypress/wallet.setup.js');
|
|
44
|
+
Cypress.log({ name: 'metamask', message: `useNetwork ${network.chainName || network.chainId}` });
|
|
45
|
+
cy.window({ log: false }).then((win) => {
|
|
46
|
+
win.dappressNetwork = { settled: false };
|
|
47
|
+
win.dappressNetwork.request = win.ethereum
|
|
48
|
+
.request({ method: 'wallet_addEthereumChain', params: [network] })
|
|
49
|
+
.then((result) => ({ result }), (error) => ({ error }))
|
|
50
|
+
.finally(() => (win.dappressNetwork.settled = true));
|
|
51
|
+
});
|
|
52
|
+
cy.wait(1500, { log: false });
|
|
53
|
+
cy.window({ log: false }).then((win) => {
|
|
54
|
+
if (!win.dappressNetwork.settled) metamask('approveNetworkChange');
|
|
55
|
+
});
|
|
56
|
+
cy.window({ log: false }).then({ timeout: 60000 }, (win) => win.dappressNetwork.request).then(({ error }) => {
|
|
57
|
+
if (error) throw new Error(`[dappress] MetaMask refused the network: ${error.message}`);
|
|
58
|
+
});
|
|
59
|
+
waitForChainId(network.chainId);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// The address the dapp sees, asked to the injected provider rather than to the
|
|
63
|
+
// MetaMask UI, so it keeps working across MetaMask versions.
|
|
64
|
+
Cypress.Commands.add('getAccountAddress', () => {
|
|
65
|
+
Cypress.log({ name: 'metamask', message: 'getAccountAddress' });
|
|
66
|
+
return provider('eth_accounts').then(([address]) => address);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// MetaMask announces the new chain to the dapp shortly after the switch
|
|
70
|
+
function waitForChainId(chainId, attempts = 20) {
|
|
71
|
+
return provider('eth_chainId').then((current) => {
|
|
72
|
+
if (current === chainId) return current;
|
|
73
|
+
if (attempts === 0) throw new Error(`[dappress] The dapp is on chain ${current}, not ${chainId}`);
|
|
74
|
+
return cy.wait(500, { log: false }).then(() => waitForChainId(chainId, attempts - 1));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Onboard or unlock the wallet once per spec, before the dapp needs it.
|
|
79
|
+
if (options.autoSetup !== false) {
|
|
80
|
+
before(() => {
|
|
81
|
+
cy.setupMetaMask();
|
|
82
|
+
});
|
|
83
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/// <reference types="cypress" />
|
|
2
|
+
|
|
3
|
+
/** A network, as passed to MetaMask's wallet_addEthereumChain. */
|
|
4
|
+
export interface Network {
|
|
5
|
+
chainId: string;
|
|
6
|
+
chainName: string;
|
|
7
|
+
rpcUrls: string[];
|
|
8
|
+
nativeCurrency: { name: string; symbol: string; decimals: number };
|
|
9
|
+
blockExplorerUrls?: string[];
|
|
10
|
+
iconUrls?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** What cypress/wallet.setup.ts exports. */
|
|
14
|
+
export interface WalletSetup {
|
|
15
|
+
/** Prefer DAPPRESS_SEED_PHRASE in cypress.env.json or the environment: this file is usually committed. */
|
|
16
|
+
seedPhrase?: string;
|
|
17
|
+
/** The network the dapp is moved onto by cy.connectToDapp(). */
|
|
18
|
+
network?: Network;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DappressOptions extends WalletSetup {
|
|
22
|
+
/** Password of the throwaway browser profile's wallet. Default: Tester@1234. */
|
|
23
|
+
password?: string;
|
|
24
|
+
/** MetaMask release to load, downloaded from GitHub on first run. */
|
|
25
|
+
metamaskVersion?: string;
|
|
26
|
+
/** Run cy.setupMetaMask() before the first test of each spec. Default: true. */
|
|
27
|
+
autoSetup?: boolean;
|
|
28
|
+
/** Import the wallet once and reuse the profile across runs. Keeps the vault under cacheDir. Default: false. */
|
|
29
|
+
cache?: boolean;
|
|
30
|
+
/** Time allowed for MetaMask to display a request, in ms. Default: 20000. */
|
|
31
|
+
timeout?: number;
|
|
32
|
+
/** Where MetaMask builds are cached. Default: ~/.cache/dappress. */
|
|
33
|
+
cacheDir?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Wire Dappress into a Cypress project, from setupNodeEvents:
|
|
38
|
+
*
|
|
39
|
+
* setupNodeEvents(on, config) {
|
|
40
|
+
* return configureDappress(on, config);
|
|
41
|
+
* }
|
|
42
|
+
*/
|
|
43
|
+
export function configureDappress(
|
|
44
|
+
on: Cypress.PluginEvents,
|
|
45
|
+
config: Cypress.PluginConfigOptions,
|
|
46
|
+
options?: DappressOptions,
|
|
47
|
+
): Cypress.PluginConfigOptions;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/// <reference types="cypress" />
|
|
2
|
+
|
|
3
|
+
import type { Network } from './index';
|
|
4
|
+
|
|
5
|
+
export type WalletState = 'onboarding' | 'locked' | 'unlocked';
|
|
6
|
+
|
|
7
|
+
declare global {
|
|
8
|
+
namespace Cypress {
|
|
9
|
+
interface Chainable {
|
|
10
|
+
/** Imports or unlocks the wallet. Runs by itself before each spec unless autoSetup is false. */
|
|
11
|
+
setupMetaMask(): Chainable<WalletState>;
|
|
12
|
+
/** Accepts the connection request, then moves the dapp onto the wallet setup's network if there is one. */
|
|
13
|
+
connectToDapp(): Chainable<void>;
|
|
14
|
+
rejectConnection(): Chainable<void>;
|
|
15
|
+
/** Answers a signature request (personal_sign, eth_signTypedData_*). */
|
|
16
|
+
confirmSignature(): Chainable<void>;
|
|
17
|
+
rejectSignature(): Chainable<void>;
|
|
18
|
+
/** Answers a transaction, including ERC-20 approvals. */
|
|
19
|
+
confirmTransaction(): Chainable<void>;
|
|
20
|
+
rejectTransaction(): Chainable<void>;
|
|
21
|
+
/** Answers wallet_addEthereumChain. */
|
|
22
|
+
approveNewNetwork(): Chainable<void>;
|
|
23
|
+
rejectNewNetwork(): Chainable<void>;
|
|
24
|
+
/** Answers the permission asked before switching to a network the dapp isn't allowed on yet. */
|
|
25
|
+
approveSwitchNetwork(): Chainable<void>;
|
|
26
|
+
rejectSwitchNetwork(): Chainable<void>;
|
|
27
|
+
/** Answers wallet_watchAsset ("Add suggested tokens"). */
|
|
28
|
+
approveAddToken(): Chainable<void>;
|
|
29
|
+
rejectAddToken(): Chainable<void>;
|
|
30
|
+
/** Moves the dapp onto a network, adding it to MetaMask if needed. Defaults to the wallet setup's network. Yields the chain id. */
|
|
31
|
+
useNetwork(network?: Network): Chainable<string>;
|
|
32
|
+
/** The address the dapp is connected with. */
|
|
33
|
+
getAccountAddress(): Chainable<string>;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|