diff --git a/.github/workflows/e2e_cypress-action.yml b/.github/workflows/e2e_cypress-action.yml index ea06af5b9..bfb1f9315 100644 --- a/.github/workflows/e2e_cypress-action.yml +++ b/.github/workflows/e2e_cypress-action.yml @@ -52,6 +52,9 @@ jobs: ${{ runner.os }}-pnpm-v1- continue-on-error: true + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + - name: Install dependencies run: pnpm install --frozen-lockfile --prefer-offline diff --git a/.github/workflows/e2e_debug.yml b/.github/workflows/e2e_debug.yml index bf87fe882..8651cd92c 100644 --- a/.github/workflows/e2e_debug.yml +++ b/.github/workflows/e2e_debug.yml @@ -52,6 +52,9 @@ jobs: ${{ runner.os }}-pnpm-v1- continue-on-error: true + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + - name: Install dependencies run: pnpm install --frozen-lockfile --prefer-offline diff --git a/.github/workflows/e2e_headful.yml b/.github/workflows/e2e_headful.yml index b46a78be3..a1da7bebc 100644 --- a/.github/workflows/e2e_headful.yml +++ b/.github/workflows/e2e_headful.yml @@ -55,6 +55,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --prefer-offline + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + - name: Install linux deps run: | sudo apt-get install --no-install-recommends -y \ diff --git a/Dockerfile b/Dockerfile index fc501b82d..b45873a59 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,19 @@ # syntax=docker/dockerfile:1 FROM --platform=linux/amd64 synthetixio/docker-e2e:18.16-ubuntu as base +RUN apt update && apt install -y nginx + +ENV PATH "$PATH:/root/.foundry/bin" + +RUN curl -L https://foundry.paradigm.xyz | bash && \ + foundryup && \ + forge --version && \ + anvil --version && \ + cast --version + RUN mkdir /app WORKDIR /app -RUN apt update && apt install -y nginx - COPY nginx.conf /etc/nginx/sites-available/default COPY package.json ./ diff --git a/commands/foundry.js b/commands/foundry.js new file mode 100644 index 000000000..bccd1981e --- /dev/null +++ b/commands/foundry.js @@ -0,0 +1,193 @@ +const { findNetwork } = require('../helpers'); +const which = require('which'); + +const log = require('debug')('synpress:foundry'); + +let activeChains; + +module.exports = { + async resetState() { + log('Resetting state of foundry'); + activeChains = undefined; + }, + async getActiveChains() { + return activeChains; + }, + async forkChains(options) { + await validateIfAnvilIsInstalledOrThrow(); + + if (typeof options === 'object') { + const chains = await module.exports.runAnvilWithViem( + options.chainsToFork, + ); + + return { chains }; + } else if (typeof options === 'string') { + if (isNaN(options)) { + // todo: add support for: + // (multiple) network IDs + // (single) network name + // (multiple) network names + } else { + // todo: add support for: + // (single) network ID + } + + throw new Error('Not implemented'); + } + }, + async setupViem(anvilChainType) { + try { + const { + createTestClient, + createPublicClient, + createWalletClient, + http, + } = require('viem'); + + const testClient = createTestClient({ + chain: anvilChainType, + mode: 'anvil', + transport: http(), + }); + + const publicClient = createPublicClient({ + chain: anvilChainType, + transport: http(), + }); + + const walletClient = createWalletClient({ + chain: anvilChainType, + transport: http(), + }); + + return { testClient, publicClient, walletClient }; + } catch (error) { + throw new Error('There was an error while trying to setup Viem.', error); + } + }, + async runAnvilWithViem(chains) { + try { + const { ethers } = require('ethers'); + const anvilClient = await import('@viem/anvil'); + + const pool = anvilClient.createPool(); + + for (const [index, [chain, options]] of Object.entries( + Object.entries(chains), + )) { + // use fork url if provided, if not then find it in presets + const forkUrl = + options.forkUrl || (await findNetwork(chain)).rpcUrls.public.http[0]; + + const poolOptions = { + ...options, + forkUrl, + }; + + // remove nativeCurrency because its not supported by anvil + if (poolOptions.nativeCurrency) { + delete poolOptions.nativeCurrency; + } + + const anvilInstance = await pool.start(index, poolOptions); + + const anvilUrl = `${anvilInstance.host}:${anvilInstance.port}`; + const provider = new ethers.JsonRpcProvider(`http://${anvilUrl}`); + const { chainId, name } = await provider.getNetwork(); + chains[chain].anvilClientDetails = { + anvilPool: pool, + anvilPoolId: Number(index), + provider, + anvilInstance, + anvilUrl: `http://${anvilUrl}`, + anvilChainId: Number(chainId), + anvilChainName: name, + anvilChainType: { + id: Number(chainId), + name: name, + network: name, + nativeCurrency: options.nativeCurrency + ? options.nativeCurrency + : { + decimals: 18, + name: 'Anvil', + symbol: 'ANV', + }, + rpcUrls: { + default: { + http: [`http://${anvilUrl}`], + webSocket: [`ws://${anvilUrl}`], + }, + public: { + http: [`http://${anvilUrl}`], + webSocket: [`ws://${anvilUrl}`], + }, + }, + }, + }; + + chains[chain].viemClients = await module.exports.setupViem( + chains[chain].anvilClientDetails.anvilChainType, + ); + } + + activeChains = chains; + return chains; + } catch (error) { + throw new Error('There was an error while trying to run anvil.', error); + } + }, + async stopAnvil(anvilInstance) { + try { + await anvilInstance.stop(); + return true; + } catch (error) { + throw new Error('There was an error while trying to stop anvil.', error); + } + }, + async stopAnvilPoolId(anvilPool, anvilPoolId) { + try { + await anvilPool.stop(anvilPoolId); + } catch (error) { + throw new Error( + `There was an error while trying to stop anvil pool with id ${anvilPoolId}`, + error, + ); + } + }, + async stopAnvilPool(anvilPool) { + try { + if (Object.values(activeChains)[0]) { + await Object.values( + activeChains, + )[0].anvilClientDetails.anvilPool.empty(); + } else { + await anvilPool.empty(); + } + return true; + } catch (error) { + throw new Error( + `There was an error while trying to stop anvil pool`, + error, + ); + } + }, +}; + +class AnvilNotInstalledError extends Error { + constructor(message) { + super(message); + this.name = 'AnvilNotInstalledError'; + } +} + +async function validateIfAnvilIsInstalledOrThrow() { + try { + await which('anvil'); + } catch (e) { + throw new AnvilNotInstalledError( + 'Anvil not detected! Forking is possible thanks to Anvil, a local testnet node shipped with Foundry. To install the Foundry toolchain please refer here: https://book.getfoundry.sh/getting-started/installation', + ); + } +} diff --git a/commands/synpress.js b/commands/synpress.js index f42b25b5b..49b08b091 100644 --- a/commands/synpress.js +++ b/commands/synpress.js @@ -2,6 +2,7 @@ const log = require('debug')('synpress:synpress'); const playwright = require('./playwright'); const metamask = require('./metamask'); const helpers = require('../helpers'); +const foundry = require('./foundry'); module.exports = { async resetState() { @@ -9,5 +10,7 @@ module.exports = { await playwright.resetState(); await metamask.resetState(); await helpers.resetState(); + await foundry.resetState(); + return true; }, }; diff --git a/package.json b/package.json index df0ac85d9..d8d3df708 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,8 @@ "node-fetch": "^2.6.1", "underscore": "^1.13.6", "viem": "^1.6.0", - "wait-on": "^7.0.1" + "wait-on": "^7.0.1", + "which": "^4.0.0" }, "devDependencies": { "@metamask/test-dapp": "^7.0.1", diff --git a/plugins/index.js b/plugins/index.js index 51fe4965b..33bcaae5b 100644 --- a/plugins/index.js +++ b/plugins/index.js @@ -2,6 +2,7 @@ const helpers = require('../helpers'); const playwright = require('../commands/playwright'); const metamask = require('../commands/metamask'); const etherscan = require('../commands/etherscan'); +const foundry = require('../commands/foundry'); /** * @type {Cypress.PluginConfig} @@ -50,6 +51,10 @@ module.exports = (on, config) => { console.warn('\u001B[33m', 'WARNING:', message, '\u001B[0m'); return true; }, + // foundry commands + forkChains: foundry.forkChains, + stopAnvil: foundry.stopAnvil, + stopAnvilPool: foundry.stopAnvilPool, // playwright commands initPlaywright: playwright.init, clearPlaywright: playwright.clear, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9bfa0f14..a5abf123b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ dependencies: wait-on: specifier: ^7.0.1 version: 7.0.1(debug@4.3.4) + which: + specifier: ^4.0.0 + version: 4.0.0 devDependencies: '@metamask/test-dapp': @@ -6602,6 +6605,11 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + /isexe@3.1.1: + resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} + engines: {node: '>=16'} + dev: false + /isobject@3.0.1: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} @@ -10478,6 +10486,14 @@ packages: isexe: 2.0.0 dev: true + /which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + dependencies: + isexe: 3.1.1 + dev: false + /wide-align@1.1.5: resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} dependencies: diff --git a/support/commands.js b/support/commands.js index 73869e3fc..0d28963ea 100644 --- a/support/commands.js +++ b/support/commands.js @@ -1,6 +1,24 @@ import '@testing-library/cypress/add-commands'; import 'cypress-wait-until'; +// foundry commands + +Cypress.Commands.add('forkChains', options => { + return cy.task('forkChains', options); +}); + +Cypress.Commands.add('getActiveChains', options => { + return cy.task('getActiveChains', options); +}); + +Cypress.Commands.add('stopAnvil', anvilInstance => { + return cy.task('stopAnvil', anvilInstance); +}); + +Cypress.Commands.add('stopAnvilPool', anvilPool => { + return cy.task('stopAnvilPool', anvilPool); +}); + // playwright commands Cypress.Commands.add('initPlaywright', () => { diff --git a/support/index.d.ts b/support/index.d.ts index 804e32d54..3697360e0 100644 --- a/support/index.d.ts +++ b/support/index.d.ts @@ -1,5 +1,47 @@ declare namespace Cypress { interface Chainable { + // foundry commands + /** + * Setup Foundry, run Anvil fork pool and expose Viem clients + * @example + * cy.forkChains({chainsToFork: {optimism: {forkUrl: undefined, forkBlockNumber: undefined, host: '0.0.0.0', nativeCurrency: {decimals: 18, name: 'Optimism Ether', symbol: 'oETH'} } } }); + * cy.forkChains({chainsToFork: {optimism: {forkUrl: 'https://rpc.ankr.com/optimism', forkBlockNumber: 123123123, host: '0.0.0.0', nativeCurrency: {decimals: 18, name: 'Optimism Ether', symbol: 'oETH'} } } }); + */ + forkChains(options: { + chainsToFork: { + [chain: string]: { + forkUrl?: string; + forkBlockNumber?: number; + host: string; + nativeCurrency: { + decimals: number; + name: string; + symbol: string; + }; + }; + }; + }): Chainable; + /** + * Returns active forked chains + * @example + * cy.getActiveChains(); + */ + getActiveChains(): Chainable; + /** + * Stop Anvil instance + * @example + * cy.stopAnvil(anvilInstance) + */ + stopAnvil(anvilInstance): Chainable; + /** + * Stop Anvil pool + * @example + * cy.stopAnvilPool() + * cy.stopAnvilPool(anvilPool) + */ + stopAnvilPool(anvilPool?): Chainable; + + // playwright commands /** * Connect playwright with Cypress instance * @example @@ -60,7 +102,7 @@ declare namespace Cypress { * If preset for your custom chain is not available, you can add custom network by yourself. * @example * cy.addMetamaskNetwork('optimism') // works only if chain is available as preset - * cy.addMetamaskNetwork({name: 'optimism', rpcUrl: 'https://mainnet.optimism.io', chainId: 10, symbol: 'oETH', blockExplorer: 'https://https://optimistic.etherscan.io', isTestnet: false}) + * cy.addMetamaskNetwork({name: 'optimism', rpcUrl: 'https://mainnet.optimism.io', chainId: 10, symbol: 'oETH', blockExplorer: 'https://optimistic.etherscan.io', isTestnet: false}) * cy.addMetamaskNetwork({id: 10, name: 'optimism', nativeCurrency: { symbol: 'OP' }, rpcUrls: { default: { http: ['https://mainnet.optimism.io'] } }, testnet: false }) */ addMetamaskNetwork( @@ -427,9 +469,9 @@ declare namespace Cypress { * @example * cy.setupMetamask() // will use defaults * cy.setupMetamask('secret, words, ...', 'optimism', 'password for metamask') // works only if chain is available as preset - * cy.setupMetamask('secret, words, ...', {name: 'optimism', rpcUrl: 'https://mainnet.optimism.io', chainId: 10, symbol: 'oETH', blockExplorer: 'https://https://optimistic.etherscan.io', isTestnet: false}, 'password for metamask') + * cy.setupMetamask('secret, words, ...', {name: 'optimism', rpcUrl: 'https://mainnet.optimism.io', chainId: 10, symbol: 'oETH', blockExplorer: 'https://optimistic.etherscan.io', isTestnet: false}, 'password for metamask') * cy.setupMetamask('private_key', 'goerli', 'password for metamask') - * cy.setupMetamask('private_key', {name: 'optimism', rpcUrl: 'https://mainnet.optimism.io', chainId: 10, symbol: 'oETH', blockExplorer: 'https://https://optimistic.etherscan.io', isTestnet: false}, 'password for metamask') + * cy.setupMetamask('private_key', {name: 'optimism', rpcUrl: 'https://mainnet.optimism.io', chainId: 10, symbol: 'oETH', blockExplorer: 'https://optimistic.etherscan.io', isTestnet: false}, 'password for metamask') */ setupMetamask( secretWordsOrPrivateKey?: string, diff --git a/tests/e2e/specs/metamask-spec.js b/tests/e2e/specs/1-metamask-spec.js similarity index 100% rename from tests/e2e/specs/metamask-spec.js rename to tests/e2e/specs/1-metamask-spec.js diff --git a/tests/e2e/specs/playwright-spec.js b/tests/e2e/specs/2-playwright-spec.js similarity index 100% rename from tests/e2e/specs/playwright-spec.js rename to tests/e2e/specs/2-playwright-spec.js diff --git a/tests/e2e/specs/3-foundry-spec.js b/tests/e2e/specs/3-foundry-spec.js new file mode 100644 index 000000000..29f38581a --- /dev/null +++ b/tests/e2e/specs/3-foundry-spec.js @@ -0,0 +1,99 @@ +/* eslint-disable ui-testing/missing-assertion-in-test */ +describe('Foundry', () => { + context('Anvil commands', () => { + before(() => { + cy.setupMetamask( + 'test test test test test test test test test test test junk', + 'sepolia', + 'Tester@1234', + ).then(setupFinished => { + expect(setupFinished).to.be.true; + }); + }); + + it(`forkChains should setup a pool with fork of optimism chain with specified block number (108516344)`, () => { + cy.forkChains({ + chainsToFork: { + optimism: { + forkUrl: 'https://rpc.ankr.com/optimism', + forkBlockNumber: 108516344, + host: '0.0.0.0', + nativeCurrency: { + decimals: 18, + name: 'Optimism Ether', + symbol: 'oETH', + }, + }, + }, + }).then(data => { + const chains = Object.keys(data.chains); + for (const chain of chains) { + const chainData = data.chains[chain]; + const { anvilChainType } = chainData.anvilClientDetails; + const networkName = `${anvilChainType.name}-108516344`; + const rpcUrl = anvilChainType.rpcUrls.default.http[0]; + const chainId = anvilChainType.id; + const symbol = anvilChainType.nativeCurrency.symbol; + cy.addMetamaskNetwork({ + networkName, + rpcUrl, + chainId, + symbol, + isTestnet: true, + }); + } + }); + + // anvil will be killed automatically with nodejs process, so it's not mandatory to kill it manually + cy.stopAnvilPool(); + }); + + it(`forkChains should setup a pool of forks with ethereum mainnet (without forkUrl) and optimism mainnet (without forkBlockNumber)`, () => { + cy.forkChains({ + chainsToFork: { + mainnet: { + // if forkUrl is undefined, it will use @viem/chains defaults + forkUrl: undefined, + forkBlockNumber: undefined, + host: '0.0.0.0', + nativeCurrency: { + decimals: 18, + name: 'Ether', + symbol: 'ETH', + }, + }, + optimism: { + forkUrl: 'https://rpc.ankr.com/optimism', + forkBlockNumber: undefined, + host: '0.0.0.0', + nativeCurrency: { + decimals: 18, + name: 'Optimism Ether', + symbol: 'oETH', + }, + }, + }, + }).then(data => { + const chains = Object.keys(data.chains); + for (const chain of chains) { + const chainData = data.chains[chain]; + const { anvilChainType } = chainData.anvilClientDetails; + const networkName = anvilChainType.name; + const rpcUrl = anvilChainType.rpcUrls.default.http[0]; + const chainId = anvilChainType.id; + const symbol = anvilChainType.nativeCurrency.symbol; + cy.addMetamaskNetwork({ + networkName, + rpcUrl, + chainId, + symbol, + isTestnet: true, + }); + } + }); + + // anvil will be killed automatically with nodejs process, so it's not mandatory to kill it manually + cy.stopAnvilPool(); + }); + }); +});