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,7 @@
|
|
|
1
|
+
import { type Browser } from 'puppeteer-core';
|
|
2
|
+
export declare function captureDebuggerUrl(on: Cypress.PluginEvents): void;
|
|
3
|
+
/**
|
|
4
|
+
* Run `action` with a Puppeteer browser connected to Cypress's browser, then
|
|
5
|
+
* give the focus back to the Cypress tab and disconnect. The browser stays open.
|
|
6
|
+
*/
|
|
7
|
+
export declare function withBrowser<T>(action: (browser: Browser) => Promise<T>): Promise<T>;
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Connects Puppeteer to the browser Cypress launched, through the debugging
|
|
3
|
+
// URL Cypress hands out in after:browser:launch (Cypress 13.6+).
|
|
4
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
5
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
6
|
+
};
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.captureDebuggerUrl = captureDebuggerUrl;
|
|
9
|
+
exports.withBrowser = withBrowser;
|
|
10
|
+
const puppeteer_core_1 = __importDefault(require("puppeteer-core"));
|
|
11
|
+
let debuggerUrl;
|
|
12
|
+
function captureDebuggerUrl(on) {
|
|
13
|
+
on('after:browser:launch', (browser, options) => {
|
|
14
|
+
debuggerUrl = options.webSocketDebuggerUrl;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Run `action` with a Puppeteer browser connected to Cypress's browser, then
|
|
19
|
+
* give the focus back to the Cypress tab and disconnect. The browser stays open.
|
|
20
|
+
*/
|
|
21
|
+
async function withBrowser(action) {
|
|
22
|
+
if (!debuggerUrl) {
|
|
23
|
+
throw new Error('[dappress] No browser to connect to. Is configureDappress() called in setupNodeEvents, with Cypress 13.6 or later?');
|
|
24
|
+
}
|
|
25
|
+
const browser = await puppeteer_core_1.default.connect({ browserWSEndpoint: debuggerUrl, defaultViewport: null });
|
|
26
|
+
try {
|
|
27
|
+
return await action(browser);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
// The browser side only gets the message, so keep the full stack in the terminal
|
|
31
|
+
console.error(error.stack || error);
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await focusCypressTab(browser).catch(() => { });
|
|
36
|
+
await browser.disconnect();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// MetaMask opens its own tabs and popups; Chrome throttles the Cypress tab
|
|
40
|
+
// while it is in the background. The Cypress runner lives under /__/.
|
|
41
|
+
async function focusCypressTab(browser) {
|
|
42
|
+
const target = browser.targets().find((candidate) => candidate.type() === 'page' && candidate.url().includes('/__/'));
|
|
43
|
+
if (target)
|
|
44
|
+
await (await target.page())?.bringToFront();
|
|
45
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DappressOptions, Network, PublicOptions, ResolvedOptions } from './types';
|
|
2
|
+
export declare const DEFAULTS: {
|
|
3
|
+
metamaskVersion: string;
|
|
4
|
+
seedPhrase: string | null;
|
|
5
|
+
password: string;
|
|
6
|
+
network: Network | null;
|
|
7
|
+
backupAndSync: boolean;
|
|
8
|
+
autoSetup: boolean;
|
|
9
|
+
cache: boolean;
|
|
10
|
+
timeout: number;
|
|
11
|
+
cacheDir: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The plugin options, in order of precedence: environment variables, the
|
|
15
|
+
* Cypress `env` block (cypress.env.json), the wallet setup file
|
|
16
|
+
* (cypress/wallet.setup.ts or .js), the options given to configureDappress(),
|
|
17
|
+
* then the defaults above.
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveOptions(userOptions?: DappressOptions, cypressConfig?: Partial<Pick<Cypress.PluginConfigOptions, 'env' | 'projectRoot'>>): ResolvedOptions;
|
|
20
|
+
/** The subset of options that is safe to expose to the browser side (no secrets). */
|
|
21
|
+
export declare function publicOptions(options: ResolvedOptions): PublicOptions;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.DEFAULTS = void 0;
|
|
7
|
+
exports.resolveOptions = resolveOptions;
|
|
8
|
+
exports.publicOptions = publicOptions;
|
|
9
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
10
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
const bip39_1 = require("@scure/bip39");
|
|
13
|
+
const english_1 = require("@scure/bip39/wordlists/english");
|
|
14
|
+
exports.DEFAULTS = {
|
|
15
|
+
metamaskVersion: '13.50.0',
|
|
16
|
+
// None: a new wallet is made for each run. A phrase known to others is not
|
|
17
|
+
// blank, since MetaMask restores the accounts saved for it elsewhere.
|
|
18
|
+
seedPhrase: null,
|
|
19
|
+
password: 'Tester@1234',
|
|
20
|
+
// The network cy.connectToDapp() moves the dapp onto, as a wallet_addEthereumChain parameter; none by default
|
|
21
|
+
network: null,
|
|
22
|
+
// MetaMask's backup and sync saves the accounts and contacts of a seed phrase
|
|
23
|
+
// and restores them on other installs. Off, a test's accounts don't come back.
|
|
24
|
+
backupAndSync: false,
|
|
25
|
+
// Run cy.setupMetaMask() automatically before the first test of each spec
|
|
26
|
+
autoSetup: true,
|
|
27
|
+
// Import the wallet once and reuse the profile across runs (keeps the vault under cacheDir)
|
|
28
|
+
cache: false,
|
|
29
|
+
// How long to wait for MetaMask to show a confirmation after a dapp request (ms)
|
|
30
|
+
timeout: 20000,
|
|
31
|
+
cacheDir: node_path_1.default.join(node_os_1.default.homedir(), '.cache', 'dappress'),
|
|
32
|
+
};
|
|
33
|
+
// A .ts file loads when the Cypress config is itself in TypeScript
|
|
34
|
+
const WALLET_SETUP_FILES = ['cypress/wallet.setup.ts', 'cypress/wallet.setup.js'];
|
|
35
|
+
/**
|
|
36
|
+
* The plugin options, in order of precedence: environment variables, the
|
|
37
|
+
* Cypress `env` block (cypress.env.json), the wallet setup file
|
|
38
|
+
* (cypress/wallet.setup.ts or .js), the options given to configureDappress(),
|
|
39
|
+
* then the defaults above.
|
|
40
|
+
*/
|
|
41
|
+
function resolveOptions(userOptions = {}, cypressConfig = {}) {
|
|
42
|
+
const env = cypressConfig.env || {};
|
|
43
|
+
const fromEnv = {
|
|
44
|
+
metamaskVersion: process.env.DAPPRESS_METAMASK_VERSION || env.DAPPRESS_METAMASK_VERSION,
|
|
45
|
+
seedPhrase: process.env.DAPPRESS_SEED_PHRASE || env.DAPPRESS_SEED_PHRASE,
|
|
46
|
+
password: process.env.DAPPRESS_PASSWORD || env.DAPPRESS_PASSWORD,
|
|
47
|
+
};
|
|
48
|
+
const options = { ...exports.DEFAULTS, ...userOptions, ...loadWalletSetup(cypressConfig.projectRoot) };
|
|
49
|
+
for (const [key, value] of Object.entries(fromEnv)) {
|
|
50
|
+
if (value)
|
|
51
|
+
options[key] = value;
|
|
52
|
+
}
|
|
53
|
+
const seedPhrase = options.seedPhrase || newWallet(options);
|
|
54
|
+
return { ...options, seedPhrase };
|
|
55
|
+
}
|
|
56
|
+
/** No seed phrase was given: make one for this run. It is never written anywhere. */
|
|
57
|
+
function newWallet(options) {
|
|
58
|
+
console.log('[dappress] No seed phrase configured: using a new wallet for this run');
|
|
59
|
+
if (options.cache) {
|
|
60
|
+
console.warn('[dappress] The profile cache needs a seed phrase of your own: importing the wallet in this run instead');
|
|
61
|
+
options.cache = false;
|
|
62
|
+
}
|
|
63
|
+
return (0, bip39_1.generateMnemonic)(english_1.wordlist);
|
|
64
|
+
}
|
|
65
|
+
/** The project's wallet setup file: { seedPhrase?, network? }. */
|
|
66
|
+
function loadWalletSetup(projectRoot = process.cwd()) {
|
|
67
|
+
const file = WALLET_SETUP_FILES.map((name) => node_path_1.default.join(projectRoot, name)).find((candidate) => node_fs_1.default.existsSync(candidate));
|
|
68
|
+
if (!file)
|
|
69
|
+
return {};
|
|
70
|
+
// The file is the project's, found at run time: there is nothing to import statically
|
|
71
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
72
|
+
const loaded = require(file);
|
|
73
|
+
const setup = loaded.default || loaded; // `export default` or `module.exports`
|
|
74
|
+
const unknown = Object.keys(setup).filter((key) => !['seedPhrase', 'network'].includes(key));
|
|
75
|
+
if (unknown.length)
|
|
76
|
+
throw new Error(`[dappress] Unknown keys in ${node_path_1.default.basename(file)}: ${unknown.join(', ')}`);
|
|
77
|
+
return setup;
|
|
78
|
+
}
|
|
79
|
+
/** The subset of options that is safe to expose to the browser side (no secrets). */
|
|
80
|
+
function publicOptions(options) {
|
|
81
|
+
return { metamaskVersion: options.metamaskVersion, autoSetup: options.autoSetup, network: options.network };
|
|
82
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ResolvedOptions } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Download and unzip the MetaMask Chrome build for `version` into the cache.
|
|
4
|
+
* Returns the path of the unpacked extension (the folder holding manifest.json).
|
|
5
|
+
* Already cached versions are reused.
|
|
6
|
+
*/
|
|
7
|
+
export declare function prepareExtension({ metamaskVersion, cacheDir }: Pick<ResolvedOptions, 'metamaskVersion' | 'cacheDir'>): Promise<string>;
|
package/dist/download.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.prepareExtension = prepareExtension;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const promises_1 = require("node:stream/promises");
|
|
10
|
+
const node_stream_1 = require("node:stream");
|
|
11
|
+
const extract_zip_1 = __importDefault(require("extract-zip"));
|
|
12
|
+
function releaseUrl(version) {
|
|
13
|
+
return `https://github.com/MetaMask/metamask-extension/releases/download/v${version}/metamask-chrome-${version}.zip`;
|
|
14
|
+
}
|
|
15
|
+
function extensionDir(cacheDir, version) {
|
|
16
|
+
return node_path_1.default.join(cacheDir, 'metamask', version);
|
|
17
|
+
}
|
|
18
|
+
async function download(url, destination) {
|
|
19
|
+
const response = await fetch(url, { redirect: 'follow' });
|
|
20
|
+
if (!response.ok || !response.body) {
|
|
21
|
+
throw new Error(`[dappress] Could not download ${url}: HTTP ${response.status}`);
|
|
22
|
+
}
|
|
23
|
+
await node_fs_1.default.promises.mkdir(node_path_1.default.dirname(destination), { recursive: true });
|
|
24
|
+
await (0, promises_1.pipeline)(node_stream_1.Readable.fromWeb(response.body), node_fs_1.default.createWriteStream(destination));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Download and unzip the MetaMask Chrome build for `version` into the cache.
|
|
28
|
+
* Returns the path of the unpacked extension (the folder holding manifest.json).
|
|
29
|
+
* Already cached versions are reused.
|
|
30
|
+
*/
|
|
31
|
+
async function prepareExtension({ metamaskVersion, cacheDir }) {
|
|
32
|
+
const dir = extensionDir(cacheDir, metamaskVersion);
|
|
33
|
+
const manifest = node_path_1.default.join(dir, 'manifest.json');
|
|
34
|
+
if (node_fs_1.default.existsSync(manifest))
|
|
35
|
+
return dir;
|
|
36
|
+
const zipPath = node_path_1.default.join(cacheDir, 'metamask', `metamask-chrome-${metamaskVersion}.zip`);
|
|
37
|
+
if (!node_fs_1.default.existsSync(zipPath)) {
|
|
38
|
+
console.log(`[dappress] Downloading MetaMask ${metamaskVersion}…`);
|
|
39
|
+
await download(releaseUrl(metamaskVersion), zipPath);
|
|
40
|
+
}
|
|
41
|
+
console.log(`[dappress] Unpacking MetaMask ${metamaskVersion}…`);
|
|
42
|
+
await node_fs_1.default.promises.rm(dir, { recursive: true, force: true });
|
|
43
|
+
await (0, extract_zip_1.default)(zipPath, { dir });
|
|
44
|
+
if (!node_fs_1.default.existsSync(manifest)) {
|
|
45
|
+
throw new Error(`[dappress] ${zipPath} does not look like a MetaMask build: no manifest.json after extraction`);
|
|
46
|
+
}
|
|
47
|
+
return dir;
|
|
48
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/// <reference types="cypress" preserve="true" />
|
|
2
|
+
import type { DappressOptions } from './types';
|
|
3
|
+
export type { DappressOptions, Network, WalletSetup } from './types';
|
|
4
|
+
/**
|
|
5
|
+
* Wire Dappress into a Cypress project. Call it from setupNodeEvents:
|
|
6
|
+
*
|
|
7
|
+
* setupNodeEvents(on, config) {
|
|
8
|
+
* return configureDappress(on, config);
|
|
9
|
+
* }
|
|
10
|
+
*
|
|
11
|
+
* It loads the MetaMask extension into the browser Cypress launches, and
|
|
12
|
+
* registers the tasks behind the cy.* commands from 'dappress/support'.
|
|
13
|
+
*/
|
|
14
|
+
export declare function configureDappress(on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions, userOptions?: DappressOptions): Cypress.PluginConfigOptions;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/// <reference types="cypress" preserve="true" />
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.configureDappress = configureDappress;
|
|
5
|
+
const config_1 = require("./config");
|
|
6
|
+
const browser_1 = require("./browser");
|
|
7
|
+
const download_1 = require("./download");
|
|
8
|
+
const profile_1 = require("./profile");
|
|
9
|
+
const actions_1 = require("./actions");
|
|
10
|
+
/**
|
|
11
|
+
* Wire Dappress into a Cypress project. Call it from setupNodeEvents:
|
|
12
|
+
*
|
|
13
|
+
* setupNodeEvents(on, config) {
|
|
14
|
+
* return configureDappress(on, config);
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* It loads the MetaMask extension into the browser Cypress launches, and
|
|
18
|
+
* registers the tasks behind the cy.* commands from 'dappress/support'.
|
|
19
|
+
*/
|
|
20
|
+
function configureDappress(on, config, userOptions = {}) {
|
|
21
|
+
const options = (0, config_1.resolveOptions)(userOptions, config);
|
|
22
|
+
if (config.chromeWebSecurity === false) {
|
|
23
|
+
console.warn('[dappress] chromeWebSecurity is off: MetaMask cannot start its snaps, so adding or importing an account will hang');
|
|
24
|
+
}
|
|
25
|
+
// A cached wallet gets its requests in MetaMask's popup window, which headless Chrome doesn't open
|
|
26
|
+
const usesCache = (browser) => options.cache && !browser.isHeadless;
|
|
27
|
+
// Build the wallet profile before the run, out of the time Cypress allows a browser to come up
|
|
28
|
+
on('before:run', async ({ browser }) => {
|
|
29
|
+
if (browser && usesCache(browser))
|
|
30
|
+
await (0, profile_1.prepareProfile)({ browserPath: browser.path, extensionDir: await (0, download_1.prepareExtension)(options), options });
|
|
31
|
+
});
|
|
32
|
+
on('before:browser:launch', async (browser, launchOptions) => {
|
|
33
|
+
if (browser.family !== 'chromium') {
|
|
34
|
+
throw new Error(`[dappress] MetaMask is a Chromium extension; ${browser.name} is not supported`);
|
|
35
|
+
}
|
|
36
|
+
const extensionDir = await (0, download_1.prepareExtension)(options);
|
|
37
|
+
launchOptions.extensions.push(extensionDir);
|
|
38
|
+
if (browser.isHeadless) {
|
|
39
|
+
// Cypress leaves extensions out of a headless launch, so ask Chrome directly
|
|
40
|
+
launchOptions.args.push(`--load-extension=${extensionDir}`);
|
|
41
|
+
if (options.cache)
|
|
42
|
+
console.warn('[dappress] The wallet cache needs a headed browser: importing the wallet in this run instead');
|
|
43
|
+
}
|
|
44
|
+
if (usesCache(browser)) {
|
|
45
|
+
const profileDir = await (0, profile_1.prepareProfile)({ browserPath: browser.path, extensionDir, options });
|
|
46
|
+
await (0, profile_1.installProfile)(profileDir, browser, config.isTextTerminal);
|
|
47
|
+
}
|
|
48
|
+
return launchOptions;
|
|
49
|
+
});
|
|
50
|
+
(0, browser_1.captureDebuggerUrl)(on);
|
|
51
|
+
on('task', (0, actions_1.createTasks)(options));
|
|
52
|
+
// Non-sensitive settings the browser side reads with Cypress.expose()
|
|
53
|
+
config.expose = { ...config.expose, dappress: (0, config_1.publicOptions)(options) };
|
|
54
|
+
return config;
|
|
55
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Browser, Page } from 'puppeteer-core';
|
|
2
|
+
/**
|
|
3
|
+
* MetaMask's extension id. Cypress loads its own extension in headed
|
|
4
|
+
* Chromium browsers, so the id is the one whose home page is titled "MetaMask".
|
|
5
|
+
*/
|
|
6
|
+
export declare function findExtensionId(browser: Browser, timeout?: number): Promise<string>;
|
|
7
|
+
/** The full-screen MetaMask page, opened if it isn't already. */
|
|
8
|
+
export declare function getHomePage(browser: Browser, extensionId: string): Promise<Page>;
|
|
9
|
+
/** The pages open where dapp requests show up: the side panel, a popup. */
|
|
10
|
+
export declare function getRequestPages(browser: Browser, extensionId: string): Promise<Page[]>;
|
|
11
|
+
/**
|
|
12
|
+
* The page a dapp request is shown on. Waits for the popup to open, or takes
|
|
13
|
+
* the side panel. A popup still on its home route is one closing after the
|
|
14
|
+
* previous request, or one that hasn't routed to the request yet: skipped.
|
|
15
|
+
*/
|
|
16
|
+
export declare function getConfirmationPage(browser: Browser, extensionId: string, timeout: number): Promise<Page>;
|
|
17
|
+
export declare function withUsableWindow(page: Page): Promise<Page>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Find MetaMask's pages in the browser Cypress launched: the full-screen home
|
|
3
|
+
// page, and the surface where dapp requests show up. On Chrome that surface
|
|
4
|
+
// is the side panel when it is open, and the "MetaMask Dialog" popup otherwise.
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.findExtensionId = findExtensionId;
|
|
7
|
+
exports.getHomePage = getHomePage;
|
|
8
|
+
exports.getRequestPages = getRequestPages;
|
|
9
|
+
exports.getConfirmationPage = getConfirmationPage;
|
|
10
|
+
exports.withUsableWindow = withUsableWindow;
|
|
11
|
+
const page_helpers_1 = require("./page-helpers");
|
|
12
|
+
const HOME_PATH = '/home.html';
|
|
13
|
+
const CONFIRMATION_PATHS = ['/notification.html', '/sidepanel.html'];
|
|
14
|
+
function extensionIdOf(url) {
|
|
15
|
+
const match = /^chrome-extension:\/\/([a-z]{32})\//.exec(url);
|
|
16
|
+
return match ? match[1] : null;
|
|
17
|
+
}
|
|
18
|
+
function isHomeRoute(url) {
|
|
19
|
+
return url.includes('/notification.html') && /notification\.html(#\/?)?$/.test(url);
|
|
20
|
+
}
|
|
21
|
+
function pagesOf(browser, extensionId, pathname) {
|
|
22
|
+
return browser.targets().filter((target) => target.type() === 'page' && target.url().startsWith(`chrome-extension://${extensionId}${pathname}`));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* MetaMask's extension id. Cypress loads its own extension in headed
|
|
26
|
+
* Chromium browsers, so the id is the one whose home page is titled "MetaMask".
|
|
27
|
+
*/
|
|
28
|
+
async function findExtensionId(browser, timeout = 30000) {
|
|
29
|
+
const deadline = Date.now() + timeout;
|
|
30
|
+
while (Date.now() < deadline) {
|
|
31
|
+
const ids = new Set(browser
|
|
32
|
+
.targets()
|
|
33
|
+
.map((target) => extensionIdOf(target.url()))
|
|
34
|
+
.filter((id) => id !== null));
|
|
35
|
+
for (const id of ids) {
|
|
36
|
+
if (await isMetaMask(browser, id))
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
39
|
+
await (0, page_helpers_1.sleep)(500);
|
|
40
|
+
}
|
|
41
|
+
throw new Error('[dappress] MetaMask extension not found in the browser. Is it loaded in before:browser:launch?');
|
|
42
|
+
}
|
|
43
|
+
async function isMetaMask(browser, extensionId) {
|
|
44
|
+
const [target] = pagesOf(browser, extensionId, HOME_PATH);
|
|
45
|
+
if (target) {
|
|
46
|
+
// A page still loading or redirecting loses its execution context while its
|
|
47
|
+
// title is read; not MetaMask yet, findExtensionId looks again
|
|
48
|
+
try {
|
|
49
|
+
const page = await pageOf(target);
|
|
50
|
+
return (await page.title()).startsWith('MetaMask');
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const page = await browser.newPage();
|
|
57
|
+
try {
|
|
58
|
+
const response = await page.goto(`chrome-extension://${extensionId}${HOME_PATH}`, { waitUntil: 'domcontentloaded' });
|
|
59
|
+
return Boolean(response && response.ok()) && (await page.title()).startsWith('MetaMask');
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
await page.close();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// A target of type "page" has a page; Puppeteer types it as one that may not
|
|
69
|
+
async function pageOf(target) {
|
|
70
|
+
const page = await target.page();
|
|
71
|
+
if (!page)
|
|
72
|
+
throw new Error(`[dappress] No page behind ${target.url()}`);
|
|
73
|
+
return page;
|
|
74
|
+
}
|
|
75
|
+
/** The full-screen MetaMask page, opened if it isn't already. */
|
|
76
|
+
async function getHomePage(browser, extensionId) {
|
|
77
|
+
const [target] = pagesOf(browser, extensionId, HOME_PATH);
|
|
78
|
+
if (target)
|
|
79
|
+
return pageOf(target);
|
|
80
|
+
const page = await browser.newPage();
|
|
81
|
+
await page.goto(`chrome-extension://${extensionId}${HOME_PATH}`, { waitUntil: 'domcontentloaded' });
|
|
82
|
+
return page;
|
|
83
|
+
}
|
|
84
|
+
/** The pages open where dapp requests show up: the side panel, a popup. */
|
|
85
|
+
async function getRequestPages(browser, extensionId) {
|
|
86
|
+
const pages = [];
|
|
87
|
+
for (const pathname of CONFIRMATION_PATHS) {
|
|
88
|
+
for (const target of pagesOf(browser, extensionId, pathname)) {
|
|
89
|
+
const page = await target.page();
|
|
90
|
+
if (page)
|
|
91
|
+
pages.push(pathname === '/notification.html' ? await withUsableWindow(page) : page);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return pages;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The page a dapp request is shown on. Waits for the popup to open, or takes
|
|
98
|
+
* the side panel. A popup still on its home route is one closing after the
|
|
99
|
+
* previous request, or one that hasn't routed to the request yet: skipped.
|
|
100
|
+
*/
|
|
101
|
+
async function getConfirmationPage(browser, extensionId, timeout) {
|
|
102
|
+
const deadline = Date.now() + timeout;
|
|
103
|
+
while (Date.now() < deadline) {
|
|
104
|
+
for (const pathname of CONFIRMATION_PATHS) {
|
|
105
|
+
const [target] = pagesOf(browser, extensionId, pathname).filter((candidate) => !isHomeRoute(candidate.url()));
|
|
106
|
+
if (target)
|
|
107
|
+
return pathname === '/notification.html' ? withUsableWindow(await pageOf(target)) : pageOf(target);
|
|
108
|
+
}
|
|
109
|
+
await (0, page_helpers_1.sleep)(250);
|
|
110
|
+
}
|
|
111
|
+
const seen = browser
|
|
112
|
+
.targets()
|
|
113
|
+
.filter((target) => extensionIdOf(target.url()) === extensionId)
|
|
114
|
+
.map((target) => `${target.type()} ${target.url()}`)
|
|
115
|
+
.join(', ');
|
|
116
|
+
throw new Error(`[dappress] MetaMask showed no confirmation within ${timeout}ms. Did the dapp send a request? MetaMask pages seen: ${seen || 'none'}`);
|
|
117
|
+
}
|
|
118
|
+
// MetaMask asks Chrome for a 400x620 popup. Under Xvfb on a GitHub runner the
|
|
119
|
+
// window it gets is 1x1: nothing is laid out, the pane of a confirmation that
|
|
120
|
+
// must be read to the end has no height, so it can never be scrolled there and
|
|
121
|
+
// its Confirm button stays disabled. The popup is given its size back first.
|
|
122
|
+
const POPUP = { width: 400, height: 620 };
|
|
123
|
+
async function withUsableWindow(page) {
|
|
124
|
+
const session = await page.target().createCDPSession();
|
|
125
|
+
try {
|
|
126
|
+
const { windowId, bounds } = await session.send('Browser.getWindowForTarget');
|
|
127
|
+
if (bounds.windowState === 'normal' && (bounds.width ?? 0) >= 200 && (bounds.height ?? 0) >= 200)
|
|
128
|
+
return page;
|
|
129
|
+
console.log(`[dappress] MetaMask's popup is ${bounds.width}x${bounds.height} (${bounds.windowState}); resizing it to ${POPUP.width}x${POPUP.height}`);
|
|
130
|
+
if (bounds.windowState !== 'normal')
|
|
131
|
+
await session.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });
|
|
132
|
+
await session.send('Browser.setWindowBounds', { windowId, bounds: { ...POPUP } });
|
|
133
|
+
// Let the page lay itself out at its new size before anything is looked for in it
|
|
134
|
+
await (0, page_helpers_1.sleep)(300);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
console.warn(`[dappress] Could not check the popup window's size: ${error.message}`);
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
await session.detach().catch(() => { });
|
|
141
|
+
}
|
|
142
|
+
return page;
|
|
143
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Page } from 'puppeteer-core';
|
|
2
|
+
import type { ConnectOptions, ResolvedOptions, TransactionOptions, WalletState } from './types';
|
|
3
|
+
export declare const decisions: {
|
|
4
|
+
connectToDapp: string[];
|
|
5
|
+
rejectConnection: string[];
|
|
6
|
+
approveNewNetwork: string[];
|
|
7
|
+
rejectNewNetwork: string[];
|
|
8
|
+
approveSwitchNetwork: string[];
|
|
9
|
+
rejectSwitchNetwork: string[];
|
|
10
|
+
approveAddToken: string[];
|
|
11
|
+
rejectAddToken: string[];
|
|
12
|
+
confirmSignature: string[];
|
|
13
|
+
rejectSignature: string[];
|
|
14
|
+
confirmTransaction: string[];
|
|
15
|
+
rejectTransaction: string[];
|
|
16
|
+
};
|
|
17
|
+
export type Decision = keyof typeof decisions;
|
|
18
|
+
/** Which screen the extension home page shows, once MetaMask has started. */
|
|
19
|
+
export declare function walletState(page: Page): Promise<WalletState | 'unknown'>;
|
|
20
|
+
/** Import a wallet from its seed phrase on a fresh MetaMask install. */
|
|
21
|
+
export declare function onboard(page: Page, { seedPhrase, password, backupAndSync }: Pick<ResolvedOptions, 'seedPhrase' | 'password' | 'backupAndSync'>): Promise<void>;
|
|
22
|
+
export declare function unlock(page: Page, { password }: Pick<ResolvedOptions, 'password'>): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* A page that was showing the unlock form when the wallet got unlocked from
|
|
25
|
+
* another one keeps showing it, and the dapp's requests wait behind it. The
|
|
26
|
+
* password is given there too, as a user would; what follows is the wallet
|
|
27
|
+
* or a request, so only the form is watched.
|
|
28
|
+
*/
|
|
29
|
+
export declare function leaveUnlockForm(page: Page, { password }: Pick<ResolvedOptions, 'password'>): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Add an account to the wallet and select it. Yields its name, "Account N".
|
|
32
|
+
* A click that lands while the list is still settling is lost, so the button
|
|
33
|
+
* is pressed again when no account shows up.
|
|
34
|
+
*/
|
|
35
|
+
export declare function addAccount(page: Page): Promise<string>;
|
|
36
|
+
/** Make `name` the selected account of the wallet. */
|
|
37
|
+
export declare function switchAccount(page: Page, name: string): Promise<void>;
|
|
38
|
+
/** Import an account from its private key and select it. Yields its name. */
|
|
39
|
+
export declare function importAccount(page: Page, privateKey: string): Promise<string>;
|
|
40
|
+
/** Lock the wallet from the menu of its home page. Nothing to do on a wallet already locked. */
|
|
41
|
+
export declare function lock(page: Page): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Disconnect the site at `origin` from the wallet, on its page of the
|
|
44
|
+
* "Permissions" screen. The dapp gets an empty accountsChanged.
|
|
45
|
+
*/
|
|
46
|
+
export declare function disconnectSite(page: Page, origin: string): Promise<void>;
|
|
47
|
+
/** The options a command took from the test: those of the commands that take any. */
|
|
48
|
+
type CommandOptions = ConnectOptions & TransactionOptions;
|
|
49
|
+
/** Press the button of `decision` on the confirmation shown on `page`, once it is set as `options` ask. */
|
|
50
|
+
export declare function decide(decision: Decision, page: Page, timeout: number, options?: CommandOptions): Promise<void>;
|
|
51
|
+
/** wallet_addEthereumChain on a network MetaMask knows behaves like a switch: either prompt may show. */
|
|
52
|
+
export declare function approveNetworkChange(page: Page, timeout: number): Promise<void>;
|
|
53
|
+
export {};
|