dappress 0.3.3 → 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/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+ // The types of the public API, shared by the plugin (index.ts) and the
3
+ // commands (support.ts), and the options as the plugin resolves them.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dappress",
3
- "version": "0.3.3",
3
+ "version": "0.5.0",
4
4
  "description": "MetaMask automation for Cypress: load the real extension, import a test wallet, answer dapp requests with cy.* commands. Tracks the latest MetaMask release.",
5
5
  "license": "MIT",
6
6
  "author": "Benjamin Lassaut",
@@ -21,28 +21,28 @@
21
21
  "wallet",
22
22
  "dapp"
23
23
  ],
24
- "main": "src/index.js",
25
- "types": "types/index.d.ts",
24
+ "main": "dist/index.js",
25
+ "types": "dist/index.d.ts",
26
26
  "exports": {
27
27
  ".": {
28
- "types": "./types/index.d.ts",
29
- "default": "./src/index.js"
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
30
  },
31
31
  "./support": {
32
- "types": "./types/support.d.ts",
33
- "default": "./src/support.js"
32
+ "types": "./dist/support.d.ts",
33
+ "default": "./dist/support.js"
34
34
  }
35
35
  },
36
36
  "typesVersions": {
37
37
  "*": {
38
38
  "support": [
39
- "types/support.d.ts"
39
+ "dist/support.d.ts"
40
40
  ]
41
41
  }
42
42
  },
43
43
  "files": [
44
- "src",
45
- "types",
44
+ "dist",
45
+ "CHANGELOG.md",
46
46
  "README.md",
47
47
  "LICENSE"
48
48
  ],
@@ -50,10 +50,15 @@
50
50
  "node": ">=20"
51
51
  },
52
52
  "scripts": {
53
- "conformance": "node scripts/conformance.js",
54
- "matrix": "node scripts/matrix.js",
55
- "test": "node --test scripts/*.test.js",
56
- "check-types": "tsc -p tsconfig.json"
53
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
54
+ "prepack": "npm run build",
55
+ "conformance": "tsx scripts/conformance.ts",
56
+ "matrix": "tsx scripts/matrix.ts",
57
+ "test": "tsx --test scripts/*.test.ts src/*.test.ts",
58
+ "check-types": "tsc -p tsconfig.json",
59
+ "lint": "eslint .",
60
+ "format": "prettier --write .",
61
+ "format:check": "prettier --check ."
57
62
  },
58
63
  "peerDependencies": {
59
64
  "cypress": ">=13.6.0"
@@ -64,8 +69,15 @@
64
69
  "puppeteer-core": "^24.43.1"
65
70
  },
66
71
  "devDependencies": {
72
+ "@eslint/js": "^10.0.1",
67
73
  "@types/node": "^20.19.43",
68
74
  "cypress": "^16.1.1",
69
- "typescript": "^5.6.0"
75
+ "eslint": "^10.12.0",
76
+ "eslint-config-prettier": "^10.1.8",
77
+ "globals": "^17.13.0",
78
+ "prettier": "^3.9.9",
79
+ "tsx": "^4.23.15",
80
+ "typescript": "^5.6.0",
81
+ "typescript-eslint": "^8.71.0"
70
82
  }
71
83
  }
