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