dappress 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- package/README.md +31 -12
- package/dist/actions.d.ts +2 -0
- package/dist/actions.js +114 -0
- package/dist/browser.d.ts +7 -0
- package/dist/browser.js +45 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +82 -0
- package/dist/download.d.ts +7 -0
- package/dist/download.js +48 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +55 -0
- package/dist/metamask-pages.d.ts +17 -0
- package/dist/metamask-pages.js +143 -0
- package/dist/metamask.d.ts +53 -0
- package/dist/metamask.js +830 -0
- package/dist/page-helpers.d.ts +33 -0
- package/dist/page-helpers.js +199 -0
- package/dist/profile.d.ts +15 -0
- package/dist/profile.js +165 -0
- package/dist/support.d.ts +45 -0
- package/dist/support.js +113 -0
- package/dist/types.d.ts +93 -0
- package/dist/types.js +4 -0
- package/package.json +27 -15
- package/src/actions.js +0 -84
- package/src/browser.js +0 -42
- package/src/config.js +0 -78
- package/src/download.js +0 -50
- package/src/index.js +0 -56
- package/src/metamask-pages.js +0 -133
- package/src/metamask.js +0 -808
- package/src/page-helpers.js +0 -194
- package/src/profile.js +0 -125
- package/src/support.js +0 -102
- package/types/index.d.ts +0 -55
- package/types/support.d.ts +0 -88
package/src/page-helpers.js
DELETED
|
@@ -1,194 +0,0 @@
|
|
|
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
|
-
await clickFresh(page, selector, options);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Wait until the element is both visible and enabled, then click it. The
|
|
63
|
-
* element is taken again at each turn: MetaMask re-renders its screens, and
|
|
64
|
-
* a handle on a replaced element reads its last state forever.
|
|
65
|
-
*/
|
|
66
|
-
async function clickWhenEnabled(page, selector, { timeout = DEFAULT_TIMEOUT, whileDisabled } = {}) {
|
|
67
|
-
const deadline = Date.now() + timeout;
|
|
68
|
-
let nudged = 0;
|
|
69
|
-
for (;;) {
|
|
70
|
-
const element = await waitFor(page, selector, { timeout });
|
|
71
|
-
if (!(await element.evaluate((el) => el.disabled))) break;
|
|
72
|
-
if (Date.now() > deadline) {
|
|
73
|
-
throw await failure(page, `"${describe(selector)}" stayed disabled for ${timeout}ms. ${await describeDisabled(page, element)}`);
|
|
74
|
-
}
|
|
75
|
-
// Something the page wants done before it enables the button, every half second
|
|
76
|
-
if (whileDisabled && Date.now() - nudged > 500) {
|
|
77
|
-
await whileDisabled();
|
|
78
|
-
nudged = Date.now();
|
|
79
|
-
}
|
|
80
|
-
await sleep(100);
|
|
81
|
-
}
|
|
82
|
-
await clickFresh(page, selector, { timeout });
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// What may keep a button disabled: the button itself (a spinner, a class),
|
|
86
|
-
// a pane left to scroll, the page not being the focused one
|
|
87
|
-
async function describeDisabled(page, element) {
|
|
88
|
-
try {
|
|
89
|
-
const state = await page.evaluate((button) => {
|
|
90
|
-
const panes = [...document.querySelectorAll('[style*="overflow"]')]
|
|
91
|
-
.filter((pane) => pane.scrollHeight > pane.clientHeight)
|
|
92
|
-
.map((pane) => `${pane.scrollTop}+${pane.clientHeight}/${pane.scrollHeight}`);
|
|
93
|
-
return {
|
|
94
|
-
button: button.outerHTML.replace(/\s+/g, ' ').slice(0, 300),
|
|
95
|
-
panesToScroll: panes,
|
|
96
|
-
viewport: `${document.documentElement.clientWidth}x${document.documentElement.clientHeight}`,
|
|
97
|
-
focused: document.hasFocus(),
|
|
98
|
-
visibility: document.visibilityState,
|
|
99
|
-
};
|
|
100
|
-
}, element);
|
|
101
|
-
state.window = await windowBounds(page);
|
|
102
|
-
return `Button: ${JSON.stringify(state)}.`;
|
|
103
|
-
} catch {
|
|
104
|
-
return '';
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// The size and state of the window the page is in, as "400x620 normal"
|
|
109
|
-
async function windowBounds(page) {
|
|
110
|
-
const session = await page.target().createCDPSession();
|
|
111
|
-
try {
|
|
112
|
-
const { bounds } = await session.send('Browser.getWindowForTarget');
|
|
113
|
-
return `${bounds.width}x${bounds.height} ${bounds.windowState}`;
|
|
114
|
-
} catch {
|
|
115
|
-
return 'unknown';
|
|
116
|
-
} finally {
|
|
117
|
-
await session.detach().catch(() => {});
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Find the element and click it; once more if a re-render replaced it between the two
|
|
122
|
-
async function clickFresh(page, selector, options) {
|
|
123
|
-
for (let attempt = 0; ; attempt++) {
|
|
124
|
-
const element = await waitFor(page, selector, options);
|
|
125
|
-
await waitForStill(element);
|
|
126
|
-
try {
|
|
127
|
-
return await element.click();
|
|
128
|
-
} catch (error) {
|
|
129
|
-
if (attempt > 0 || !/detached/.test(error.message)) throw error;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Click by dispatching the event on the element itself, for buttons something may cover. */
|
|
135
|
-
async function dispatchClick(page, selector, options) {
|
|
136
|
-
const element = await waitFor(page, selector, options);
|
|
137
|
-
await element.evaluate((el) => el.click());
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** MetaMask animates its menus and confirmations in; a click during the animation lands elsewhere. */
|
|
141
|
-
async function waitForStill(element, timeout = 2000) {
|
|
142
|
-
const deadline = Date.now() + timeout;
|
|
143
|
-
let previous = JSON.stringify(await element.boundingBox());
|
|
144
|
-
while (Date.now() < deadline) {
|
|
145
|
-
await sleep(100);
|
|
146
|
-
const current = JSON.stringify(await element.boundingBox());
|
|
147
|
-
if (current === previous) return;
|
|
148
|
-
previous = current;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Replace the content of a field with `text`. */
|
|
153
|
-
async function fill(page, selector, text, options) {
|
|
154
|
-
const element = await waitFor(page, selector, options);
|
|
155
|
-
await element.click({ clickCount: 3 });
|
|
156
|
-
await element.type(text);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* The error for something that went wrong on `page`: what the page shows, in
|
|
161
|
-
* words for the log and as a screenshot to look at.
|
|
162
|
-
*/
|
|
163
|
-
async function failure(page, message) {
|
|
164
|
-
const prefix = `[dappress] ${message} on ${page.url()}`;
|
|
165
|
-
const shown = await pageText(page);
|
|
166
|
-
try {
|
|
167
|
-
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
|
168
|
-
const file = path.join(SCREENSHOTS_DIR, `${Date.now()}.png`);
|
|
169
|
-
await page.screenshot({ path: file });
|
|
170
|
-
return new Error(`${prefix}. ${shown}Screenshot: ${file}`);
|
|
171
|
-
} catch {
|
|
172
|
-
return new Error(`${prefix}. ${shown}`.trim());
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// The visible text of the page, shortened: enough to tell which MetaMask
|
|
177
|
-
// screen it is from the log alone, when the screenshot is out of reach
|
|
178
|
-
async function pageText(page) {
|
|
179
|
-
try {
|
|
180
|
-
const text = await Promise.race([
|
|
181
|
-
page.evaluate(() => document.body.innerText.replace(/\s+/g, ' ').trim()),
|
|
182
|
-
sleep(2000).then(() => null),
|
|
183
|
-
]);
|
|
184
|
-
return text ? `The page shows: "${text.length > 500 ? `${text.slice(0, 500)}…` : text}". ` : '';
|
|
185
|
-
} catch {
|
|
186
|
-
return '';
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function sleep(ms) {
|
|
191
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
module.exports = { describe, waitFor, isVisible, isGone, click, clickWhenEnabled, dispatchClick, fill, failure, sleep };
|
package/src/profile.js
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
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, options.backupAndSync].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: [
|
|
35
|
-
`--load-extension=${extensionDir}`,
|
|
36
|
-
`--disable-extensions-except=${extensionDir}`,
|
|
37
|
-
'--no-first-run',
|
|
38
|
-
// No sandbox on Linux: Ubuntu 23.10+ (GitHub's ubuntu-latest) blocks the
|
|
39
|
-
// user namespaces it needs, and this browser only imports a test wallet
|
|
40
|
-
...(process.platform === 'linux' ? ['--no-sandbox'] : []),
|
|
41
|
-
],
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
await withMetaMask(launch, (home) => metamask.onboard(home, options));
|
|
45
|
-
// Reopen the profile: the wallet must now be there, locked, with no onboarding left
|
|
46
|
-
await withMetaMask(launch, async (home) => {
|
|
47
|
-
const state = await metamask.walletState(home);
|
|
48
|
-
if (state !== 'locked') throw new Error(`[dappress] The cached profile did not keep the wallet: MetaMask is ${state} at ${home.url()}`);
|
|
49
|
-
await metamask.unlock(home, options);
|
|
50
|
-
});
|
|
51
|
-
fs.writeFileSync(path.join(dir, 'ready'), '');
|
|
52
|
-
return dir;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// MetaMask persists its state a moment after the last screen; leave it that moment before closing
|
|
56
|
-
async function withMetaMask(launch, action) {
|
|
57
|
-
const browser = await launch();
|
|
58
|
-
try {
|
|
59
|
-
const home = await getHomePage(browser, await findExtensionId(browser));
|
|
60
|
-
await keepInFront(browser, home);
|
|
61
|
-
await action(home);
|
|
62
|
-
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
63
|
-
} finally {
|
|
64
|
-
await browser.close();
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Chrome stops rendering a tab that isn't the active one, and Puppeteer waits
|
|
69
|
-
// on rendering to find an element or to click it: a hidden tab never answers.
|
|
70
|
-
// So the home page is made the active tab, and again whenever MetaMask opens
|
|
71
|
-
// a tab of its own, as it does for its onboarding right after the install.
|
|
72
|
-
async function keepInFront(browser, home) {
|
|
73
|
-
const toFront = () => home.bringToFront().catch(() => {});
|
|
74
|
-
await toFront();
|
|
75
|
-
browser.on('targetcreated', (target) => {
|
|
76
|
-
if (target.type() !== 'page') return;
|
|
77
|
-
toFront();
|
|
78
|
-
setTimeout(toFront, 500);
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Where Cypress is about to create the browser profile:
|
|
83
|
-
// <app data>/Cypress/cy/<env>/browsers/<name>-<channel>/run-<pid of the Cypress process>,
|
|
84
|
-
// or .../interactive in open mode. The pid is our parent's: Cypress runs
|
|
85
|
-
// setupNodeEvents in a child process. Returns null when it can't be found.
|
|
86
|
-
function cypressProfileDir(browser, isTextTerminal) {
|
|
87
|
-
const appData = [
|
|
88
|
-
path.join(os.homedir(), 'Library', 'Application Support'),
|
|
89
|
-
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),
|
|
90
|
-
process.env.APPDATA,
|
|
91
|
-
].filter(Boolean);
|
|
92
|
-
const cypressData = appData.map((dir) => path.join(dir, 'Cypress', 'cy')).find((dir) => fs.existsSync(dir));
|
|
93
|
-
if (!cypressData) return null;
|
|
94
|
-
const env = ['production', 'development'].find((name) => fs.existsSync(path.join(cypressData, name)));
|
|
95
|
-
if (!env) return null;
|
|
96
|
-
return path.join(cypressData, env, 'browsers', `${browser.name}-${browser.channel}`, isTextTerminal ? `run-${process.ppid}` : 'interactive');
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Copy MetaMask's data from the cached profile into the profile Cypress is
|
|
101
|
-
* about to launch. Returns false when that profile can't be located; the
|
|
102
|
-
* wallet is then imported during the run instead.
|
|
103
|
-
*/
|
|
104
|
-
async function installProfile(profileDir, browser, isTextTerminal) {
|
|
105
|
-
const userDataDir = cypressProfileDir(browser, isTextTerminal);
|
|
106
|
-
if (!userDataDir) {
|
|
107
|
-
console.warn('[dappress] Could not locate the Cypress browser profile; importing the wallet in this run instead');
|
|
108
|
-
return false;
|
|
109
|
-
}
|
|
110
|
-
for (const entry of metamaskData(profileDir)) {
|
|
111
|
-
await fs.promises.cp(path.join(profileDir, entry), path.join(userDataDir, entry), { recursive: true });
|
|
112
|
-
}
|
|
113
|
-
return true;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Where Chrome keeps an extension's storage: chrome.storage.local and IndexedDB
|
|
117
|
-
function metamaskData(profileDir) {
|
|
118
|
-
const base = path.join(profileDir, 'Default');
|
|
119
|
-
const entries = ['Local Extension Settings', 'IndexedDB'];
|
|
120
|
-
return entries
|
|
121
|
-
.filter((entry) => fs.existsSync(path.join(base, entry)))
|
|
122
|
-
.flatMap((entry) => fs.readdirSync(path.join(base, entry)).map((name) => path.join('Default', entry, name)));
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
module.exports = { prepareProfile, installProfile };
|
package/src/support.js
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
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
|
-
// `shown` is what the command log displays for the argument, if anything
|
|
11
|
-
function metamask(action, argument = null, shown = '') {
|
|
12
|
-
Cypress.log({ name: 'metamask', message: `${action} ${shown}`.trim() });
|
|
13
|
-
return cy.task(`dappress:${action}`, argument, { log: false });
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function provider(method, params) {
|
|
17
|
-
return cy.window({ log: false }).then((win) => win.ethereum.request({ method, params }));
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
Cypress.Commands.add('setupMetaMask', () => metamask('setupWallet'));
|
|
21
|
-
Cypress.Commands.add('rejectConnection', () => metamask('rejectConnection'));
|
|
22
|
-
Cypress.Commands.add('approveNewNetwork', () => metamask('approveNewNetwork'));
|
|
23
|
-
Cypress.Commands.add('rejectNewNetwork', () => metamask('rejectNewNetwork'));
|
|
24
|
-
Cypress.Commands.add('approveSwitchNetwork', () => metamask('approveSwitchNetwork'));
|
|
25
|
-
Cypress.Commands.add('rejectSwitchNetwork', () => metamask('rejectSwitchNetwork'));
|
|
26
|
-
Cypress.Commands.add('confirmSignature', () => metamask('confirmSignature'));
|
|
27
|
-
Cypress.Commands.add('rejectSignature', () => metamask('rejectSignature'));
|
|
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) : ''));
|
|
30
|
-
Cypress.Commands.add('rejectTransaction', () => metamask('rejectTransaction'));
|
|
31
|
-
Cypress.Commands.add('approveAddToken', () => metamask('approveAddToken'));
|
|
32
|
-
Cypress.Commands.add('rejectAddToken', () => metamask('rejectAddToken'));
|
|
33
|
-
|
|
34
|
-
// The wallet's own accounts. A new or imported account becomes the selected
|
|
35
|
-
// one; the dapp sees it once it connects with it.
|
|
36
|
-
Cypress.Commands.add('addAccount', () => metamask('addAccount'));
|
|
37
|
-
Cypress.Commands.add('switchAccount', (name) => metamask('switchAccount', name, name));
|
|
38
|
-
// The private key is kept out of the command log
|
|
39
|
-
Cypress.Commands.add('importAccount', (privateKey) => metamask('importAccount', privateKey));
|
|
40
|
-
|
|
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(', '));
|
|
55
|
-
if (options.network) cy.useNetwork(options.network);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// Move the dapp onto `network` (a wallet_addEthereumChain parameter), adding
|
|
59
|
-
// it to MetaMask if needed. A switch to a network the dapp is already allowed
|
|
60
|
-
// on settles at once; anything else makes MetaMask ask, and we approve.
|
|
61
|
-
Cypress.Commands.add('useNetwork', (network = options.network) => {
|
|
62
|
-
if (!network) throw new Error('[dappress] cy.useNetwork() needs a network, or one in cypress/wallet.setup.js');
|
|
63
|
-
Cypress.log({ name: 'metamask', message: `useNetwork ${network.chainName || network.chainId}` });
|
|
64
|
-
cy.window({ log: false }).then((win) => {
|
|
65
|
-
win.dappressNetwork = { settled: false };
|
|
66
|
-
win.dappressNetwork.request = win.ethereum
|
|
67
|
-
.request({ method: 'wallet_addEthereumChain', params: [network] })
|
|
68
|
-
.then((result) => ({ result }), (error) => ({ error }))
|
|
69
|
-
.finally(() => (win.dappressNetwork.settled = true));
|
|
70
|
-
});
|
|
71
|
-
cy.wait(1500, { log: false });
|
|
72
|
-
cy.window({ log: false }).then((win) => {
|
|
73
|
-
if (!win.dappressNetwork.settled) metamask('approveNetworkChange');
|
|
74
|
-
});
|
|
75
|
-
cy.window({ log: false }).then({ timeout: 60000 }, (win) => win.dappressNetwork.request).then(({ error }) => {
|
|
76
|
-
if (error) throw new Error(`[dappress] MetaMask refused the network: ${error.message}`);
|
|
77
|
-
});
|
|
78
|
-
waitForChainId(network.chainId);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// The address the dapp sees, asked to the injected provider rather than to the
|
|
82
|
-
// MetaMask UI, so it keeps working across MetaMask versions.
|
|
83
|
-
Cypress.Commands.add('getAccountAddress', () => {
|
|
84
|
-
Cypress.log({ name: 'metamask', message: 'getAccountAddress' });
|
|
85
|
-
return provider('eth_accounts').then(([address]) => address);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
// MetaMask announces the new chain to the dapp shortly after the switch
|
|
89
|
-
function waitForChainId(chainId, attempts = 20) {
|
|
90
|
-
return provider('eth_chainId').then((current) => {
|
|
91
|
-
if (current === chainId) return current;
|
|
92
|
-
if (attempts === 0) throw new Error(`[dappress] The dapp is on chain ${current}, not ${chainId}`);
|
|
93
|
-
return cy.wait(500, { log: false }).then(() => waitForChainId(chainId, attempts - 1));
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Onboard or unlock the wallet once per spec, before the dapp needs it.
|
|
98
|
-
if (options.autoSetup !== false) {
|
|
99
|
-
before(() => {
|
|
100
|
-
cy.setupMetaMask();
|
|
101
|
-
});
|
|
102
|
-
}
|
package/types/index.d.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
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
|
-
/**
|
|
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
|
-
*/
|
|
19
|
-
seedPhrase?: string;
|
|
20
|
-
/** The network the dapp is moved onto by cy.connectToDapp(). */
|
|
21
|
-
network?: Network;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface DappressOptions extends WalletSetup {
|
|
25
|
-
/** Password of the throwaway browser profile's wallet. Default: Tester@1234. */
|
|
26
|
-
password?: string;
|
|
27
|
-
/** MetaMask release to load, downloaded from GitHub on first run. */
|
|
28
|
-
metamaskVersion?: string;
|
|
29
|
-
/**
|
|
30
|
-
* Keep MetaMask's backup and sync on. It saves the accounts and contacts of a
|
|
31
|
-
* seed phrase and restores them on other installs. Default: false.
|
|
32
|
-
*/
|
|
33
|
-
backupAndSync?: boolean;
|
|
34
|
-
/** Run cy.setupMetaMask() before the first test of each spec. Default: true. */
|
|
35
|
-
autoSetup?: boolean;
|
|
36
|
-
/** Import the wallet once and reuse the profile across runs. Keeps the vault under cacheDir. Headed runs only. Default: false. */
|
|
37
|
-
cache?: boolean;
|
|
38
|
-
/** Time allowed for MetaMask to display a request, in ms. Default: 20000. */
|
|
39
|
-
timeout?: number;
|
|
40
|
-
/** Where MetaMask builds are cached. Default: ~/.cache/dappress. */
|
|
41
|
-
cacheDir?: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Wire Dappress into a Cypress project, from setupNodeEvents:
|
|
46
|
-
*
|
|
47
|
-
* setupNodeEvents(on, config) {
|
|
48
|
-
* return configureDappress(on, config);
|
|
49
|
-
* }
|
|
50
|
-
*/
|
|
51
|
-
export function configureDappress(
|
|
52
|
-
on: Cypress.PluginEvents,
|
|
53
|
-
config: Cypress.PluginConfigOptions,
|
|
54
|
-
options?: DappressOptions,
|
|
55
|
-
): Cypress.PluginConfigOptions;
|
package/types/support.d.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/// <reference types="cypress" />
|
|
2
|
-
|
|
3
|
-
import type { Network } from './index';
|
|
4
|
-
|
|
5
|
-
export type WalletState = 'onboarding' | 'locked' | 'unlocked';
|
|
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
|
-
|
|
17
|
-
declare global {
|
|
18
|
-
namespace Cypress {
|
|
19
|
-
interface Chainable {
|
|
20
|
-
/** Imports or unlocks the wallet. Runs by itself before each spec unless autoSetup is false. */
|
|
21
|
-
setupMetaMask(): Chainable<WalletState>;
|
|
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>;
|
|
24
|
-
rejectConnection(): Chainable<void>;
|
|
25
|
-
/** Disconnects the dapp from the wallet's permissions screen: the dapp gets an empty accountsChanged. */
|
|
26
|
-
disconnectFromDapp(): Chainable<void>;
|
|
27
|
-
/** Answers a signature request (personal_sign, eth_signTypedData_*). */
|
|
28
|
-
confirmSignature(): Chainable<void>;
|
|
29
|
-
rejectSignature(): 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>;
|
|
32
|
-
rejectTransaction(): Chainable<void>;
|
|
33
|
-
/** Answers wallet_addEthereumChain. */
|
|
34
|
-
approveNewNetwork(): Chainable<void>;
|
|
35
|
-
rejectNewNetwork(): Chainable<void>;
|
|
36
|
-
/** Answers the permission asked before switching to a network the dapp isn't allowed on yet. */
|
|
37
|
-
approveSwitchNetwork(): Chainable<void>;
|
|
38
|
-
rejectSwitchNetwork(): Chainable<void>;
|
|
39
|
-
/** Answers wallet_watchAsset ("Add suggested tokens"). */
|
|
40
|
-
approveAddToken(): Chainable<void>;
|
|
41
|
-
rejectAddToken(): Chainable<void>;
|
|
42
|
-
/** Adds an account to the wallet and selects it. Yields its name, "Account N". */
|
|
43
|
-
addAccount(): Chainable<string>;
|
|
44
|
-
/** Selects an account of the wallet by its name. */
|
|
45
|
-
switchAccount(name: string): Chainable<void>;
|
|
46
|
-
/** Imports an account from its private key and selects it. Yields its name. */
|
|
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>;
|
|
52
|
-
/** Moves the dapp onto a network, adding it to MetaMask if needed. Defaults to the wallet setup's network. Yields the chain id. */
|
|
53
|
-
useNetwork(network?: Network): Chainable<string>;
|
|
54
|
-
/** The address the dapp is connected with. */
|
|
55
|
-
getAccountAddress(): Chainable<string>;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
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
|
-
}
|