├── .nvmrc ├── CODEOWNERS ├── test ├── dapp │ ├── start.ts │ ├── contract │ │ ├── Counter.sol │ │ └── index.ts │ ├── package.json │ ├── public │ │ ├── index.html │ │ ├── data.js │ │ └── main.js │ └── server.ts ├── 1-init.spec.ts ├── helpers │ ├── itForWallet.ts │ └── walletTest.ts ├── 3-dapp.spec.ts └── 2-wallet.spec.ts ├── src ├── helpers │ ├── index.ts │ ├── selectors.ts │ └── actions.ts ├── wallets │ ├── metamask │ │ ├── actions │ │ │ ├── helpers │ │ │ │ ├── index.ts │ │ │ │ ├── selectors.ts │ │ │ │ └── actions.ts │ │ │ ├── countAccounts.ts │ │ │ ├── util.ts │ │ │ ├── lock.ts │ │ │ ├── sign.ts │ │ │ ├── switchAccount.ts │ │ │ ├── unlock.ts │ │ │ ├── confirmNetworkSwitch.ts │ │ │ ├── reject.ts │ │ │ ├── hasNetwork.ts │ │ │ ├── switchNetwork.ts │ │ │ ├── deleteAccount.ts │ │ │ ├── index.ts │ │ │ ├── approve.ts │ │ │ ├── createAccount.ts │ │ │ ├── getTokenBalance.ts │ │ │ ├── deleteNetwork.ts │ │ │ ├── updateNetworkRpc.ts │ │ │ ├── signin.ts │ │ │ ├── importPk.ts │ │ │ ├── addToken.ts │ │ │ ├── confirmTransaction.ts │ │ │ └── addNetwork.ts │ │ ├── setup.ts │ │ ├── metamask.ts │ │ └── setup │ │ │ └── setupActions.ts │ ├── wallets.ts │ ├── wallet.ts │ └── coinbase │ │ ├── coinbase.ts │ │ └── actions.ts ├── index.ts ├── bootstrap.ts ├── downloader │ ├── request.ts │ ├── constants.ts │ ├── file.ts │ ├── version.ts │ ├── github.ts │ ├── downloader.ts │ └── downloader.test.ts ├── launch.ts └── types.ts ├── .knip.json ├── AUTHORS ├── .gitignore ├── .prettierrc.js ├── tsconfig.build.json ├── .changeset └── config.json ├── .github ├── workflows │ ├── pr.yaml │ ├── release.yml │ └── test.yaml ├── ISSUE_TEMPLATE │ ├── support_request.md │ ├── feature_request.md │ └── bug_report.md ├── pull_request_template.md └── dependabot.yml ├── tsconfig.json ├── LICENSE ├── vitest.setup.ts ├── vitest.config.ts ├── playwright.config.js ├── .eslintrc.js ├── README.md ├── package.json ├── eslint.config.js ├── docs └── API.md └── CHANGELOG.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v16 2 | 3 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @TenKeyLabs/ten-key-labs-coders 2 | -------------------------------------------------------------------------------- /test/dapp/start.ts: -------------------------------------------------------------------------------- 1 | import { start } from './server'; 2 | 3 | start(); 4 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions'; 2 | export * from './selectors'; 3 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions'; 2 | export * from './selectors'; 3 | -------------------------------------------------------------------------------- /.knip.json: -------------------------------------------------------------------------------- 1 | { 2 | "project": [ 3 | "src/**/*.ts", 4 | "test/**/*.ts", 5 | "!test/dapp/**/*" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # This is the official list of dappwright authors for copyright purposes. 2 | 3 | Dwayne Forde 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *DS_Store 2 | node_modules 3 | build 4 | dist 5 | .vscode/ 6 | *.log 7 | **/Counter.js 8 | test-results/ 9 | playwright-report/ 10 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | arrowParens: 'always', 7 | }; 8 | -------------------------------------------------------------------------------- /test/dapp/contract/Counter.sol: -------------------------------------------------------------------------------- 1 | pragma solidity >= 0.8.16; 2 | contract Counter { 3 | uint256 public count; 4 | 5 | function increase() external { 6 | count++; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src/**/*" 5 | ], 6 | "exclude": [ 7 | "src/**/*.test.ts", 8 | "test/**/*", 9 | "node_modules" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/countAccounts.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | 3 | export const countAccounts = (_: Page) => async (): Promise => { 4 | // eslint-disable-next-line no-console 5 | console.warn('countAccounts not yet implemented'); 6 | return -1; 7 | }; 8 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": ["@changesets/changelog-github", { "repo": "TenKeyLabs/dappwright" }], 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/util.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | 3 | export const performPopupAction = async (page: Page, action: (popup: Page) => Promise): Promise => { 4 | const popup = await page.context().waitForEvent('page'); // Wait for the popup to show up 5 | 6 | await action(popup); 7 | if (!popup.isClosed()) await popup.waitForEvent('close'); 8 | }; 9 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/lock.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | 3 | import { clickOnButton } from '../../../helpers'; 4 | import { openAccountOptionsMenu } from './helpers'; 5 | 6 | export const lock = (page: Page) => async (): Promise => { 7 | await page.bringToFront(); 8 | 9 | await openAccountOptionsMenu(page); 10 | await clickOnButton(page, 'Lock MetaMask'); 11 | }; 12 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/sign.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | 3 | import { performPopupAction } from './util'; 4 | 5 | export const sign = (page: Page) => async (): Promise => { 6 | await performPopupAction(page, async (popup) => { 7 | await popup.bringToFront(); 8 | await popup.reload(); 9 | 10 | await popup.getByTestId('confirm-footer-button').click(); 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/switchAccount.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { openAccountMenu } from './helpers'; 3 | 4 | export const switchAccount = 5 | (page: Page) => 6 | async (name: string): Promise => { 7 | await page.bringToFront(); 8 | await openAccountMenu(page); 9 | 10 | await page.getByRole('dialog').getByRole('button', { name, exact: true }).click(); 11 | }; 12 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/unlock.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { closePopup } from '../setup/setupActions'; 3 | 4 | export const unlock = 5 | (page: Page) => 6 | async (password = 'password1234'): Promise => { 7 | await page.bringToFront(); 8 | 9 | await page.getByTestId('unlock-password').fill(password); 10 | await page.getByTestId('unlock-submit').click(); 11 | 12 | await closePopup(page); 13 | }; 14 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/confirmNetworkSwitch.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { waitForChromeState } from '../../../helpers'; 3 | import { performPopupAction } from './util'; 4 | 5 | export const confirmNetworkSwitch = (page: Page) => async (): Promise => { 6 | await performPopupAction(page, async (popup) => { 7 | await popup.getByTestId('page-container-footer-next').click(); 8 | await waitForChromeState(page); 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/reject.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | 3 | import { performPopupAction } from './util'; 4 | 5 | export const reject = (page: Page) => async (): Promise => { 6 | await performPopupAction(page, async (popup) => { 7 | const cancelButton = popup.getByTestId('confirm-footer-cancel-button'); 8 | const rejectButton = popup.getByTestId('cancel-btn'); 9 | 10 | await cancelButton.or(rejectButton).click(); 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | name: Semantic PR 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | main: 12 | name: Validate PR title 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: amannn/action-semantic-pull-request@v6 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | with: 19 | types: | 20 | fix 21 | feat 22 | chore 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Support request 3 | about: Ask questions about using app 4 | title: '' 5 | labels: 'help wanted' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the problem** 10 | A clear and concise description of what the problem is. 11 | 12 | **Screenshots** 13 | If applicable, add screenshots to help explain your problem. 14 | 15 | **System:** 16 | 17 | - dAppwright version [e.g. 2.2.0] 18 | - Playwright version [e.g 26.0] 19 | - NodeJs version [e.g v15.8.0] 20 | - OS: [e.g. MacOS] 21 | - OS version [e.g. 15.3.2] 22 | -------------------------------------------------------------------------------- /test/1-init.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@playwright/test'; 2 | import { testWithWallet as test } from './helpers/walletTest'; 3 | 4 | test.describe(`when the test environment is initialized`, () => { 5 | test('should open, test page', async ({ page }) => { 6 | expect(page).toBeTruthy(); 7 | 8 | await page.goto('http://localhost:8080'); 9 | expect(await page.title()).toEqual('Local wallet test'); 10 | }); 11 | 12 | test('should open the wallet', async ({ wallet }) => { 13 | expect(wallet.page).toBeTruthy(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // re-export 2 | 3 | import { bootstrap } from './bootstrap'; 4 | import { launch } from './launch'; 5 | import { getWallet } from './wallets/wallets'; 6 | 7 | const defaultObject = { bootstrap, launch, getWallet }; 8 | export default defaultObject; 9 | 10 | export { bootstrap } from './bootstrap'; 11 | export { launch } from './launch'; 12 | export * from './types'; 13 | export { CoinbaseWallet } from './wallets/coinbase/coinbase'; 14 | export { MetaMaskWallet } from './wallets/metamask/metamask'; 15 | export { getWallet } from './wallets/wallets'; 16 | -------------------------------------------------------------------------------- /src/wallets/metamask/setup.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { Step, WalletOptions } from '../wallets'; 3 | 4 | /** 5 | * Setup MetaMask with base account 6 | * */ 7 | 8 | export const setup = 9 | (page: Page, defaultMetamaskSteps: Step[]) => 10 | async (options?: Options, steps: Step[] = defaultMetamaskSteps): Promise => { 11 | // goes through the installation steps required by metamask 12 | for (const step of steps) { 13 | await step(page, options); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "es2021", 8 | "dom" 9 | ], 10 | "typeRoots": [ 11 | "./node_modules/@types", 12 | "./types" 13 | ], 14 | "declaration": true, 15 | "esModuleInterop": true, 16 | "outDir": "dist" 17 | }, 18 | "include": [ 19 | "src/**/*", 20 | "test/**/*", 21 | "vitest.setup.ts", 22 | "vitest.config.ts" 23 | ], 24 | "exclude": [ 25 | "node_modules" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/hasNetwork.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { openNetworkDropdown } from './helpers'; 3 | 4 | export const hasNetwork = 5 | (page: Page) => 6 | async (name: string): Promise => { 7 | await page.bringToFront(); 8 | await openNetworkDropdown(page); 9 | 10 | const hasNetwork = await page.locator('.multichain-network-list-menu').locator('p', { hasText: name }).isVisible(); 11 | await page.getByRole('dialog').getByRole('button', { name: 'Close' }).first().click(); 12 | 13 | return hasNetwork; 14 | }; 15 | -------------------------------------------------------------------------------- /test/dapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dappwright-test-dapp", 3 | "version": "0.0.1", 4 | "engines": { 5 | "node": ">=20" 6 | }, 7 | "scripts": { 8 | "start": "node --require ts-node/register start.ts" 9 | }, 10 | "license": "MIT", 11 | "dependencies": { 12 | "web3": "4.16.0", 13 | "web3-eth-contract": "^4.7.2", 14 | "solc": "0.8.30" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "^22.10.2", 18 | "ganache": "^7.4.3", 19 | "serve-handler": "6.1.6", 20 | "ts-node": "10.9.2", 21 | "typescript": "^5.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/switchNetwork.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { waitForChromeState } from '../../../helpers'; 3 | import { openNetworkDropdown } from './helpers'; 4 | 5 | export const switchNetwork = 6 | (page: Page) => 7 | async (network = 'main'): Promise => { 8 | await page.bringToFront(); 9 | await openNetworkDropdown(page); 10 | 11 | const networkListItem = page.locator('.multichain-network-list-item').filter({ has: page.getByTestId(network) }); 12 | await networkListItem.click(); 13 | 14 | await waitForChromeState(page); 15 | }; 16 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | **Short description of work done** 6 | 7 | 8 | 9 | ### PR Checklist 10 | 11 | 12 | 13 | - [ ] I have run linter locally 14 | - [ ] I have run unit and integration tests locally 15 | 16 | ### Issues 17 | 18 | 19 | 20 | 21 | 22 | Closes # 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | commit-message: 8 | prefix: 'chore:' 9 | - package-ecosystem: npm 10 | directory: / 11 | target-branch: main 12 | schedule: 13 | interval: monthly 14 | commit-message: 15 | prefix: 'chore:' 16 | groups: 17 | major: 18 | patterns: ["*"] 19 | update-types: 20 | - major 21 | minor-patch: 22 | patterns: ["*"] 23 | update-types: 24 | - minor 25 | - patch 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Ten Key Labs Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use files included in this repository except in compliance 5 | with the License. You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/deleteAccount.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | 3 | import { clickOnButton, clickOnElement, waitForChromeState } from '../../../helpers'; 4 | import { openAccountMenu } from './helpers'; 5 | 6 | export const deleteAccount = 7 | (page: Page) => 8 | async (name: string): Promise => { 9 | await page.bringToFront(); 10 | await openAccountMenu(page); 11 | 12 | await page.getByRole('button', { name: `${name} Options` }).click(); 13 | await clickOnElement(page, 'Remove account'); 14 | await clickOnButton(page, 'Remove'); 15 | 16 | await waitForChromeState(page); 17 | }; 18 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './addNetwork'; 2 | export * from './addToken'; 3 | export * from './approve'; 4 | export * from './confirmTransaction'; 5 | export * from './createAccount'; 6 | export * from './deleteAccount'; 7 | export * from './deleteNetwork'; 8 | export * from './getTokenBalance'; 9 | export * from './importPk'; 10 | export * from './lock'; 11 | export * from './reject'; 12 | export * from './sign'; 13 | export * from './signin'; 14 | export * from './switchAccount'; 15 | export * from './switchNetwork'; 16 | export * from './unlock'; 17 | export * from './updateNetworkRpc'; 18 | export * from './util'; 19 | -------------------------------------------------------------------------------- /src/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { BrowserContext, Page } from 'playwright-core'; 2 | import { launch } from './launch'; 3 | import { Dappwright, OfficialOptions } from './types'; 4 | import { WalletOptions } from './wallets/wallets'; 5 | 6 | export const bootstrap = async ( 7 | browserName: string, 8 | { seed, password, showTestNets, ...launchOptions }: OfficialOptions & WalletOptions, 9 | ): Promise<[Dappwright, Page, BrowserContext]> => { 10 | const { browserContext, wallet } = await launch(browserName, launchOptions); 11 | 12 | await wallet.setup({ seed, password, showTestNets }); 13 | 14 | return [wallet, wallet.page, browserContext]; 15 | }; 16 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/helpers/selectors.ts: -------------------------------------------------------------------------------- 1 | import { ElementHandle, Page } from 'playwright-core'; 2 | 3 | export const getSettingsSwitch = (page: Page, text: string): Promise => 4 | page.waitForSelector([`//span[contains(.,'${text}')]/parent::div/following-sibling::div/label/div`].join('|')); 5 | 6 | export const getErrorMessage = async (page: Page): Promise => { 7 | try { 8 | const errorElement = await page.waitForSelector(`.mm-help-text.mm-box--color-error-default`, { timeout: 1000 }); 9 | return await errorElement.innerText(); 10 | } catch (_) { 11 | return undefined; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/approve.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | 3 | import { waitForChromeState } from '../../../helpers'; 4 | import { performPopupAction } from './util'; 5 | 6 | export const approve = (page: Page) => async (): Promise => { 7 | await performPopupAction(page, async (popup) => { 8 | await connect(popup); 9 | await waitForChromeState(page); 10 | }); 11 | }; 12 | 13 | export const connect = async (popup: Page): Promise => { 14 | // Wait for popup to load 15 | await popup.waitForLoadState(); 16 | await popup.bringToFront(); 17 | 18 | // Go through the prompts 19 | await popup.getByTestId('confirm-btn').click(); 20 | }; 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import os from 'os'; 3 | import path from 'path'; 4 | import { afterEach, beforeEach } from 'vitest'; 5 | 6 | // Global test setup 7 | beforeEach(() => { 8 | // Clean up any existing test directories before each test 9 | cleanupTestDirectories(); 10 | }); 11 | 12 | afterEach(() => { 13 | // Clean up after each test 14 | cleanupTestDirectories(); 15 | }); 16 | 17 | function cleanupTestDirectories(): void { 18 | const testDir = path.join(os.tmpdir(), 'dappwright-test'); 19 | if (fs.existsSync(testDir)) { 20 | fs.rmSync(testDir, { recursive: true, force: true }); 21 | } 22 | } 23 | 24 | // Mock environment variables for testing 25 | process.env.NODE_ENV = 'test'; 26 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'node', 6 | globals: true, 7 | include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 8 | exclude: [ 9 | '**/node_modules/**', 10 | '**/dist/**', 11 | '**/cypress/**', 12 | '**/.{idea,git,cache,output,temp}/**', 13 | 'test/**', // Exclude Playwright tests 14 | ], 15 | setupFiles: ['./vitest.setup.ts'], 16 | coverage: { 17 | provider: 'v8', 18 | reporter: ['text', 'json', 'html'], 19 | include: ['src/**/*.ts'], 20 | exclude: ['src/**/*.d.ts', 'src/**/*.test.ts', 'src/**/*.spec.ts', 'src/index.ts'], 21 | }, 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/createAccount.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { waitForChromeState } from '../../../helpers'; 3 | import { openAccountMenu } from './helpers'; 4 | 5 | export const createAccount = 6 | (page: Page) => 7 | async (name?: string): Promise => { 8 | await page.bringToFront(); 9 | await openAccountMenu(page); 10 | 11 | await page.getByTestId('multichain-account-menu-popover-action-button').click(); 12 | await page.getByTestId('multichain-account-menu-popover-add-account').click(); 13 | 14 | if (name) await page.getByLabel('Account name').fill(name); 15 | 16 | await page.getByRole('button', { name: 'Add account' }).click(); 17 | 18 | await waitForChromeState(page); 19 | }; 20 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/getTokenBalance.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | 3 | export const getTokenBalance = 4 | (page: Page) => 5 | async (tokenSymbol: string): Promise => { 6 | await page.bringToFront(); 7 | await page.waitForTimeout(1000); 8 | 9 | const tokenValueRegex = new RegExp(String.raw`\d ${tokenSymbol}$`); 10 | const valueElement = page.getByTestId('multichain-token-list-item-value').filter({ hasText: tokenValueRegex }); 11 | 12 | if (!(await valueElement.isVisible())) { 13 | throw new Error(`Token ${tokenSymbol} not found`); 14 | } 15 | 16 | const valueText = await valueElement.textContent(); 17 | const balance = valueText.split(' ')[0]; 18 | 19 | return parseFloat(balance); 20 | }; 21 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/deleteNetwork.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { clickOnButton, waitForChromeState } from '../../../helpers'; 3 | import { openNetworkDropdown } from './helpers'; 4 | 5 | export const deleteNetwork = 6 | (page: Page) => 7 | async (name: string): Promise => { 8 | await page.bringToFront(); 9 | 10 | await openNetworkDropdown(page); 11 | const networkListItem = page.locator('.multichain-network-list-item').filter({ has: page.getByTestId(name) }); 12 | await networkListItem.hover(); 13 | await networkListItem.getByTestId(/network-list-item-options-button.*/).click(); 14 | 15 | await clickOnButton(page, 'Delete'); 16 | await clickOnButton(page, 'Delete'); 17 | await waitForChromeState(page); 18 | }; 19 | -------------------------------------------------------------------------------- /src/downloader/request.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from 'http'; 2 | import { get } from 'https'; 3 | 4 | export const request = (url: string): Promise => 5 | new Promise((resolve) => { 6 | const request = get(url, (response) => { 7 | if (response.statusCode == 302) { 8 | const redirectRequest = get(response.headers.location, resolve); 9 | redirectRequest.on('error', (error) => { 10 | // eslint-disable-next-line no-console 11 | console.warn('request redirected error:', error.message); 12 | throw error; 13 | }); 14 | } else { 15 | resolve(response); 16 | } 17 | }); 18 | request.on('error', (error) => { 19 | // eslint-disable-next-line no-console 20 | console.warn('request error:', error.message); 21 | throw error; 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Logs** 21 | 22 | ```shell 23 | Some Logs from console 24 | ``` 25 | 26 | **Expected behavior** 27 | A clear and concise description of what you expected to happen. 28 | 29 | **Screenshots** 30 | If applicable, add screenshots to help explain your problem. 31 | 32 | **System:** 33 | 34 | - dAppwright version [e.g. 2.2.0] 35 | - Playwright version [e.g 26.0] 36 | - NodeJs version [e.g v15.8.0] 37 | - OS: [e.g. MacOS] 38 | - OS version [e.g. 15.3.2] 39 | 40 | **Additional context** 41 | Add any other context about the problem here. 42 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@v6 17 | 18 | - name: Setup Node.js 20 19 | uses: actions/setup-node@v6 20 | with: 21 | node-version: 20 22 | 23 | - name: Install Dependencies 24 | run: yarn 25 | 26 | - name: Create Release Pull Request or Publish to npm 27 | id: changesets 28 | uses: changesets/action@v1 29 | with: 30 | title: 'chore: version packages' 31 | commit: 'chore: version packages' 32 | publish: yarn changeset:publish 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 36 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/updateNetworkRpc.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from 'playwright-core'; 2 | import { clickOnButton } from '../../../helpers'; 3 | import type { UpdateNetworkRpc } from '../../../types'; 4 | 5 | import { openNetworkDropdown } from './helpers'; 6 | 7 | export const updateNetworkRpc = 8 | (page: Page) => 9 | async ({ chainId, rpc }: UpdateNetworkRpc): Promise => { 10 | await page.bringToFront(); 11 | await openNetworkDropdown(page); 12 | await clickOnButton(page, 'Add a custom network'); 13 | 14 | await page.getByTestId('network-form-chain-id').fill(String(chainId)); 15 | await clickOnButton(page, 'edit the original network'); 16 | 17 | await page.getByTestId('test-add-rpc-drop-down').click(); 18 | await clickOnButton(page, 'Add RPC URL'); 19 | await page.getByTestId('rpc-url-input-test').fill(rpc); 20 | await clickOnButton(page, 'Add URL'); 21 | 22 | await clickOnButton(page, 'Save'); 23 | }; 24 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/signin.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | 3 | import { waitForChromeState } from '../../../helpers'; 4 | import { connect } from './approve'; 5 | import { performPopupAction } from './util'; 6 | 7 | export const signin = (page: Page) => async (): Promise => { 8 | await performPopupAction(page, async (popup) => { 9 | await popup.waitForSelector('#app-content .app'); 10 | 11 | const [signatureTextVisible, signinTextVisible] = await Promise.all([ 12 | popup.getByText('Signature request').isVisible(), 13 | popup.getByText('Sign-in request').isVisible(), 14 | ]); 15 | 16 | if (!signatureTextVisible && !signinTextVisible) { 17 | await connect(popup); 18 | } 19 | 20 | const signInButton = popup.getByTestId('confirm-footer-button'); 21 | await signInButton.scrollIntoViewIfNeeded(); 22 | await signInButton.click(); 23 | 24 | await waitForChromeState(page); 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /src/downloader/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Constants used across the downloader module 3 | */ 4 | 5 | // Overrides for consistent navigation experience across wallet extensions 6 | export const EXTENSION_ID = 'gadekpdjmpjjnnemgnhkbjgnjpdaakgh'; 7 | export const EXTENSION_PUB_KEY = 8 | 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnpiOcYGaEp02v5On5luCk/4g9j+ujgWeGlpZVibaSz6kUlyiZvcVNIIUXR568uv5NrEi5+j9+HbzshLALhCn9S43E7Ha6Xkdxs3kOEPBu8FRNwFh2S7ivVr6ixnl2FCGwfkP1S1r7k665eC1/xYdJKGCc8UByfSw24Rtl5odUqZX1SaE6CsQEMymCFcWhpE3fV+LZ6RWWJ63Zm1ac5KmKzXdj7wZzN3onI0Csc8riBZ0AujkThJmCR8tZt2PkVUDX9exa0XkJb79pe0Ken5Bt2jylJhmQB7R3N1pVNhNQt17Sytnwz6zG2YsB2XNd/1VYJe52cPNJc7zvhQJpHjh5QIDAQAB'; 9 | 10 | // File markers for download state tracking 11 | export const DOWNLOAD_STATE_FILES = { 12 | downloading: '.downloading', 13 | success: '.success', 14 | error: '.error', 15 | } as const; 16 | 17 | // Configuration constants 18 | export const DOWNLOAD_CONFIG = { 19 | pollIntervalMs: 2000, 20 | } as const; 21 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/helpers/actions.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { getSettingsSwitch } from './selectors'; 3 | 4 | export const clickOnSettingsSwitch = async (page: Page, text: string): Promise => { 5 | const button = await getSettingsSwitch(page, text); 6 | await button.click(); 7 | }; 8 | 9 | export const openNetworkDropdown = async (page: Page): Promise => { 10 | const networkDropdown = page.getByTestId('network-display'); 11 | await networkDropdown.waitFor({ state: 'visible' }); 12 | await networkDropdown.click(); 13 | }; 14 | 15 | export const openAccountOptionsMenu = async (page: Page): Promise => { 16 | const accountOptionsMenuButton = page.getByTestId('account-options-menu-button'); 17 | await accountOptionsMenuButton.scrollIntoViewIfNeeded(); 18 | await accountOptionsMenuButton.click(); 19 | }; 20 | 21 | export const openAccountMenu = async (page: Page): Promise => { 22 | await page.getByTestId('account-menu-icon').click(); 23 | }; 24 | -------------------------------------------------------------------------------- /test/helpers/itForWallet.ts: -------------------------------------------------------------------------------- 1 | import { Dappwright } from '../../src'; 2 | import { CoinbaseWallet } from '../../src/wallets/coinbase/coinbase'; 3 | import { MetaMaskWallet } from '../../src/wallets/metamask/metamask'; 4 | import { WalletTypes } from '../../src/wallets/wallets'; 5 | 6 | const conditionalCallback = ( 7 | wallet: Dappwright, 8 | walletType: WalletTypes, 9 | callback: () => Promise, 10 | ): Promise => { 11 | if (wallet instanceof walletType) { 12 | return callback(); 13 | } else { 14 | return new Promise((resolve) => { 15 | resolve(); 16 | }); 17 | } 18 | }; 19 | 20 | // For wallet logic within an test 21 | export const forMetaMask = (wallet: Dappwright, callback: () => Promise): Promise => { 22 | return conditionalCallback(wallet, MetaMaskWallet, callback); 23 | }; 24 | 25 | export const forCoinbase = (wallet: Dappwright, callback: () => Promise): Promise => { 26 | return conditionalCallback(wallet, CoinbaseWallet, callback); 27 | }; 28 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/importPk.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | 3 | import { clickOnButton, typeOnInputField } from '../../../helpers'; 4 | import { getErrorMessage, openAccountMenu } from './helpers'; 5 | 6 | export const importPk = 7 | (page: Page) => 8 | async (privateKey: string): Promise => { 9 | await page.bringToFront(); 10 | await openAccountMenu(page); 11 | 12 | await page.getByTestId('multichain-account-menu-popover-action-button').click(); 13 | 14 | await page.getByTestId('multichain-account-menu-popover-add-imported-account').click(); 15 | await typeOnInputField(page, 'your private key', privateKey); 16 | await page.getByTestId('import-account-confirm-button').click(); 17 | 18 | const errorMessage = await getErrorMessage(page); 19 | if (errorMessage) { 20 | await clickOnButton(page, 'Cancel'); 21 | await page.getByRole('dialog').getByRole('button', { name: 'Close' }).first().click(); 22 | throw new SyntaxError(errorMessage); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/addToken.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { clickOnButton } from '../../../helpers'; 3 | import { AddToken } from '../../../types'; 4 | 5 | export const addToken = 6 | (page: Page) => 7 | async ({ tokenAddress, symbol, decimals = 0 }: AddToken): Promise => { 8 | await page.bringToFront(); 9 | 10 | await page.getByTestId('asset-list-control-bar-action-button').click(); 11 | await page.getByTestId('importTokens__button').click(); 12 | await clickOnButton(page, 'Custom token'); 13 | await page.getByTestId('import-tokens-modal-custom-address').fill(tokenAddress); 14 | 15 | await page.waitForTimeout(500); 16 | 17 | if (symbol) { 18 | await page.getByTestId('import-tokens-modal-custom-symbol').fill(symbol); 19 | } 20 | 21 | await page.getByTestId('import-tokens-modal-custom-decimals').fill(decimals.toString()); 22 | 23 | await clickOnButton(page, 'Next'); 24 | await page.getByTestId('import-tokens-modal-import-button').click(); 25 | }; 26 | -------------------------------------------------------------------------------- /test/helpers/walletTest.ts: -------------------------------------------------------------------------------- 1 | import { test as base } from '@playwright/test'; 2 | import { BrowserContext } from 'playwright-core'; 3 | import { bootstrap, Dappwright, getWallet, OfficialOptions } from '../../src'; 4 | 5 | export const testWithWallet = base.extend<{ wallet: Dappwright }, { walletContext: BrowserContext }>({ 6 | walletContext: [ 7 | async ({}, use, info) => { 8 | const projectMetadata = info.project.metadata as OfficialOptions; 9 | const [_, __, browserContext] = await bootstrap('', { 10 | ...projectMetadata, 11 | headless: info.project.use.headless, 12 | }); 13 | 14 | await use(browserContext); 15 | await browserContext.close(); 16 | }, 17 | { scope: 'worker' }, 18 | ], 19 | context: async ({ walletContext }, use) => { 20 | await use(walletContext); 21 | }, 22 | wallet: async ({ walletContext }, use, info) => { 23 | const projectMetadata = info.project.metadata; 24 | const wallet = await getWallet(projectMetadata.wallet, walletContext); 25 | await use(wallet); 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /src/downloader/file.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import StreamZip from 'node-stream-zip'; 3 | import os from 'os'; 4 | import path from 'path'; 5 | import { WalletIdOptions } from '../wallets/wallets'; 6 | import { EXTENSION_PUB_KEY } from './constants'; 7 | 8 | export const downloadDir = (walletId: WalletIdOptions, version: string): string => { 9 | return path.resolve(os.tmpdir(), 'dappwright', walletId, version.replace(/\./g, '_')); 10 | }; 11 | 12 | export const extractZip = async (zipData: string, destination: string): Promise => { 13 | const zip = new StreamZip.async({ file: zipData }); 14 | fs.mkdirSync(destination, { recursive: true }); 15 | await zip.extract(null, destination); 16 | }; 17 | 18 | // Set the chrome extension public key 19 | export const editExtensionPubKey = (extensionPath: string): void => { 20 | const manifestPath = path.resolve(extensionPath, 'manifest.json'); 21 | const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); 22 | manifest.key = EXTENSION_PUB_KEY; 23 | fs.writeFileSync(manifestPath, JSON.stringify(manifest)); 24 | }; 25 | -------------------------------------------------------------------------------- /src/helpers/selectors.ts: -------------------------------------------------------------------------------- 1 | import { ElementHandle, Page } from 'playwright-core'; 2 | 3 | export const getElementByContent = (page: Page, text: string, type = '*'): Promise => 4 | page.waitForSelector(`//${type}[contains(text(), '${text}')]`); 5 | 6 | export const getInputByLabel = ( 7 | page: Page, 8 | text: string, 9 | excludeSpan = false, 10 | timeout = 2000, 11 | ): Promise => 12 | page.waitForSelector( 13 | [ 14 | `//label[contains(.,'${text}')]/following-sibling::textarea`, 15 | `//label[contains(.,'${text}')]/following-sibling::*//input`, 16 | `//h6[contains(.,'${text}')]/parent::node()/parent::node()/following-sibling::input`, 17 | `//h6[contains(.,'${text}')]/parent::node()/parent::node()/following-sibling::*//input`, 18 | ...(!excludeSpan 19 | ? [ 20 | `//span[contains(.,'${text}')]/parent::node()/parent::node()/following-sibling::*//input`, 21 | `//span[contains(.,'${text}')]/following-sibling::*//input`, 22 | ] 23 | : []), 24 | ].join('|'), 25 | { timeout }, 26 | ); 27 | -------------------------------------------------------------------------------- /test/dapp/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Local wallet test 8 | 9 | 10 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/confirmTransaction.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { TransactionOptions } from '../../../types'; 3 | 4 | import { performPopupAction } from './util'; 5 | 6 | export const confirmTransaction = 7 | (page: Page) => 8 | async (options?: TransactionOptions): Promise => { 9 | await performPopupAction(page, async (popup) => { 10 | if (options) { 11 | await popup.getByTestId('edit-gas-fee-icon').click(); 12 | await popup.getByTestId('edit-gas-fee-item-custom').click(); 13 | 14 | if (options.gas) { 15 | await popup.getByTestId('base-fee-input').fill(String(options.gas)); 16 | } 17 | 18 | if (options.priority) { 19 | await popup.getByTestId('priority-fee-input').fill(String(options.priority)); 20 | } 21 | 22 | if (options.gasLimit) { 23 | await popup.getByTestId('advanced-gas-fee-edit').click(); 24 | await popup.getByTestId('gas-limit-input').fill(String(options.gasLimit)); 25 | } 26 | 27 | await popup.getByRole('button', { name: 'Save' }).click(); 28 | } 29 | 30 | await popup.getByTestId('confirm-footer-button').click(); 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /test/dapp/contract/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import solc from 'solc'; 4 | 5 | type ContractSources = Record; 6 | 7 | function buildSources(): ContractSources { 8 | const sources: ContractSources = {}; 9 | const contractsLocation = __dirname; 10 | const contractsFiles = fs.readdirSync(contractsLocation); 11 | 12 | contractsFiles.forEach((file) => { 13 | const contractFullPath = path.resolve(contractsLocation, file); 14 | if (contractFullPath.endsWith('.sol')) { 15 | sources[file] = { 16 | content: fs.readFileSync(contractFullPath, 'utf8'), 17 | }; 18 | } 19 | }); 20 | 21 | return sources; 22 | } 23 | 24 | const INPUT = { 25 | language: 'Solidity', 26 | sources: buildSources(), 27 | settings: { 28 | outputSelection: { 29 | // eslint-disable-next-line @typescript-eslint/naming-convention 30 | '*': { 31 | // eslint-disable-next-line @typescript-eslint/naming-convention 32 | '*': ['abi', 'evm.bytecode'], 33 | }, 34 | }, 35 | }, 36 | }; 37 | 38 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 39 | export function compileContracts(): any { 40 | return JSON.parse(solc.compile(JSON.stringify(INPUT))).contracts; 41 | } 42 | -------------------------------------------------------------------------------- /playwright.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@playwright/test'; 2 | import { CoinbaseWallet, MetaMaskWallet } from './src'; 3 | 4 | export default defineConfig({ 5 | testIgnore: '**/*.test.ts', 6 | retries: process.env.CI ? 1 : 0, 7 | timeout: process.env.CI ? 120000 : 60000, 8 | use: { 9 | trace: process.env.CI ? 'retain-on-first-failure' : 'on', 10 | headless: false, 11 | }, 12 | maxFailures: process.env.CI ? 0 : 1, 13 | reporter: [['list'], ['html', { open: 'on-failure' }]], 14 | webServer: { 15 | command: 'cd test/dapp && yarn start', 16 | url: 'http://localhost:8080', 17 | timeout: 120 * 1000, 18 | reuseExistingServer: false, 19 | }, 20 | projects: [ 21 | { 22 | name: 'MetaMask', 23 | metadata: { 24 | wallet: 'metamask', 25 | version: MetaMaskWallet.recommendedVersion, 26 | seed: 'pioneer casual canoe gorilla embrace width fiction bounce spy exhibit another dog', 27 | password: 'password1234!@#$', 28 | }, 29 | }, 30 | { 31 | name: 'Coinbase', 32 | metadata: { 33 | wallet: 'coinbase', 34 | version: CoinbaseWallet.recommendedVersion, 35 | seed: 'pioneer casual canoe gorilla embrace width fiction bounce spy exhibit another dog', 36 | password: 'password1234!@#$', 37 | }, 38 | dependencies: ["MetaMask"], 39 | }, 40 | ], 41 | }); 42 | -------------------------------------------------------------------------------- /src/helpers/actions.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { getElementByContent, getInputByLabel } from './selectors'; 3 | 4 | export const waitForChromeState = async (page: Page): Promise => { 5 | await page.waitForTimeout(3000); 6 | }; 7 | 8 | export const clickOnElement = async (page: Page, text: string, type?: string): Promise => { 9 | const element = await getElementByContent(page, text, type); 10 | await element.click(); 11 | }; 12 | 13 | export const clickOnButton = async (page: Page, text: string): Promise => { 14 | await page.getByRole('button', { name: text, exact: true }).click(); 15 | }; 16 | 17 | /** 18 | * 19 | * @param page 20 | * @param label 21 | * @param text 22 | * @param clear 23 | * @param excludeSpan 24 | * @param optional 25 | * @returns true if found and updated, false otherwise 26 | */ 27 | export const typeOnInputField = async ( 28 | page: Page, 29 | label: string, 30 | text: string, 31 | clear = false, 32 | excludeSpan = false, 33 | optional = false, 34 | ): Promise => { 35 | let input; 36 | try { 37 | input = await getInputByLabel(page, label, excludeSpan, 5000); 38 | } catch (e) { 39 | if (optional) return false; 40 | throw e; 41 | } 42 | 43 | if (clear) 44 | await page.evaluate((node) => { 45 | node.value = ''; 46 | }, input); 47 | await input.type(text); 48 | return true; 49 | }; 50 | -------------------------------------------------------------------------------- /src/wallets/wallets.ts: -------------------------------------------------------------------------------- 1 | import { BrowserContext, Page } from 'playwright-core'; 2 | import { EXTENSION_ID } from '../downloader/constants'; 3 | import { CoinbaseWallet } from './coinbase/coinbase'; 4 | import { MetaMaskWallet } from './metamask/metamask'; 5 | 6 | export type WalletTypes = typeof CoinbaseWallet | typeof MetaMaskWallet; 7 | const WALLETS: WalletTypes[] = [CoinbaseWallet, MetaMaskWallet]; 8 | 9 | export type Step = (page: Page, options?: Options) => void; 10 | export type WalletIdOptions = 'metamask' | 'coinbase'; 11 | export type WalletOptions = { 12 | seed?: string; 13 | password?: string; 14 | showTestNets?: boolean; 15 | }; 16 | 17 | export const getWalletType = (id: WalletIdOptions): WalletTypes => { 18 | const walletType = WALLETS.find((wallet) => { 19 | return wallet.id === id; 20 | }); 21 | 22 | if (!walletType) throw new Error(`Wallet ${id} not supported`); 23 | 24 | return walletType; 25 | }; 26 | 27 | export const closeWalletSetupPopup = (id: WalletIdOptions, browserContext: BrowserContext): void => { 28 | browserContext.on('page', async (page) => { 29 | if (page.url() === walletHomeUrl(id)) { 30 | await page.close(); 31 | } 32 | }); 33 | }; 34 | 35 | export const getWallet = async (id: WalletIdOptions, browserContext: BrowserContext): Promise => { 36 | const wallet = getWalletType(id); 37 | const page = browserContext.pages()[0]; 38 | 39 | if (page.url() === 'about:blank') { 40 | await page.goto(walletHomeUrl(id)); 41 | } 42 | 43 | return new wallet(page); 44 | }; 45 | 46 | const walletHomeUrl = (id: WalletIdOptions): string => { 47 | const wallet = getWalletType(id); 48 | return `chrome-extension://${EXTENSION_ID}${wallet.homePath}`; 49 | }; 50 | -------------------------------------------------------------------------------- /src/launch.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import os from 'os'; 3 | import * as path from 'path'; 4 | import playwright from 'playwright-core'; 5 | 6 | import { DappwrightLaunchResponse, OfficialOptions } from './types'; 7 | import { closeWalletSetupPopup, getWallet, getWalletType } from './wallets/wallets'; 8 | 9 | /** 10 | * Launch Playwright chromium instance with wallet plugin installed 11 | * */ 12 | const sessionPath = path.resolve(os.tmpdir(), 'dappwright', 'session'); 13 | 14 | export async function launch(browserName: string, options: OfficialOptions): Promise { 15 | const { ...officialOptions } = options; 16 | const wallet = getWalletType(officialOptions.wallet); 17 | if (!wallet) throw new Error('Wallet not supported'); 18 | 19 | const extensionPath = await wallet.download(officialOptions); 20 | const extensionList = [extensionPath].concat(officialOptions.additionalExtensions || []); 21 | 22 | const browserArgs = [ 23 | `--disable-extensions-except=${extensionList.join(',')}`, 24 | `--load-extension=${extensionList.join(',')}`, 25 | '--lang=en-US', 26 | ]; 27 | 28 | if (options.headless != false) browserArgs.push(`--headless=new`); 29 | 30 | const workerIndex = process.env.TEST_WORKER_INDEX || '0'; 31 | const userDataDir = path.join(sessionPath, options.wallet, workerIndex); 32 | 33 | fs.rmSync(userDataDir, { recursive: true, force: true }); 34 | 35 | const browserContext = await playwright.chromium.launchPersistentContext(userDataDir, { 36 | headless: false, 37 | args: browserArgs, 38 | }); 39 | 40 | closeWalletSetupPopup(wallet.id, browserContext); 41 | 42 | return { 43 | wallet: await getWallet(wallet.id, browserContext), 44 | browserContext, 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/addNetwork.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { clickOnButton } from '../../../helpers'; 3 | import { AddNetwork } from '../../../types'; 4 | 5 | import { getErrorMessage, openNetworkDropdown } from './helpers'; 6 | import { switchNetwork } from './switchNetwork'; 7 | 8 | export const addNetwork = 9 | (page: Page) => 10 | async ({ networkName, rpc, chainId, symbol }: AddNetwork): Promise => { 11 | await openNetworkDropdown(page); 12 | await clickOnButton(page, 'Add a custom network'); 13 | 14 | await page.getByTestId('network-form-network-name').fill(networkName); 15 | await page.getByTestId('test-add-rpc-drop-down').click(); 16 | await clickOnButton(page, 'Add RPC URL'); 17 | await page.getByTestId('rpc-url-input-test').fill(rpc); 18 | await clickOnButton(page, 'Add URL'); 19 | await page.getByTestId('network-form-chain-id').fill(String(chainId)); 20 | await page.getByTestId('network-form-ticker-input').fill(symbol); 21 | 22 | const errorMessage = await getErrorMessage(page); 23 | if (errorMessage) { 24 | await page.getByRole('dialog').getByRole('button', { name: 'Close' }).click(); 25 | throw new SyntaxError(errorMessage); 26 | } 27 | 28 | await clickOnButton(page, 'Save'); 29 | 30 | // This popup is fairly random in terms of timing 31 | // and can show before switch to network click is gone 32 | const gotItClick = (): Promise => 33 | page.waitForTimeout(2000).then(() => 34 | page 35 | .locator('button', { hasText: 'Got it' }) 36 | .isVisible() 37 | .then((gotItButtonVisible) => { 38 | if (gotItButtonVisible) return clickOnButton(page, 'Got it'); 39 | return Promise.resolve(); 40 | }), 41 | ); 42 | 43 | await Promise.all([switchNetwork(page)(networkName), gotItClick()]); 44 | }; 45 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v6 15 | 16 | - name: Setup Node.js 20 17 | uses: actions/setup-node@v6 18 | with: 19 | cache: yarn 20 | node-version: 20 21 | 22 | - name: Install dependencies 23 | run: yarn install --frozen-lockfile 24 | 25 | - name: Lint 26 | run: yarn run lint 27 | build: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v6 32 | 33 | - name: Setup Node.js 20 34 | uses: actions/setup-node@v6 35 | with: 36 | cache: yarn 37 | node-version: 20 38 | 39 | - name: Install dependencies 40 | run: yarn install --frozen-lockfile 41 | 42 | - name: Build 43 | run: yarn run build 44 | 45 | integration: 46 | runs-on: ubuntu-latest 47 | container: 48 | image: mcr.microsoft.com/playwright:v1.56.1-jammy 49 | steps: 50 | - name: Checkout code 51 | uses: actions/checkout@v6 52 | 53 | - name: Setup Node.js 20 54 | uses: actions/setup-node@v6 55 | with: 56 | cache: yarn 57 | node-version: 20 58 | 59 | - name: Install dependencies 60 | run: yarn install --frozen-lockfile 61 | 62 | - name: Install dapp dependencies 63 | run: cd ./test/dapp && yarn install --frozen-lockfile 64 | 65 | - name: Test 66 | env: 67 | GITHUB_TOKEN: ${{ github.token }} 68 | run: | 69 | xvfb-run --auto-servernum yarn run test:ci 70 | - name: Upload test results 71 | if: failure() 72 | uses: actions/upload-artifact@v5 73 | with: 74 | name: playwright-traces 75 | path: test-results 76 | -------------------------------------------------------------------------------- /src/downloader/version.ts: -------------------------------------------------------------------------------- 1 | import { WalletIdOptions } from '../wallets/wallets'; 2 | 3 | export const printVersion = (walletId: WalletIdOptions, version: string, recommendedVersion: string): void => { 4 | /* eslint-disable no-console */ 5 | console.log(''); // new line 6 | if (version === 'latest') 7 | console.warn( 8 | '\x1b[33m%s\x1b[0m', 9 | // eslint-disable-next-line max-len 10 | `It is not recommended to run ${walletId} with "latest" version. Use it at your own risk or set to the recommended version "${recommendedVersion}".`, 11 | ); 12 | else if (isNewerVersion(recommendedVersion, version)) 13 | console.warn( 14 | '\x1b[33m%s\x1b[0m', 15 | `Seems you are running a newer version (${version}) of ${walletId} than recommended by the Dappwright team. 16 | Use it at your own risk or set to the recommended version "${recommendedVersion}".`, 17 | ); 18 | else if (isNewerVersion(version, recommendedVersion)) 19 | console.warn( 20 | '\x1b[33m%s\x1b[0m', 21 | `Seems you are running an older version (${version}) of ${walletId} than recommended by the Dappwright team. 22 | Use it at your own risk or set the recommended version "${recommendedVersion}".`, 23 | ); 24 | else console.log(`Using ${walletId} v${version}`); 25 | 26 | console.log(''); // new line 27 | }; 28 | 29 | const isNewerVersion = (current: string, comparingWith: string): boolean => { 30 | if (current === comparingWith) return false; 31 | 32 | const currentFragments = current.replace(/[^\d.-]/g, '').split('.'); 33 | const comparingWithFragments = comparingWith.replace(/[^\d.-]/g, '').split('.'); 34 | 35 | const length = 36 | currentFragments.length > comparingWithFragments.length ? currentFragments.length : comparingWithFragments.length; 37 | for (let i = 0; i < length; i++) { 38 | if ((Number(currentFragments[i]) || 0) === (Number(comparingWithFragments[i]) || 0)) continue; 39 | return (Number(comparingWithFragments[i]) || 0) > (Number(currentFragments[i]) || 0); 40 | } 41 | return true; 42 | }; 43 | -------------------------------------------------------------------------------- /src/wallets/wallet.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from 'playwright-core'; 2 | import type { AddNetwork, AddToken, Dappwright, OfficialOptions, TransactionOptions, UpdateNetworkRpc } from '../types'; 3 | import type { Step, WalletIdOptions, WalletOptions } from './wallets'; 4 | 5 | export default abstract class Wallet implements Dappwright { 6 | version: string; 7 | page: Page; 8 | 9 | constructor(page: Page) { 10 | this.page = page; 11 | } 12 | 13 | // Name of the wallet 14 | static id: WalletIdOptions; 15 | static recommendedVersion: string; 16 | static releasesUrl: string; 17 | static homePath: string; 18 | 19 | // Extension downloader 20 | static download: (options: OfficialOptions) => Promise; 21 | 22 | // Setup 23 | abstract setup: (options?: WalletOptions, steps?: Step[]) => Promise; 24 | abstract defaultSetupSteps: Step[]; 25 | 26 | // Wallet actions 27 | abstract addNetwork: (options: AddNetwork) => Promise; 28 | abstract addToken: (options: AddToken) => Promise; 29 | abstract approve: () => Promise; 30 | abstract createAccount: (name?: string) => Promise; 31 | abstract confirmNetworkSwitch: () => Promise; 32 | abstract confirmTransaction: (options?: TransactionOptions) => Promise; 33 | abstract countAccounts: () => Promise; 34 | abstract deleteAccount: (name: string) => Promise; 35 | abstract deleteNetwork: (name: string) => Promise; 36 | abstract getTokenBalance: (tokenSymbol: string) => Promise; 37 | abstract hasNetwork: (name: string) => Promise; 38 | abstract importPK: (pk: string) => Promise; 39 | abstract lock: () => Promise; 40 | abstract reject: () => Promise; 41 | abstract sign: () => Promise; 42 | abstract signin: () => Promise; 43 | abstract switchAccount: (name: string) => Promise; 44 | abstract switchNetwork: (network: string) => Promise; 45 | abstract unlock: (password?: string) => Promise; 46 | abstract updateNetworkRpc: (options: UpdateNetworkRpc) => Promise; 47 | } 48 | -------------------------------------------------------------------------------- /src/wallets/coinbase/coinbase.ts: -------------------------------------------------------------------------------- 1 | import downloader from '../../downloader/downloader'; 2 | import { setup } from '../metamask/setup'; 3 | import Wallet from '../wallet'; 4 | import { Step, WalletIdOptions, WalletOptions } from '../wallets'; 5 | import { 6 | addNetwork, 7 | addToken, 8 | approve, 9 | confirmNetworkSwitch, 10 | confirmTransaction, 11 | countAccounts, 12 | createAccount, 13 | deleteAccount, 14 | deleteNetwork, 15 | getStarted, 16 | getTokenBalance, 17 | hasNetwork, 18 | importPK, 19 | lock, 20 | navigateHome, 21 | reject, 22 | sign, 23 | signin, 24 | switchAccount, 25 | switchNetwork, 26 | unlock, 27 | updateNetworkRpc, 28 | } from './actions'; 29 | 30 | export class CoinbaseWallet extends Wallet { 31 | static id = 'coinbase' as WalletIdOptions; 32 | static recommendedVersion = '3.123.0'; 33 | static releasesUrl = 'https://api.github.com/repos/TenKeyLabs/coinbase-wallet-archive/releases'; 34 | static homePath = '/index.html'; 35 | 36 | options: WalletOptions; 37 | 38 | // Extension Downloader 39 | static download = downloader(this.id, this.releasesUrl, this.recommendedVersion); 40 | 41 | // Setup 42 | defaultSetupSteps: Step[] = [getStarted, navigateHome]; 43 | setup = setup(this.page, this.defaultSetupSteps); 44 | 45 | // Actions 46 | addNetwork = addNetwork(this.page); 47 | addToken = addToken; 48 | approve = approve(this.page); 49 | createAccount = createAccount(this.page); 50 | confirmNetworkSwitch = confirmNetworkSwitch; 51 | confirmTransaction = confirmTransaction(this.page); 52 | countAccounts = countAccounts(this.page); 53 | deleteAccount = deleteAccount; 54 | deleteNetwork = deleteNetwork(this.page); 55 | getTokenBalance = getTokenBalance(this.page); 56 | hasNetwork = hasNetwork(this.page); 57 | importPK = importPK; 58 | lock = lock(this.page); 59 | reject = reject(this.page); 60 | sign = sign(this.page); 61 | signin = signin; 62 | switchAccount = switchAccount(this.page); 63 | switchNetwork = switchNetwork; 64 | unlock = unlock(this.page); 65 | updateNetworkRpc = updateNetworkRpc; 66 | } 67 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | mocha: true, 5 | node: true, 6 | es6: true, 7 | }, 8 | parser: '@typescript-eslint/parser', 9 | plugins: ['@typescript-eslint', 'eslint-plugin-import', 'prettier'], 10 | extends: ['prettier', 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], 11 | rules: { 12 | 'prettier/prettier': ['error', {}, { usePrettierrc: true }], 13 | '@typescript-eslint/no-require-imports': 'error', 14 | '@typescript-eslint/no-unused-vars': [ 15 | 'error', 16 | { 17 | varsIgnorePattern: '^_', 18 | argsIgnorePattern: '^_', 19 | }, 20 | ], 21 | '@typescript-eslint/explicit-function-return-type': [ 22 | 'error', 23 | { 24 | allowExpressions: true, 25 | }, 26 | ], 27 | '@typescript-eslint/ban-ts-comment': 'error', 28 | '@typescript-eslint/no-explicit-any': 'error', 29 | '@typescript-eslint/explicit-module-boundary-types': 'error', 30 | '@typescript-eslint/no-use-before-define': 'off', 31 | 'prefer-const': 'error', 32 | 'no-consecutive-blank-lines': 0, 33 | 'no-console': 'error', 34 | '@typescript-eslint/naming-convention': [ 35 | 'error', 36 | { 37 | selector: ['classProperty', 'parameterProperty', 'objectLiteralProperty', 'classMethod', 'parameter'], 38 | format: ['camelCase'], 39 | leadingUnderscore: 'allow', 40 | }, 41 | //variable must be in camel or upper case 42 | { 43 | selector: 'variable', 44 | format: ['camelCase', 'UPPER_CASE'], 45 | leadingUnderscore: 'allow', 46 | filter: { 47 | regex: '^_', 48 | match: false, 49 | }, 50 | }, 51 | // {selector: "variable", modifiers: ["global"], format: ["PascalCase", "UPPER_CASE"]}, 52 | //classes and types must be in PascalCase 53 | { selector: ['typeLike', 'enum'], format: ['PascalCase'] }, 54 | { selector: 'enumMember', format: null }, 55 | { selector: 'typeProperty', format: ['PascalCase', 'camelCase'] }, 56 | //ignore rules on destructured params 57 | { 58 | selector: 'variable', 59 | modifiers: ['destructured'], 60 | format: null, 61 | }, 62 | ], 63 | }, 64 | overrides: [ 65 | { 66 | files: ['**/test/**/*.ts'], 67 | rules: { 68 | 'no-console': 'off', 69 | }, 70 | }, 71 | ], 72 | }; 73 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserContext, Page } from 'playwright-core'; 2 | import type Wallet from './wallets/wallet'; 3 | import type { WalletIdOptions } from './wallets/wallets'; 4 | export { CoinbaseWallet } from './wallets/coinbase/coinbase'; 5 | export { MetaMaskWallet } from './wallets/metamask/metamask'; 6 | 7 | export type LaunchOptions = OfficialOptions | DappwrightBrowserLaunchArgumentOptions; 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | type DappwrightBrowserLaunchArgumentOptions = Omit; 11 | 12 | export type DappwrightConfig = Partial<{ 13 | dappwright: LaunchOptions; 14 | }>; 15 | 16 | export type OfficialOptions = DappwrightBrowserLaunchArgumentOptions & { 17 | wallet: WalletIdOptions; 18 | version: 'latest' | string; 19 | headless?: boolean; 20 | additionalExtensions?: string[]; 21 | }; 22 | 23 | export type DappwrightLaunchResponse = { 24 | wallet: Wallet; 25 | browserContext: BrowserContext; 26 | }; 27 | 28 | export type AddNetwork = { 29 | networkName: string; 30 | rpc: string; 31 | chainId: number; 32 | symbol: string; 33 | }; 34 | 35 | export type UpdateNetworkRpc = { 36 | chainId: number; 37 | rpc: string; 38 | }; 39 | 40 | export type AddToken = { 41 | tokenAddress: string; 42 | symbol?: string; 43 | decimals?: number; 44 | }; 45 | 46 | export type TransactionOptions = { 47 | gas?: number; 48 | gasLimit?: number; 49 | priority: number; 50 | }; 51 | 52 | export type Dappwright = { 53 | addNetwork: (options: AddNetwork) => Promise; 54 | addToken: (options: AddToken) => Promise; 55 | approve: () => Promise; 56 | confirmNetworkSwitch: () => Promise; 57 | confirmTransaction: (options?: TransactionOptions) => Promise; 58 | createAccount: (name?: string) => Promise; 59 | deleteAccount: (name: string) => Promise; 60 | deleteNetwork: (name: string) => Promise; 61 | getTokenBalance: (tokenSymbol: string) => Promise; 62 | hasNetwork: (name: string) => Promise; 63 | importPK: (pk: string) => Promise; 64 | lock: () => Promise; 65 | reject: () => Promise; 66 | sign: () => Promise; 67 | signin: () => Promise; 68 | switchAccount: (name: string) => Promise; 69 | switchNetwork: (network: string) => Promise; 70 | unlock: (password?: string) => Promise; 71 | updateNetworkRpc: (options: UpdateNetworkRpc) => Promise; 72 | 73 | page: Page; 74 | }; 75 | -------------------------------------------------------------------------------- /src/wallets/metamask/metamask.ts: -------------------------------------------------------------------------------- 1 | import downloader from '../../downloader/downloader'; 2 | import Wallet from '../wallet'; 3 | import type { Step, WalletIdOptions, WalletOptions } from '../wallets'; 4 | import { 5 | addNetwork, 6 | addToken, 7 | approve, 8 | confirmTransaction, 9 | createAccount, 10 | deleteAccount, 11 | deleteNetwork, 12 | getTokenBalance, 13 | importPk, 14 | lock, 15 | reject, 16 | sign, 17 | signin, 18 | switchAccount, 19 | switchNetwork, 20 | unlock, 21 | updateNetworkRpc, 22 | } from './actions'; 23 | import { confirmNetworkSwitch } from './actions/confirmNetworkSwitch'; 24 | import { countAccounts } from './actions/countAccounts'; 25 | import { hasNetwork } from './actions/hasNetwork'; 26 | import { setup } from './setup'; 27 | import { 28 | adjustSettings, 29 | closePopup, 30 | createPassword, 31 | doOnboarding, 32 | goToSettings, 33 | importAccount, 34 | } from './setup/setupActions'; 35 | 36 | export class MetaMaskWallet extends Wallet { 37 | static id = 'metamask' as WalletIdOptions; 38 | static recommendedVersion = '12.23.1'; 39 | static releasesUrl = 'https://api.github.com/repos/metamask/metamask-extension/releases'; 40 | static homePath = '/home.html'; 41 | 42 | options: WalletOptions; 43 | 44 | // Extension Downloader 45 | static download = downloader(this.id, this.releasesUrl, this.recommendedVersion); 46 | 47 | // Setup 48 | defaultSetupSteps: Step[] = [ 49 | importAccount, 50 | createPassword, 51 | doOnboarding, 52 | closePopup, 53 | goToSettings, 54 | adjustSettings, 55 | ]; 56 | setup = setup(this.page, this.defaultSetupSteps); 57 | 58 | // Actions 59 | addNetwork = addNetwork(this.page); 60 | addToken = addToken(this.page); 61 | approve = approve(this.page); 62 | createAccount = createAccount(this.page); 63 | confirmNetworkSwitch = confirmNetworkSwitch(this.page); 64 | confirmTransaction = confirmTransaction(this.page); 65 | countAccounts = countAccounts(this.page); 66 | deleteAccount = deleteAccount(this.page); 67 | deleteNetwork = deleteNetwork(this.page); 68 | getTokenBalance = getTokenBalance(this.page); 69 | hasNetwork = hasNetwork(this.page); 70 | importPK = importPk(this.page); 71 | lock = lock(this.page); 72 | reject = reject(this.page); 73 | sign = sign(this.page); 74 | signin = signin(this.page); 75 | switchAccount = switchAccount(this.page); 76 | switchNetwork = switchNetwork(this.page); 77 | unlock = unlock(this.page); 78 | updateNetworkRpc = updateNetworkRpc(this.page); 79 | } 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dAppwright 2 | 3 | E2E testing for dApps using Playwright + MetaMask & Coinbase Wallet 4 | 5 | ## Installation 6 | 7 | ``` 8 | $ npm install -s @tenkeylabs/dappwright 9 | $ yarn add @tenkeylabs/dappwright 10 | ``` 11 | 12 | ## Usage 13 | 14 | ### Quick setup with Hardhat 15 | 16 | ```typescript 17 | # test.spec.ts 18 | 19 | import { test as base } from '@playwright/test'; 20 | import { BrowserContext } from 'playwright-core'; 21 | import { bootstrap, Dappwright, getWallet, OfficialOptions } from '@tenkeylabs/dappwright'; 22 | 23 | export const testWithWallet = base.extend<{ wallet: Dappwright }, { walletContext: BrowserContext }>({ 24 | walletContext: [ 25 | async ({}, use, info) => { 26 | // Launch context with extension 27 | const [wallet, _, context] = await dappwright.bootstrap("", { 28 | wallet: "metamask", 29 | version: MetaMaskWallet.recommendedVersion, 30 | seed: "test test test test test test test test test test test junk", // Hardhat's default https://hardhat.org/hardhat-network/docs/reference#accounts 31 | headless: false, 32 | }); 33 | 34 | await use(context); 35 | await context.close(); 36 | }, 37 | { scope: 'worker' }, 38 | ], 39 | context: async ({ walletContext }, use) => { 40 | await use(walletContext); 41 | }, 42 | wallet: async ({ walletContext }, use, info) => { 43 | const projectMetadata = info.project.metadata; 44 | const wallet = await getWallet(projectMetadata.wallet, walletContext); 45 | await use(wallet); 46 | }, 47 | }); 48 | 49 | test.beforeEach(async ({ page }) => { 50 | await page.goto("http://localhost:8080"); 51 | }); 52 | 53 | test("should be able to connect", async ({ wallet, page }) => { 54 | await page.click("#connect-button"); 55 | await wallet.approve(); 56 | 57 | const connectStatus = page.getByTestId("connect-status"); 58 | expect(connectStatus).toHaveValue("connected"); 59 | 60 | await page.click("#switch-network-button"); 61 | 62 | const networkStatus = page.getByTestId("network-status"); 63 | expect(networkStatus).toHaveValue("31337"); 64 | }); 65 | ``` 66 | 67 | ### Alternative Setups 68 | 69 | There are a number of different ways integrate dAppwright into your test suite. For some other examples, please check out dAppwright's [example application repo](https://github.com/TenKeyLabs/dappwright-examples). 70 | 71 | ## Special Thanks 72 | 73 | This project is a fork of the [Chainsafe](https://github.com/chainsafe/dappeteer) and [Decentraland](https://github.com/decentraland/dappeteer) version of dAppeteer. 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tenkeylabs/dappwright", 3 | "version": "2.12.0", 4 | "description": "End-to-End (E2E) testing for dApps using Playwright + MetaMask", 5 | "source": "src/index.ts", 6 | "main": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "module": "dist/index.mjs", 9 | "exports": { 10 | "types": "./dist/index.d.ts", 11 | "require": "./dist/index.js", 12 | "import": "./dist/index.mjs", 13 | "default": "./dist/index.modern.mjs" 14 | }, 15 | "unpkg": "dist/index.umd.js", 16 | "files": [ 17 | "dist/" 18 | ], 19 | "engines": { 20 | "node": ">=20" 21 | }, 22 | "scripts": { 23 | "prebuild": "rimraf dist", 24 | "build": "microbundle --tsconfig tsconfig.build.json --external os,https,zlib,stream,util,events,path", 25 | "dev": "microbundle --tsconfig tsconfig.build.json --external os,https,zlib,stream,util,events,path watch", 26 | "lint": "yarn run lint:code && yarn run lint:unused", 27 | "lint:fix": "yarn run lint:code --fix", 28 | "lint:code": "eslint", 29 | "lint:unused": "knip", 30 | "test": "playwright test", 31 | "test:ci": "playwright test", 32 | "test:debug": "playwright test --debug --timeout 0 test/", 33 | "test:metamask:debug": "playwright test --project Metamask --debug --timeout 0", 34 | "test:coinbase:debug": "playwright test --project Coinbase --debug --timeout 0", 35 | "test:unit": "vitest", 36 | "test:unit:watch": "vitest --watch", 37 | "test:unit:run": "vitest run", 38 | "test:unit:coverage": "vitest run --coverage", 39 | "changeset:publish": "yarn build && changeset publish" 40 | }, 41 | "repository": { 42 | "type": "git", 43 | "url": "https://github.com/TenKeyLabs/dappwright.git" 44 | }, 45 | "keywords": [ 46 | "e2e", 47 | "testing", 48 | "metamask", 49 | "playwright", 50 | "dapp", 51 | "ethereum" 52 | ], 53 | "contributors": [ 54 | "Dwayne Forde " 55 | ], 56 | "license": "MIT", 57 | "dependencies": { 58 | "node-stream-zip": "^1.13.0" 59 | }, 60 | "devDependencies": { 61 | "@changesets/changelog-github": "^0.5.0", 62 | "@changesets/cli": "^2.26.0", 63 | "@playwright/test": "^1.51.0", 64 | "@types/node": "^25.0.0", 65 | "@typescript-eslint/eslint-plugin": "^8.29.0", 66 | "@typescript-eslint/parser": "^8.29.0", 67 | "@vitest/coverage-v8": "^4.0.15", 68 | "eslint": "^9.24.0", 69 | "eslint-config-prettier": "^10.1.0", 70 | "eslint-plugin-import": "^2.31.0", 71 | "eslint-plugin-prettier": "^5.2.0", 72 | "globals": "^16.3.0", 73 | "knip": "^5.62.0", 74 | "microbundle": "^0.15.1", 75 | "prettier": "^3.0.3", 76 | "rimraf": "^6.0.1", 77 | "typescript": "^5.0", 78 | "vitest": "^4.0.15" 79 | }, 80 | "peerDependencies": { 81 | "playwright-core": ">1.0" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/downloader/github.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { get } from 'https'; 3 | import path from 'path'; 4 | import { request } from './request'; 5 | 6 | type GithubRelease = { downloadUrl: string; filename: string; tag: string }; 7 | 8 | type GithubResponse = 9 | | { 10 | message?: string; 11 | } 12 | | [GithubReleaseResponse]; 13 | 14 | /* eslint-disable @typescript-eslint/naming-convention */ 15 | type GithubReleaseResponse = { 16 | tag_name: string; 17 | assets: { name: string; browser_download_url: string }[]; 18 | draft: boolean; 19 | }; 20 | /* eslint-enable @typescript-eslint/naming-convention */ 21 | 22 | export const getGithubRelease = (releasesUrl: string, version: string): Promise => 23 | new Promise((resolve, reject) => { 24 | const tagRegex = RegExp(`v?${version}$`, 'img'); 25 | // eslint-disable-next-line @typescript-eslint/naming-convention 26 | const options = { headers: { 'User-Agent': 'Mozilla/5.0' } }; 27 | 28 | if (process.env.GITHUB_TOKEN) options.headers['Authorization'] = `Bearer ${process.env.GITHUB_TOKEN}`; 29 | const url = new URL(releasesUrl); 30 | url.searchParams.set('per_page', '100'); 31 | const request = get(url.toString(), options, (response) => { 32 | let body = ''; 33 | response.on('data', (chunk) => { 34 | body += chunk; 35 | }); 36 | 37 | response.on('end', () => { 38 | const data = JSON.parse(body) as GithubResponse; 39 | if (!Array.isArray(data)) { 40 | return reject( 41 | // eslint-disable-next-line max-len 42 | `There was a problem connecting to github API to get the extension release (URL: ${releasesUrl}). Error: ${data.message}`, 43 | ); 44 | } 45 | 46 | for (const result of data) { 47 | if (result.draft) continue; 48 | if (version === 'latest' || tagRegex.test(result.tag_name)) { 49 | for (const asset of result.assets) { 50 | if (asset.name.includes('chrome')) 51 | resolve({ 52 | downloadUrl: asset.browser_download_url, 53 | filename: asset.name, 54 | tag: result.tag_name, 55 | }); 56 | } 57 | } 58 | } 59 | reject(`Version ${version} not found!`); 60 | }); 61 | }); 62 | request.on('error', (error) => { 63 | // eslint-disable-next-line no-console 64 | console.warn('getGithubRelease error:', error.message); 65 | throw error; 66 | }); 67 | }); 68 | 69 | export const downloadGithubRelease = (name: string, url: string, location: string): Promise => 70 | new Promise(async (resolve) => { 71 | if (!fs.existsSync(location)) { 72 | fs.mkdirSync(location, { recursive: true }); 73 | } 74 | const fileLocation = path.join(location, name); 75 | const file = fs.createWriteStream(fileLocation); 76 | const stream = await request(url); 77 | stream.pipe(file); 78 | stream.on('end', () => { 79 | resolve(fileLocation); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /test/dapp/public/data.js: -------------------------------------------------------------------------------- 1 | const ContractInfo = { 2 | "abi": [ 3 | { 4 | "constant": true, 5 | "inputs": [], 6 | "name": "count", 7 | "outputs": [ 8 | { 9 | "name": "", 10 | "type": "uint256" 11 | } 12 | ], 13 | "payable": false, 14 | "stateMutability": "view", 15 | "type": "function", 16 | "signature": "0x06661abd" 17 | }, 18 | { 19 | "constant": false, 20 | "inputs": [], 21 | "name": "increase", 22 | "outputs": [], 23 | "payable": false, 24 | "stateMutability": "nonpayable", 25 | "type": "function", 26 | "signature": "0xe8927fbc" 27 | } 28 | ], 29 | "evm": { 30 | "bytecode": { 31 | "linkReferences": {}, 32 | "object": "608060405234801561001057600080fd5b5060bd8061001f6000396000f3fe6080604052348015600f57600080fd5b5060043610604f576000357c01000000000000000000000000000000000000000000000000000000009004806306661abd146054578063e8927fbc146070575b600080fd5b605a6078565b6040518082815260200191505060405180910390f35b6076607e565b005b60005481565b600080815480929190600101919050555056fea165627a7a72305820fc33f994f18ba4440c94570ea658ed551e4c9914f16ddfae05477a898ea71e410029", 33 | "opcodes": "PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH2 0x10 JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0xBD DUP1 PUSH2 0x1F PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN INVALID PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH1 0xF JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0x4 CALLDATASIZE LT PUSH1 0x4F JUMPI PUSH1 0x0 CALLDATALOAD PUSH29 0x100000000000000000000000000000000000000000000000000000000 SWAP1 DIV DUP1 PUSH4 0x6661ABD EQ PUSH1 0x54 JUMPI DUP1 PUSH4 0xE8927FBC EQ PUSH1 0x70 JUMPI JUMPDEST PUSH1 0x0 DUP1 REVERT JUMPDEST PUSH1 0x5A PUSH1 0x78 JUMP JUMPDEST PUSH1 0x40 MLOAD DUP1 DUP3 DUP2 MSTORE PUSH1 0x20 ADD SWAP2 POP POP PUSH1 0x40 MLOAD DUP1 SWAP2 SUB SWAP1 RETURN JUMPDEST PUSH1 0x76 PUSH1 0x7E JUMP JUMPDEST STOP JUMPDEST PUSH1 0x0 SLOAD DUP2 JUMP JUMPDEST PUSH1 0x0 DUP1 DUP2 SLOAD DUP1 SWAP3 SWAP2 SWAP1 PUSH1 0x1 ADD SWAP2 SWAP1 POP SSTORE POP JUMP INVALID LOG1 PUSH6 0x627A7A723058 KECCAK256 0xfc CALLER 0xf9 SWAP5 CALL DUP12 LOG4 DIFFICULTY 0xc SWAP5 JUMPI 0xe 0xa6 PC 0xed SSTORE 0x1e 0x4c SWAP10 EQ CALL PUSH14 0xDFAE05477A898EA71E4100290000 ", 34 | "sourceMap": "33:109:0:-;;;;8:9:-1;5:2;;;30:1;27;20:12;5:2;33:109:0;;;;;;;" 35 | } 36 | }, 37 | "address": "0x0fC7E4bD0784Af9b444015557CDBdA05d9D4D46e", 38 | "jsonInterface": [ 39 | { 40 | "constant": true, 41 | "inputs": [], 42 | "name": "count", 43 | "outputs": [ 44 | { 45 | "name": "", 46 | "type": "uint256" 47 | } 48 | ], 49 | "payable": false, 50 | "stateMutability": "view", 51 | "type": "function", 52 | "signature": "0x06661abd" 53 | }, 54 | { 55 | "constant": false, 56 | "inputs": [], 57 | "name": "increase", 58 | "outputs": [], 59 | "payable": false, 60 | "stateMutability": "nonpayable", 61 | "type": "function", 62 | "signature": "0xe8927fbc" 63 | } 64 | ] 65 | } -------------------------------------------------------------------------------- /test/dapp/server.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import * as http from 'http'; 3 | import * as path from 'path'; 4 | 5 | import ganache, { Provider, Server } from 'ganache'; 6 | import handler from 'serve-handler'; 7 | import Web3 from 'web3'; 8 | import { Contract } from 'web3-eth-contract'; 9 | import { compileContracts } from './contract'; 10 | 11 | const counterContract: { address: string } | null = null; 12 | 13 | let httpServer: http.Server; 14 | let chainNode: Server; 15 | 16 | export function getCounterContract(): { address: string } | null { 17 | return counterContract; 18 | } 19 | 20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 21 | export async function start(): Promise> { 22 | const provider = await waitForGanache(); 23 | await startTestServer(); 24 | return await deployContract(provider); 25 | } 26 | 27 | export async function stop(): Promise { 28 | await new Promise((resolve) => { 29 | httpServer.close(() => { 30 | resolve(); 31 | }); 32 | }); 33 | await chainNode.close(); 34 | } 35 | 36 | export async function waitForGanache(): Promise { 37 | console.log('Starting ganache...'); 38 | chainNode = ganache.server({ 39 | chain: { chainId: 31337 }, 40 | wallet: { seed: 'asd123' }, 41 | logging: { quiet: true }, 42 | flavor: 'ethereum', 43 | }); 44 | await chainNode.listen(8545); 45 | return chainNode.provider; 46 | } 47 | 48 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 49 | async function deployContract(provider: Provider): Promise> { 50 | console.log('Deploying test contract...'); 51 | const web3 = new Web3(provider as unknown as Web3['currentProvider']); 52 | const compiledContracts = compileContracts(); 53 | const counterContractInfo = compiledContracts['Counter.sol']['Counter']; 54 | const counterContractDef = new web3.eth.Contract(counterContractInfo.abi); 55 | 56 | // deploy contract 57 | const accounts = await web3.eth.getAccounts(); 58 | const counterContract = await counterContractDef 59 | .deploy({ data: counterContractInfo.evm.bytecode.object }) 60 | .send({ from: accounts[0], gas: String(4000000) }); 61 | console.log('Contract deployed at', counterContract.options.address); 62 | 63 | // export contract spec 64 | const dataJsPath = path.join(__dirname, 'public', 'Counter.js'); 65 | const data = `const ContractInfo = ${JSON.stringify( 66 | { ...counterContractInfo, ...counterContract.options }, 67 | null, 68 | 2, 69 | )}`; 70 | await new Promise((resolve) => { 71 | fs.writeFile(dataJsPath, data, resolve); 72 | }); 73 | console.log('path:', dataJsPath); 74 | 75 | return counterContract; 76 | } 77 | 78 | async function startTestServer(): Promise { 79 | console.log('Starting test server...'); 80 | httpServer = http.createServer((request, response) => { 81 | return handler(request, response, { 82 | public: path.join(__dirname, 'public'), 83 | cleanUrls: true, 84 | }); 85 | }); 86 | 87 | await new Promise((resolve) => { 88 | httpServer.listen(8080, 'localhost', () => { 89 | console.log('Server running at http://localhost:8080'); 90 | resolve(); 91 | }); 92 | }); 93 | } 94 | -------------------------------------------------------------------------------- /src/wallets/metamask/setup/setupActions.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | 3 | import { waitForChromeState } from '../../../helpers'; 4 | import { WalletOptions } from '../../wallets'; 5 | import { clickOnSettingsSwitch, openAccountOptionsMenu } from '../actions/helpers'; 6 | 7 | export async function goToSettings(metamaskPage: Page): Promise { 8 | await openAccountOptionsMenu(metamaskPage); 9 | await metamaskPage.getByTestId('global-menu-settings').click(); 10 | } 11 | 12 | export async function adjustSettings(metamaskPage: Page): Promise { 13 | await goToSettings(metamaskPage); 14 | await metamaskPage.locator('.tab-bar__tab', { hasText: 'Advanced' }).click(); 15 | 16 | await clickOnSettingsSwitch(metamaskPage, 'Show test networks'); 17 | await metamaskPage.getByRole('button', { name: 'Close' }).click(); 18 | 19 | await waitForChromeState(metamaskPage); 20 | } 21 | 22 | export async function importAccount( 23 | metamaskPage: Page, 24 | { seed = 'already turtle birth enroll since owner keep patch skirt drift any dinner' }: WalletOptions, 25 | ): Promise { 26 | await metamaskPage.getByTestId('onboarding-get-started-button').click(); 27 | await metamaskPage.getByTestId('terms-of-use-scroll-button').click(); 28 | 29 | // await expect(metamaskPage.getByTestId('terms-of-use__checkbox')).toBeEnabled({ timeout: 5000 }); 30 | await metamaskPage.getByTestId('terms-of-use-checkbox').click(); 31 | 32 | await metamaskPage.getByTestId('terms-of-use-agree-button').click(); 33 | await metamaskPage.getByTestId('onboarding-import-wallet').click(); 34 | await metamaskPage.getByTestId('srp-input-import__srp-note').pressSequentially(seed); 35 | 36 | await metamaskPage.getByTestId('import-srp-confirm').click(); 37 | } 38 | 39 | export async function createPassword(metamaskPage: Page, { password = 'password1234' }: WalletOptions): Promise { 40 | await metamaskPage.getByTestId('create-password-new-input').fill(password); 41 | await metamaskPage.getByTestId('create-password-confirm-input').fill(password); 42 | await metamaskPage.getByTestId('create-password-terms').click(); 43 | await metamaskPage.getByTestId('create-password-submit').click(); 44 | } 45 | 46 | export async function doOnboarding(metamaskPage: Page): Promise { 47 | await metamaskPage.getByTestId('metametrics-no-thanks').click(); 48 | await metamaskPage.getByTestId('manage-default-settings').click(); 49 | await metamaskPage.getByTestId('category-item-General').click(); 50 | await metamaskPage.getByTestId('backup-and-sync-toggle-container').click(); 51 | await metamaskPage.getByTestId('category-back-button').click(); 52 | await metamaskPage.getByTestId('privacy-settings-back-button').click(); 53 | await metamaskPage.getByTestId('onboarding-complete-done').click(); 54 | await metamaskPage.getByTestId('pin-extension-done').click(); 55 | await metamaskPage.getByTestId('not-now-button').click(); 56 | } 57 | 58 | export const closePopup = async (page: Page): Promise => { 59 | /* For some reason popup deletes close button and then create new one (react stuff) 60 | * hacky solution can be found here => https://github.com/puppeteer/puppeteer/issues/3496 */ 61 | await new Promise((resolve) => setTimeout(resolve, 1000)); 62 | if (await page.getByTestId('popover-close').isVisible()) { 63 | await page.getByTestId('popover-close').click(); 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("eslint/config"); 2 | const typescriptEslint = require('@typescript-eslint/eslint-plugin'); 3 | const typescriptParser = require('@typescript-eslint/parser'); 4 | const importPlugin = require('eslint-plugin-import'); 5 | const prettierPlugin = require('eslint-plugin-prettier'); 6 | const prettierConfig = require('eslint-config-prettier'); 7 | const globals = require('globals'); 8 | 9 | module.exports = defineConfig([ 10 | { 11 | files: ['**/*.ts'], 12 | ignores: ['dist/**', 'node_modules/**'], 13 | languageOptions: { 14 | globals: { 15 | ...globals.mocha, 16 | ...globals.node, 17 | ...globals.es5, 18 | }, 19 | parser: typescriptParser, 20 | parserOptions: { 21 | project: ['./tsconfig.json'], // Add the path to your TypeScript config file 22 | ecmaVersion: 2022, 23 | sourceType: 'module', 24 | }, 25 | }, 26 | plugins: { 27 | '@typescript-eslint': typescriptEslint, 28 | 'import': importPlugin, 29 | 'prettier': prettierPlugin 30 | }, 31 | rules: { 32 | // Include rules from eslint:recommended 33 | ...typescriptEslint.configs.recommended.rules, 34 | ...prettierConfig.rules, 35 | 36 | // Prettier rules 37 | 'prettier/prettier': ['error', {}, { usePrettierrc: true }], 38 | 39 | // TypeScript rules 40 | '@typescript-eslint/no-require-imports': 'error', 41 | "@typescript-eslint/no-unused-vars": [ 42 | "error", 43 | { 44 | argsIgnorePattern: "^_", 45 | varsIgnorePattern: "^_", 46 | caughtErrorsIgnorePattern: "^_", 47 | }, 48 | ], 49 | '@typescript-eslint/explicit-function-return-type': [ 50 | 'error', 51 | { 52 | allowExpressions: true, 53 | }, 54 | ], 55 | '@typescript-eslint/ban-ts-comment': 'error', 56 | '@typescript-eslint/no-explicit-any': 'error', 57 | '@typescript-eslint/explicit-module-boundary-types': 'error', 58 | '@typescript-eslint/no-use-before-define': 'off', 59 | 60 | // General ESLint rules 61 | 'prefer-const': 'error', 62 | 'no-consecutive-blank-lines': 0, 63 | 'no-console': 'error', 64 | 'max-len': ['error', { code: 120, ignoreUrls: true, ignoreStrings: true, ignoreComments: true }], 65 | 66 | // // Naming convention rules 67 | '@typescript-eslint/naming-convention': [ 68 | 'error', 69 | { 70 | selector: ['classProperty', 'parameterProperty', 'objectLiteralProperty', 'classMethod', 'parameter'], 71 | format: ['camelCase'], 72 | leadingUnderscore: 'allow', 73 | }, 74 | // Variable must be in camel or upper case 75 | { 76 | selector: 'variable', 77 | format: ['camelCase', 'UPPER_CASE'], 78 | leadingUnderscore: 'allow', 79 | filter: { 80 | regex: '^_', 81 | match: false, 82 | }, 83 | }, 84 | // Classes and types must be in PascalCase 85 | { selector: ['typeLike', 'enum'], format: ['PascalCase'] }, 86 | { selector: 'enumMember', format: null }, 87 | { selector: 'typeProperty', format: ['PascalCase', 'camelCase'] }, 88 | // Ignore rules on destructured params 89 | { 90 | selector: 'variable', 91 | modifiers: ['destructured'], 92 | format: null, 93 | }, 94 | ], 95 | } 96 | }, 97 | // Override for test files 98 | { 99 | files: ['**/test/**/*.ts'], 100 | rules: { 101 | 'no-console': 'off', 102 | }, 103 | } 104 | ]); 105 | -------------------------------------------------------------------------------- /test/3-dapp.spec.ts: -------------------------------------------------------------------------------- 1 | import { CoinbaseWallet, MetaMaskWallet } from '../src'; 2 | import { forCoinbase, forMetaMask } from './helpers/itForWallet'; 3 | import { testWithWallet as test } from './helpers/walletTest'; 4 | 5 | // Adding manually only needed for Metamask since Coinbase does this automatically 6 | test.beforeAll(async ({ wallet }) => { 7 | if (wallet instanceof MetaMaskWallet) { 8 | try { 9 | await wallet.addNetwork({ 10 | networkName: 'GoChain Testnet', 11 | rpc: 'http://localhost:8545', 12 | chainId: 31337, 13 | symbol: 'GO', 14 | }); 15 | } catch (_) { 16 | // Gracefully fail when running serially (ie. ci) 17 | } 18 | } 19 | }); 20 | 21 | test.describe('when interacting with dapps', () => { 22 | test.beforeEach(async ({ page }) => { 23 | await page.goto('http://localhost:8080'); 24 | await page.waitForSelector('#ready'); 25 | await page.waitForTimeout(1000); // Coinbase wallet needs a bit more time to load 26 | }); 27 | 28 | test('should be able to reject to connect', async ({ wallet, page }) => { 29 | await page.click('.connect-button'); 30 | await wallet.reject(); 31 | 32 | await page.waitForSelector('#connect-rejected'); 33 | }); 34 | 35 | test('should be able to connect', async ({ wallet, page }) => { 36 | await forCoinbase(wallet, async () => { 37 | await page.click('.connect-button'); 38 | await wallet.approve(); 39 | 40 | await page.waitForSelector('#connected'); 41 | }); 42 | }); 43 | 44 | test('should be able to sign in', async ({ wallet, page }) => { 45 | await forMetaMask(wallet, async () => { 46 | await page.click('.signin-button'); 47 | await wallet.signin(); 48 | 49 | await page.waitForSelector('#signedIn'); 50 | }); 51 | }); 52 | 53 | test('should sign SIWE complient message', async ({ wallet, page }) => { 54 | await forMetaMask(wallet, async () => { 55 | await page.click('.sign-siwe-message'); 56 | await wallet.signin(); 57 | 58 | await page.waitForSelector('#siweSigned'); 59 | }); 60 | }); 61 | 62 | test('should be able to sign in again', async ({ wallet, page }) => { 63 | await forMetaMask(wallet, async () => { 64 | await page.click('.signin-button'); 65 | await wallet.signin(); 66 | 67 | await page.waitForSelector('#signedIn'); 68 | }); 69 | }); 70 | 71 | test('should be able to switch networks', async ({ wallet, page }) => { 72 | await page.click('.switch-network-live-test-button'); 73 | 74 | await forMetaMask(wallet, async () => { 75 | await wallet.confirmNetworkSwitch(); 76 | }); 77 | 78 | await page.waitForSelector('#switchNetwork'); 79 | await page.click('.switch-network-local-test-button'); 80 | }); 81 | 82 | test('should be able to sign messages', async ({ wallet, page }) => { 83 | await page.click('.sign-button'); 84 | await wallet.sign(); 85 | 86 | await page.waitForSelector('#signed'); 87 | }); 88 | 89 | test.describe('when confirming a transaction', () => { 90 | test.beforeEach(async ({ page }) => { 91 | await page.click('.connect-button'); 92 | await page.waitForSelector('#connected'); 93 | await page.click('.switch-network-local-test-button'); 94 | }); 95 | 96 | test('should be able to reject', async ({ wallet, page }) => { 97 | await page.click('.transfer-button'); 98 | await wallet.reject(); 99 | 100 | await page.waitForSelector('#transfer-rejected'); 101 | }); 102 | 103 | test('should be able to confirm without altering gas settings', async ({ wallet, page }) => { 104 | if (wallet instanceof CoinbaseWallet && process.env.CI) test.skip(); // this page doesn't load in github actions 105 | 106 | await page.click('.increase-button'); 107 | await wallet.confirmTransaction(); 108 | 109 | await page.waitForSelector('#increased'); 110 | }); 111 | 112 | test('should be able to confirm with custom gas settings', async ({ wallet, page }) => { 113 | if (wallet instanceof CoinbaseWallet) test.skip(); 114 | 115 | await page.click('.transfer-button'); 116 | 117 | await wallet.confirmTransaction({ 118 | gas: 4, 119 | priority: 3, 120 | gasLimit: 202020, 121 | }); 122 | 123 | await page.waitForSelector('#transferred'); 124 | }); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | # dAppwright API 2 | 3 | Methods provided by dAppwright. 4 | For additional information read root [readme](../README.md) 5 | 6 | - [Launch dAppwright](#launch) 7 | - [Setup Metamask](#setup) 8 | - [Bootstrap dAppwright](#bootstrap) 9 | - [Get Metamask Window](#getMetamask) 10 | - [dAppwright methods](#methods) 11 | - [switchAccount](#switchAccount) 12 | - [importPK](#importPK) 13 | - [lock](#lock) 14 | - [unlock](#unlock) 15 | - [switchNetwork](#switchNetwork) 16 | - [addNetwork](#addNetwork) 17 | - [updateNetworkRpc](#updateNetworkRpc) 18 | - [addToken](#addToken) 19 | - [confirmTransaction](#confirmTransaction) 20 | - [sign](#sign) 21 | - [approve](#approve) 22 | - [helpers](#helpers) 23 | - [getTokenBalance](#getTokenBalance) 24 | - [deleteAccount](#deleteAccount) 25 | - [deleteNetwork](#deleteNetwork) 26 | - [page](#page) 27 | 28 | # dAppwright setup methods 29 | 30 | 31 | 32 | ## `dappwright.launch(browserName: string, options: OfficialOptions | CustomOptions): Promise` 33 | 34 | ```typescript 35 | interface OfficialOptions { 36 | metamaskVersion: 'latest' | string; 37 | metamaskLocation?: Path; 38 | } 39 | 40 | type Path = string | { download: string; extract: string }; 41 | ``` 42 | 43 | or 44 | 45 | ```typescript 46 | interface CustomOptions { 47 | metamaskPath: string; 48 | } 49 | ``` 50 | 51 | returns an instance of `browser` same as `playwright.launch`, but it also installs the MetaMask extension. [It supports all the regular `playwright.launch` options](https://playwright.dev/docs/api/class-browser#browser-new-context) 52 | 53 | 54 | 55 | ## `dappwright.setupMetamask(browser: BrowserContext, options: MetamaskOptions = {}, steps: Step[]): Promise` 56 | 57 | ```typescript 58 | interface MetamaskOptions { 59 | seed?: string; 60 | password?: string; 61 | showTestNets?: boolean; 62 | } 63 | ``` 64 | 65 | ```typescript 66 | type Step = (page: Page, options?: Options) => void; 67 | ``` 68 | 69 | 70 | 71 | ## `dappwright.bootstrap(puppeteerLib: typeof puppeteer, options: OfficialOptions & MetamaskOptions): Promise<[Dappwright, Page, Browser]>` 72 | 73 | ```typescript 74 | interface OfficialOptions { 75 | metamaskVersion: 'latest' | string; 76 | metamaskLocation?: Path; 77 | } 78 | ``` 79 | 80 | it runs `dappwright.launch` and `dappwright.setup` and return array with dappwright, page and browser 81 | 82 | 83 | 84 | ## `dappwright.getMetamaskWindow(browser: Browser, version?: string): Promise` 85 | 86 | 87 | 88 | # dAppwright methods 89 | 90 | `metamask` is used as placeholder for dAppwright returned by [`setupMetamask`](setup) or [`getMetamaskWindow`](getMetamask) 91 | 92 | 93 | 94 | ## `metamask.switchAccount(accountNumber: number): Promise` 95 | 96 | it commands MetaMask to switch to a different account, by passing the index/position of the account in the accounts list. 97 | 98 | 99 | 100 | ## `metamask.importPK(privateKey: string): Promise` 101 | 102 | it commands MetaMask to import an private key. It can only be used while you haven't signed in yet, otherwise it throws. 103 | 104 | 105 | 106 | ## `metamask.lock(): Promise` 107 | 108 | signs out from MetaMask. It can only be used if you arelady signed it, otherwise it throws. 109 | 110 | 111 | 112 | ## `metamask.unlock(password: string): Promise` 113 | 114 | it unlocks the MetaMask extension. It can only be used in you locked/signed out before, otherwise it throws. The password is optional, it defaults to `password1234`. 115 | 116 | 117 | 118 | ## `metamask.switchNetwork(network: string): Promise` 119 | 120 | it changes the current selected network. `networkName` can take the following values: `"main"`, `"ropsten"`, `"rinkeby"`, `"kovan"`, `"localhost"`. 121 | 122 | 123 | 124 | ## `metamask.addNetwork(options: AddNetwork): Promise` 125 | 126 | ```typescript 127 | interface AddNetwork { 128 | networkName: string; 129 | rpc: string; 130 | chainId: number; 131 | symbol: string; 132 | } 133 | ``` 134 | 135 | it adds a custom network to MetaMask. 136 | 137 | 138 | 139 | ## `metamask.updateNetworkRpc(options: UpdateNetworkRpc): Promise` 140 | 141 | ```typescript 142 | interface UpdateNetworkRpc { 143 | chainId: number; 144 | rpc: string; 145 | } 146 | ``` 147 | 148 | updates the RPC URL for an existing network in MetaMask. This method leverages MetaMask's error flow to locate and modify pre-existing networks (e.g., Ethereum Mainnet). 149 | 150 | ### Example 151 | 152 | ```typescript 153 | await metamask.updateNetworkRpc({ chainId: 1, rpc: 'https://custom-eth-rpc.com' }); 154 | ``` 155 | 156 | 157 | 158 | ## `metamask.addToken(tokenAddress: string): Promise` 159 | 160 | ```typescript 161 | interface AddToken { 162 | tokenAddress: string; 163 | symbol?: string; 164 | decimals?: number; 165 | } 166 | ``` 167 | 168 | it adds a custom token to MetaMask. 169 | 170 | 171 | 172 | ## `metamask.confirmTransaction(options?: TransactionOptions): Promise` 173 | 174 | ```typescript 175 | interface TransactionOptions { 176 | gas?: number; 177 | gasLimit?: number; 178 | priority?: number; 179 | } 180 | ``` 181 | 182 | commands MetaMask to submit a transaction. For this to work MetaMask has to be in a transaction confirmation state (basically promting the user to submit/reject a transaction). You can (optionally) pass an object with `gas` and/or `gasLimit`, by default they are `20` and `50000` respectively. 183 | 184 | 185 | 186 | ## `metamask.sign(): Promise` 187 | 188 | commands MetaMask to sign a message. For this to work MetaMask must be in a sign confirmation state. 189 | 190 | 191 | 192 | ## `metamask.approve(): Promise` 193 | 194 | enables the app to connect to MetaMask account in privacy mode 195 | 196 | 197 | 198 | ## `metamask.helpers` 199 | 200 | 201 | 202 | ### `metamask.helpers.getTokenBalance(tokenSymbol: string): Promise` 203 | 204 | get balance of specific token 205 | 206 | 207 | 208 | ### `metamask.helpers.deleteAccount(accountNumber: number): Promise` 209 | 210 | deletes account containing name with specified number 211 | 212 | 213 | 214 | ### `metamask.helpers.deleteNetwork(): Promise` 215 | 216 | deletes custom network from metamask 217 | 218 | 219 | 220 | ## `metamask.page` is Metamask plugin `Page` 221 | 222 | **for advanced usages** in case you need custom features. 223 | -------------------------------------------------------------------------------- /test/dapp/public/main.js: -------------------------------------------------------------------------------- 1 | async function start() { 2 | const provider = new ethers.BrowserProvider(window.ethereum, 'any'); 3 | let counterContract; 4 | let accounts = await provider.listAccounts(); 5 | 6 | window.ethereum.on('chainChanged', function (chainId) { 7 | const switchNetwork = document.createElement('div'); 8 | switchNetwork.id = 'switchNetwork'; 9 | switchNetwork.textContent = `switchNetwork - ${parseInt(chainId, 16)}`; 10 | document.body.appendChild(switchNetwork); 11 | }); 12 | 13 | const connectButton = document.querySelector('.connect-button'); 14 | connectButton.addEventListener('click', async function () { 15 | try { 16 | accounts = await ethereum.request({ 17 | method: 'eth_requestAccounts', 18 | }); 19 | counterContract = new ethers.Contract( 20 | ContractInfo.address, 21 | ContractInfo.abi, 22 | await provider.getSigner(accounts[0]), 23 | ); 24 | } catch { 25 | const connectRejected = document.createElement('div'); 26 | connectRejected.id = 'connect-rejected'; 27 | connectRejected.textContent = 'connect rejected'; 28 | document.body.appendChild(connectRejected); 29 | return; 30 | } 31 | 32 | const connected = document.createElement('div'); 33 | connected.id = 'connected'; 34 | connected.textContent = 'connected'; 35 | document.body.appendChild(connected); 36 | }); 37 | 38 | const personalSign = async function (message, signedMessageId = 'signedIn', signedMessage = 'signed in') { 39 | try { 40 | accounts = await ethereum.request({ 41 | method: 'eth_requestAccounts', 42 | }); 43 | const from = accounts[0]; 44 | await ethereum.request({ 45 | method: 'personal_sign', 46 | params: [message, from], 47 | }); 48 | const signedIn = document.createElement('div'); 49 | signedIn.id = signedMessageId; 50 | signedIn.textContent = signedMessage; 51 | document.body.appendChild(signedIn); 52 | } catch (err) { 53 | console.error(err); 54 | } 55 | }; 56 | 57 | const getSiweMessage = async function ({ origin, account, uri, version, chainId, issuedAt, expirationTime }) { 58 | return ( 59 | `${origin} wants you to sign in with your Ethereum account:\n` + 60 | `${account}\n` + 61 | '\n' + 62 | '\n' + 63 | `URI: ${uri}\n` + 64 | `Version: ${version}\n` + 65 | `Chain ID: ${chainId}\n` + 66 | 'Nonce: 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\n' + 67 | `Issued At: ${issuedAt}\n` + 68 | `Expiration Time: ${expirationTime}` 69 | ); 70 | }; 71 | 72 | const signinButton = document.querySelector('.signin-button'); 73 | signinButton.addEventListener('click', async function () { 74 | const domain = window.location.host; 75 | const from = accounts[0]; 76 | const message = `${domain} wants you to sign in with your Ethereum account:\n${from}\n\nI accept the MetaMask Terms of Service: https://community.metamask.io/tos\n\nURI: https://${domain}\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24.000Z`; 77 | personalSign(message); 78 | }); 79 | 80 | const signSiweMessage = document.querySelector('.sign-siwe-message'); 81 | signSiweMessage.addEventListener('click', async function () { 82 | const message = await getSiweMessage({ 83 | origin: window.location.host, 84 | uri: window.location.href, 85 | account: accounts[0], 86 | version: 1, 87 | chainId: 1, 88 | nonce: 1, 89 | issuedAt: new Date().toISOString(), 90 | expirationTime: new Date().toISOString(), 91 | }); 92 | 93 | personalSign(message, 'siweSigned', 'signed SIWE message'); 94 | }); 95 | 96 | const switchToLiveTestNetworkButton = document.querySelector('.switch-network-live-test-button'); 97 | switchToLiveTestNetworkButton.addEventListener('click', async function () { 98 | const chainId = '0xaa36a7'; 99 | 100 | await ethereum.request({ 101 | method: 'wallet_switchEthereumChain', 102 | params: [{ chainId }], 103 | }); 104 | }); 105 | 106 | const switchToLocalTestNetworkButton = document.querySelector('.switch-network-local-test-button'); 107 | switchToLocalTestNetworkButton.addEventListener('click', async function () { 108 | const chainId = '0x7A69'; 109 | 110 | await ethereum.request({ 111 | method: 'wallet_switchEthereumChain', 112 | params: [{ chainId }], 113 | }); 114 | }); 115 | 116 | const increaseButton = document.querySelector('.increase-button'); 117 | increaseButton.addEventListener('click', async function () { 118 | await counterContract.increase({ from: accounts[0] }); 119 | const increase = document.createElement('div'); 120 | increase.id = 'increased'; 121 | increase.textContent = 'increased'; 122 | document.body.appendChild(increase); 123 | }); 124 | 125 | const increaseFeesButton = document.querySelector('.increase-fees-button'); 126 | increaseFeesButton.addEventListener('click', async function () { 127 | await counterContract.increase({ from: accounts[0] }); 128 | const increaseFees = document.createElement('div'); 129 | increaseFees.id = 'increasedFees'; 130 | increaseFees.textContent = 'increasedFees'; 131 | document.body.appendChild(increaseFees); 132 | }); 133 | 134 | const signButton = document.querySelector('.sign-button'); 135 | signButton.addEventListener('click', async function () { 136 | const accounts = await provider.send('eth_requestAccounts', []); 137 | const signer = await provider.getSigner(accounts[0]); 138 | await signer.signMessage('TEST'); 139 | const signed = document.createElement('div'); 140 | signed.id = 'signed'; 141 | signed.textContent = 'signed'; 142 | document.body.appendChild(signed); 143 | }); 144 | 145 | const transferButton = document.querySelector('.transfer-button'); 146 | transferButton.addEventListener('click', async function () { 147 | const accounts = await provider.send('eth_requestAccounts', []); 148 | try { 149 | await ethereum.request({ 150 | method: 'eth_sendTransaction', 151 | params: [{ to: accounts[0], from: accounts[0], value: '10000000000000000' }], 152 | }); 153 | } catch { 154 | const transferRejected = document.createElement('div'); 155 | transferRejected.id = 'transfer-rejected'; 156 | transferRejected.textContent = 'transfer rejected'; 157 | document.body.appendChild(transferRejected); 158 | return; 159 | } 160 | const transfer = document.createElement('div'); 161 | transfer.id = 'transferred'; 162 | transfer.textContent = 'transferred'; 163 | document.body.appendChild(transfer); 164 | }); 165 | 166 | const ready = document.createElement('div'); 167 | ready.id = 'ready'; 168 | ready.textContent = 'ready'; 169 | document.body.appendChild(ready); 170 | } 171 | 172 | start(); 173 | -------------------------------------------------------------------------------- /src/downloader/downloader.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import { OfficialOptions } from '../types'; 5 | import { WalletIdOptions } from '../wallets/wallets'; 6 | import { DOWNLOAD_CONFIG, DOWNLOAD_STATE_FILES } from './constants'; 7 | import { downloadDir, editExtensionPubKey, extractZip } from './file'; 8 | import { downloadGithubRelease, getGithubRelease } from './github'; 9 | import { printVersion } from './version'; 10 | 11 | type DownloadResult = { 12 | path: string; 13 | wasDownloaded: boolean; 14 | }; 15 | 16 | // Re-export constants for backward compatibility 17 | 18 | /** 19 | * Download state file paths for a given directory 20 | */ 21 | interface DownloadStatePaths { 22 | readonly rootDir: string; 23 | readonly downloadingFile: string; 24 | readonly successFile: string; 25 | readonly errorFile: string; 26 | } 27 | 28 | /** 29 | * Main download function - creates and coordinates wallet extension downloads 30 | * 31 | * @param walletId - The wallet identifier 32 | * @param releasesUrl - GitHub releases URL for the wallet 33 | * @param recommendedVersion - The recommended version to suggest 34 | * @returns Function that handles the download process 35 | */ 36 | const createWalletDownloader = (walletId: WalletIdOptions, releasesUrl: string, recommendedVersion: string) => { 37 | return async (options: OfficialOptions): Promise => { 38 | const { version } = options; 39 | const result = await downloadWalletExtension(walletId, version, releasesUrl, recommendedVersion); 40 | return result.path; 41 | }; 42 | }; 43 | 44 | async function downloadWalletExtension( 45 | walletId: WalletIdOptions, 46 | version: string, 47 | releasesUrl: string, 48 | recommendedVersion: string, 49 | ): Promise { 50 | const paths = createDownloadStatePaths(downloadDir(walletId, version)); 51 | 52 | if (!version) { 53 | // eslint-disable-next-line no-console 54 | console.info(`Running tests on local ${walletId} build`); 55 | return { path: paths.rootDir, wasDownloaded: false }; 56 | } 57 | 58 | if (isPrimaryWorker() && !isDownloadComplete(paths)) { 59 | printVersion(walletId, version, recommendedVersion); 60 | await performDownload(walletId, version, releasesUrl, paths); 61 | return { path: paths.rootDir, wasDownloaded: true }; 62 | } else { 63 | await waitForDownloadCompletion(walletId, paths); 64 | return { path: paths.rootDir, wasDownloaded: false }; 65 | } 66 | } 67 | 68 | /** 69 | * Perform the actual download process 70 | */ 71 | async function performDownload( 72 | walletId: WalletIdOptions, 73 | version: string, 74 | releasesUrl: string, 75 | paths: DownloadStatePaths, 76 | ): Promise { 77 | prepareRootDir(paths); 78 | markDownloadStarted(paths); 79 | 80 | try { 81 | // eslint-disable-next-line no-console 82 | console.info(`Downloading ${walletId} ${version}...`); 83 | 84 | const releaseInfo = await getGithubRelease(releasesUrl, version); 85 | const walletFolder = path.dirname(paths.rootDir); 86 | const zipPath = await downloadGithubRelease(releaseInfo.filename, releaseInfo.downloadUrl, walletFolder); 87 | 88 | await extractZip(zipPath, paths.rootDir); 89 | editExtensionPubKey(paths.rootDir); 90 | 91 | markDownloadSuccess(paths); 92 | } catch (error) { 93 | handleDownloadError(paths, error); 94 | throw error; 95 | } finally { 96 | cleanupDownloadingFlag(paths); 97 | } 98 | } 99 | 100 | /** 101 | * Create download state paths for a given directory 102 | */ 103 | function createDownloadStatePaths(downloadPath: string): DownloadStatePaths { 104 | return { 105 | rootDir: downloadPath, 106 | downloadingFile: path.join(downloadPath, DOWNLOAD_STATE_FILES.downloading), 107 | successFile: path.join(downloadPath, DOWNLOAD_STATE_FILES.success), 108 | errorFile: path.join(downloadPath, DOWNLOAD_STATE_FILES.error), 109 | }; 110 | } 111 | 112 | /** 113 | * Check if download completed successfully 114 | */ 115 | function isDownloadComplete(paths: DownloadStatePaths): boolean { 116 | return fs.existsSync(paths.successFile); 117 | } 118 | 119 | /** 120 | * Check if download failed 121 | */ 122 | function hasDownloadError(paths: DownloadStatePaths): boolean { 123 | return fs.existsSync(paths.errorFile); 124 | } 125 | 126 | /** 127 | * Get error message from failed download 128 | */ 129 | function getErrorMessage(paths: DownloadStatePaths): string | null { 130 | if (!hasDownloadError(paths)) { 131 | return null; 132 | } 133 | 134 | try { 135 | return fs.readFileSync(paths.errorFile, 'utf-8'); 136 | } catch { 137 | return 'Unknown error occurred during download'; 138 | } 139 | } 140 | 141 | /** 142 | * Ensure the root directory exists 143 | */ 144 | function ensureRootDirExists(rootDir: string): void { 145 | if (!fs.existsSync(rootDir)) { 146 | fs.mkdirSync(rootDir, { recursive: true }); 147 | } 148 | } 149 | 150 | /** 151 | * Mark download as starting 152 | */ 153 | function markDownloadStarted(paths: DownloadStatePaths): void { 154 | ensureRootDirExists(paths.rootDir); 155 | fs.writeFileSync(paths.downloadingFile, ''); 156 | } 157 | 158 | /** 159 | * Mark download as successful and cleanup temporary files 160 | */ 161 | function markDownloadSuccess(paths: DownloadStatePaths): void { 162 | fs.writeFileSync(paths.successFile, ''); 163 | deleteFileIfExists(paths.errorFile); 164 | } 165 | 166 | /** 167 | * Mark download as failed with error message 168 | */ 169 | function markDownloadError(paths: DownloadStatePaths, errorMessage: string): void { 170 | ensureRootDirExists(paths.rootDir); 171 | fs.writeFileSync(paths.errorFile, errorMessage); 172 | } 173 | 174 | /** 175 | * Clean up the downloading flag file 176 | */ 177 | function cleanupDownloadingFlag(paths: DownloadStatePaths): void { 178 | deleteFileIfExists(paths.downloadingFile); 179 | } 180 | 181 | /** 182 | * Prepare root directory for download by cleaning and creating it 183 | */ 184 | function prepareRootDir(paths: DownloadStatePaths): void { 185 | if (fs.existsSync(paths.rootDir)) { 186 | fs.rmSync(paths.rootDir, { recursive: true, force: true }); 187 | } 188 | fs.mkdirSync(paths.rootDir, { recursive: true }); 189 | } 190 | 191 | /** 192 | * Utility function to safely delete a file if it exists 193 | */ 194 | function deleteFileIfExists(filePath: string): void { 195 | if (fs.existsSync(filePath)) { 196 | fs.unlinkSync(filePath); 197 | } 198 | } 199 | 200 | /** 201 | * Utility function for sleeping/waiting 202 | */ 203 | function sleep(ms: number): Promise { 204 | return new Promise((resolve) => setTimeout(resolve, ms)); 205 | } 206 | 207 | /** 208 | * Check if this is the primary worker responsible for downloading 209 | */ 210 | function isPrimaryWorker(): boolean { 211 | return process.env.TEST_PARALLEL_INDEX === '0'; 212 | } 213 | 214 | /** 215 | * Wait for the primary worker to complete the download 216 | */ 217 | async function waitForDownloadCompletion(walletId: WalletIdOptions, paths: DownloadStatePaths): Promise { 218 | while (!isDownloadComplete(paths)) { 219 | if (hasDownloadError(paths)) { 220 | const errorMessage = getErrorMessage(paths) || 'Unknown error'; 221 | throw new Error(`Primary worker failed to download ${walletId}: ${errorMessage}`); 222 | } 223 | 224 | // eslint-disable-next-line no-console 225 | console.info(`Waiting for primary worker to download ${walletId}...`); 226 | await sleep(DOWNLOAD_CONFIG.pollIntervalMs); 227 | } 228 | } 229 | 230 | /** 231 | * Handle download errors by logging and marking the error state 232 | */ 233 | function handleDownloadError(paths: DownloadStatePaths, error: unknown): void { 234 | const errorMessage = error instanceof Error ? error.message : String(error); 235 | markDownloadError(paths, errorMessage); 236 | } 237 | 238 | export default createWalletDownloader; 239 | -------------------------------------------------------------------------------- /test/2-wallet.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@playwright/test'; 2 | import crypto from 'crypto'; 3 | import { Dappwright, MetaMaskWallet } from '../src'; 4 | import { openAccountMenu } from '../src/wallets/metamask/actions/helpers'; 5 | import { forCoinbase, forMetaMask } from './helpers/itForWallet'; 6 | import { testWithWallet as test } from './helpers/walletTest'; 7 | 8 | // TODO: Add this to the wallet interface 9 | const countAccounts = async (wallet: Dappwright): Promise => { 10 | let count; 11 | 12 | if (wallet instanceof MetaMaskWallet) { 13 | await openAccountMenu(wallet.page); 14 | count = (await wallet.page.$$('.multichain-account-list-item')).length; 15 | await wallet.page.getByRole('dialog').getByRole('button', { name: 'Close' }).first().click(); 16 | } else { 17 | await wallet.page.getByTestId('portfolio-header--switcher-cell-pressable').click(); 18 | count = (await wallet.page.$$('//button[@data-testid="wallet-switcher--wallet-item-cell-pressable"]')).length; 19 | await wallet.page.getByTestId('portfolio-header--switcher-cell-pressable').click(); 20 | } 21 | 22 | return count; 23 | }; 24 | 25 | // Adding manually only needed for Metamask since Coinbase does this automatically 26 | test.beforeAll(async ({ wallet }) => { 27 | if (wallet instanceof MetaMaskWallet) { 28 | await wallet.addNetwork({ 29 | networkName: 'GoChain Testnet', 30 | rpc: 'http://localhost:8545', 31 | chainId: 31337, 32 | symbol: 'GO', 33 | }); 34 | } 35 | }); 36 | 37 | test.describe('when interacting with the wallet', () => { 38 | test('should lock and unlock', async ({ wallet }) => { 39 | await wallet.lock(); 40 | await wallet.unlock('password1234!@#$'); 41 | }); 42 | 43 | test.describe('account management', () => { 44 | test.describe('createAccount', () => { 45 | test('should create a new wallet/account', async ({ wallet }) => { 46 | const accountName = crypto.randomBytes(20).toString('hex'); 47 | const walletCount = await countAccounts(wallet); 48 | 49 | expect(await countAccounts(wallet)).toEqual(walletCount); 50 | 51 | await wallet.createAccount(accountName); 52 | 53 | const expectedAccountName = wallet instanceof MetaMaskWallet ? accountName : 'Address 2'; 54 | expect(wallet.page.getByText(expectedAccountName)); 55 | expect(await countAccounts(wallet)).toEqual(walletCount + 1); 56 | }); 57 | }); 58 | 59 | test.describe('switchAccount', () => { 60 | test('should switch accounts', async ({ wallet }) => { 61 | const accountName: string = wallet instanceof MetaMaskWallet ? 'Account 1' : 'Address 1'; 62 | await wallet.switchAccount(accountName); 63 | 64 | expect(wallet.page.getByText(accountName)); 65 | }); 66 | }); 67 | }); 68 | 69 | test.describe('network configurations', () => { 70 | const networkOptions = { 71 | networkName: 'Cronos Mainnet', 72 | rpc: 'https://evm.cronos.org', 73 | chainId: 25, 74 | symbol: 'CRO', 75 | }; 76 | 77 | test.describe('hasNetwork', () => { 78 | test('should return true if a network has been configured', async ({ wallet }) => { 79 | expect(await wallet.hasNetwork('Ethereum')).toBeTruthy(); 80 | }); 81 | 82 | test('should return false if a network has not been configured', async ({ wallet }) => { 83 | expect(await wallet.hasNetwork('not there')).toBeFalsy(); 84 | }); 85 | }); 86 | 87 | test.describe('addNetwork', () => { 88 | test('should configure a new network', async ({ wallet }) => { 89 | await wallet.addNetwork(networkOptions); 90 | 91 | expect(await wallet.hasNetwork(networkOptions.networkName)).toBeTruthy(); 92 | }); 93 | 94 | test('should fail if network already exists', async ({ wallet }) => { 95 | await expect(wallet.addNetwork(networkOptions)).rejects.toThrowError(SyntaxError); 96 | }); 97 | }); 98 | 99 | test.describe('switchNetwork', () => { 100 | test('should switch network, localhost', async ({ wallet }) => { 101 | if (wallet instanceof MetaMaskWallet) { 102 | await wallet.switchNetwork('Sepolia'); 103 | 104 | const selectedNetwork = wallet.page.getByTestId('network-display').getByText('Sepolia'); 105 | expect(selectedNetwork).toBeVisible(); 106 | } else { 107 | console.warn('Coinbase skips network switching'); 108 | } 109 | }); 110 | }); 111 | 112 | test.describe('deleteNetwork', () => { 113 | test('should delete a network configuration', async ({ wallet }) => { 114 | await wallet.deleteNetwork(networkOptions.networkName); 115 | 116 | expect(await wallet.hasNetwork(networkOptions.networkName)).toBeFalsy(); 117 | }); 118 | }); 119 | 120 | test.describe('updateNetworkRpc', () => { 121 | test('should update RPC URL for an existing network', async ({ wallet }) => { 122 | await forMetaMask(wallet, async () => { 123 | await wallet.updateNetworkRpc({ 124 | chainId: 31337, 125 | rpc: 'http://127.0.0.1:8545', 126 | }); 127 | }); 128 | }); 129 | }); 130 | 131 | // TODO: Come back to this since metamask doesn't consider this to be an error anymore but blocks 132 | // test('should fail to add network with wrong chain ID', async ({ wallet }) => { 133 | // await expect( 134 | // metamask.addNetwork({ 135 | // networkName: 'Optimistic Ethereum Testnet Kovan', 136 | // rpc: 'https://kovan.optimism.io/', 137 | // chainId: 99999, 138 | // symbol: 'KUR', 139 | // }), 140 | // ).rejects.toThrowError(SyntaxError); 141 | // await metamask.page.pause(); 142 | // }); 143 | }); 144 | 145 | // Metamask only 146 | test.describe('private keys', () => { 147 | test.describe('importPK', () => { 148 | test('should import private key', async ({ wallet }) => { 149 | await forMetaMask(wallet, async () => { 150 | const beforeImport = await countAccounts(wallet); 151 | await wallet.importPK('4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b10'); 152 | const afterImport = await countAccounts(wallet); 153 | 154 | expect(beforeImport + 1).toEqual(afterImport); 155 | }); 156 | }); 157 | 158 | test('should throw error on duplicated private key', async ({ wallet }) => { 159 | await forMetaMask(wallet, async () => { 160 | await expect( 161 | wallet.importPK('4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b10'), 162 | ).rejects.toThrowError(SyntaxError); 163 | }); 164 | }); 165 | 166 | test('should throw error on wrong key', async ({ wallet }) => { 167 | await forMetaMask(wallet, async () => { 168 | await expect( 169 | wallet.importPK('4f3edf983ac636a65a$@!ce7c78d9aa706d3b113bce9c46f30d7d21715b23b10'), 170 | ).rejects.toThrowError(SyntaxError); 171 | }); 172 | }); 173 | 174 | test('should throw error on to short key', async ({ wallet }) => { 175 | await forMetaMask(wallet, async () => { 176 | await expect( 177 | wallet.importPK('4f3edf983ac636a65ace7c78d9aa706d3b113bce9c46f30d7d21715b23b10'), 178 | ).rejects.toThrowError(SyntaxError); 179 | }); 180 | }); 181 | }); 182 | 183 | test('should be able to delete an imported account', async ({ wallet }) => { 184 | await forMetaMask(wallet, async () => { 185 | const beforeDelete = await countAccounts(wallet); 186 | await wallet.deleteAccount(`Account ${beforeDelete - 1}`); 187 | const afterDelete = await countAccounts(wallet); 188 | 189 | expect(beforeDelete - 1).toEqual(afterDelete); 190 | }); 191 | }); 192 | }); 193 | 194 | test.describe('getTokenBalance', () => { 195 | test.beforeEach(async ({ wallet }) => { 196 | if (wallet instanceof MetaMaskWallet) await wallet.switchNetwork('GoChain Testnet'); 197 | }); 198 | 199 | test('should return token balance', async ({ wallet }) => { 200 | let tokenBalance: number; 201 | 202 | await forMetaMask(wallet, async () => { 203 | tokenBalance = await wallet.getTokenBalance('GO'); 204 | expect(tokenBalance).toBeLessThanOrEqual(1000); 205 | expect(tokenBalance).toBeGreaterThanOrEqual(999.999); 206 | }); 207 | 208 | // Unable to get local balance from Coinbase wallet. This is Sepolia value for now. 209 | await forCoinbase(wallet, async () => { 210 | tokenBalance = await wallet.getTokenBalance('ETH'); 211 | // expect(tokenBalance).toEqual(999.999); 212 | }); 213 | }); 214 | 215 | test('should return 0 token balance when token not found', async ({ wallet }) => { 216 | await expect(wallet.getTokenBalance('TKLBUCKS')).rejects.toThrowError(new Error('Token TKLBUCKS not found')); 217 | }); 218 | }); 219 | 220 | test.describe('when working with tokens', () => { 221 | test('should add token', async ({ wallet }) => { 222 | await forMetaMask(wallet, async () => { 223 | await wallet.addToken({ 224 | tokenAddress: '0x4f96fe3b7a6cf9725f59d353f723c1bdb64ca6aa', 225 | symbol: 'KAKI', 226 | }); 227 | }); 228 | }); 229 | }); 230 | }); 231 | -------------------------------------------------------------------------------- /src/wallets/coinbase/actions.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from 'playwright-core'; 2 | import { waitForChromeState } from '../../helpers'; 3 | import { AddNetwork, AddToken, UpdateNetworkRpc } from '../../types'; 4 | import { performPopupAction } from '../metamask/actions'; 5 | import { WalletOptions } from '../wallets'; 6 | 7 | const goHome = async (page: Page): Promise => { 8 | await page.getByTestId('portfolio-navigation-link').click(); 9 | }; 10 | 11 | export const navigateHome = async (page: Page): Promise => { 12 | await page.goto(page.url().split('?')[0]); 13 | }; 14 | 15 | export async function getStarted( 16 | page: Page, 17 | { 18 | seed = 'already turtle birth enroll since owner keep patch skirt drift any dinner', 19 | password = 'password1234!!!!', 20 | }: WalletOptions, 21 | ): Promise { 22 | // Welcome screen 23 | await page.getByTestId('btn-import-existing-wallet').click(); 24 | 25 | // Import Wallet 26 | await page.getByTestId('btn-import-recovery-phrase').click(); 27 | await page.getByRole('button', { name: 'Acknowledge' }).click(); 28 | await page.getByTestId('secret-input').fill(seed); 29 | await page.getByTestId('btn-import-wallet').click(); 30 | await page.getByTestId('setPassword').fill(password); 31 | await page.getByTestId('setPasswordVerify').fill(password); 32 | await page.getByTestId('terms-and-privacy-policy').check(); 33 | await page.getByTestId('btn-password-continue').click(); 34 | 35 | // Allow extension state/settings to settle 36 | await waitForChromeState(page); 37 | } 38 | 39 | export const approve = (page: Page) => async (): Promise => { 40 | await performPopupAction(page, async (popup: Page) => { 41 | await popup.getByTestId('allow-authorize-button').click(); 42 | }); 43 | }; 44 | 45 | export const reject = (page: Page) => async (): Promise => { 46 | await performPopupAction(page, async (popup: Page) => { 47 | const denyButton = popup.getByTestId('deny-authorize-button'); 48 | const cancelButton = popup.getByTestId('request-cancel-button'); 49 | 50 | await denyButton.or(cancelButton).click(); 51 | }); 52 | }; 53 | 54 | export const sign = (page: Page) => async (): Promise => { 55 | await performPopupAction(page, async (popup: Page) => { 56 | await popup.getByTestId('sign-message').click(); 57 | }); 58 | }; 59 | 60 | export const signin = async (): Promise => { 61 | // eslint-disable-next-line no-console 62 | console.warn('signin not implemented'); 63 | }; 64 | 65 | export const lock = (page: Page) => async (): Promise => { 66 | await page.getByTestId('settings-navigation-link').click(); 67 | await page.getByTestId('lock-wallet-button').click(); 68 | }; 69 | 70 | export const unlock = 71 | (page: Page) => 72 | async (password = 'password1234!!!!'): Promise => { 73 | // last() because it seems to be a rendering issue of some sort 74 | await page.getByTestId('unlock-with-password').last().fill(password); 75 | await page.getByTestId('unlock-wallet-button').last().click(); 76 | 77 | // Go back home since wallet returns to last visited page when unlocked. 78 | await goHome(page); 79 | 80 | // Wait for homescreen data to load 81 | await page.waitForSelector("//div[@data-testid='asset-list']//*[not(text='')]", { timeout: 10000 }); 82 | }; 83 | 84 | export const confirmTransaction = (page: Page) => async (): Promise => { 85 | await performPopupAction(page, async (popup: Page): Promise => { 86 | try { 87 | // Help prompt appears once 88 | await (await popup.waitForSelector("text='Got it'", { timeout: 1000 })).click(); 89 | } catch { 90 | // Ignore missing help prompt 91 | } 92 | 93 | await popup.getByTestId('request-confirm-button').click(); 94 | }); 95 | }; 96 | 97 | export const addNetwork = 98 | (page: Page) => 99 | async (options: AddNetwork): Promise => { 100 | await page.getByTestId('settings-navigation-link').click(); 101 | await page.getByTestId('network-setting-cell-pressable').click(); 102 | await page.getByTestId('add-custom-network').click(); 103 | await page.getByTestId('custom-network-name-input').fill(options.networkName); 104 | await page.getByTestId('custom-network-rpc-url-input').fill(options.rpc); 105 | await page.getByTestId('custom-network-chain-id-input').fill(options.chainId.toString()); 106 | await page.getByTestId('custom-network-currency-symbol-input').fill(options.symbol); 107 | await page.getByTestId('custom-network-save').click(); 108 | 109 | // Check for error messages 110 | let errorNode; 111 | try { 112 | errorNode = await page.waitForSelector('//span[@data-testid="text-input-error-label"]', { 113 | timeout: 50, 114 | }); 115 | } catch { 116 | // No errors found 117 | } 118 | 119 | if (errorNode) { 120 | const errorMessage = await errorNode.textContent(); 121 | throw new SyntaxError(errorMessage); 122 | } 123 | 124 | await waitForChromeState(page); 125 | await goHome(page); 126 | }; 127 | 128 | export const deleteNetwork = 129 | (page: Page) => 130 | async (name: string): Promise => { 131 | await page.getByTestId('settings-navigation-link').click(); 132 | await page.getByTestId('network-setting-cell-pressable').click(); 133 | 134 | // Search for network then click on the first result 135 | await page.getByTestId('network-list-search').fill(name); 136 | await (await page.waitForSelector('//div[@data-testid="list-"][1]//button')).click(); 137 | 138 | await page.getByTestId('custom-network-delete').click(); 139 | await goHome(page); 140 | }; 141 | 142 | export const hasNetwork = 143 | (page: Page) => 144 | async (name: string): Promise => { 145 | await page.getByTestId('settings-navigation-link').click(); 146 | await page.getByTestId('network-setting').click(); 147 | await page.getByTestId('network-list-search').fill(name); 148 | const networkIsListed = await page.isVisible('//div[@data-testid="list-"][1]//button'); 149 | await goHome(page); 150 | return networkIsListed; 151 | }; 152 | 153 | export const getTokenBalance = 154 | (page: Page) => 155 | async (tokenSymbol: string): Promise => { 156 | const tokenValueRegex = new RegExp(String.raw` ${tokenSymbol}`); 157 | 158 | const readFromCryptoTab = async (): Promise => { 159 | await page.bringToFront(); 160 | await page.getByTestId('portfolio-selector-nav-tabLabel--crypto').click(); 161 | const tokenItem = page.getByTestId(/asset-item.*cell-pressable/).filter({ 162 | hasText: tokenValueRegex, 163 | }); 164 | 165 | await page.waitForTimeout(500); 166 | 167 | return (await tokenItem.isVisible()) ? tokenItem : null; 168 | }; 169 | 170 | const readFromTestnetTab = async (): Promise => { 171 | await page.getByTestId('portfolio-selector-nav-tabLabel--testnet').click(); 172 | 173 | const tokenItem = page.getByTestId(/asset-item.*cell-pressable/).filter({ 174 | hasText: tokenValueRegex, 175 | }); 176 | 177 | await page.waitForTimeout(500); 178 | 179 | return (await tokenItem.isVisible()) ? tokenItem : null; 180 | }; 181 | 182 | const readAttempts = [readFromCryptoTab, readFromTestnetTab]; 183 | 184 | let button: Locator | undefined; 185 | for (const readAttempt of readAttempts) { 186 | button = await readAttempt(); 187 | } 188 | 189 | if (!button) throw new Error(`Token ${tokenSymbol} not found`); 190 | 191 | const text = await button.textContent(); 192 | const currencyAmount = text.replaceAll(/ |,/g, '').split(tokenSymbol)[2]; 193 | 194 | return currencyAmount ? Number(currencyAmount) : 0; 195 | }; 196 | 197 | export const countAccounts = (page: Page) => async (): Promise => { 198 | await page.getByTestId('wallet-switcher--dropdown').click(); 199 | const count = await page.locator('//*[@data-testid="wallet-switcher--dropdown"]/*/*[2]/*').count(); 200 | await page.getByTestId('wallet-switcher--dropdown').click(); 201 | return count; 202 | }; 203 | 204 | export const createAccount = 205 | (page: Page) => 206 | async (name?: string): Promise => { 207 | if (name) { 208 | // eslint-disable-next-line no-console 209 | console.warn('parameter "name" is not supported for Coinbase'); 210 | } 211 | 212 | await page.getByTestId('portfolio-header--switcher-cell-pressable').click(); 213 | await page.getByTestId('wallet-switcher--manage').click(); 214 | await page.getByTestId('manage-wallets-account-item--action-cell-pressable').click(); 215 | 216 | // Help prompt appears once 217 | try { 218 | await page.getByTestId('add-new-wallet--continue').click({ timeout: 2000 }); 219 | } catch { 220 | // Ignore missing help prompt 221 | } 222 | 223 | await waitForChromeState(page); 224 | }; 225 | 226 | export const switchAccount = 227 | (page: Page) => 228 | async (name: string): Promise => { 229 | await page.getByTestId('portfolio-header--switcher-cell-pressable').click(); 230 | 231 | const nameRegex = new RegExp(`${name} \\$`); 232 | await page.getByRole('button', { name: nameRegex }).click(); 233 | }; 234 | 235 | // 236 | // Unimplemented actions 237 | // 238 | 239 | export const deleteAccount = async (_: string): Promise => { 240 | // eslint-disable-next-line no-console 241 | console.warn('deleteAccount not implemented - Coinbase does not support importing/removing additional private keys'); 242 | }; 243 | 244 | export const addToken = async (_: AddToken): Promise => { 245 | // eslint-disable-next-line no-console 246 | console.warn('addToken not implemented - Coinbase does not support adding custom tokens'); 247 | }; 248 | 249 | export const importPK = async (_: string): Promise => { 250 | // eslint-disable-next-line no-console 251 | console.warn('importPK not implemented - Coinbase does not support importing/removing private keys'); 252 | }; 253 | 254 | export const switchNetwork = async (_: string): Promise => { 255 | // eslint-disable-next-line no-console 256 | console.warn('switchNetwork not implemented'); 257 | }; 258 | 259 | // TODO: Cannot implement until verified coinbase wallet bug is fixed. 260 | export const confirmNetworkSwitch = async (): Promise => { 261 | // eslint-disable-next-line no-console 262 | console.warn('confirmNetorkSwitch not implemented'); 263 | }; 264 | 265 | export const updateNetworkRpc = async (_: UpdateNetworkRpc): Promise => { 266 | // eslint-disable-next-line no-console 267 | console.warn('updateNetworkRpc not implemented - Coinbase uses different network management'); 268 | }; 269 | -------------------------------------------------------------------------------- /src/downloader/downloader.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for downloader.ts 3 | */ 4 | import fs from 'fs'; 5 | import os from 'os'; 6 | import path from 'path'; 7 | import { afterEach, beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; 8 | import { OfficialOptions } from '../types'; 9 | import { DOWNLOAD_CONFIG, DOWNLOAD_STATE_FILES } from './constants'; 10 | import createWalletDownloader from './downloader'; 11 | import { downloadDir } from './file'; 12 | 13 | // Mock external dependencies 14 | vi.mock('./github', () => ({ 15 | getGithubRelease: vi.fn(), 16 | downloadGithubRelease: vi.fn(), 17 | })); 18 | 19 | vi.mock('./file', () => ({ 20 | downloadDir: vi.fn(), 21 | editExtensionPubKey: vi.fn(), 22 | extractZip: vi.fn(), 23 | })); 24 | 25 | vi.mock('./version', () => ({ 26 | printVersion: vi.fn(), 27 | })); 28 | 29 | // Import mocked functions 30 | import { editExtensionPubKey, extractZip } from './file'; 31 | import { downloadGithubRelease, getGithubRelease } from './github'; 32 | import { printVersion } from './version'; 33 | 34 | // Create typed mocks 35 | const mockGetGithubRelease = getGithubRelease as MockedFunction; 36 | const mockDownloadGithubRelease = downloadGithubRelease as MockedFunction; 37 | const mockEditExtensionPubKey = editExtensionPubKey as MockedFunction; 38 | const mockExtractZip = extractZip as MockedFunction; 39 | const mockPrintVersion = printVersion as MockedFunction; 40 | const mockDownloadDir = downloadDir as MockedFunction; 41 | 42 | describe('createWalletDownloader', () => { 43 | let testDir: string; 44 | const mockWalletId = 'metamask'; 45 | const mockVersion = '12.16.0'; 46 | const mockReleasesUrl = 'https://api.github.com/repos/MetaMask/metamask-extension/releases'; 47 | const mockRecommendedVersion = '12.16.0'; 48 | 49 | // Helper function to create a unique test directory 50 | const createTestDir = (): string => { 51 | const dir = path.join(os.tmpdir(), 'dappwright-test', Date.now().toString(), Math.random().toString(36)); 52 | fs.mkdirSync(dir, { recursive: true }); 53 | return dir; 54 | }; 55 | 56 | // Helper function to clean up test directory 57 | const cleanupTestDir = (dir: string): void => { 58 | if (fs.existsSync(dir)) { 59 | fs.rmSync(dir, { recursive: true, force: true }); 60 | } 61 | }; 62 | 63 | // Helper function to create state files 64 | const createStateFile = (dir: string, fileName: string, content = ''): void => { 65 | fs.writeFileSync(path.join(dir, fileName), content); 66 | }; 67 | 68 | beforeEach(() => { 69 | testDir = createTestDir(); 70 | mockDownloadDir.mockReturnValue(testDir); 71 | 72 | // Reset all mocks 73 | vi.clearAllMocks(); 74 | 75 | // Reset environment variables 76 | delete process.env.TEST_PARALLEL_INDEX; 77 | }); 78 | 79 | afterEach(() => { 80 | cleanupTestDir(testDir); 81 | }); 82 | 83 | describe('factory function behavior', () => { 84 | it('should create a downloader function', () => { 85 | const downloader = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 86 | expect(typeof downloader).toBe('function'); 87 | }); 88 | 89 | it('should return a function that accepts OfficialOptions', async () => { 90 | const downloader = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 91 | const options: OfficialOptions = { 92 | wallet: 'metamask', 93 | version: '', 94 | }; 95 | 96 | const result = await downloader(options); 97 | 98 | expect(typeof result).toBe('string'); 99 | expect(result).toBe(testDir); 100 | }); 101 | }); 102 | 103 | describe('local build scenario', () => { 104 | it('should handle empty version string', async () => { 105 | const downloader = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 106 | const options: OfficialOptions = { 107 | wallet: 'metamask', 108 | version: '', 109 | }; 110 | 111 | const result = await downloader(options); 112 | 113 | expect(result).toBe(testDir); 114 | expect(mockPrintVersion).not.toHaveBeenCalled(); 115 | expect(mockGetGithubRelease).not.toHaveBeenCalled(); 116 | }); 117 | 118 | it('should handle undefined version', async () => { 119 | const downloader = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 120 | const options: OfficialOptions = { 121 | wallet: 'metamask', 122 | version: undefined, 123 | }; 124 | 125 | const result = await downloader(options); 126 | 127 | expect(result).toBe(testDir); 128 | }); 129 | }); 130 | 131 | describe('primary worker download scenario', () => { 132 | beforeEach(() => { 133 | process.env.TEST_PARALLEL_INDEX = '0'; 134 | }); 135 | 136 | it('should perform download when primary worker and download not complete', async () => { 137 | const downloader = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 138 | const options: OfficialOptions = { 139 | wallet: 'metamask', 140 | version: mockVersion, 141 | }; 142 | 143 | mockGetGithubRelease.mockResolvedValue({ 144 | filename: 'metamask-chrome-12.16.0.zip', 145 | downloadUrl: 146 | 'https://github.com/MetaMask/metamask-extension/releases/download/v12.16.0/metamask-chrome-12.16.0.zip', 147 | tag: 'v12.16.0', 148 | }); 149 | mockDownloadGithubRelease.mockResolvedValue('/tmp/metamask-chrome-12.16.0.zip'); 150 | 151 | const result = await downloader(options); 152 | 153 | expect(result).toBe(testDir); 154 | expect(mockPrintVersion).toHaveBeenCalledWith(mockWalletId, mockVersion, mockRecommendedVersion); 155 | expect(mockGetGithubRelease).toHaveBeenCalledWith(mockReleasesUrl, '12.16.0'); 156 | expect(mockDownloadGithubRelease).toHaveBeenCalled(); 157 | expect(mockExtractZip).toHaveBeenCalled(); 158 | expect(mockEditExtensionPubKey).toHaveBeenCalledWith(testDir); 159 | 160 | // Verify success file was created 161 | const successFile = path.join(testDir, DOWNLOAD_STATE_FILES.success); 162 | expect(fs.existsSync(successFile)).toBe(true); 163 | }); 164 | 165 | it('should skip download if already complete', async () => { 166 | // Create success file to simulate completed download 167 | createStateFile(testDir, DOWNLOAD_STATE_FILES.success); 168 | 169 | const downloader = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 170 | const options: OfficialOptions = { 171 | wallet: 'metamask', 172 | version: mockVersion, 173 | }; 174 | 175 | const result = await downloader(options); 176 | 177 | expect(result).toBe(testDir); 178 | // printVersion is not called when download is already complete 179 | expect(mockPrintVersion).not.toHaveBeenCalled(); 180 | expect(mockGetGithubRelease).not.toHaveBeenCalled(); 181 | }); 182 | 183 | it('should handle download errors properly', async () => { 184 | const downloader = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 185 | const options: OfficialOptions = { 186 | wallet: 'metamask', 187 | version: mockVersion, 188 | }; 189 | 190 | mockGetGithubRelease.mockRejectedValue(new Error('Network error')); 191 | 192 | await expect(downloader(options)).rejects.toThrow('Network error'); 193 | 194 | // Verify error file was created 195 | const errorFile = path.join(testDir, DOWNLOAD_STATE_FILES.error); 196 | expect(fs.existsSync(errorFile)).toBe(true); 197 | expect(fs.readFileSync(errorFile, 'utf-8')).toBe('Network error'); 198 | 199 | // Verify downloading flag was cleaned up 200 | const downloadingFile = path.join(testDir, DOWNLOAD_STATE_FILES.downloading); 201 | expect(fs.existsSync(downloadingFile)).toBe(false); 202 | }); 203 | 204 | it('should handle extraction errors', async () => { 205 | const downloader = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 206 | const options: OfficialOptions = { 207 | wallet: 'metamask', 208 | version: mockVersion, 209 | }; 210 | 211 | mockGetGithubRelease.mockResolvedValue({ 212 | filename: 'test.zip', 213 | downloadUrl: 'https://example.com/test.zip', 214 | tag: 'v12.16.0', 215 | }); 216 | mockDownloadGithubRelease.mockResolvedValue('/tmp/test.zip'); 217 | mockExtractZip.mockRejectedValue(new Error('Extraction failed')); 218 | 219 | await expect(downloader(options)).rejects.toThrow('Extraction failed'); 220 | 221 | const errorFile = path.join(testDir, DOWNLOAD_STATE_FILES.error); 222 | expect(fs.existsSync(errorFile)).toBe(true); 223 | expect(fs.readFileSync(errorFile, 'utf-8')).toBe('Extraction failed'); 224 | }); 225 | }); 226 | 227 | describe('secondary worker scenario', () => { 228 | beforeEach(() => { 229 | process.env.TEST_PARALLEL_INDEX = '1'; 230 | }); 231 | 232 | it('should wait for download completion', async () => { 233 | const downloader = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 234 | const options: OfficialOptions = { 235 | wallet: 'metamask', 236 | version: mockVersion, 237 | }; 238 | 239 | // Simulate download completion after some time 240 | setTimeout(() => { 241 | createStateFile(testDir, DOWNLOAD_STATE_FILES.success); 242 | }, 100); 243 | 244 | const result = await downloader(options); 245 | 246 | expect(result).toBe(testDir); 247 | expect(mockGetGithubRelease).not.toHaveBeenCalled(); 248 | }); 249 | 250 | it('should throw error when primary worker fails', async () => { 251 | const downloader = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 252 | const options: OfficialOptions = { 253 | wallet: 'metamask', 254 | version: mockVersion, 255 | }; 256 | 257 | // Create error file to simulate failed download 258 | createStateFile(testDir, DOWNLOAD_STATE_FILES.error, 'Download failed: Network timeout'); 259 | 260 | await expect(downloader(options)).rejects.toThrow( 261 | 'Primary worker failed to download metamask: Download failed: Network timeout', 262 | ); 263 | }); 264 | 265 | it('should handle corrupted error file', async () => { 266 | const downloader = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 267 | const options: OfficialOptions = { 268 | wallet: 'metamask', 269 | version: mockVersion, 270 | }; 271 | 272 | // Create error file with corrupted content that can't be read properly 273 | const errorFile = path.join(testDir, DOWNLOAD_STATE_FILES.error); 274 | fs.writeFileSync(errorFile, Buffer.from([0xff, 0xfe])); 275 | 276 | // Make the file unreadable by changing permissions (if supported) 277 | try { 278 | fs.chmodSync(errorFile, 0o000); 279 | 280 | await expect(downloader(options)).rejects.toThrow( 281 | 'Primary worker failed to download metamask: Unknown error occurred during download', 282 | ); 283 | 284 | // Restore permissions for cleanup 285 | fs.chmodSync(errorFile, 0o644); 286 | } catch { 287 | // Skip test on systems that don't support chmod 288 | return; 289 | } 290 | }); 291 | }); 292 | 293 | describe('file system operations', () => { 294 | beforeEach(() => { 295 | process.env.TEST_PARALLEL_INDEX = '0'; 296 | }); 297 | 298 | it('should prepare root directory correctly', async () => { 299 | // Create some existing content 300 | const existingFile = path.join(testDir, 'existing.txt'); 301 | fs.writeFileSync(existingFile, 'existing content'); 302 | expect(fs.existsSync(existingFile)).toBe(true); 303 | 304 | const downloader = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 305 | const options: OfficialOptions = { 306 | wallet: 'metamask', 307 | version: mockVersion, 308 | }; 309 | 310 | mockGetGithubRelease.mockResolvedValue({ 311 | filename: 'test.zip', 312 | downloadUrl: 'https://example.com/test.zip', 313 | tag: 'v12.16.0', 314 | }); 315 | mockDownloadGithubRelease.mockResolvedValue('/tmp/test.zip'); 316 | mockExtractZip.mockResolvedValue(); 317 | 318 | await downloader(options); 319 | 320 | // Directory should exist but existing file should be removed during preparation 321 | expect(fs.existsSync(testDir)).toBe(true); 322 | // Since prepareRootDir removes and recreates the directory, existing file won't exist 323 | expect(fs.existsSync(existingFile)).toBe(false); 324 | }); 325 | 326 | it('should create directory if it does not exist', async () => { 327 | const nonExistentDir = path.join(testDir, 'nested', 'directory'); 328 | mockDownloadDir.mockReturnValue(nonExistentDir); 329 | 330 | const downloader = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 331 | const options: OfficialOptions = { 332 | wallet: 'metamask', 333 | version: mockVersion, 334 | }; 335 | 336 | mockGetGithubRelease.mockResolvedValue({ 337 | filename: 'test.zip', 338 | downloadUrl: 'https://example.com/test.zip', 339 | tag: 'v12.16.0', 340 | }); 341 | mockDownloadGithubRelease.mockResolvedValue('/tmp/test.zip'); 342 | mockExtractZip.mockResolvedValue(); 343 | 344 | await downloader(options); 345 | 346 | expect(fs.existsSync(nonExistentDir)).toBe(true); 347 | 348 | // Cleanup the created directory 349 | cleanupTestDir(nonExistentDir); 350 | }); 351 | 352 | it('should cleanup temporary files on success', async () => { 353 | const downloader = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 354 | const options: OfficialOptions = { 355 | wallet: 'metamask', 356 | version: mockVersion, 357 | }; 358 | 359 | // Pre-create error file 360 | createStateFile(testDir, DOWNLOAD_STATE_FILES.error, 'old error'); 361 | 362 | mockGetGithubRelease.mockResolvedValue({ 363 | filename: 'test.zip', 364 | downloadUrl: 'https://example.com/test.zip', 365 | tag: 'v12.16.0', 366 | }); 367 | mockDownloadGithubRelease.mockResolvedValue('/tmp/test.zip'); 368 | mockExtractZip.mockResolvedValue(); 369 | 370 | await downloader(options); 371 | 372 | // Success file should exist, error file should be cleaned up 373 | const successFile = path.join(testDir, DOWNLOAD_STATE_FILES.success); 374 | const errorFile = path.join(testDir, DOWNLOAD_STATE_FILES.error); 375 | const downloadingFile = path.join(testDir, DOWNLOAD_STATE_FILES.downloading); 376 | 377 | expect(fs.existsSync(successFile)).toBe(true); 378 | expect(fs.existsSync(errorFile)).toBe(false); 379 | expect(fs.existsSync(downloadingFile)).toBe(false); 380 | }); 381 | }); 382 | 383 | describe('edge cases', () => { 384 | it('should handle different wallet types', async () => { 385 | const coinbaseDownloader = createWalletDownloader('coinbase', mockReleasesUrl, '3.123.0'); 386 | const options: OfficialOptions = { 387 | wallet: 'coinbase', 388 | version: '', 389 | }; 390 | 391 | const result = await coinbaseDownloader(options); 392 | 393 | expect(result).toBe(testDir); 394 | }); 395 | 396 | it('should handle non-string error objects', async () => { 397 | process.env.TEST_PARALLEL_INDEX = '0'; 398 | 399 | const downloader = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 400 | const options: OfficialOptions = { 401 | wallet: 'metamask', 402 | version: mockVersion, 403 | }; 404 | 405 | // Mock rejection with non-Error object 406 | mockGetGithubRelease.mockRejectedValue({ code: 'NETWORK_ERROR', details: 'Connection failed' }); 407 | 408 | await expect(downloader(options)).rejects.toEqual({ code: 'NETWORK_ERROR', details: 'Connection failed' }); 409 | 410 | // Verify error file contains string representation 411 | const errorFile = path.join(testDir, DOWNLOAD_STATE_FILES.error); 412 | expect(fs.existsSync(errorFile)).toBe(true); 413 | expect(fs.readFileSync(errorFile, 'utf-8')).toBe('[object Object]'); 414 | }); 415 | 416 | it('should handle very long polling scenario', async () => { 417 | process.env.TEST_PARALLEL_INDEX = '1'; 418 | 419 | const downloader = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 420 | const options: OfficialOptions = { 421 | wallet: 'metamask', 422 | version: mockVersion, 423 | }; 424 | 425 | // Mock a longer delay before marking as complete 426 | setTimeout(() => { 427 | createStateFile(testDir, DOWNLOAD_STATE_FILES.success); 428 | }, DOWNLOAD_CONFIG.pollIntervalMs + 500); 429 | 430 | const result = await downloader(options); 431 | 432 | expect(result).toBe(testDir); 433 | }, 11000); // Increased timeout to 10 seconds 434 | }); 435 | 436 | describe('concurrent execution', () => { 437 | it('should handle multiple downloaders running simultaneously', async () => { 438 | process.env.TEST_PARALLEL_INDEX = '0'; 439 | 440 | const downloader1 = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 441 | const downloader2 = createWalletDownloader(mockWalletId, mockReleasesUrl, mockRecommendedVersion); 442 | 443 | const options: OfficialOptions = { 444 | wallet: 'metamask', 445 | version: mockVersion, 446 | }; 447 | 448 | mockGetGithubRelease.mockResolvedValue({ 449 | filename: 'test.zip', 450 | downloadUrl: 'https://example.com/test.zip', 451 | tag: 'v12.16.0', 452 | }); 453 | mockDownloadGithubRelease.mockResolvedValue('/tmp/test.zip'); 454 | mockExtractZip.mockResolvedValue(); 455 | 456 | // Run both downloaders simultaneously 457 | const [result1, result2] = await Promise.all([downloader1(options), downloader2(options)]); 458 | 459 | expect(result1).toBe(testDir); 460 | expect(result2).toBe(testDir); 461 | }); 462 | }); 463 | }); 464 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.12.0 4 | 5 | ### Minor Changes 6 | 7 | - [#500](https://github.com/TenKeyLabs/dappwright/pull/500) [`2d177c3`](https://github.com/TenKeyLabs/dappwright/commit/2d177c35a553de36e10632f4a4a8e427bad77db0) Thanks [@arthurgeron](https://github.com/arthurgeron)! - Add `updateNetworkRpc` method to update RPC URLs for existing networks in MetaMask 8 | 9 | ## 2.11.4 10 | 11 | ### Patch Changes 12 | 13 | - [#508](https://github.com/TenKeyLabs/dappwright/pull/508) [`b625cce`](https://github.com/TenKeyLabs/dappwright/commit/b625ccea310109730d57b2c491a3a33c4ece3da8) Thanks [@osis](https://github.com/osis)! - fix: larger pagination window for github releases 14 | 15 | - [#509](https://github.com/TenKeyLabs/dappwright/pull/509) [`026382d`](https://github.com/TenKeyLabs/dappwright/commit/026382de2cf356a2394d8c156145545195610304) Thanks [@osis](https://github.com/osis)! - chore: bumps Metamask to 12.23.1 16 | 17 | ## 2.11.3 18 | 19 | ### Patch Changes 20 | 21 | - [#479](https://github.com/TenKeyLabs/dappwright/pull/479) [`1bc09ab`](https://github.com/TenKeyLabs/dappwright/commit/1bc09ab22dbe8eea50363762de1e5ebb5c2f9998) Thanks [@osis](https://github.com/osis)! - chore: downloader state files with tests 22 | 23 | ## 2.11.2 24 | 25 | ### Patch Changes 26 | 27 | - [#474](https://github.com/TenKeyLabs/dappwright/pull/474) [`80e7586`](https://github.com/TenKeyLabs/dappwright/commit/80e7586a033fa75875f5bd897b290745cc61a015) Thanks [@osis](https://github.com/osis)! - chore: adjusts the matching for downloaded versions more explicit 28 | 29 | - [#477](https://github.com/TenKeyLabs/dappwright/pull/477) [`b86e542`](https://github.com/TenKeyLabs/dappwright/commit/b86e542310a9453311bb43df475bf84cfaadc20c) Thanks [@osis](https://github.com/osis)! - chore: bumps metamask to 12.23.0 30 | 31 | - [#475](https://github.com/TenKeyLabs/dappwright/pull/475) [`8ddcca0`](https://github.com/TenKeyLabs/dappwright/commit/8ddcca0a211e04ea31bf946ac5397d7bb5d8b441) Thanks [@osis](https://github.com/osis)! - chore: bumps coinbase to v3.123.0 32 | 33 | - [#476](https://github.com/TenKeyLabs/dappwright/pull/476) [`d7d8c58`](https://github.com/TenKeyLabs/dappwright/commit/d7d8c58b2938a9c9a253b86684e38ccb1dbb5cd6) Thanks [@osis](https://github.com/osis)! - fix: moves multi-tab listener before first getWallet call 34 | 35 | ## 2.11.1 36 | 37 | ### Patch Changes 38 | 39 | - [#471](https://github.com/TenKeyLabs/dappwright/pull/471) [`fa554e4`](https://github.com/TenKeyLabs/dappwright/commit/fa554e4f8473228364e788a03bded3156c9b0cc2) Thanks [@osis](https://github.com/osis)! - chore(metamask): opt to click close buttons instead of the metamask logo to return home 40 | 41 | ## 2.11.0 42 | 43 | ### Minor Changes 44 | 45 | - [#459](https://github.com/TenKeyLabs/dappwright/pull/459) [`f70ebd4`](https://github.com/TenKeyLabs/dappwright/commit/f70ebd474e43429c47d75bf024ef55a68e6803d2) Thanks [@osis](https://github.com/osis)! - chore: bumps supported node version to >= 20 46 | 47 | ## 2.10.2 48 | 49 | ### Patch Changes 50 | 51 | - [#449](https://github.com/TenKeyLabs/dappwright/pull/449) [`a3560a8`](https://github.com/TenKeyLabs/dappwright/commit/a3560a8977ea204df0789885e6b6ee567727f5aa) Thanks [@osis](https://github.com/osis)! - fix(metamask): race condition can lead to acquiring dormant page context 52 | 53 | ## 2.10.1 54 | 55 | ### Patch Changes 56 | 57 | - [#444](https://github.com/TenKeyLabs/dappwright/pull/444) [`918c779`](https://github.com/TenKeyLabs/dappwright/commit/918c779be0156871270afb92e6967f1ff09ea293) Thanks [@osis](https://github.com/osis)! - chore: bumps metamask to 12.16.0 58 | 59 | - [#446](https://github.com/TenKeyLabs/dappwright/pull/446) [`3c0a444`](https://github.com/TenKeyLabs/dappwright/commit/3c0a444e70b0e6411c7df75febe4a76bf4dfbdab) Thanks [@osis](https://github.com/osis)! - chore: clearer worker downloader logging 60 | 61 | ## 2.10.0 62 | 63 | ### Minor Changes 64 | 65 | - [#440](https://github.com/TenKeyLabs/dappwright/pull/440) [`9fa6543`](https://github.com/TenKeyLabs/dappwright/commit/9fa6543b39d13ea16ed2dc2729493fc57f2268b6) Thanks [@osis](https://github.com/osis)! - feat: support for full names when creating/switching/deleting accounts 66 | 67 | ## 2.9.4 68 | 69 | ### Patch Changes 70 | 71 | - [#437](https://github.com/TenKeyLabs/dappwright/pull/437) [`ebf5315`](https://github.com/TenKeyLabs/dappwright/commit/ebf5315c018cdb6624e058d92bb74657d3cfed24) Thanks [@osis](https://github.com/osis)! - fix(metamask): scroll account menu button into view before clicking 72 | 73 | ## 2.9.3 74 | 75 | ### Patch Changes 76 | 77 | - [#432](https://github.com/TenKeyLabs/dappwright/pull/432) [`186dcf0`](https://github.com/TenKeyLabs/dappwright/pull/432/commits/186dcf07ca990bc958cd02e1d05019714de0f7b1) Thanks [@osis](https://github.com/osis)! - chore: downloader supports multiple workers 78 | - [#431](https://github.com/TenKeyLabs/dappwright/pull/431) [`813eb68`](https://github.com/TenKeyLabs/dappwright/commit/813eb68a9694d92475e97b764bbcb35942c394ca) Thanks [@osis](https://github.com/osis)! - chore(coinbase): bump to 3.109.0 79 | 80 | ## 2.9.2 81 | 82 | ### Patch Changes 83 | 84 | - [#429](https://github.com/TenKeyLabs/dappwright/pull/429) [`9ba53a5`](https://github.com/TenKeyLabs/dappwright/commit/9ba53a5c65865a6fb8fb039a398de4a111d8581a) Thanks [@osis](https://github.com/osis)! - parallel test & tracing support 85 | - chore(coinbase): wait for chrome state to settle after adding a network 86 | - chore: use the first page as the main page for extension navigation so no blank pages are lingering 87 | 88 | - [#428](https://github.com/TenKeyLabs/dappwright/pull/428) [`4ae4ed8`](https://github.com/TenKeyLabs/dappwright/commit/4ae4ed832e56703121109fe265ca3ad0fc48dbf3) Thanks [@osis](https://github.com/osis)! - bumps metamask to 12.14.2 89 | - chore(metamask): updates regex for matching token balances 90 | - fix(metamask): getErrorMessage now returns the error message instead of a locator 91 | 92 | ## 2.9.1 93 | 94 | ### Patch Changes 95 | 96 | - [#402](https://github.com/TenKeyLabs/dappwright/pull/402) [`096d4b2`](https://github.com/TenKeyLabs/dappwright/commit/096d4b21d2578aae809a1453746a383ff4641ed8) Thanks [@osis](https://github.com/osis)! - chore: bumps Coinbase to 3.96.0 and implements new token balance error handling 97 | 98 | ## 2.9.0 99 | 100 | ### Minor Changes 101 | 102 | - [#400](https://github.com/TenKeyLabs/dappwright/pull/400) [`adc3e41`](https://github.com/TenKeyLabs/dappwright/commit/adc3e410c9884dcb5ed985e09b34bb671c182152) Thanks [@osis](https://github.com/osis)! - feature: implements the ability to pull specified token balances 103 | feature: unfound specified token balances now throw an error 104 | chore: bumps ethers to 6.13.4 for the test dapp for compatibility 105 | chore: test dapp now has two network switch buttons for convenience 106 | chore: metamask - several button click target adjustments for changes in the UI 107 | chore: metamask - supports for the newer mechanism for adding a network 108 | chore: metamask - local test network details updated to avoid validation errors in the add network form. "Localhost 8545" is now "GoChain Testnet" 109 | 110 | ## 2.8.6 111 | 112 | ### Patch Changes 113 | 114 | - [`8ff1efa`](https://github.com/TenKeyLabs/dappwright/commit/8ff1efa4172d14d8900ef9d4f562f4549fbb0691) Thanks [@iankressin](https://github.com/iankressin)! - fix: fixes the signIn action for MetaMask to support SIWE-compliant messages 115 | 116 | ## 2.8.5 117 | 118 | ### Patch Changes 119 | 120 | - [#347](https://github.com/TenKeyLabs/dappwright/pull/347) [`667c39f`](https://github.com/TenKeyLabs/dappwright/commit/667c39fc943e5fa6db914dc76a70de5f13fab5ab) Thanks [@osis](https://github.com/osis)! - bumps metamask to 11.16.13 121 | 122 | ## 2.8.4 123 | 124 | ### Patch Changes 125 | 126 | - [#330](https://github.com/TenKeyLabs/dappwright/pull/330) [`e198acb`](https://github.com/TenKeyLabs/dappwright/commit/e198acb6dd5296f7e868ed036b303cf27d6e4e71) Thanks [@lushunming](https://github.com/lushunming)! - adds flag that sets chromium to US-en 127 | 128 | ## 2.8.3 129 | 130 | ### Patch Changes 131 | 132 | - [#339](https://github.com/TenKeyLabs/dappwright/pull/339) [`9253c67`](https://github.com/TenKeyLabs/dappwright/commit/9253c676b82a91bc16f3713b84a466a74bb33914) Thanks [@osis](https://github.com/osis)! - chore: bumps coinbase wallet to 3.70.0 133 | 134 | - [#338](https://github.com/TenKeyLabs/dappwright/pull/338) [`7ecd56e`](https://github.com/TenKeyLabs/dappwright/commit/7ecd56e20e8dd8360ceb64c09b6a643742f966f8) Thanks [@osis](https://github.com/osis)! - chore: bumps MetaMask to 11.16.3 135 | 136 | ## 2.8.2 137 | 138 | ### Patch Changes 139 | 140 | - [#312](https://github.com/TenKeyLabs/dappwright/pull/312) [`14f38d9`](https://github.com/TenKeyLabs/dappwright/commit/14f38d938fa89790a1ad281a8ff21d97af750557) Thanks [@osis](https://github.com/osis)! - Bumps Coinbase Wallet to 3.54.0 141 | 142 | - [#314](https://github.com/TenKeyLabs/dappwright/pull/314) [`16cf86e`](https://github.com/TenKeyLabs/dappwright/commit/16cf86e371080a9d6063d591dca7bee41eb7c168) Thanks [@osis](https://github.com/osis)! - Bumps MetaMask to 11.10.0 143 | 144 | ## 2.8.1 145 | 146 | ### Minor Changes 147 | 148 | - [#303](https://github.com/TenKeyLabs/dappwright/pull/303) [`8bed417`](https://github.com/TenKeyLabs/dappwright/commit/8bed41719b5be9aa0c421b5bd77700e4242de85d) Thanks [@IGoRFonin](https://github.com/IGoRFonin)! - feat: 🎸 update metamask version to 2.7.5 149 | 150 | ## 2.8.0 151 | 152 | ### Minor Changes 153 | 154 | - [#293](https://github.com/TenKeyLabs/dappwright/pull/293) [`9c255dc`](https://github.com/TenKeyLabs/dappwright/commit/9c255dcbc18c21888442923af814d3c24a1e1fc0) Thanks [@IGoRFonin](https://github.com/IGoRFonin)! - feat: 🎸 adds new parameter to the bootstrap interface that enables additional extensions to be loaded 155 | 156 | ## 2.7.2 157 | 158 | ### Patch Changes 159 | 160 | - [#291](https://github.com/TenKeyLabs/dappwright/pull/291) [`3027d3f`](https://github.com/TenKeyLabs/dappwright/commit/3027d3fae7f3698bd6b21be9ed892dabba45ef56) Thanks [@IGoRFonin](https://github.com/IGoRFonin)! - fix: 🐛 metamask add network got it popup 161 | 162 | ## 2.7.1 163 | 164 | ### Patch Changes 165 | 166 | - [#282](https://github.com/TenKeyLabs/dappwright/pull/282) [`b83d0ea9342989cca00de63014a010ff5b370dbf`](https://github.com/TenKeyLabs/dappwright/commit/b83d0ea9342989cca00de63014a010ff5b370dbf) Thanks [@osis](https://github.com/osis)! - fix: wallet interface includes reject 167 | 168 | ## 2.7.0 169 | 170 | ### Minor Changes 171 | 172 | - [#280](https://github.com/TenKeyLabs/dappwright/pull/280) [`9e67323110628660007740e31ecd4940811898ff`](https://github.com/TenKeyLabs/dappwright/commit/9e67323110628660007740e31ecd4940811898ff) Thanks [@osis](https://github.com/osis)! - feat: adds reject action for metamask and coinbase wallet 173 | 174 | ## 2.6.0 175 | 176 | ### Minor Changes 177 | 178 | - [#279](https://github.com/TenKeyLabs/dappwright/pull/279) [`7041e0970b190b4bdeac9c07316f53218116b84a`](https://github.com/TenKeyLabs/dappwright/commit/7041e0970b190b4bdeac9c07316f53218116b84a) Thanks [@osis](https://github.com/osis)! - feat: adds support for signin actions 179 | 180 | ### Patch Changes 181 | 182 | - [#277](https://github.com/TenKeyLabs/dappwright/pull/277) [`7f9cbd1e4628b6f390ea75b741ed300a13abe423`](https://github.com/TenKeyLabs/dappwright/commit/7f9cbd1e4628b6f390ea75b741ed300a13abe423) Thanks [@osis](https://github.com/osis)! - fix: Coinbase network settings menu selector 183 | 184 | ## 2.5.5 185 | 186 | ### Patch Changes 187 | 188 | - [#260](https://github.com/TenKeyLabs/dappwright/pull/260) [`8f34c0f`](https://github.com/TenKeyLabs/dappwright/commit/8f34c0f270d64ce4655da63a5e73700d93c6c243) Thanks [@osis](https://github.com/osis)! - fixes network confirmation action for metamsk 189 | 190 | ## 2.5.4 191 | 192 | ### Patch Changes 193 | 194 | - [#248](https://github.com/TenKeyLabs/dappwright/pull/248) [`d3e29a0`](https://github.com/TenKeyLabs/dappwright/commit/d3e29a0a6e9de40cd332c4830f1bdbc2093cf330) Thanks [@osis](https://github.com/osis)! - fix: metamask not settling on the homescreen after settings adjustments 195 | 196 | ## 2.5.3 197 | 198 | ### Patch Changes 199 | 200 | - [#242](https://github.com/TenKeyLabs/dappwright/pull/242) [`2fb8624`](https://github.com/TenKeyLabs/dappwright/commit/2fb862406e013ab6c37760bf55e253c567870431) Thanks [@osis](https://github.com/osis)! - fix: scope of switch account selector to the menu dialog 201 | 202 | ## 2.5.2 203 | 204 | ### Patch Changes 205 | 206 | - [#244](https://github.com/TenKeyLabs/dappwright/pull/244) [`f270313`](https://github.com/TenKeyLabs/dappwright/commit/f270313681fe900a1a02811650f015e51d562de3) Thanks [@osis](https://github.com/osis)! - chore: bumps coinbase wallet to 3.42.0 207 | 208 | ## 2.5.1 209 | 210 | ### Patch Changes 211 | 212 | - [#241](https://github.com/TenKeyLabs/dappwright/pull/241) [`521923a`](https://github.com/TenKeyLabs/dappwright/commit/521923a9e3d8ad2853f4a283467cdfa05713f30a) Thanks [@osis](https://github.com/osis)! - fix: exact text matches for switching networks 213 | 214 | ## 2.5.0 215 | 216 | ### Minor Changes 217 | 218 | - [#225](https://github.com/TenKeyLabs/dappwright/pull/225) [`7dc3279`](https://github.com/TenKeyLabs/dappwright/commit/7dc327945090baed94f3dba9111b93752df15bc2) Thanks [@panteo](https://github.com/panteo)! - Add support for parallel testing. 219 | 220 | ## 2.4.1 221 | 222 | ### Patch Changes 223 | 224 | - [#228](https://github.com/TenKeyLabs/dappwright/pull/228) [`10862b0`](https://github.com/TenKeyLabs/dappwright/commit/10862b0d45075515cd83d8c5f4acab96718909f3) Thanks [@osis](https://github.com/osis)! - feat: support for MetaMask 11.3 225 | 226 | ## 2.4.0 227 | 228 | ### Minor Changes 229 | 230 | - [#193](https://github.com/TenKeyLabs/dappwright/pull/193) [`89ee48d`](https://github.com/TenKeyLabs/dappwright/commit/89ee48df8873793e7f55499de95084fb4d61fa8c) Thanks [@osis](https://github.com/osis)! - feat: Adds countAccounts to the wallet interface 231 | 232 | ### Patch Changes 233 | 234 | - [#191](https://github.com/TenKeyLabs/dappwright/pull/191) [`89ee48d`](https://github.com/TenKeyLabs/dappwright/commit/e14b814c6606072150cd184a47cd941f4dbb5865) Thanks [@erin-at-work](https://github.com/erin-at-work)! - fix: Update input name in coinbase wallet onboarding flow 235 | 236 | - [#194](https://github.com/TenKeyLabs/dappwright/pull/194) [`9169864`](https://github.com/TenKeyLabs/dappwright/commit/9169864ccc379d298a741510f3686f71f917f88c) Thanks [@osis](https://github.com/osis)! - feat: Adds countAccounts to the wallet interface 237 | 238 | ## 2.3.4 239 | 240 | ### Patch Changes 241 | 242 | - [#175](https://github.com/TenKeyLabs/dappwright/pull/175) [`286fb35`](https://github.com/TenKeyLabs/dappwright/commit/286fb3528c1ddbaa6fc566d70afe38b349daa1e2) Thanks [@dependabot](https://github.com/apps/dependabot)! - chore: bump @playwright/test from 1.35.0 to 1.35.1 243 | 244 | ## 2.3.3 245 | 246 | ### Patch Changes 247 | 248 | - [#171](https://github.com/TenKeyLabs/dappwright/pull/171) [`aec40c6`](https://github.com/TenKeyLabs/dappwright/commit/aec40c671a3747e2238dac8677df7a442b46c41d) Thanks [@dependabot](https://github.com/apps/dependabot)! - chore: bump @playwright/test from 1.34.3 to 1.35.0 249 | 250 | ## 2.3.2 251 | 252 | ### Patch Changes 253 | 254 | - [#159](https://github.com/TenKeyLabs/dappwright/pull/159) [`3eee5fc`](https://github.com/TenKeyLabs/dappwright/commit/3eee5fc2f0e90ce8a0abd0e5576d9808c28b33b0) Thanks [@dependabot](https://github.com/apps/dependabot)! - chore: bump @playwright/test from 1.33.0 to 1.34.3 255 | 256 | ## 2.3.1 257 | 258 | ### Patch Changes 259 | 260 | - [#147](https://github.com/TenKeyLabs/dappwright/pull/147) [`8815d91`](https://github.com/TenKeyLabs/dappwright/commit/8815d91bf35acd96dbdf0f78e88ecc9576989649) Thanks [@dependabot](https://github.com/apps/dependabot)! - Bumps playwright to 1.33.0 261 | 262 | - [#147](https://github.com/TenKeyLabs/dappwright/pull/147) [`8815d91`](https://github.com/TenKeyLabs/dappwright/commit/8815d91bf35acd96dbdf0f78e88ecc9576989649) Thanks [@dependabot](https://github.com/apps/dependabot)! - Removes unnecessary references to playwirght in the package.json 263 | 264 | ## 2.3.0 265 | 266 | ### Minor Changes 267 | 268 | - [#130](https://github.com/TenKeyLabs/dappwright/pull/130) [`0d4a415`](https://github.com/TenKeyLabs/dappwright/commit/0d4a4159e79fb9ad649acc3559d78fff4d119f05) Thanks [@osis](https://github.com/osis)! - chore: bumps playwright and playwright/core to 1.32.3 269 | 270 | ### Patch Changes 271 | 272 | - [#132](https://github.com/TenKeyLabs/dappwright/pull/132) [`f1e0d5f`](https://github.com/TenKeyLabs/dappwright/commit/f1e0d5fee13b0eb507ff896db3a2ec04cd578650) Thanks [@osis](https://github.com/osis)! - chore: enables support for engines > 16 273 | 274 | ## 2.2.7 275 | 276 | ### Patch Changes 277 | 278 | - [#114](https://github.com/TenKeyLabs/dappwright/pull/114) [`59e9889`](https://github.com/TenKeyLabs/dappwright/commit/59e9889f8aa2556da7051a7da056c22b8559d81f) Thanks [@agualis](https://github.com/agualis)! - Improved error message when connecting to github to get the extension's release 279 | 280 | ## 2.2.6 281 | 282 | ### Patch Changes 283 | 284 | - [#88](https://github.com/TenKeyLabs/dappwright/pull/88) [`359e44a`](https://github.com/TenKeyLabs/dappwright/commit/359e44a014ec10be2603f6258301db81e05b7b6a) Thanks [@osis](https://github.com/osis)! - chore: provides an explicit default export for the lib 285 | 286 | ## 2.2.5 287 | 288 | ### Patch Changes 289 | 290 | - [#83](https://github.com/TenKeyLabs/dappwright/pull/83) [`37b82a2`](https://github.com/TenKeyLabs/dappwright/commit/37b82a2a0c7e107ffb71a47813241603a5bc23bd) Thanks [@osis](https://github.com/osis)! - updates the releases repo for coinbase wallet 291 | 292 | ## 2.2.4 293 | 294 | ### Patch Changes 295 | 296 | - [#70](https://github.com/TenKeyLabs/dappwright/pull/70) [`71f66b3`](https://github.com/TenKeyLabs/dappwright/commit/71f66b314d7316f12054d86ef7eed17076d092ed) Thanks [@osis](https://github.com/osis)! - Fixes a cache path mismatch in the downloader 297 | 298 | - [#76](https://github.com/TenKeyLabs/dappwright/pull/76) [`e8256b3`](https://github.com/TenKeyLabs/dappwright/commit/e8256b32d5fa8098c0181ab9b72739b48c70452f) Thanks [@osis](https://github.com/osis)! - extensions load with consistent ids 299 | 300 | ## 2.2.3 301 | 302 | ### Patch Changes 303 | 304 | - [#69](https://github.com/TenKeyLabs/dappwright/pull/69) [`88e2281`](https://github.com/TenKeyLabs/dappwright/commit/88e22815707d2cc6be46be22fe33554366cdc8ac) Thanks [@osis](https://github.com/osis)! 305 | - Support for Coinbase Wallet 3.6.0 306 | - Fixes static token symbol reference for Coinbase Wallet when using getTokenBalance 307 | 308 | ## 2.2.2 309 | 310 | ### Patch Changes 311 | 312 | - [#68](https://github.com/TenKeyLabs/dappwright/pull/68) [`c24d645`](https://github.com/TenKeyLabs/dappwright/commit/c24d64545545a7af27a8bb3d551219ffdbbc2495) Thanks [@osis](https://github.com/osis)! - Support for MetaMask 10.25.0 313 | 314 | ## 2.2.1 315 | 316 | ### Minor Changes 317 | 318 | - [#66](https://github.com/TenKeyLabs/dappwright/pull/66) [`9f551e2`](https://github.com/TenKeyLabs/dappwright/commit/9f551e2c7354e86809835357adc5c0314102c783) Thanks [@witem](https://github.com/witem)! - Add headless option to bootstrap 319 | 320 | ### Patch Changes 321 | 322 | - [#64](https://github.com/TenKeyLabs/dappwright/pull/64) [`e7a3eed`](https://github.com/TenKeyLabs/dappwright/commit/e7a3eeda9ce23a9afca96dbd5d82652795809bca) Thanks [@dependabot](https://github.com/apps/dependabot)! - chore: bump jest from 29.3.1 to 29.4.1 323 | 324 | - [#65](https://github.com/TenKeyLabs/dappwright/pull/65) [`bbc88af`](https://github.com/TenKeyLabs/dappwright/commit/bbc88af5c68b9755e963868cf99f55a2f0ff1a04) Thanks [@dependabot](https://github.com/apps/dependabot)! - chore: bump playwright-core from 1.29.2 to 1.30.0 325 | 326 | - [#62](https://github.com/TenKeyLabs/dappwright/pull/62) [`672f0b1`](https://github.com/TenKeyLabs/dappwright/commit/672f0b19ad8c79055ae4f40eb2df3ccf8476aa9c) Thanks [@dependabot](https://github.com/apps/dependabot)! - chore: bump typescript from 4.9.4 to 4.9.5 327 | 328 | - [#63](https://github.com/TenKeyLabs/dappwright/pull/63) [`62ea98a`](https://github.com/TenKeyLabs/dappwright/commit/62ea98a1406e41648f2d477f343e769cd42aaf51) Thanks [@dependabot](https://github.com/apps/dependabot)! - chore: bump solc from 0.8.17 to 0.8.18 329 | 330 | - [#61](https://github.com/TenKeyLabs/dappwright/pull/61) [`cd2caaa`](https://github.com/TenKeyLabs/dappwright/commit/cd2caaab84ef0636542d15b47e25cc021fe25592) Thanks [@dependabot](https://github.com/apps/dependabot)! - chore: bump ganache from 7.7.3 to 7.7.4 331 | 332 | ## 2.2.0 333 | 334 | ### Minor Changes 335 | 336 | - [#56](https://github.com/TenKeyLabs/dappwright/pull/56) [`381d229`](https://github.com/TenKeyLabs/dappwright/commit/381d22910755a87dfd66df18f38bc2b26883833f) Thanks [@osis](https://github.com/osis)! - export wallet types 337 | 338 | ### Patch Changes 339 | 340 | - [#58](https://github.com/TenKeyLabs/dappwright/pull/58) [`f6bfcab`](https://github.com/TenKeyLabs/dappwright/commit/f6bfcab42eb738ba2b3028db51648ba4affa79a2) Thanks [@osis](https://github.com/osis)! - adds missing await for coinbase account switch click 341 | 342 | - [#57](https://github.com/TenKeyLabs/dappwright/pull/57) [`6187ce6`](https://github.com/TenKeyLabs/dappwright/commit/6187ce61e3bb654cf60463c8115c998b9e7de3f0) Thanks [@osis](https://github.com/osis)! - navigate home after coinbase setup 343 | 344 | - [#58](https://github.com/TenKeyLabs/dappwright/pull/58) [`f6bfcab`](https://github.com/TenKeyLabs/dappwright/commit/f6bfcab42eb738ba2b3028db51648ba4affa79a2) Thanks [@osis](https://github.com/osis)! - adds missing await for coinbase accountSwitch & deleteNetwork clicks 345 | 346 | ## 2.1.0 347 | 348 | ### Minor Changes 349 | 350 | - [#29](https://github.com/TenKeyLabs/dappwright/pull/29) [`3a41607`](https://github.com/TenKeyLabs/dappwright/commit/3a4160702861fbf8efa90baad5e416e0c131c190) Thanks [@osis](https://github.com/osis)! - Adds Coinbase Wallet support 351 | Adds `hasNetwork` action for all wallets 352 | Adds `confirmNetworkSwitch` action for MetaMask 353 | 354 | ### Patch Changes 355 | 356 | - [#51](https://github.com/TenKeyLabs/dappwright/pull/51) [`8e464ca`](https://github.com/TenKeyLabs/dappwright/commit/8e464cac16609aeb679cc2e8aaf61720e8ac5c3e) Thanks [@osis](https://github.com/osis)! - Fixes extension url mismatch issue 357 | 358 | - [#39](https://github.com/TenKeyLabs/dappwright/pull/39) [`e3aecc6`](https://github.com/TenKeyLabs/dappwright/commit/e3aecc61853fb652a590842b4feda51f58f8a08a) Thanks [@osis](https://github.com/osis)! - Fixes import name case mismatch issue 359 | Adds wallet context to chromium session/download paths 360 | Changes popup actions behaviour to wait for a natural close event instead of potentially closing immaturely 361 | Able to handle when the extension doesn't pop up automatically on re-launches 362 | --------------------------------------------------------------------------------