dappress 0.4.0 → 0.5.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/CHANGELOG.md +98 -0
- package/README.md +158 -150
- 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
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ElementHandle, Page } from 'puppeteer-core';
|
|
2
|
+
export type Selector = string | string[];
|
|
3
|
+
type WaitOptions = {
|
|
4
|
+
timeout?: number;
|
|
5
|
+
};
|
|
6
|
+
/** A readable name for a selector, for error messages. */
|
|
7
|
+
export declare function describe(selector: Selector): string;
|
|
8
|
+
/** Wait for the first candidate to be visible and return its element handle. */
|
|
9
|
+
export declare function waitFor(page: Page, selector: Selector, { timeout }?: WaitOptions): Promise<ElementHandle<Element>>;
|
|
10
|
+
/** Returns true when `selector` is visible within `timeout` ms, false otherwise. */
|
|
11
|
+
export declare function isVisible(page: Page, selector: Selector, timeout?: number): Promise<boolean>;
|
|
12
|
+
/** Returns true once every candidate has left the page, or if the page itself closes. */
|
|
13
|
+
export declare function isGone(page: Page, selector: Selector, timeout?: number): Promise<boolean>;
|
|
14
|
+
export declare function click(page: Page, selector: Selector, options?: WaitOptions): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Wait until the element is both visible and enabled, then click it. The
|
|
17
|
+
* element is taken again at each turn: MetaMask re-renders its screens, and
|
|
18
|
+
* a handle on a replaced element reads its last state forever.
|
|
19
|
+
*/
|
|
20
|
+
export declare function clickWhenEnabled(page: Page, selector: Selector, { timeout, whileDisabled }?: WaitOptions & {
|
|
21
|
+
whileDisabled?: () => Promise<void>;
|
|
22
|
+
}): Promise<void>;
|
|
23
|
+
/** Click by dispatching the event on the element itself, for buttons something may cover. */
|
|
24
|
+
export declare function dispatchClick(page: Page, selector: Selector, options?: WaitOptions): Promise<void>;
|
|
25
|
+
/** Replace the content of a field with `text`. */
|
|
26
|
+
export declare function fill(page: Page, selector: Selector, text: string, options?: WaitOptions): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* The error for something that went wrong on `page`: what the page shows, in
|
|
29
|
+
* words for the log and as a screenshot to look at.
|
|
30
|
+
*/
|
|
31
|
+
export declare function failure(page: Page, message: string): Promise<Error>;
|
|
32
|
+
export declare function sleep(ms: number): Promise<void>;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Small helpers on top of Puppeteer's Page API. They give every interaction
|
|
3
|
+
// the same waiting and error reporting, and stay within what MetaMask's
|
|
4
|
+
// sandbox (LavaMoat) allows: no script injected into the page.
|
|
5
|
+
//
|
|
6
|
+
// A "selector" is a CSS or XPath selector, or a list of them tried together:
|
|
7
|
+
// the first one to show up wins.
|
|
8
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
9
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.describe = describe;
|
|
13
|
+
exports.waitFor = waitFor;
|
|
14
|
+
exports.isVisible = isVisible;
|
|
15
|
+
exports.isGone = isGone;
|
|
16
|
+
exports.click = click;
|
|
17
|
+
exports.clickWhenEnabled = clickWhenEnabled;
|
|
18
|
+
exports.dispatchClick = dispatchClick;
|
|
19
|
+
exports.fill = fill;
|
|
20
|
+
exports.failure = failure;
|
|
21
|
+
exports.sleep = sleep;
|
|
22
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
23
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
24
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
25
|
+
const DEFAULT_TIMEOUT = 15000;
|
|
26
|
+
const SCREENSHOTS_DIR = node_path_1.default.join(node_os_1.default.tmpdir(), 'dappress');
|
|
27
|
+
const candidates = (selector) => [].concat(selector);
|
|
28
|
+
/** A readable name for a selector, for error messages. */
|
|
29
|
+
function describe(selector) {
|
|
30
|
+
return candidates(selector)
|
|
31
|
+
.map((candidate) => candidate.replace(/^\[data-testid="(.+)"\]$/, '$1').replace(/^xpath\/.*="(.+)"\]$/, 'button "$1"'))
|
|
32
|
+
.join(' | ');
|
|
33
|
+
}
|
|
34
|
+
/** Wait for the first candidate to be visible and return its element handle. */
|
|
35
|
+
async function waitFor(page, selector, { timeout = DEFAULT_TIMEOUT } = {}) {
|
|
36
|
+
const attempts = candidates(selector).map((candidate) => page.waitForSelector(candidate, { visible: true, timeout }));
|
|
37
|
+
try {
|
|
38
|
+
// Never null: that is for a wait on a hidden element
|
|
39
|
+
return (await Promise.any(attempts));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw await failure(page, `Timed out waiting for "${describe(selector)}"`);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
// Let the losing attempts fail quietly when they time out
|
|
46
|
+
attempts.forEach((attempt) => attempt.catch(() => { }));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Returns true when `selector` is visible within `timeout` ms, false otherwise. */
|
|
50
|
+
async function isVisible(page, selector, timeout = 1500) {
|
|
51
|
+
try {
|
|
52
|
+
await waitFor(page, selector, { timeout });
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Returns true once every candidate has left the page, or if the page itself closes. */
|
|
60
|
+
async function isGone(page, selector, timeout = 1500) {
|
|
61
|
+
try {
|
|
62
|
+
await Promise.all(candidates(selector).map((candidate) => page.waitForSelector(candidate, { hidden: true, timeout })));
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return page.isClosed();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function click(page, selector, options) {
|
|
70
|
+
await clickFresh(page, selector, options);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Wait until the element is both visible and enabled, then click it. The
|
|
74
|
+
* element is taken again at each turn: MetaMask re-renders its screens, and
|
|
75
|
+
* a handle on a replaced element reads its last state forever.
|
|
76
|
+
*/
|
|
77
|
+
async function clickWhenEnabled(page, selector, { timeout = DEFAULT_TIMEOUT, whileDisabled } = {}) {
|
|
78
|
+
const deadline = Date.now() + timeout;
|
|
79
|
+
let nudged = 0;
|
|
80
|
+
for (;;) {
|
|
81
|
+
const element = await waitFor(page, selector, { timeout });
|
|
82
|
+
if (!(await element.evaluate((el) => el.disabled)))
|
|
83
|
+
break;
|
|
84
|
+
if (Date.now() > deadline) {
|
|
85
|
+
throw await failure(page, `"${describe(selector)}" stayed disabled for ${timeout}ms. ${await describeDisabled(page, element)}`);
|
|
86
|
+
}
|
|
87
|
+
// Something the page wants done before it enables the button, every half second
|
|
88
|
+
if (whileDisabled && Date.now() - nudged > 500) {
|
|
89
|
+
await whileDisabled();
|
|
90
|
+
nudged = Date.now();
|
|
91
|
+
}
|
|
92
|
+
await sleep(100);
|
|
93
|
+
}
|
|
94
|
+
await clickFresh(page, selector, { timeout });
|
|
95
|
+
}
|
|
96
|
+
// What may keep a button disabled: the button itself (a spinner, a class),
|
|
97
|
+
// a pane left to scroll, the page not being the focused one
|
|
98
|
+
async function describeDisabled(page, element) {
|
|
99
|
+
try {
|
|
100
|
+
const state = await page.evaluate((button) => {
|
|
101
|
+
const panes = [...document.querySelectorAll('[style*="overflow"]')]
|
|
102
|
+
.filter((pane) => pane.scrollHeight > pane.clientHeight)
|
|
103
|
+
.map((pane) => `${pane.scrollTop}+${pane.clientHeight}/${pane.scrollHeight}`);
|
|
104
|
+
return {
|
|
105
|
+
button: button.outerHTML.replace(/\s+/g, ' ').slice(0, 300),
|
|
106
|
+
panesToScroll: panes,
|
|
107
|
+
viewport: `${document.documentElement.clientWidth}x${document.documentElement.clientHeight}`,
|
|
108
|
+
focused: document.hasFocus(),
|
|
109
|
+
visibility: document.visibilityState,
|
|
110
|
+
};
|
|
111
|
+
}, element);
|
|
112
|
+
return `Button: ${JSON.stringify({ ...state, window: await windowBounds(page) })}.`;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// The size and state of the window the page is in, as "400x620 normal"
|
|
119
|
+
async function windowBounds(page) {
|
|
120
|
+
const session = await page.target().createCDPSession();
|
|
121
|
+
try {
|
|
122
|
+
const { bounds } = await session.send('Browser.getWindowForTarget');
|
|
123
|
+
return `${bounds.width}x${bounds.height} ${bounds.windowState}`;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return 'unknown';
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await session.detach().catch(() => { });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Find the element and click it; once more if a re-render replaced it between the two
|
|
133
|
+
async function clickFresh(page, selector, options) {
|
|
134
|
+
for (let attempt = 0;; attempt++) {
|
|
135
|
+
const element = await waitFor(page, selector, options);
|
|
136
|
+
await waitForStill(element);
|
|
137
|
+
try {
|
|
138
|
+
return await element.click();
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (attempt > 0 || !/detached/.test(error.message))
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Click by dispatching the event on the element itself, for buttons something may cover. */
|
|
147
|
+
async function dispatchClick(page, selector, options) {
|
|
148
|
+
const element = await waitFor(page, selector, options);
|
|
149
|
+
await element.evaluate((el) => el.click());
|
|
150
|
+
}
|
|
151
|
+
/** MetaMask animates its menus and confirmations in; a click during the animation lands elsewhere. */
|
|
152
|
+
async function waitForStill(element, timeout = 2000) {
|
|
153
|
+
const deadline = Date.now() + timeout;
|
|
154
|
+
let previous = JSON.stringify(await element.boundingBox());
|
|
155
|
+
while (Date.now() < deadline) {
|
|
156
|
+
await sleep(100);
|
|
157
|
+
const current = JSON.stringify(await element.boundingBox());
|
|
158
|
+
if (current === previous)
|
|
159
|
+
return;
|
|
160
|
+
previous = current;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Replace the content of a field with `text`. */
|
|
164
|
+
async function fill(page, selector, text, options) {
|
|
165
|
+
const element = await waitFor(page, selector, options);
|
|
166
|
+
await element.click({ clickCount: 3 });
|
|
167
|
+
await element.type(text);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The error for something that went wrong on `page`: what the page shows, in
|
|
171
|
+
* words for the log and as a screenshot to look at.
|
|
172
|
+
*/
|
|
173
|
+
async function failure(page, message) {
|
|
174
|
+
const prefix = `[dappress] ${message} on ${page.url()}`;
|
|
175
|
+
const shown = await pageText(page);
|
|
176
|
+
try {
|
|
177
|
+
node_fs_1.default.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
|
178
|
+
const file = node_path_1.default.join(SCREENSHOTS_DIR, `${Date.now()}.png`);
|
|
179
|
+
await page.screenshot({ path: file });
|
|
180
|
+
return new Error(`${prefix}. ${shown}Screenshot: ${file}`);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return new Error(`${prefix}. ${shown}`.trim());
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// The visible text of the page, shortened: enough to tell which MetaMask
|
|
187
|
+
// screen it is from the log alone, when the screenshot is out of reach
|
|
188
|
+
async function pageText(page) {
|
|
189
|
+
try {
|
|
190
|
+
const text = await Promise.race([page.evaluate(() => document.body.innerText.replace(/\s+/g, ' ').trim()), sleep(2000).then(() => null)]);
|
|
191
|
+
return text ? `The page shows: "${text.length > 500 ? `${text.slice(0, 500)}…` : text}". ` : '';
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return '';
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function sleep(ms) {
|
|
198
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
199
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ResolvedOptions } from './types';
|
|
2
|
+
type ProfileBuild = {
|
|
3
|
+
browserPath: string;
|
|
4
|
+
extensionDir: string;
|
|
5
|
+
options: ResolvedOptions;
|
|
6
|
+
};
|
|
7
|
+
/** The cached profile for these options, built on first use. */
|
|
8
|
+
export declare function prepareProfile({ browserPath, extensionDir, options }: ProfileBuild): Promise<string>;
|
|
9
|
+
/**
|
|
10
|
+
* Copy MetaMask's data from the cached profile into the profile Cypress is
|
|
11
|
+
* about to launch. Returns false when that profile can't be located; the
|
|
12
|
+
* wallet is then imported during the run instead.
|
|
13
|
+
*/
|
|
14
|
+
export declare function installProfile(profileDir: string, browser: Cypress.Browser, isTextTerminal?: boolean): Promise<boolean>;
|
|
15
|
+
export {};
|
package/dist/profile.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Onboard the wallet once, in a browser of our own, and keep the resulting
|
|
3
|
+
// profile. Each Cypress run then starts from a copy of it, with the wallet
|
|
4
|
+
// imported but nothing else: no connected site, no added network.
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
17
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
18
|
+
}) : function(o, v) {
|
|
19
|
+
o["default"] = v;
|
|
20
|
+
});
|
|
21
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
22
|
+
var ownKeys = function(o) {
|
|
23
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
24
|
+
var ar = [];
|
|
25
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
26
|
+
return ar;
|
|
27
|
+
};
|
|
28
|
+
return ownKeys(o);
|
|
29
|
+
};
|
|
30
|
+
return function (mod) {
|
|
31
|
+
if (mod && mod.__esModule) return mod;
|
|
32
|
+
var result = {};
|
|
33
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34
|
+
__setModuleDefault(result, mod);
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
})();
|
|
38
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
39
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
40
|
+
};
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.prepareProfile = prepareProfile;
|
|
43
|
+
exports.installProfile = installProfile;
|
|
44
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
45
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
46
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
47
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
48
|
+
const puppeteer_core_1 = __importDefault(require("puppeteer-core"));
|
|
49
|
+
const metamask = __importStar(require("./metamask"));
|
|
50
|
+
const metamask_pages_1 = require("./metamask-pages");
|
|
51
|
+
// One build at a time: Cypress calls before:browser:launch again when the
|
|
52
|
+
// browser is slow to connect, and two builds on one profile would collide
|
|
53
|
+
const builds = new Map();
|
|
54
|
+
/** The cached profile for these options, built on first use. */
|
|
55
|
+
function prepareProfile({ browserPath, extensionDir, options }) {
|
|
56
|
+
const key = node_crypto_1.default
|
|
57
|
+
.createHash('sha256')
|
|
58
|
+
.update([options.metamaskVersion, options.seedPhrase, options.password, options.backupAndSync].join('|'))
|
|
59
|
+
.digest('hex')
|
|
60
|
+
.slice(0, 16);
|
|
61
|
+
let build = builds.get(key);
|
|
62
|
+
if (!build) {
|
|
63
|
+
build = buildProfile(node_path_1.default.join(options.cacheDir, 'profiles', key), { browserPath, extensionDir, options });
|
|
64
|
+
builds.set(key, build);
|
|
65
|
+
}
|
|
66
|
+
return build;
|
|
67
|
+
}
|
|
68
|
+
async function buildProfile(dir, { browserPath, extensionDir, options }) {
|
|
69
|
+
if (node_fs_1.default.existsSync(node_path_1.default.join(dir, 'ready')))
|
|
70
|
+
return dir;
|
|
71
|
+
console.log('[dappress] Importing the wallet once, into a cached profile…');
|
|
72
|
+
await node_fs_1.default.promises.rm(dir, { recursive: true, force: true });
|
|
73
|
+
const launch = () => puppeteer_core_1.default.launch({
|
|
74
|
+
executablePath: browserPath,
|
|
75
|
+
headless: false,
|
|
76
|
+
userDataDir: dir,
|
|
77
|
+
args: [
|
|
78
|
+
`--load-extension=${extensionDir}`,
|
|
79
|
+
`--disable-extensions-except=${extensionDir}`,
|
|
80
|
+
'--no-first-run',
|
|
81
|
+
// No sandbox on Linux: Ubuntu 23.10+ (GitHub's ubuntu-latest) blocks the
|
|
82
|
+
// user namespaces it needs, and this browser only imports a test wallet
|
|
83
|
+
...(process.platform === 'linux' ? ['--no-sandbox'] : []),
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
await withMetaMask(launch, (home) => metamask.onboard(home, options));
|
|
87
|
+
// Reopen the profile: the wallet must now be there, locked, with no onboarding left
|
|
88
|
+
await withMetaMask(launch, async (home) => {
|
|
89
|
+
const state = await metamask.walletState(home);
|
|
90
|
+
if (state !== 'locked')
|
|
91
|
+
throw new Error(`[dappress] The cached profile did not keep the wallet: MetaMask is ${state} at ${home.url()}`);
|
|
92
|
+
await metamask.unlock(home, options);
|
|
93
|
+
});
|
|
94
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(dir, 'ready'), '');
|
|
95
|
+
return dir;
|
|
96
|
+
}
|
|
97
|
+
// MetaMask persists its state a moment after the last screen; leave it that moment before closing
|
|
98
|
+
async function withMetaMask(launch, action) {
|
|
99
|
+
const browser = await launch();
|
|
100
|
+
try {
|
|
101
|
+
const home = await (0, metamask_pages_1.getHomePage)(browser, await (0, metamask_pages_1.findExtensionId)(browser));
|
|
102
|
+
await keepInFront(browser, home);
|
|
103
|
+
await action(home);
|
|
104
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await browser.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Chrome stops rendering a tab that isn't the active one, and Puppeteer waits
|
|
111
|
+
// on rendering to find an element or to click it: a hidden tab never answers.
|
|
112
|
+
// So the home page is made the active tab, and again whenever MetaMask opens
|
|
113
|
+
// a tab of its own, as it does for its onboarding right after the install.
|
|
114
|
+
async function keepInFront(browser, home) {
|
|
115
|
+
const toFront = () => home.bringToFront().catch(() => { });
|
|
116
|
+
await toFront();
|
|
117
|
+
browser.on('targetcreated', (target) => {
|
|
118
|
+
if (target.type() !== 'page')
|
|
119
|
+
return;
|
|
120
|
+
toFront();
|
|
121
|
+
setTimeout(toFront, 500);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// Where Cypress is about to create the browser profile:
|
|
125
|
+
// <app data>/Cypress/cy/<env>/browsers/<name>-<channel>/run-<pid of the Cypress process>,
|
|
126
|
+
// or .../interactive in open mode. The pid is our parent's: Cypress runs
|
|
127
|
+
// setupNodeEvents in a child process. Returns null when it can't be found.
|
|
128
|
+
function cypressProfileDir(browser, isTextTerminal) {
|
|
129
|
+
const appData = [
|
|
130
|
+
node_path_1.default.join(node_os_1.default.homedir(), 'Library', 'Application Support'),
|
|
131
|
+
process.env.XDG_CONFIG_HOME || node_path_1.default.join(node_os_1.default.homedir(), '.config'),
|
|
132
|
+
process.env.APPDATA,
|
|
133
|
+
].filter((dir) => Boolean(dir));
|
|
134
|
+
const cypressData = appData.map((dir) => node_path_1.default.join(dir, 'Cypress', 'cy')).find((dir) => node_fs_1.default.existsSync(dir));
|
|
135
|
+
if (!cypressData)
|
|
136
|
+
return null;
|
|
137
|
+
const env = ['production', 'development'].find((name) => node_fs_1.default.existsSync(node_path_1.default.join(cypressData, name)));
|
|
138
|
+
if (!env)
|
|
139
|
+
return null;
|
|
140
|
+
return node_path_1.default.join(cypressData, env, 'browsers', `${browser.name}-${browser.channel}`, isTextTerminal ? `run-${process.ppid}` : 'interactive');
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Copy MetaMask's data from the cached profile into the profile Cypress is
|
|
144
|
+
* about to launch. Returns false when that profile can't be located; the
|
|
145
|
+
* wallet is then imported during the run instead.
|
|
146
|
+
*/
|
|
147
|
+
async function installProfile(profileDir, browser, isTextTerminal) {
|
|
148
|
+
const userDataDir = cypressProfileDir(browser, isTextTerminal);
|
|
149
|
+
if (!userDataDir) {
|
|
150
|
+
console.warn('[dappress] Could not locate the Cypress browser profile; importing the wallet in this run instead');
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
for (const entry of metamaskData(profileDir)) {
|
|
154
|
+
await node_fs_1.default.promises.cp(node_path_1.default.join(profileDir, entry), node_path_1.default.join(userDataDir, entry), { recursive: true });
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
// Where Chrome keeps an extension's storage: chrome.storage.local and IndexedDB
|
|
159
|
+
function metamaskData(profileDir) {
|
|
160
|
+
const base = node_path_1.default.join(profileDir, 'Default');
|
|
161
|
+
const entries = ['Local Extension Settings', 'IndexedDB'];
|
|
162
|
+
return entries
|
|
163
|
+
.filter((entry) => node_fs_1.default.existsSync(node_path_1.default.join(base, entry)))
|
|
164
|
+
.flatMap((entry) => node_fs_1.default.readdirSync(node_path_1.default.join(base, entry)).map((name) => node_path_1.default.join('Default', entry, name)));
|
|
165
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/// <reference types="cypress" preserve="true" />
|
|
2
|
+
import type { ConnectOptions, Network, TransactionOptions, WalletState } from './types';
|
|
3
|
+
export type { ConnectOptions, CustomGas, GasEstimate, TransactionOptions, WalletState } from './types';
|
|
4
|
+
declare global {
|
|
5
|
+
namespace Cypress {
|
|
6
|
+
interface Chainable {
|
|
7
|
+
/** Imports or unlocks the wallet. Runs by itself before each spec unless autoSetup is false. */
|
|
8
|
+
setupMetaMask(): Chainable<WalletState>;
|
|
9
|
+
/** 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. */
|
|
10
|
+
connectToDapp(options?: ConnectOptions): Chainable<void>;
|
|
11
|
+
rejectConnection(): Chainable<void>;
|
|
12
|
+
/** Disconnects the dapp from the wallet's permissions screen: the dapp gets an empty accountsChanged. */
|
|
13
|
+
disconnectFromDapp(): Chainable<void>;
|
|
14
|
+
/** Answers a signature request (personal_sign, eth_signTypedData_*). */
|
|
15
|
+
confirmSignature(): Chainable<void>;
|
|
16
|
+
rejectSignature(): Chainable<void>;
|
|
17
|
+
/** Answers a transaction, including ERC-20 approvals. Confirming takes what to set first: a spending cap, a network fee. */
|
|
18
|
+
confirmTransaction(options?: TransactionOptions): Chainable<void>;
|
|
19
|
+
rejectTransaction(): Chainable<void>;
|
|
20
|
+
/** Answers wallet_addEthereumChain. */
|
|
21
|
+
approveNewNetwork(): Chainable<void>;
|
|
22
|
+
rejectNewNetwork(): Chainable<void>;
|
|
23
|
+
/** Answers the permission asked before switching to a network the dapp isn't allowed on yet. */
|
|
24
|
+
approveSwitchNetwork(): Chainable<void>;
|
|
25
|
+
rejectSwitchNetwork(): Chainable<void>;
|
|
26
|
+
/** Answers wallet_watchAsset ("Add suggested tokens"). */
|
|
27
|
+
approveAddToken(): Chainable<void>;
|
|
28
|
+
rejectAddToken(): Chainable<void>;
|
|
29
|
+
/** Adds an account to the wallet and selects it. Yields its name, "Account N". */
|
|
30
|
+
addAccount(): Chainable<string>;
|
|
31
|
+
/** Selects an account of the wallet by its name. */
|
|
32
|
+
switchAccount(name: string): Chainable<void>;
|
|
33
|
+
/** Imports an account from its private key and selects it. Yields its name. */
|
|
34
|
+
importAccount(privateKey: string): Chainable<string>;
|
|
35
|
+
/** Locks the wallet from MetaMask's menu: requests wait behind its unlock screen until it is unlocked. */
|
|
36
|
+
lockWallet(): Chainable<void>;
|
|
37
|
+
/** Unlocks a locked wallet with the configured password. Yields how it was found: "unlocked" when there was nothing to do. */
|
|
38
|
+
unlockWallet(): Chainable<WalletState>;
|
|
39
|
+
/** Moves the dapp onto a network, adding it to MetaMask if needed. Defaults to the wallet setup's network. Yields the chain id. */
|
|
40
|
+
useNetwork(network?: Network): Chainable<string>;
|
|
41
|
+
/** The address the dapp is connected with. */
|
|
42
|
+
getAccountAddress(): Chainable<string>;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dist/support.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/// <reference types="cypress" preserve="true" />
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
// A network change MetaMask makes without asking is answered well within this (ms)
|
|
5
|
+
const SILENT_SWITCH = 1500;
|
|
6
|
+
// How long MetaMask may take to announce the new chain to the dapp (ms)
|
|
7
|
+
const CHAIN_ANNOUNCED = 10000;
|
|
8
|
+
const options = Cypress.expose('dappress') || {};
|
|
9
|
+
const dappWindow = () => cy.window({ log: false });
|
|
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
|
+
function provider(method, params) {
|
|
16
|
+
return dappWindow().then((win) => win.ethereum.request({ method, params }));
|
|
17
|
+
}
|
|
18
|
+
Cypress.Commands.add('setupMetaMask', () => metamask('setupWallet'));
|
|
19
|
+
Cypress.Commands.add('rejectConnection', () => metamask('rejectConnection'));
|
|
20
|
+
Cypress.Commands.add('approveNewNetwork', () => metamask('approveNewNetwork'));
|
|
21
|
+
Cypress.Commands.add('rejectNewNetwork', () => metamask('rejectNewNetwork'));
|
|
22
|
+
Cypress.Commands.add('approveSwitchNetwork', () => metamask('approveSwitchNetwork'));
|
|
23
|
+
Cypress.Commands.add('rejectSwitchNetwork', () => metamask('rejectSwitchNetwork'));
|
|
24
|
+
Cypress.Commands.add('confirmSignature', () => metamask('confirmSignature'));
|
|
25
|
+
Cypress.Commands.add('rejectSignature', () => metamask('rejectSignature'));
|
|
26
|
+
// Options are what to set on the confirmation before confirming it: { spendingCap, gas }
|
|
27
|
+
Cypress.Commands.add('confirmTransaction', (options) => metamask('confirmTransaction', options, options ? JSON.stringify(options) : ''));
|
|
28
|
+
Cypress.Commands.add('rejectTransaction', () => metamask('rejectTransaction'));
|
|
29
|
+
Cypress.Commands.add('approveAddToken', () => metamask('approveAddToken'));
|
|
30
|
+
Cypress.Commands.add('rejectAddToken', () => metamask('rejectAddToken'));
|
|
31
|
+
// The wallet's own accounts. A new or imported account becomes the selected
|
|
32
|
+
// one; the dapp sees it once it connects with it.
|
|
33
|
+
Cypress.Commands.add('addAccount', () => metamask('addAccount'));
|
|
34
|
+
Cypress.Commands.add('switchAccount', (name) => metamask('switchAccount', name, name));
|
|
35
|
+
// The private key is kept out of the command log
|
|
36
|
+
Cypress.Commands.add('importAccount', (privateKey) => metamask('importAccount', privateKey));
|
|
37
|
+
// A locked wallet keeps the dapp's requests behind its unlock screen
|
|
38
|
+
Cypress.Commands.add('lockWallet', () => metamask('lockWallet'));
|
|
39
|
+
Cypress.Commands.add('unlockWallet', () => metamask('unlockWallet'));
|
|
40
|
+
// The wallet's side of a disconnection: the site to remove from its
|
|
41
|
+
// permissions is the dapp under test, known by the origin of its window.
|
|
42
|
+
Cypress.Commands.add('disconnectFromDapp', () => {
|
|
43
|
+
cy.location('origin', { log: false }).then((origin) => metamask('disconnectFromDapp', origin, origin));
|
|
44
|
+
});
|
|
45
|
+
// Connect, then move the dapp onto the network from the wallet setup, if there is one.
|
|
46
|
+
// `accounts` names the accounts to connect with, in place of the one MetaMask suggests.
|
|
47
|
+
Cypress.Commands.add('connectToDapp', (connection) => {
|
|
48
|
+
metamask('connectToDapp', connection, connection?.accounts?.join(', '));
|
|
49
|
+
if (options.network)
|
|
50
|
+
cy.useNetwork(options.network);
|
|
51
|
+
});
|
|
52
|
+
// Move the dapp onto `network` (a wallet_addEthereumChain parameter), adding
|
|
53
|
+
// it to MetaMask if needed. A switch to a network the dapp is already allowed
|
|
54
|
+
// on settles at once; anything else makes MetaMask ask, and we approve.
|
|
55
|
+
Cypress.Commands.add('useNetwork', (network = options.network ?? undefined) => {
|
|
56
|
+
if (!network)
|
|
57
|
+
throw new Error('[dappress] cy.useNetwork() needs a network, or one in cypress/wallet.setup.js');
|
|
58
|
+
Cypress.log({ name: 'metamask', message: `useNetwork ${network.chainName || network.chainId}` });
|
|
59
|
+
let request;
|
|
60
|
+
// Whether MetaMask asks: the request is still pending once a silent switch would have been answered
|
|
61
|
+
dappWindow()
|
|
62
|
+
.then((win) => {
|
|
63
|
+
request = win.ethereum.request({ method: 'wallet_addEthereumChain', params: [network] }).then((result) => ({ result }), (error) => ({ error }));
|
|
64
|
+
const silent = new Promise((resolve) => setTimeout(resolve, SILENT_SWITCH, false));
|
|
65
|
+
return Promise.race([request.then(() => true), silent]);
|
|
66
|
+
})
|
|
67
|
+
.then((answered) => {
|
|
68
|
+
if (!answered)
|
|
69
|
+
metamask('approveNetworkChange');
|
|
70
|
+
});
|
|
71
|
+
dappWindow()
|
|
72
|
+
.then({ timeout: 60000 }, () => request)
|
|
73
|
+
.then(({ error }) => {
|
|
74
|
+
if (error)
|
|
75
|
+
throw new Error(`[dappress] MetaMask refused the network: ${error.message}`);
|
|
76
|
+
});
|
|
77
|
+
waitForChainId(network.chainId);
|
|
78
|
+
});
|
|
79
|
+
// The address the dapp sees, asked to the injected provider rather than to the
|
|
80
|
+
// MetaMask UI, so it keeps working across MetaMask versions.
|
|
81
|
+
Cypress.Commands.add('getAccountAddress', () => {
|
|
82
|
+
Cypress.log({ name: 'metamask', message: 'getAccountAddress' });
|
|
83
|
+
return provider('eth_accounts').then(([address]) => address);
|
|
84
|
+
});
|
|
85
|
+
// MetaMask announces the new chain to the dapp shortly after the switch: the
|
|
86
|
+
// dapp is on it already, or the provider's chainChanged says when it is
|
|
87
|
+
function waitForChainId(chainId) {
|
|
88
|
+
return dappWindow().then({ timeout: CHAIN_ANNOUNCED + 1000 }, (win) => {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
let current;
|
|
91
|
+
const settle = (act) => {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
win.ethereum.removeListener('chainChanged', onChainChanged);
|
|
94
|
+
act();
|
|
95
|
+
};
|
|
96
|
+
const onChainChanged = (announced) => {
|
|
97
|
+
current = announced;
|
|
98
|
+
if (announced === chainId)
|
|
99
|
+
settle(() => resolve(chainId));
|
|
100
|
+
};
|
|
101
|
+
const timer = setTimeout(() => settle(() => reject(new Error(`[dappress] The dapp is on chain ${current}, not ${chainId}`))), CHAIN_ANNOUNCED);
|
|
102
|
+
// Listening first: a change announced while the chain is asked for is not missed
|
|
103
|
+
win.ethereum.on('chainChanged', onChainChanged);
|
|
104
|
+
win.ethereum.request({ method: 'eth_chainId' }).then(onChainChanged, () => { });
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
// Onboard or unlock the wallet once per spec, before the dapp needs it.
|
|
109
|
+
if (options.autoSetup !== false) {
|
|
110
|
+
before(() => {
|
|
111
|
+
cy.setupMetaMask();
|
|
112
|
+
});
|
|
113
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** A network, as passed to MetaMask's wallet_addEthereumChain. */
|
|
2
|
+
export interface Network {
|
|
3
|
+
chainId: string;
|
|
4
|
+
chainName: string;
|
|
5
|
+
rpcUrls: string[];
|
|
6
|
+
nativeCurrency: {
|
|
7
|
+
name: string;
|
|
8
|
+
symbol: string;
|
|
9
|
+
decimals: number;
|
|
10
|
+
};
|
|
11
|
+
blockExplorerUrls?: string[];
|
|
12
|
+
iconUrls?: string[];
|
|
13
|
+
}
|
|
14
|
+
/** What cypress/wallet.setup.ts exports. */
|
|
15
|
+
export interface WalletSetup {
|
|
16
|
+
/**
|
|
17
|
+
* The wallet to import. Without one, Dappress makes a new wallet for each run.
|
|
18
|
+
* Prefer DAPPRESS_SEED_PHRASE in cypress.env.json or the environment: this file is usually committed.
|
|
19
|
+
*/
|
|
20
|
+
seedPhrase?: string;
|
|
21
|
+
/** The network the dapp is moved onto by cy.connectToDapp(). */
|
|
22
|
+
network?: Network;
|
|
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
|
+
/** The options once resolved: every one has a value, the seed phrase included. */
|
|
44
|
+
export interface ResolvedOptions {
|
|
45
|
+
metamaskVersion: string;
|
|
46
|
+
seedPhrase: string;
|
|
47
|
+
password: string;
|
|
48
|
+
network: Network | null;
|
|
49
|
+
backupAndSync: boolean;
|
|
50
|
+
autoSetup: boolean;
|
|
51
|
+
cache: boolean;
|
|
52
|
+
timeout: number;
|
|
53
|
+
cacheDir: string;
|
|
54
|
+
}
|
|
55
|
+
/** The subset of the options the browser side reads with Cypress.expose(). */
|
|
56
|
+
export type PublicOptions = Pick<ResolvedOptions, 'metamaskVersion' | 'autoSetup' | 'network'>;
|
|
57
|
+
export type WalletState = 'onboarding' | 'locked' | 'unlocked';
|
|
58
|
+
export type ConnectOptions = {
|
|
59
|
+
/**
|
|
60
|
+
* The accounts to connect the dapp with, by their names in MetaMask:
|
|
61
|
+
* `['Account 1', 'Account 2']`. These and no other are connected, in the
|
|
62
|
+
* order MetaMask gives them to the dapp, whatever their order here.
|
|
63
|
+
* Default: the account MetaMask suggests, the wallet's selected one.
|
|
64
|
+
*/
|
|
65
|
+
accounts?: string[];
|
|
66
|
+
};
|
|
67
|
+
/** What cy.confirmTransaction() sets on the confirmation before confirming it. */
|
|
68
|
+
export interface TransactionOptions {
|
|
69
|
+
/**
|
|
70
|
+
* On an ERC-20 approval, the amount the spender may use, in tokens, as a user types it: 5 or '2.5'.
|
|
71
|
+
* A string keeps an amount a number would round. Fails on a transaction that has no spending cap.
|
|
72
|
+
*/
|
|
73
|
+
spendingCap?: number | string;
|
|
74
|
+
/**
|
|
75
|
+
* The network fee: one of the estimates MetaMask's fee editor offers for the network, or custom values.
|
|
76
|
+
* Fails when MetaMask doesn't offer that estimate.
|
|
77
|
+
*/
|
|
78
|
+
gas?: GasEstimate | CustomGas;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* An estimate of MetaMask's fee editor, by the name it shows. Low, Market and Aggressive are offered on the
|
|
82
|
+
* networks MetaMask has fee estimates for; Network suggested on those it only has a gas price for, such as a local node.
|
|
83
|
+
*/
|
|
84
|
+
export type GasEstimate = 'low' | 'market' | 'aggressive' | 'networkSuggested';
|
|
85
|
+
/** The fields of the fee editor's advanced form, on an EIP-1559 transaction. One left out keeps MetaMask's value. */
|
|
86
|
+
export interface CustomGas {
|
|
87
|
+
/** "Max base fee", in GWEI: the transaction's maxFeePerGas. */
|
|
88
|
+
maxBaseFee?: number | string;
|
|
89
|
+
/** "Priority fee", in GWEI: the transaction's maxPriorityFeePerGas. At most the max base fee. */
|
|
90
|
+
priorityFee?: number | string;
|
|
91
|
+
/** "Gas limit", in units of gas: 21000 or more. */
|
|
92
|
+
gasLimit?: number | string;
|
|
93
|
+
}
|