dappress 0.3.2 → 0.4.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/README.md +30 -1
- package/package.json +1 -1
- package/src/actions.js +22 -5
- package/src/metamask-pages.js +21 -3
- package/src/metamask.js +389 -8
- package/src/support.js +16 -4
- package/types/support.d.ts +50 -4
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
**MetaMask automation for Cypress.**
|
|
4
4
|
|
|
5
|
+
[](https://github.com/blassaut/dappress/blob/conformance-reports/MATRIX.md)
|
|
6
|
+
|
|
5
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.
|
|
6
8
|
|
|
7
9
|
- **Current MetaMask.** The extension version is a configuration value. Each release is verified by a conformance suite, with one test per command.
|
|
@@ -124,10 +126,12 @@ The wallet is imported once, before the first test of each spec. Each command wa
|
|
|
124
126
|
| Command | What it does |
|
|
125
127
|
|---|---|
|
|
126
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 |
|
|
127
130
|
| `cy.rejectConnection()` | Rejects the connection request |
|
|
131
|
+
| `cy.disconnectFromDapp()` | Disconnects the dapp from MetaMask's permissions screen. The dapp receives an empty `accountsChanged`. |
|
|
128
132
|
| `cy.confirmSignature()` | Signs the message (`personal_sign`, `eth_signTypedData_*`) |
|
|
129
133
|
| `cy.rejectSignature()` | Rejects the signature request |
|
|
130
|
-
| `cy.confirmTransaction()` | Sends the transaction, including ERC-20 approvals |
|
|
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). |
|
|
131
135
|
| `cy.rejectTransaction()` | Rejects the transaction |
|
|
132
136
|
| `cy.approveNewNetwork()` | Adds the network requested by `wallet_addEthereumChain` |
|
|
133
137
|
| `cy.rejectNewNetwork()` | Rejects the network |
|
|
@@ -138,10 +142,35 @@ The wallet is imported once, before the first test of each spec. Each command wa
|
|
|
138
142
|
| `cy.addAccount()` | Adds an account to the wallet and selects it. Yields its name, such as `Account 2`. |
|
|
139
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. |
|
|
140
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. |
|
|
141
147
|
| `cy.useNetwork(network?)` | Moves the dapp onto a network, adding it to MetaMask when needed. Defaults to the network of the wallet setup. |
|
|
142
148
|
| `cy.getAccountAddress()` | Yields the address the dapp is connected with |
|
|
143
149
|
| `cy.setupMetaMask()` | Imports the wallet, or unlocks it. Called automatically before each spec. |
|
|
144
150
|
|
|
151
|
+
### Transaction options
|
|
152
|
+
|
|
153
|
+
`cy.confirmTransaction()` takes what a user may change on the confirmation before confirming it:
|
|
154
|
+
|
|
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' });
|
|
158
|
+
|
|
159
|
+
// The network fee, from the advanced form of MetaMask's fee editor
|
|
160
|
+
cy.confirmTransaction({ gas: { maxBaseFee: 30, priorityFee: 2, gasLimit: 100000 } });
|
|
161
|
+
|
|
162
|
+
// Or one of the estimates the fee editor lists
|
|
163
|
+
cy.confirmTransaction({ gas: 'aggressive' });
|
|
164
|
+
```
|
|
165
|
+
|
|
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.
|
|
173
|
+
|
|
145
174
|
### Configuration
|
|
146
175
|
|
|
147
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:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dappress",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
package/src/actions.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const { withBrowser } = require('./browser');
|
|
6
6
|
const metamask = require('./metamask');
|
|
7
|
-
const { findExtensionId, getHomePage, getConfirmationPage } = require('./metamask-pages');
|
|
7
|
+
const { findExtensionId, getHomePage, getRequestPages, getConfirmationPage } = require('./metamask-pages');
|
|
8
8
|
|
|
9
9
|
function createTasks(options) {
|
|
10
10
|
// Found once: the browser is the same for the whole run
|
|
@@ -16,11 +16,13 @@ function createTasks(options) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/** Onboard the wallet or unlock it, so the dapp can talk to it. Safe to call repeatedly. */
|
|
19
|
-
|
|
19
|
+
const setupWallet = (browser) => openWallet(browser, { onboard: true });
|
|
20
|
+
|
|
21
|
+
async function openWallet(browser, { onboard }) {
|
|
20
22
|
const home = await getHomePage(browser, await metamaskId(browser));
|
|
21
23
|
const state = await metamask.walletState(home);
|
|
22
24
|
console.log(`[dappress] MetaMask is ${state}`);
|
|
23
|
-
if (state === 'onboarding') await metamask.onboard(home, options);
|
|
25
|
+
if (state === 'onboarding' && onboard) await metamask.onboard(home, options);
|
|
24
26
|
if (state === 'locked') await metamask.unlock(home, options);
|
|
25
27
|
if (state === 'unknown') throw new Error(`[dappress] Unexpected MetaMask screen at ${home.url()}`);
|
|
26
28
|
await home.close();
|
|
@@ -38,6 +40,18 @@ function createTasks(options) {
|
|
|
38
40
|
}
|
|
39
41
|
};
|
|
40
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Unlock a locked wallet with the configured password, and say how it was
|
|
45
|
+
* found. Nothing to do on one already unlocked.
|
|
46
|
+
*/
|
|
47
|
+
async function unlockWallet(browser) {
|
|
48
|
+
const state = await openWallet(browser, { onboard: false });
|
|
49
|
+
if (state === 'onboarding') throw new Error('[dappress] No wallet to unlock: MetaMask is at its onboarding');
|
|
50
|
+
// The side panel, or a popup opened by a request, keeps its unlock form after an unlock made elsewhere
|
|
51
|
+
for (const page of await getRequestPages(browser, await metamaskId(browser))) await metamask.leaveUnlockForm(page, options);
|
|
52
|
+
return state;
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
/** Approve the prompt a network change raised: "Add network" or the permission to switch. */
|
|
42
56
|
async function approveNetworkChange(browser) {
|
|
43
57
|
const page = await getConfirmationPage(browser, await metamaskId(browser), options.timeout);
|
|
@@ -45,9 +59,9 @@ function createTasks(options) {
|
|
|
45
59
|
}
|
|
46
60
|
|
|
47
61
|
/** The task for a decision: find the confirmation, press its button. */
|
|
48
|
-
const decide = (decision) => async (browser) => {
|
|
62
|
+
const decide = (decision) => async (browser, argument) => {
|
|
49
63
|
const page = await getConfirmationPage(browser, await metamaskId(browser), options.timeout);
|
|
50
|
-
await metamask.decide(decision, page, options.timeout);
|
|
64
|
+
await metamask.decide(decision, page, options.timeout, argument);
|
|
51
65
|
};
|
|
52
66
|
|
|
53
67
|
const actions = {
|
|
@@ -56,6 +70,9 @@ function createTasks(options) {
|
|
|
56
70
|
addAccount: onHomePage(metamask.addAccount),
|
|
57
71
|
switchAccount: onHomePage(metamask.switchAccount),
|
|
58
72
|
importAccount: onHomePage(metamask.importAccount),
|
|
73
|
+
lockWallet: onHomePage(metamask.lock),
|
|
74
|
+
unlockWallet,
|
|
75
|
+
disconnectFromDapp: onHomePage(metamask.disconnectSite),
|
|
59
76
|
};
|
|
60
77
|
for (const decision of Object.keys(metamask.decisions)) actions[decision] = decide(decision);
|
|
61
78
|
|
package/src/metamask-pages.js
CHANGED
|
@@ -41,8 +41,14 @@ async function findExtensionId(browser, timeout = 30000) {
|
|
|
41
41
|
async function isMetaMask(browser, extensionId) {
|
|
42
42
|
const [target] = pagesOf(browser, extensionId, HOME_PATH);
|
|
43
43
|
if (target) {
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
// A page still loading or redirecting loses its execution context while its
|
|
45
|
+
// title is read; not MetaMask yet, findExtensionId looks again
|
|
46
|
+
try {
|
|
47
|
+
const page = await target.page();
|
|
48
|
+
return (await page.title()).startsWith('MetaMask');
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
46
52
|
}
|
|
47
53
|
const page = await browser.newPage();
|
|
48
54
|
try {
|
|
@@ -64,6 +70,18 @@ async function getHomePage(browser, extensionId) {
|
|
|
64
70
|
return page;
|
|
65
71
|
}
|
|
66
72
|
|
|
73
|
+
/** The pages open where dapp requests show up: the side panel, a popup. */
|
|
74
|
+
async function getRequestPages(browser, extensionId) {
|
|
75
|
+
const pages = [];
|
|
76
|
+
for (const pathname of CONFIRMATION_PATHS) {
|
|
77
|
+
for (const target of pagesOf(browser, extensionId, pathname)) {
|
|
78
|
+
const page = await target.page();
|
|
79
|
+
if (page) pages.push(pathname === '/notification.html' ? await withUsableWindow(page) : page);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return pages;
|
|
83
|
+
}
|
|
84
|
+
|
|
67
85
|
/**
|
|
68
86
|
* The page a dapp request is shown on. Waits for the popup to open, or takes
|
|
69
87
|
* the side panel. A popup still on its home route is one closing after the
|
|
@@ -112,4 +130,4 @@ async function withUsableWindow(page) {
|
|
|
112
130
|
return page;
|
|
113
131
|
}
|
|
114
132
|
|
|
115
|
-
module.exports = { findExtensionId, getHomePage, getConfirmationPage, withUsableWindow };
|
|
133
|
+
module.exports = { findExtensionId, getHomePage, getRequestPages, getConfirmationPage, withUsableWindow };
|
package/src/metamask.js
CHANGED
|
@@ -12,6 +12,7 @@ const testId = (id) => `[data-testid="${id}"]`;
|
|
|
12
12
|
// XPath rather than Puppeteer's ::-p-text(): the latter needs MutationObserver,
|
|
13
13
|
// which MetaMask's sandbox (LavaMoat) blocks in its pages.
|
|
14
14
|
const buttonText = (text) => `xpath/.//button[normalize-space(.)="${text}"]`;
|
|
15
|
+
const linkText = (text) => `xpath/.//a[normalize-space(.)="${text}"]`;
|
|
15
16
|
const passwordInput = (position) => `xpath/(.//input[@type="password"])[${position}]`;
|
|
16
17
|
|
|
17
18
|
const selectors = {
|
|
@@ -46,6 +47,27 @@ const selectors = {
|
|
|
46
47
|
header: testId('parent-selector-header-navbar'),
|
|
47
48
|
accountMenu: testId('account-menu-icon'),
|
|
48
49
|
},
|
|
50
|
+
// The menu of the home header, a drawer: settings, permissions, lock
|
|
51
|
+
menu: {
|
|
52
|
+
open: [testId('account-options-menu-button'), 'button[aria-label="Account options"]'],
|
|
53
|
+
lock: [testId('global-menu-lock'), buttonText('Lock')],
|
|
54
|
+
permissions: [testId('global-menu-connected-sites'), linkText('Permissions')],
|
|
55
|
+
},
|
|
56
|
+
// "Permissions": the sites connected to the wallet, then the page of one site
|
|
57
|
+
permissions: {
|
|
58
|
+
list: testId('parent-selector-permission-list'),
|
|
59
|
+
// Shown first when the wallet also holds token permissions; "Connections" leads to the list
|
|
60
|
+
hub: testId('parent-selector-gator-permissions'),
|
|
61
|
+
connections: 'xpath/.//p[normalize-space(.)="Connections"]',
|
|
62
|
+
// A site is listed by its host, "localhost:3000"
|
|
63
|
+
site: (host) => [
|
|
64
|
+
`xpath/.//*[@data-testid="connection-list-item"][.//p[normalize-space(.)="${host}"]]`,
|
|
65
|
+
`xpath/.//p[normalize-space(.)="${host}"]`,
|
|
66
|
+
],
|
|
67
|
+
disconnect: [testId('disconnect-button'), 'button[aria-label="Disconnect"]'],
|
|
68
|
+
// "Disconnect", in the modal that asks to confirm
|
|
69
|
+
confirmDisconnect: [testId('disconnect-all'), `xpath/.//*[@data-testid="disconnect-all-modal"]//button[normalize-space(.)="Disconnect"]`],
|
|
70
|
+
},
|
|
49
71
|
// The account list, opened from the home header
|
|
50
72
|
accounts: {
|
|
51
73
|
name: '[data-testid^="multichain-account-cell-name-"]',
|
|
@@ -62,6 +84,19 @@ const selectors = {
|
|
|
62
84
|
confirm: [testId('confirm-btn'), buttonText('Connect')],
|
|
63
85
|
cancel: [testId('cancel-btn'), buttonText('Cancel')],
|
|
64
86
|
},
|
|
87
|
+
// The accounts of a connection: the request names the one it suggests, and
|
|
88
|
+
// pressing it opens the list of the wallet's accounts, a checkbox on each
|
|
89
|
+
connectAccounts: {
|
|
90
|
+
// The suggested account, or their count when there are several
|
|
91
|
+
edit: [testId('account-selection-section'), '[data-testid="parent-selector-connect-page"] .multichain-account-cell, [data-testid^="accounts-count-"]'],
|
|
92
|
+
checkbox: (name) => `xpath/.//*[contains(@class, "multichain-account-cell")][.//*[@data-testid="multichain-account-cell-name-${name}"]]//input[@type="checkbox"]`,
|
|
93
|
+
save: [testId('connect-more-accounts-button'), buttonText('Save')],
|
|
94
|
+
// What the request shows once the list is saved: the account alone, or how many there are
|
|
95
|
+
chosen: (names) =>
|
|
96
|
+
names.length === 1
|
|
97
|
+
? `[data-testid="parent-selector-connect-page"] [data-testid="multichain-account-cell-name-${names[0]}"]`
|
|
98
|
+
: testId(`accounts-count-${names.length}`),
|
|
99
|
+
},
|
|
65
100
|
// Signatures, transactions and "Add network" share the same confirmation footer
|
|
66
101
|
confirmation: {
|
|
67
102
|
confirm: [testId('confirm-footer-button'), buttonText('Confirm'), buttonText('Approve')],
|
|
@@ -71,6 +106,24 @@ const selectors = {
|
|
|
71
106
|
// The pane the content scrolls in, by its inline style: it has no test id
|
|
72
107
|
scrollPane: '[style*="overflow: auto"]',
|
|
73
108
|
},
|
|
109
|
+
// What a transaction lets its reader change before confirming it: the
|
|
110
|
+
// spending cap of an ERC-20 approval, and the network fee
|
|
111
|
+
transaction: {
|
|
112
|
+
// The pencil of the "Spending cap" row, before the amount it edits
|
|
113
|
+
editSpendingCap: [testId('edit-spending-cap-icon'), 'xpath/.//button[@aria-label="Edit"][following::*[@data-testid="simulation-token-value"]]'],
|
|
114
|
+
spendingCap: testId('simulation-token-value'),
|
|
115
|
+
spendingCapInput: [testId('custom-spending-cap-input'), '.edit-spending-cap-modal input'],
|
|
116
|
+
// "Save", the last button of the modal's footer: it has no test id
|
|
117
|
+
spendingCapSave: ['.edit-spending-cap-modal .mm-modal-footer__button:last-child', 'xpath/.//*[contains(@class, "edit-spending-cap-modal")]//button[normalize-space(.)="Save"]'],
|
|
118
|
+
// The pencil of the "Network fee" row opens the fee editor: a list of
|
|
119
|
+
// estimates, whose last entry, "Advanced", leads to a form
|
|
120
|
+
editGas: [testId('edit-gas-fee-icon'), `${testId('gas-fee-section')} button[aria-label="Edit"]`],
|
|
121
|
+
gasEstimates: testId('gas-fee-estimates-modal'),
|
|
122
|
+
gasOption: (key, label) => [testId(`gas-option-${key}`), `xpath/.//*[@data-testid="gas-fee-estimates-modal"]//p[normalize-space(.)="${label}"]`],
|
|
123
|
+
gasForm: testId('gas-fee-advanced-eip1559-modal'),
|
|
124
|
+
gasField: (id) => [`#${id}`, `${testId(id)} input`],
|
|
125
|
+
gasSave: [testId('gas-fee-modal-save-button'), buttonText('Save')],
|
|
126
|
+
},
|
|
74
127
|
// The older footer, still used by the permission update a network switch
|
|
75
128
|
// asks for, by "Add suggested tokens", and by template confirmations
|
|
76
129
|
pageContainer: {
|
|
@@ -151,9 +204,28 @@ async function fillSeedPhrase(page, seedPhrase) {
|
|
|
151
204
|
}
|
|
152
205
|
|
|
153
206
|
async function unlock(page, { password }) {
|
|
207
|
+
await submitPassword(page, password);
|
|
208
|
+
await reachHome(page, { password });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function submitPassword(page, password) {
|
|
154
212
|
await fill(page, selectors.unlock.password, password);
|
|
155
213
|
await click(page, selectors.unlock.submit);
|
|
156
|
-
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A page that was showing the unlock form when the wallet got unlocked from
|
|
218
|
+
* another one keeps showing it, and the dapp's requests wait behind it. The
|
|
219
|
+
* password is given there too, as a user would; what follows is the wallet
|
|
220
|
+
* or a request, so only the form is watched.
|
|
221
|
+
*/
|
|
222
|
+
async function leaveUnlockForm(page, { password }) {
|
|
223
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
224
|
+
if (!(await isVisible(page, selectors.unlock.password, 500))) return;
|
|
225
|
+
await submitPassword(page, password);
|
|
226
|
+
if (await isGone(page, selectors.unlock.password, 10000)) return;
|
|
227
|
+
}
|
|
228
|
+
throw await failure(page, 'MetaMask kept showing its unlock form');
|
|
157
229
|
}
|
|
158
230
|
|
|
159
231
|
/**
|
|
@@ -184,10 +256,7 @@ async function reachHome(page, { password, backupAndSync }) {
|
|
|
184
256
|
await sleep(1000);
|
|
185
257
|
await reloadHome(page);
|
|
186
258
|
}
|
|
187
|
-
if (await isVisible(page, selectors.unlock.password, 500))
|
|
188
|
-
await fill(page, selectors.unlock.password, password);
|
|
189
|
-
await click(page, selectors.unlock.submit);
|
|
190
|
-
}
|
|
259
|
+
if (await isVisible(page, selectors.unlock.password, 500)) await submitPassword(page, password);
|
|
191
260
|
}
|
|
192
261
|
throw await failure(page, 'MetaMask did not show the wallet');
|
|
193
262
|
}
|
|
@@ -322,8 +391,152 @@ async function importAccount(page, privateKey) {
|
|
|
322
391
|
throw await failure(page, 'MetaMask did not import the account');
|
|
323
392
|
}
|
|
324
393
|
|
|
325
|
-
/**
|
|
326
|
-
function
|
|
394
|
+
/** Lock the wallet from the menu of its home page. Nothing to do on a wallet already locked. */
|
|
395
|
+
async function lock(page) {
|
|
396
|
+
if ((await walletState(page)) === 'locked') return;
|
|
397
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
398
|
+
await openMenu(page);
|
|
399
|
+
await click(page, selectors.menu.lock);
|
|
400
|
+
if (await isVisible(page, selectors.unlock.password, 10000)) return;
|
|
401
|
+
}
|
|
402
|
+
throw await failure(page, 'MetaMask did not lock');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// As with the account list, a click on the menu is lost while the home screen
|
|
406
|
+
// settles or swallowed by a modal, so it is pressed again until the menu shows.
|
|
407
|
+
async function openMenu(page) {
|
|
408
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
409
|
+
await page.bringToFront().catch(() => {});
|
|
410
|
+
await dismissModal(page);
|
|
411
|
+
await click(page, selectors.menu.open);
|
|
412
|
+
if (await isVisible(page, selectors.menu.lock, 5000)) return;
|
|
413
|
+
}
|
|
414
|
+
throw await failure(page, 'MetaMask did not open its menu');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Disconnect the site at `origin` from the wallet, on its page of the
|
|
419
|
+
* "Permissions" screen. The dapp gets an empty accountsChanged.
|
|
420
|
+
*/
|
|
421
|
+
async function disconnectSite(page, origin) {
|
|
422
|
+
const s = selectors.permissions;
|
|
423
|
+
const { host } = new URL(origin);
|
|
424
|
+
await openConnections(page);
|
|
425
|
+
if (!(await isVisible(page, s.site(host), 5000))) throw await failure(page, `MetaMask lists no connection to "${host}"`);
|
|
426
|
+
await openSite(page, host);
|
|
427
|
+
// Disconnecting goes back to the list, without the site. A lost click
|
|
428
|
+
// leaves the site's page or the modal showing, and is pressed again.
|
|
429
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
430
|
+
if (!(await isVisible(page, s.confirmDisconnect, 500))) await click(page, s.disconnect);
|
|
431
|
+
if (!(await isVisible(page, s.confirmDisconnect, 5000))) continue;
|
|
432
|
+
await click(page, s.confirmDisconnect);
|
|
433
|
+
if ((await isGone(page, [...s.confirmDisconnect, ...s.disconnect], 5000)) && (await isGone(page, s.site(host), 5000))) return;
|
|
434
|
+
}
|
|
435
|
+
throw await failure(page, `MetaMask did not disconnect "${host}"`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The list of connected sites, from the menu's "Permissions". */
|
|
439
|
+
async function openConnections(page) {
|
|
440
|
+
const s = selectors.permissions;
|
|
441
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
442
|
+
await openMenu(page);
|
|
443
|
+
await click(page, selectors.menu.permissions);
|
|
444
|
+
if (!(await isVisible(page, [s.list, s.hub], 10000))) continue;
|
|
445
|
+
if (await isVisible(page, s.list, 500)) return;
|
|
446
|
+
// The hub goes on to the list by itself when there is nothing else to show
|
|
447
|
+
if (await isVisible(page, s.connections, 3000)) await click(page, s.connections);
|
|
448
|
+
if (await isVisible(page, s.list, 10000)) return;
|
|
449
|
+
}
|
|
450
|
+
throw await failure(page, 'MetaMask did not open its permissions');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function openSite(page, host) {
|
|
454
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
455
|
+
await click(page, selectors.permissions.site(host));
|
|
456
|
+
if (await isVisible(page, selectors.permissions.disconnect, 5000)) return;
|
|
457
|
+
}
|
|
458
|
+
throw await failure(page, `MetaMask did not open the permissions of "${host}"`);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// What a command sets on its confirmation before pressing the button, when the
|
|
462
|
+
// test passed it options: adjustments[command](page, options, timeout)
|
|
463
|
+
const adjustments = {
|
|
464
|
+
connectToDapp: chooseAccounts,
|
|
465
|
+
confirmTransaction: adjustTransaction,
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Connect the dapp with the accounts named in `accounts` and no other, in the
|
|
470
|
+
* list the connection request opens: the boxes of the others are unticked,
|
|
471
|
+
* theirs ticked, and the list saved, which goes back to the request.
|
|
472
|
+
*/
|
|
473
|
+
async function chooseAccounts(page, { accounts }) {
|
|
474
|
+
if (accounts === undefined) return;
|
|
475
|
+
if (!Array.isArray(accounts) || accounts.length === 0) throw new Error('[dappress] connectToDapp({ accounts }) needs the name of at least one account');
|
|
476
|
+
const wanted = [...new Set(accounts)];
|
|
477
|
+
await openConnectAccounts(page);
|
|
478
|
+
const listed = await accountNames(page);
|
|
479
|
+
const unknown = wanted.filter((name) => !listed.includes(name));
|
|
480
|
+
if (unknown.length) {
|
|
481
|
+
const quoted = (names) => names.map((name) => `"${name}"`).join(', ');
|
|
482
|
+
throw await failure(page, `MetaMask lists no account named ${quoted(unknown)} to connect: it lists ${quoted(listed)}`);
|
|
483
|
+
}
|
|
484
|
+
for (const name of listed) await tickAccount(page, name, wanted.includes(name));
|
|
485
|
+
await saveConnectAccounts(page, wanted);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// As elsewhere, a click that lands while the request settles is lost: pressed again until the list shows
|
|
489
|
+
async function openConnectAccounts(page) {
|
|
490
|
+
const s = selectors.connectAccounts;
|
|
491
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
492
|
+
if (!(await isVisible(page, s.save, 500))) await click(page, s.edit);
|
|
493
|
+
if (await isVisible(page, s.save, 5000)) return;
|
|
494
|
+
}
|
|
495
|
+
throw await failure(page, 'MetaMask did not open the accounts of the connection');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// A click on an account's row ticks or unticks its box. The box is read
|
|
499
|
+
// before each click: a second click on a row that took the first undoes it.
|
|
500
|
+
async function tickAccount(page, name, ticked) {
|
|
501
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
502
|
+
if (await isTicked(page, name, ticked, 500)) return;
|
|
503
|
+
await click(page, selectors.accounts.cell(name));
|
|
504
|
+
if (await isTicked(page, name, ticked, 3000)) return;
|
|
505
|
+
}
|
|
506
|
+
throw await failure(page, `MetaMask did not ${ticked ? 'tick' : 'untick'} "${name}"`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function isTicked(page, name, ticked, timeout) {
|
|
510
|
+
const deadline = Date.now() + timeout;
|
|
511
|
+
do {
|
|
512
|
+
const checkbox = await page.$(selectors.connectAccounts.checkbox(name));
|
|
513
|
+
const state = checkbox ? await checkbox.evaluate((el) => el.checked).catch(() => null) : null;
|
|
514
|
+
if (state === ticked) return true;
|
|
515
|
+
await sleep(250);
|
|
516
|
+
} while (Date.now() < deadline);
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Saving goes back to the request, which then shows what was chosen
|
|
521
|
+
async function saveConnectAccounts(page, names) {
|
|
522
|
+
const s = selectors.connectAccounts;
|
|
523
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
524
|
+
if (await isVisible(page, s.save, 500)) await clickWhenEnabled(page, s.save);
|
|
525
|
+
if (!(await isGone(page, s.save, 5000))) continue;
|
|
526
|
+
if (await isVisible(page, s.chosen(names), 5000)) return;
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
throw await failure(page, `MetaMask did not keep ${names.map((name) => `"${name}"`).join(', ')} as the accounts to connect`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Press the button of `decision` on the confirmation shown on `page`, once it is set as `options` ask. */
|
|
533
|
+
async function decide(decision, page, timeout, options) {
|
|
534
|
+
if (options) {
|
|
535
|
+
const adjust = adjustments[decision];
|
|
536
|
+
if (!adjust) throw new Error(`[dappress] ${decision} takes no options`);
|
|
537
|
+
await waitFor(page, decisions[decision], { timeout });
|
|
538
|
+
await adjust(page, options, timeout);
|
|
539
|
+
}
|
|
327
540
|
return pressAndWaitForDismissal(page, decisions[decision], timeout);
|
|
328
541
|
}
|
|
329
542
|
|
|
@@ -332,6 +545,161 @@ function approveNetworkChange(page, timeout) {
|
|
|
332
545
|
return pressAndWaitForDismissal(page, [...selectors.confirmation.confirm, ...selectors.pageContainer.confirm], timeout);
|
|
333
546
|
}
|
|
334
547
|
|
|
548
|
+
// The estimates of the fee editor, by the name MetaMask shows: its test id and its English label
|
|
549
|
+
const gasEstimates = { low: ['low', 'Low'], market: ['medium', 'Market'], aggressive: ['high', 'Aggressive'], networkSuggested: ['gasPrice', 'Network suggested'] };
|
|
550
|
+
// The fields of the fee editor's advanced form, by the name MetaMask labels them with
|
|
551
|
+
const gasFields = { maxBaseFee: 'max-base-fee-input', priorityFee: 'priority-fee-input', gasLimit: 'gas-input' };
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Set what the test asked of a transaction before it is confirmed. The cap
|
|
555
|
+
* goes first: saving it makes MetaMask estimate the gas limit again.
|
|
556
|
+
*/
|
|
557
|
+
async function adjustTransaction(page, options) {
|
|
558
|
+
const { spendingCap, gas, ...unknown } = options;
|
|
559
|
+
const [stray] = Object.keys(unknown);
|
|
560
|
+
if (stray) throw new Error(`[dappress] confirmTransaction takes spendingCap and gas, not "${stray}"`);
|
|
561
|
+
const cap = spendingCap === undefined ? undefined : amount(spendingCap, 'spendingCap');
|
|
562
|
+
const fee = gas === undefined ? undefined : gasChoice(gas);
|
|
563
|
+
await dismissModal(page);
|
|
564
|
+
if (cap !== undefined) await setSpendingCap(page, cap);
|
|
565
|
+
if (fee !== undefined) await setGas(page, fee);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// An amount as a user types it, "5" or "2.5", from a number or a string
|
|
569
|
+
function amount(value, name) {
|
|
570
|
+
const text = String(value).trim();
|
|
571
|
+
if (!/^\d+(\.\d+)?$/.test(text)) throw new Error(`[dappress] ${name} is an amount such as 5 or '2.5', not ${JSON.stringify(value)}`);
|
|
572
|
+
return text;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// The estimate to pick, or the fields of the advanced form to fill with what
|
|
576
|
+
function gasChoice(gas) {
|
|
577
|
+
if (typeof gas === 'string') {
|
|
578
|
+
if (!gasEstimates[gas]) throw new Error(`[dappress] gas is ${Object.keys(gasEstimates).join(', ')} or custom values, not "${gas}"`);
|
|
579
|
+
return gas;
|
|
580
|
+
}
|
|
581
|
+
const fields = Object.keys(gas || {});
|
|
582
|
+
const [stray] = fields.filter((field) => !gasFields[field]);
|
|
583
|
+
if (stray || !fields.length) throw new Error(`[dappress] Custom gas takes ${Object.keys(gasFields).join(', ')}, not ${stray ? `"${stray}"` : 'nothing'}`);
|
|
584
|
+
return Object.fromEntries(fields.map((field) => [field, amount(gas[field], `gas.${field}`)]));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Set the amount an ERC-20 approval lets the spender use, in tokens, from the
|
|
589
|
+
* pencil of its "Spending cap" row. The row shows once MetaMask has read the
|
|
590
|
+
* token, and only an ERC-20 approval has one.
|
|
591
|
+
*/
|
|
592
|
+
async function setSpendingCap(page, cap) {
|
|
593
|
+
const s = selectors.transaction;
|
|
594
|
+
if (!(await isVisible(page, s.editSpendingCap, 10000))) throw await failure(page, 'MetaMask shows no spending cap to edit: spendingCap is for an ERC-20 approval');
|
|
595
|
+
const shown = await textOf(page, s.spendingCap);
|
|
596
|
+
const asked = await openSpendingCap(page);
|
|
597
|
+
await type(page, s.spendingCapInput, cap, 'spending cap');
|
|
598
|
+
if (!(await pressToClose(page, s.spendingCapSave, s.spendingCapInput))) throw await failure(page, `MetaMask did not save the spending cap of ${cap}`);
|
|
599
|
+
// The modal closes before the confirmation has the new cap, and confirming
|
|
600
|
+
// meanwhile approves the old one. Two caps may read the same once MetaMask
|
|
601
|
+
// has rounded them, so a row that stays as it was is not a failure.
|
|
602
|
+
const deadline = Date.now() + (Number(asked) === Number(cap) ? 0 : 5000);
|
|
603
|
+
while (Date.now() < deadline && (await textOf(page, s.spendingCap)) === shown) await sleep(250);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Open the spending cap's modal. Yields the cap the dapp asked for, which its field starts with. */
|
|
607
|
+
async function openSpendingCap(page) {
|
|
608
|
+
const s = selectors.transaction;
|
|
609
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
610
|
+
if (!(await isVisible(page, s.spendingCapInput, 500))) await click(page, s.editSpendingCap);
|
|
611
|
+
if (await isVisible(page, s.spendingCapInput, 5000)) return valueOf(page, s.spendingCapInput);
|
|
612
|
+
}
|
|
613
|
+
throw await failure(page, 'MetaMask did not open the spending cap');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Set the network fee in the fee editor, opened from the pencil of the
|
|
618
|
+
* "Network fee" row: one of its estimates, or the values of its advanced form.
|
|
619
|
+
*/
|
|
620
|
+
async function setGas(page, gas) {
|
|
621
|
+
const s = selectors.transaction;
|
|
622
|
+
if (!(await isVisible(page, s.editGas, 10000))) throw await failure(page, 'MetaMask shows no network fee to edit on this confirmation');
|
|
623
|
+
await openFeeEditor(page);
|
|
624
|
+
if (typeof gas === 'string') await pickGasEstimate(page, gas);
|
|
625
|
+
else await fillGasForm(page, gas);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function openFeeEditor(page) {
|
|
629
|
+
const s = selectors.transaction;
|
|
630
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
631
|
+
if (!(await isVisible(page, s.gasEstimates, 500))) await click(page, s.editGas);
|
|
632
|
+
if (await isVisible(page, s.gasEstimates, 5000)) return;
|
|
633
|
+
}
|
|
634
|
+
throw await failure(page, 'MetaMask did not open its fee editor');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Picking an estimate closes the editor. MetaMask lists the estimates it has
|
|
638
|
+
// for the network: Low, Market and Aggressive where it has fee estimates,
|
|
639
|
+
// "Network suggested" where it only has a gas price, as on a local node.
|
|
640
|
+
async function pickGasEstimate(page, name) {
|
|
641
|
+
const s = selectors.transaction;
|
|
642
|
+
const option = s.gasOption(...gasEstimates[name]);
|
|
643
|
+
if (!(await isVisible(page, option, 5000))) throw await failure(page, `MetaMask offers no "${name}" fee for this transaction`);
|
|
644
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
645
|
+
await click(page, option);
|
|
646
|
+
if (await isGone(page, s.gasEstimates, 10000)) return;
|
|
647
|
+
}
|
|
648
|
+
throw await failure(page, `MetaMask did not take the "${name}" fee`);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function fillGasForm(page, values) {
|
|
652
|
+
const s = selectors.transaction;
|
|
653
|
+
await openGasForm(page);
|
|
654
|
+
// The form checks each fee against the other as it stood, and keeps the old
|
|
655
|
+
// value of a fee it refused: the max base fee is typed again once the
|
|
656
|
+
// priority fee is in, for two fees that both move below or above the old ones.
|
|
657
|
+
const fields = Object.keys(gasFields).filter((field) => values[field] !== undefined);
|
|
658
|
+
if (values.maxBaseFee !== undefined && values.priorityFee !== undefined) fields.push('maxBaseFee');
|
|
659
|
+
for (const field of fields) await type(page, s.gasField(gasFields[field]), values[field], field);
|
|
660
|
+
// "Save" stays disabled on a value the form refuses, and the error quotes what the form says of it
|
|
661
|
+
if (!(await pressToClose(page, s.gasSave, s.gasForm))) throw await failure(page, 'MetaMask did not save the network fee');
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// "Advanced", in the list of estimates, replaces the list with the form. A
|
|
665
|
+
// transaction with a gas price gets another form, which has none of these fields.
|
|
666
|
+
async function openGasForm(page) {
|
|
667
|
+
const s = selectors.transaction;
|
|
668
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
669
|
+
if (!(await isVisible(page, s.gasForm, 500))) await click(page, s.gasOption('advanced', 'Advanced'));
|
|
670
|
+
if (await isVisible(page, s.gasForm, 5000)) return;
|
|
671
|
+
}
|
|
672
|
+
throw await failure(page, 'MetaMask did not open the advanced fee form of an EIP-1559 transaction');
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Type into a field and read it back: keys pressed while MetaMask re-renders are lost
|
|
676
|
+
async function type(page, selector, text, what) {
|
|
677
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
678
|
+
await fill(page, selector, text);
|
|
679
|
+
if (Number(await valueOf(page, selector)) === Number(text)) return;
|
|
680
|
+
}
|
|
681
|
+
throw await failure(page, `MetaMask's field for the ${what} did not take "${text}"`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Press the button that saves a modal until the modal closes: a lost click leaves it open
|
|
685
|
+
async function pressToClose(page, button, content) {
|
|
686
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
687
|
+
await clickWhenEnabled(page, button);
|
|
688
|
+
if (await isGone(page, content, 10000)) return true;
|
|
689
|
+
}
|
|
690
|
+
return false;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function valueOf(page, selector) {
|
|
694
|
+
const field = await waitFor(page, selector);
|
|
695
|
+
return field.evaluate((el) => el.value);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function textOf(page, selector) {
|
|
699
|
+
const element = await page.$(selector);
|
|
700
|
+
return element ? element.evaluate((el) => el.textContent.trim()).catch(() => null) : null;
|
|
701
|
+
}
|
|
702
|
+
|
|
335
703
|
/**
|
|
336
704
|
* Press a footer button, then wait for the confirmation to go away: the
|
|
337
705
|
* popup closes, the side panel goes back to the home screen. A click that
|
|
@@ -424,4 +792,17 @@ async function dismissModal(page) {
|
|
|
424
792
|
await dispatchClick(page, selectors.modal.lastButton);
|
|
425
793
|
}
|
|
426
794
|
|
|
427
|
-
module.exports = {
|
|
795
|
+
module.exports = {
|
|
796
|
+
decisions,
|
|
797
|
+
walletState,
|
|
798
|
+
onboard,
|
|
799
|
+
unlock,
|
|
800
|
+
leaveUnlockForm,
|
|
801
|
+
lock,
|
|
802
|
+
addAccount,
|
|
803
|
+
switchAccount,
|
|
804
|
+
importAccount,
|
|
805
|
+
disconnectSite,
|
|
806
|
+
decide,
|
|
807
|
+
approveNetworkChange,
|
|
808
|
+
};
|
package/src/support.js
CHANGED
|
@@ -25,7 +25,8 @@ Cypress.Commands.add('approveSwitchNetwork', () => metamask('approveSwitchNetwor
|
|
|
25
25
|
Cypress.Commands.add('rejectSwitchNetwork', () => metamask('rejectSwitchNetwork'));
|
|
26
26
|
Cypress.Commands.add('confirmSignature', () => metamask('confirmSignature'));
|
|
27
27
|
Cypress.Commands.add('rejectSignature', () => metamask('rejectSignature'));
|
|
28
|
-
|
|
28
|
+
// Options are what to set on the confirmation before confirming it: { spendingCap, gas }
|
|
29
|
+
Cypress.Commands.add('confirmTransaction', (options) => metamask('confirmTransaction', options, options ? JSON.stringify(options) : ''));
|
|
29
30
|
Cypress.Commands.add('rejectTransaction', () => metamask('rejectTransaction'));
|
|
30
31
|
Cypress.Commands.add('approveAddToken', () => metamask('approveAddToken'));
|
|
31
32
|
Cypress.Commands.add('rejectAddToken', () => metamask('rejectAddToken'));
|
|
@@ -37,9 +38,20 @@ Cypress.Commands.add('switchAccount', (name) => metamask('switchAccount', name,
|
|
|
37
38
|
// The private key is kept out of the command log
|
|
38
39
|
Cypress.Commands.add('importAccount', (privateKey) => metamask('importAccount', privateKey));
|
|
39
40
|
|
|
40
|
-
//
|
|
41
|
-
Cypress.Commands.add('
|
|
42
|
-
|
|
41
|
+
// A locked wallet keeps the dapp's requests behind its unlock screen
|
|
42
|
+
Cypress.Commands.add('lockWallet', () => metamask('lockWallet'));
|
|
43
|
+
Cypress.Commands.add('unlockWallet', () => metamask('unlockWallet'));
|
|
44
|
+
|
|
45
|
+
// The wallet's side of a disconnection: the site to remove from its
|
|
46
|
+
// permissions is the dapp under test, known by the origin of its window.
|
|
47
|
+
Cypress.Commands.add('disconnectFromDapp', () => {
|
|
48
|
+
cy.location('origin', { log: false }).then((origin) => metamask('disconnectFromDapp', origin, origin));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Connect, then move the dapp onto the network from the wallet setup, if there is one.
|
|
52
|
+
// `accounts` names the accounts to connect with, in place of the one MetaMask suggests.
|
|
53
|
+
Cypress.Commands.add('connectToDapp', (connection) => {
|
|
54
|
+
metamask('connectToDapp', connection, connection?.accounts?.join(', '));
|
|
43
55
|
if (options.network) cy.useNetwork(options.network);
|
|
44
56
|
});
|
|
45
57
|
|
package/types/support.d.ts
CHANGED
|
@@ -4,19 +4,31 @@ import type { Network } from './index';
|
|
|
4
4
|
|
|
5
5
|
export type WalletState = 'onboarding' | 'locked' | 'unlocked';
|
|
6
6
|
|
|
7
|
+
export type ConnectOptions = {
|
|
8
|
+
/**
|
|
9
|
+
* The accounts to connect the dapp with, by their names in MetaMask:
|
|
10
|
+
* `['Account 1', 'Account 2']`. These and no other are connected, in the
|
|
11
|
+
* order MetaMask gives them to the dapp, whatever their order here.
|
|
12
|
+
* Default: the account MetaMask suggests, the wallet's selected one.
|
|
13
|
+
*/
|
|
14
|
+
accounts?: string[];
|
|
15
|
+
};
|
|
16
|
+
|
|
7
17
|
declare global {
|
|
8
18
|
namespace Cypress {
|
|
9
19
|
interface Chainable {
|
|
10
20
|
/** Imports or unlocks the wallet. Runs by itself before each spec unless autoSetup is false. */
|
|
11
21
|
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>;
|
|
22
|
+
/** Accepts the connection request, with the accounts named in `accounts` if any, then moves the dapp onto the wallet setup's network if there is one. */
|
|
23
|
+
connectToDapp(options?: ConnectOptions): Chainable<void>;
|
|
14
24
|
rejectConnection(): Chainable<void>;
|
|
25
|
+
/** Disconnects the dapp from the wallet's permissions screen: the dapp gets an empty accountsChanged. */
|
|
26
|
+
disconnectFromDapp(): Chainable<void>;
|
|
15
27
|
/** Answers a signature request (personal_sign, eth_signTypedData_*). */
|
|
16
28
|
confirmSignature(): Chainable<void>;
|
|
17
29
|
rejectSignature(): Chainable<void>;
|
|
18
|
-
/** Answers a transaction, including ERC-20 approvals. */
|
|
19
|
-
confirmTransaction(): Chainable<void>;
|
|
30
|
+
/** Answers a transaction, including ERC-20 approvals. Confirming takes what to set first: a spending cap, a network fee. */
|
|
31
|
+
confirmTransaction(options?: TransactionOptions): Chainable<void>;
|
|
20
32
|
rejectTransaction(): Chainable<void>;
|
|
21
33
|
/** Answers wallet_addEthereumChain. */
|
|
22
34
|
approveNewNetwork(): Chainable<void>;
|
|
@@ -33,6 +45,10 @@ declare global {
|
|
|
33
45
|
switchAccount(name: string): Chainable<void>;
|
|
34
46
|
/** Imports an account from its private key and selects it. Yields its name. */
|
|
35
47
|
importAccount(privateKey: string): Chainable<string>;
|
|
48
|
+
/** Locks the wallet from MetaMask's menu: requests wait behind its unlock screen until it is unlocked. */
|
|
49
|
+
lockWallet(): Chainable<void>;
|
|
50
|
+
/** Unlocks a locked wallet with the configured password. Yields how it was found: "unlocked" when there was nothing to do. */
|
|
51
|
+
unlockWallet(): Chainable<WalletState>;
|
|
36
52
|
/** Moves the dapp onto a network, adding it to MetaMask if needed. Defaults to the wallet setup's network. Yields the chain id. */
|
|
37
53
|
useNetwork(network?: Network): Chainable<string>;
|
|
38
54
|
/** The address the dapp is connected with. */
|
|
@@ -40,3 +56,33 @@ declare global {
|
|
|
40
56
|
}
|
|
41
57
|
}
|
|
42
58
|
}
|
|
59
|
+
|
|
60
|
+
/** What cy.confirmTransaction() sets on the confirmation before confirming it. */
|
|
61
|
+
export interface TransactionOptions {
|
|
62
|
+
/**
|
|
63
|
+
* On an ERC-20 approval, the amount the spender may use, in tokens, as a user types it: 5 or '2.5'.
|
|
64
|
+
* A string keeps an amount a number would round. Fails on a transaction that has no spending cap.
|
|
65
|
+
*/
|
|
66
|
+
spendingCap?: number | string;
|
|
67
|
+
/**
|
|
68
|
+
* The network fee: one of the estimates MetaMask's fee editor offers for the network, or custom values.
|
|
69
|
+
* Fails when MetaMask doesn't offer that estimate.
|
|
70
|
+
*/
|
|
71
|
+
gas?: GasEstimate | CustomGas;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* An estimate of MetaMask's fee editor, by the name it shows. Low, Market and Aggressive are offered on the
|
|
76
|
+
* networks MetaMask has fee estimates for; Network suggested on those it only has a gas price for, such as a local node.
|
|
77
|
+
*/
|
|
78
|
+
export type GasEstimate = 'low' | 'market' | 'aggressive' | 'networkSuggested';
|
|
79
|
+
|
|
80
|
+
/** The fields of the fee editor's advanced form, on an EIP-1559 transaction. One left out keeps MetaMask's value. */
|
|
81
|
+
export interface CustomGas {
|
|
82
|
+
/** "Max base fee", in GWEI: the transaction's maxFeePerGas. */
|
|
83
|
+
maxBaseFee?: number | string;
|
|
84
|
+
/** "Priority fee", in GWEI: the transaction's maxPriorityFeePerGas. At most the max base fee. */
|
|
85
|
+
priorityFee?: number | string;
|
|
86
|
+
/** "Gas limit", in units of gas: 21000 or more. */
|
|
87
|
+
gasLimit?: number | string;
|
|
88
|
+
}
|