package/src/actions.js DELETED
@@ -1,67 +0,0 @@
1
- // The Node side of every cy.* command, registered as Cypress tasks. Each
2
- // action gets a Puppeteer browser connected to the Cypress browser, finds the
3
- // MetaMask page it needs and drives it through src/metamask.js.
4
-
5
- const { withBrowser } = require('./browser');
6
- const metamask = require('./metamask');
7
- const { findExtensionId, getHomePage, getConfirmationPage } = require('./metamask-pages');
8
-
9
- function createTasks(options) {
10
- // Found once: the browser is the same for the whole run
11
- let extensionId;
12
-
13
- async function metamaskId(browser) {
14
- extensionId = extensionId || (await findExtensionId(browser));
15
- return extensionId;
16
- }
17
-
18
- /** Onboard the wallet or unlock it, so the dapp can talk to it. Safe to call repeatedly. */
19
- async function setupWallet(browser) {
20
- const home = await getHomePage(browser, await metamaskId(browser));
21
- const state = await metamask.walletState(home);
22
- console.log(`[dappress] MetaMask is ${state}`);
23
- if (state === 'onboarding') await metamask.onboard(home, options);
24
- if (state === 'locked') await metamask.unlock(home, options);
25
- if (state === 'unknown') throw new Error(`[dappress] Unexpected MetaMask screen at ${home.url()}`);
26
- await home.close();
27
- return state;
28
- }
29
-
30
- /** A task that drives the wallet's own screens, from its full-screen page. */
31
- const onHomePage = (flow) => async (browser, argument) => {
32
- const home = await getHomePage(browser, await metamaskId(browser));
33
- try {
34
- await home.bringToFront();
35
- return await flow(home, argument);
36
- } finally {
37
- await home.close();
38
- }
39
- };
40
-
41
- /** Approve the prompt a network change raised: "Add network" or the permission to switch. */
42
- async function approveNetworkChange(browser) {
43
- const page = await getConfirmationPage(browser, await metamaskId(browser), options.timeout);
44
- await metamask.approveNetworkChange(page, options.timeout);
45
- }
46
-
47
- /** The task for a decision: find the confirmation, press its button. */
48
- const decide = (decision) => async (browser) => {
49
- const page = await getConfirmationPage(browser, await metamaskId(browser), options.timeout);
50
- await metamask.decide(decision, page, options.timeout);
51
- };
52
-
53
- const actions = {
54
- setupWallet,
55
- approveNetworkChange,
56
- addAccount: onHomePage(metamask.addAccount),
57
- switchAccount: onHomePage(metamask.switchAccount),
58
- importAccount: onHomePage(metamask.importAccount),
59
- };
60
- for (const decision of Object.keys(metamask.decisions)) actions[decision] = decide(decision);
61
-
62
- // cy.task() needs a value back: null when the action has nothing to say
63
- const asTask = (action) => async (argument) => (await withBrowser((browser) => action(browser, argument))) ?? null;
64
- return Object.fromEntries(Object.entries(actions).map(([name, action]) => [`dappress:${name}`, asTask(action)]));
65
- }
66
-
67
- module.exports = { createTasks };
package/src/browser.js DELETED
@@ -1,42 +0,0 @@
1
- // Connects Puppeteer to the browser Cypress launched, through the debugging
2
- // URL Cypress hands out in after:browser:launch (Cypress 13.6+).
3
-
4
- const puppeteer = require('puppeteer-core');
5
-
6
- let debuggerUrl;
7
-
8
- function captureDebuggerUrl(on) {
9
- on('after:browser:launch', (browser, options) => {
10
- debuggerUrl = options.webSocketDebuggerUrl;
11
- });
12
- }
13
-
14
- /**
15
- * Run `action` with a Puppeteer browser connected to Cypress's browser, then
16
- * give the focus back to the Cypress tab and disconnect. The browser stays open.
17
- */
18
- async function withBrowser(action) {
19
- if (!debuggerUrl) {
20
- throw new Error('[dappress] No browser to connect to. Is configureDappress() called in setupNodeEvents, with Cypress 13.6 or later?');
21
- }
22
- const browser = await puppeteer.connect({ browserWSEndpoint: debuggerUrl, defaultViewport: null });
23
- try {
24
- return await action(browser);
25
- } catch (error) {
26
- // The browser side only gets the message, so keep the full stack in the terminal
27
- console.error(error.stack || error);
28
- throw error;
29
- } finally {
30
- await focusCypressTab(browser).catch(() => {});
31
- await browser.disconnect();
32
- }
33
- }
34
-
35
- // MetaMask opens its own tabs and popups; Chrome throttles the Cypress tab
36
- // while it is in the background. The Cypress runner lives under /__/.
37
- async function focusCypressTab(browser) {
38
- const target = browser.targets().find((candidate) => candidate.type() === 'page' && candidate.url().includes('/__/'));
39
- if (target) await (await target.page()).bringToFront();
40
- }
41
-
42
- module.exports = { captureDebuggerUrl, withBrowser };
package/src/config.js DELETED
@@ -1,78 +0,0 @@
1
- const fs = require('node:fs');
2
- const os = require('node:os');
3
- const path = require('node:path');
4
- const { generateMnemonic } = require('@scure/bip39');
5
- const { wordlist } = require('@scure/bip39/wordlists/english');
6
-
7
- const DEFAULTS = {
8
- metamaskVersion: '13.50.0',
9
- // None: a new wallet is made for each run. A phrase known to others is not
10
- // blank, since MetaMask restores the accounts saved for it elsewhere.
11
- seedPhrase: null,
12
- password: 'Tester@1234',
13
- // The network cy.connectToDapp() moves the dapp onto, as a wallet_addEthereumChain parameter; none by default
14
- network: null,
15
- // MetaMask's backup and sync saves the accounts and contacts of a seed phrase
16
- // and restores them on other installs. Off, a test's accounts don't come back.
17
- backupAndSync: false,
18
- // Run cy.setupMetaMask() automatically before the first test of each spec
19
- autoSetup: true,
20
- // Import the wallet once and reuse the profile across runs (keeps the vault under cacheDir)
21
- cache: false,
22
- // How long to wait for MetaMask to show a confirmation after a dapp request (ms)
23
- timeout: 20000,
24
- cacheDir: path.join(os.homedir(), '.cache', 'dappress'),
25
- };
26
-
27
- // A .ts file loads when the Cypress config is itself in TypeScript
28
- const WALLET_SETUP_FILES = ['cypress/wallet.setup.ts', 'cypress/wallet.setup.js'];
29
-
30
- /**
31
- * The plugin options, in order of precedence: environment variables, the
32
- * Cypress `env` block (cypress.env.json), the wallet setup file
33
- * (cypress/wallet.setup.ts or .js), the options given to configureDappress(),
34
- * then the defaults above.
35
- */
36
- function resolveOptions(userOptions = {}, cypressConfig = {}) {
37
- const env = cypressConfig.env || {};
38
- const fromEnv = {
39
- metamaskVersion: process.env.DAPPRESS_METAMASK_VERSION || env.DAPPRESS_METAMASK_VERSION,
40
- seedPhrase: process.env.DAPPRESS_SEED_PHRASE || env.DAPPRESS_SEED_PHRASE,
41
- password: process.env.DAPPRESS_PASSWORD || env.DAPPRESS_PASSWORD,
42
- };
43
-
44
- const options = { ...DEFAULTS, ...userOptions, ...loadWalletSetup(cypressConfig.projectRoot) };
45
- for (const [key, value] of Object.entries(fromEnv)) {
46
- if (value) options[key] = value;
47
- }
48
- if (!options.seedPhrase) useNewWallet(options);
49
- return options;
50
- }
51
-
52
- /** No seed phrase was given: make one for this run. It is never written anywhere. */
53
- function useNewWallet(options) {
54
- options.seedPhrase = generateMnemonic(wordlist);
55
- console.log('[dappress] No seed phrase configured: using a new wallet for this run');
56
- if (options.cache) {
57
- console.warn('[dappress] The profile cache needs a seed phrase of your own: importing the wallet in this run instead');
58
- options.cache = false;
59
- }
60
- }
61
-
62
- /** The project's wallet setup file: { seedPhrase?, network? }. */
63
- function loadWalletSetup(projectRoot = process.cwd()) {
64
- const file = WALLET_SETUP_FILES.map((name) => path.join(projectRoot, name)).find((candidate) => fs.existsSync(candidate));
65
- if (!file) return {};
66
- const loaded = require(file);
67
- const setup = loaded.default || loaded; // `export default` or `module.exports`
68
- const unknown = Object.keys(setup).filter((key) => !['seedPhrase', 'network'].includes(key));
69
- if (unknown.length) throw new Error(`[dappress] Unknown keys in ${path.basename(file)}: ${unknown.join(', ')}`);
70
- return setup;
71
- }
72
-
73
- /** The subset of options that is safe to expose to the browser side (no secrets). */
74
- function publicOptions(options) {
75
- return { metamaskVersion: options.metamaskVersion, autoSetup: options.autoSetup, network: options.network };
76
- }
77
-
78
- module.exports = { DEFAULTS, resolveOptions, publicOptions };
package/src/download.js DELETED
@@ -1,50 +0,0 @@
1
- const fs = require('node:fs');
2
- const path = require('node:path');
3
- const { pipeline } = require('node:stream/promises');
4
- const { Readable } = require('node:stream');
5
- const extractZip = require('extract-zip');
6
-
7
- function releaseUrl(version) {
8
- return `https://github.com/MetaMask/metamask-extension/releases/download/v${version}/metamask-chrome-${version}.zip`;
9
- }
10
-
11
- function extensionDir(cacheDir, version) {
12
- return path.join(cacheDir, 'metamask', version);
13
- }
14
-
15
- async function download(url, destination) {
16
- const response = await fetch(url, { redirect: 'follow' });
17
- if (!response.ok || !response.body) {
18
- throw new Error(`[dappress] Could not download ${url}: HTTP ${response.status}`);
19
- }
20
- await fs.promises.mkdir(path.dirname(destination), { recursive: true });
21
- await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(destination));
22
- }
23
-
24
- /**
25
- * Download and unzip the MetaMask Chrome build for `version` into the cache.
26
- * Returns the path of the unpacked extension (the folder holding manifest.json).
27
- * Already cached versions are reused.
28
- */
29
- async function prepareExtension({ metamaskVersion, cacheDir }) {
30
- const dir = extensionDir(cacheDir, metamaskVersion);
31
- const manifest = path.join(dir, 'manifest.json');
32
- if (fs.existsSync(manifest)) return dir;
33
-
34
- const zipPath = path.join(cacheDir, 'metamask', `metamask-chrome-${metamaskVersion}.zip`);
35
- if (!fs.existsSync(zipPath)) {
36
- console.log(`[dappress] Downloading MetaMask ${metamaskVersion}…`);
37
- await download(releaseUrl(metamaskVersion), zipPath);
38
- }
39
-
40
- console.log(`[dappress] Unpacking MetaMask ${metamaskVersion}…`);
41
- await fs.promises.rm(dir, { recursive: true, force: true });
42
- await extractZip(zipPath, { dir });
43
-
44
- if (!fs.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
- }
49
-
50
- module.exports = { prepareExtension };
package/src/index.js DELETED
@@ -1,56 +0,0 @@
1
- const { resolveOptions, publicOptions } = require('./config');
2
- const { captureDebuggerUrl } = require('./browser');
3
- const { prepareExtension } = require('./download');
4
- const { prepareProfile, installProfile } = require('./profile');
5
- const { createTasks } = require('./actions');
6
-
7
- /**
8
- * Wire Dappress into a Cypress project. Call it from setupNodeEvents:
9
- *
10
- * setupNodeEvents(on, config) {
11
- * return configureDappress(on, config);
12
- * }
13
- *
14
- * It loads the MetaMask extension into the browser Cypress launches, and
15
- * registers the tasks behind the cy.* commands from 'dappress/support'.
16
- */
17
- function configureDappress(on, config, userOptions = {}) {
18
- const options = resolveOptions(userOptions, config);
19
- if (config.chromeWebSecurity === false) {
20
- console.warn('[dappress] chromeWebSecurity is off: MetaMask cannot start its snaps, so adding or importing an account will hang');
21
- }
22
-
23
- // A cached wallet gets its requests in MetaMask's popup window, which headless Chrome doesn't open
24
- const usesCache = (browser) => options.cache && !browser.isHeadless;
25
-
26
- // Build the wallet profile before the run, out of the time Cypress allows a browser to come up
27
- on('before:run', async ({ browser }) => {
28
- if (browser && usesCache(browser)) await prepareProfile({ browserPath: browser.path, extensionDir: await prepareExtension(options), options });
29
- });
30
-
31
- on('before:browser:launch', async (browser, launchOptions) => {
32
- if (browser.family !== 'chromium') {
33
- throw new Error(`[dappress] MetaMask is a Chromium extension; ${browser.name} is not supported`);
34
- }
35
- const extensionDir = await prepareExtension(options);
36
- launchOptions.extensions.push(extensionDir);
37
- if (browser.isHeadless) {
38
- // Cypress leaves extensions out of a headless launch, so ask Chrome directly
39
- launchOptions.args.push(`--load-extension=${extensionDir}`);
40
- if (options.cache) console.warn('[dappress] The wallet cache needs a headed browser: importing the wallet in this run instead');
41
- }
42
- if (usesCache(browser)) {
43
- const profileDir = await prepareProfile({ browserPath: browser.path, extensionDir, options });
44
- await installProfile(profileDir, browser, config.isTextTerminal);
45
- }
46
- return launchOptions;
47
- });
48
- captureDebuggerUrl(on);
49
- on('task', createTasks(options));
50
-
51
- // Non-sensitive settings the browser side reads with Cypress.expose()
52
- config.expose = { ...config.expose, dappress: publicOptions(options) };
53
- return config;
54
- }
55
-
56
- module.exports = { configureDappress };
@@ -1,121 +0,0 @@
1
- // Find MetaMask's pages in the browser Cypress launched: the full-screen home
2
- // page, and the surface where dapp requests show up. On Chrome that surface
3
- // is the side panel when it is open, and the "MetaMask Dialog" popup otherwise.
4
-
5
- const { sleep } = require('./page-helpers');
6
-
7
- const HOME_PATH = '/home.html';
8
- const CONFIRMATION_PATHS = ['/notification.html', '/sidepanel.html'];
9
-
10
- function extensionIdOf(url) {
11
- const match = /^chrome-extension:\/\/([a-z]{32})\//.exec(url);
12
- return match ? match[1] : null;
13
- }
14
-
15
- function isHomeRoute(url) {
16
- return url.includes('/notification.html') && /notification\.html(#\/?)?$/.test(url);
17
- }
18
-
19
- function pagesOf(browser, extensionId, pathname) {
20
- return browser
21
- .targets()
22
- .filter((target) => target.type() === 'page' && target.url().startsWith(`chrome-extension://${extensionId}${pathname}`));
23
- }
24
-
25
- /**
26
- * MetaMask's extension id. Cypress loads its own extension in headed
27
- * Chromium browsers, so the id is the one whose home page is titled "MetaMask".
28
- */
29
- async function findExtensionId(browser, timeout = 30000) {
30
- const deadline = Date.now() + timeout;
31
- while (Date.now() < deadline) {
32
- const ids = new Set(browser.targets().map((target) => extensionIdOf(target.url())).filter(Boolean));
33
- for (const id of ids) {
34
- if (await isMetaMask(browser, id)) return id;
35
- }
36
- await sleep(500);
37
- }
38
- throw new Error('[dappress] MetaMask extension not found in the browser. Is it loaded in before:browser:launch?');
39
- }
40
-
41
- async function isMetaMask(browser, extensionId) {
42
- const [target] = pagesOf(browser, extensionId, HOME_PATH);
43
- if (target) {
44
- // A page still loading or redirecting loses its execution context while its
45
- // title is read; not MetaMask yet, findExtensionId looks again
46
- try {
47
- const page = await target.page();
48
- return (await page.title()).startsWith('MetaMask');
49
- } catch {
50
- return false;
51
- }
52
- }
53
- const page = await browser.newPage();
54
- try {
55
- const response = await page.goto(`chrome-extension://${extensionId}${HOME_PATH}`, { waitUntil: 'domcontentloaded' });
56
- return Boolean(response && response.ok()) && (await page.title()).startsWith('MetaMask');
57
- } catch {
58
- return false;
59
- } finally {
60
- await page.close();
61
- }
62
- }
63
-
64
- /** The full-screen MetaMask page, opened if it isn't already. */
65
- async function getHomePage(browser, extensionId) {
66
- const [target] = pagesOf(browser, extensionId, HOME_PATH);
67
- if (target) return target.page();
68
- const page = await browser.newPage();
69
- await page.goto(`chrome-extension://${extensionId}${HOME_PATH}`, { waitUntil: 'domcontentloaded' });
70
- return page;
71
- }
72
-
73
- /**
74
- * The page a dapp request is shown on. Waits for the popup to open, or takes
75
- * the side panel. A popup still on its home route is one closing after the
76
- * previous request, or one that hasn't routed to the request yet: skipped.
77
- */
78
- async function getConfirmationPage(browser, extensionId, timeout) {
79
- const deadline = Date.now() + timeout;
80
- while (Date.now() < deadline) {
81
- for (const pathname of CONFIRMATION_PATHS) {
82
- const [target] = pagesOf(browser, extensionId, pathname).filter((candidate) => !isHomeRoute(candidate.url()));
83
- if (target) return pathname === '/notification.html' ? withUsableWindow(await target.page()) : target.page();
84
- }
85
- await sleep(250);
86
- }
87
- const seen = browser
88
- .targets()
89
- .filter((target) => extensionIdOf(target.url()) === extensionId)
90
- .map((target) => `${target.type()} ${target.url()}`)
91
- .join(', ');
92
- throw new Error(
93
- `[dappress] MetaMask showed no confirmation within ${timeout}ms. Did the dapp send a request? MetaMask pages seen: ${seen || 'none'}`,
94
- );
95
- }
96
-
97
- // MetaMask asks Chrome for a 400x620 popup. Under Xvfb on a GitHub runner the
98
- // window it gets is 1x1: nothing is laid out, the pane of a confirmation that
99
- // must be read to the end has no height, so it can never be scrolled there and
100
- // its Confirm button stays disabled. The popup is given its size back first.
101
- const POPUP = { width: 400, height: 620 };
102
-
103
- async function withUsableWindow(page) {
104
- const session = await page.target().createCDPSession();
105
- try {
106
- const { windowId, bounds } = await session.send('Browser.getWindowForTarget');
107
- if (bounds.windowState === 'normal' && bounds.width >= 200 && bounds.height >= 200) return page;
108
- console.log(`[dappress] MetaMask's popup is ${bounds.width}x${bounds.height} (${bounds.windowState}); resizing it to ${POPUP.width}x${POPUP.height}`);
109
- if (bounds.windowState !== 'normal') await session.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });
110
- await session.send('Browser.setWindowBounds', { windowId, bounds: { ...POPUP } });
111
- // Let the page lay itself out at its new size before anything is looked for in it
112
- await sleep(300);
113
- } catch (error) {
114
- console.warn(`[dappress] Could not check the popup window's size: ${error.message}`);
115
- } finally {
116
- await session.detach().catch(() => {});
117
- }
118
- return page;
119
- }
120
-
121
- module.exports = { findExtensionId, getHomePage, getConfirmationPage, withUsableWindow };