├── .changeset └── config.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── support_request.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── pr.yaml │ ├── release.yml │ └── test.yaml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── AUTHORS ├── CHANGELOG.md ├── CODEOWNERS ├── LICENSE ├── README.md ├── docs └── API.md ├── eslint.config.js ├── package.json ├── playwright.config.js ├── src ├── bootstrap.ts ├── downloader │ ├── downloader.ts │ ├── file.ts │ ├── github.ts │ ├── request.ts │ └── version.ts ├── helpers │ ├── actions.ts │ ├── index.ts │ └── selectors.ts ├── index.ts ├── jest │ └── config.ts ├── launch.ts ├── types.ts └── wallets │ ├── coinbase │ ├── actions.ts │ └── coinbase.ts │ ├── metamask │ ├── actions │ │ ├── addNetwork.ts │ │ ├── addToken.ts │ │ ├── approve.ts │ │ ├── confirmNetworkSwitch.ts │ │ ├── confirmTransaction.ts │ │ ├── countAccounts.ts │ │ ├── createAccount.ts │ │ ├── deleteAccount.ts │ │ ├── deleteNetwork.ts │ │ ├── getTokenBalance.ts │ │ ├── hasNetwork.ts │ │ ├── helpers │ │ │ ├── actions.ts │ │ │ ├── index.ts │ │ │ └── selectors.ts │ │ ├── importPk.ts │ │ ├── index.ts │ │ ├── lock.ts │ │ ├── reject.ts │ │ ├── sign.ts │ │ ├── signin.ts │ │ ├── switchAccount.ts │ │ ├── switchNetwork.ts │ │ ├── unlock.ts │ │ └── util.ts │ ├── metamask.ts │ ├── setup.ts │ └── setup │ │ ├── index.ts │ │ └── setupActions.ts │ ├── wallet.ts │ └── wallets.ts ├── test ├── 1-init.spec.ts ├── 2-wallet.spec.ts ├── 3-dapp.spec.ts ├── dapp │ ├── contract │ │ ├── Counter.sol │ │ └── index.ts │ ├── public │ │ ├── data.js │ │ ├── ethers-6.13.4.umd.min.js │ │ ├── index.html │ │ └── main.js │ ├── server.ts │ └── start.ts └── helpers │ ├── itForWallet.ts │ └── walletTest.ts ├── tsconfig.build.json ├── tsconfig.json ├── types └── solc │ └── index.d.ts └── yarn.lock /.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 | -------------------------------------------------------------------------------- /.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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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/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@v5 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | with: 19 | types: | 20 | fix 21 | feat 22 | chore 23 | -------------------------------------------------------------------------------- /.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@v4 17 | 18 | - name: Setup Node.js 20 19 | uses: actions/setup-node@v4 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 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 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@v4 15 | 16 | - name: Setup Node.js 20 17 | uses: actions/setup-node@v4 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 | 28 | test: 29 | runs-on: ubuntu-latest 30 | container: 31 | image: mcr.microsoft.com/playwright:v1.52.0-jammy 32 | steps: 33 | - name: Checkout code 34 | uses: actions/checkout@v4 35 | 36 | - name: Setup Node.js 20 37 | uses: actions/setup-node@v4 38 | with: 39 | cache: yarn 40 | node-version: 20 41 | 42 | - name: Install dependencies 43 | run: yarn install --frozen-lockfile 44 | 45 | - name: Test 46 | env: 47 | GITHUB_TOKEN: ${{ github.token }} 48 | run: | 49 | xvfb-run --auto-servernum yarn run test:ci 50 | - name: Upload test results 51 | if: failure() 52 | uses: actions/upload-artifact@v4 53 | with: 54 | name: playwright-traces 55 | path: test-results 56 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16 2 | 3 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | arrowParens: 'always', 7 | }; 8 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # This is the official list of dappwright authors for copyright purposes. 2 | 3 | Dwayne Forde 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.11.0 4 | 5 | ### Minor Changes 6 | 7 | - [#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 8 | 9 | ## 2.10.2 10 | 11 | ### Patch Changes 12 | 13 | - [#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 14 | 15 | ## 2.10.1 16 | 17 | ### Patch Changes 18 | 19 | - [#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 20 | 21 | - [#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 22 | 23 | ## 2.10.0 24 | 25 | ### Minor Changes 26 | 27 | - [#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 28 | 29 | ## 2.9.4 30 | 31 | ### Patch Changes 32 | 33 | - [#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 34 | 35 | ## 2.9.3 36 | 37 | ### Patch Changes 38 | 39 | - [#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 40 | - [#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 41 | 42 | ## 2.9.2 43 | 44 | ### Patch Changes 45 | 46 | - [#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 47 | 48 | - chore(coinbase): wait for chrome state to settle after adding a network 49 | - chore: use the first page as the main page for extension navigation so no blank pages are lingering 50 | 51 | - [#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 52 | - chore(metamask): updates regex for matching token balances 53 | - fix(metamask): getErrorMessage now returns the error message instead of a locator 54 | 55 | ## 2.9.1 56 | 57 | ### Patch Changes 58 | 59 | - [#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 60 | 61 | ## 2.9.0 62 | 63 | ### Minor Changes 64 | 65 | - [#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 66 | feature: unfound specified token balances now throw an error 67 | chore: bumps ethers to 6.13.4 for the test dapp for compatibility 68 | chore: test dapp now has two network switch buttons for convenience 69 | chore: metamask - several button click target adjustments for changes in the UI 70 | chore: metamask - supports for the newer mechanism for adding a network 71 | chore: metamask - local test network details updated to avoid validation errors in the add network form. "Localhost 8545" is now "GoChain Testnet" 72 | 73 | ## 2.8.6 74 | 75 | ### Patch Changes 76 | 77 | - [`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 78 | 79 | ## 2.8.5 80 | 81 | ### Patch Changes 82 | 83 | - [#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 84 | 85 | ## 2.8.4 86 | 87 | ### Patch Changes 88 | 89 | - [#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 90 | 91 | ## 2.8.3 92 | 93 | ### Patch Changes 94 | 95 | - [#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 96 | 97 | - [#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 98 | 99 | ## 2.8.2 100 | 101 | ### Patch Changes 102 | 103 | - [#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 104 | 105 | - [#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 106 | 107 | ## 2.8.1 108 | 109 | ### Minor Changes 110 | 111 | - [#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 112 | 113 | ## 2.8.0 114 | 115 | ### Minor Changes 116 | 117 | - [#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 118 | 119 | ## 2.7.2 120 | 121 | ### Patch Changes 122 | 123 | - [#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 124 | 125 | ## 2.7.1 126 | 127 | ### Patch Changes 128 | 129 | - [#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 130 | 131 | ## 2.7.0 132 | 133 | ### Minor Changes 134 | 135 | - [#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 136 | 137 | ## 2.6.0 138 | 139 | ### Minor Changes 140 | 141 | - [#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 142 | 143 | ### Patch Changes 144 | 145 | - [#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 146 | 147 | ## 2.5.5 148 | 149 | ### Patch Changes 150 | 151 | - [#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 152 | 153 | ## 2.5.4 154 | 155 | ### Patch Changes 156 | 157 | - [#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 158 | 159 | ## 2.5.3 160 | 161 | ### Patch Changes 162 | 163 | - [#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 164 | 165 | ## 2.5.2 166 | 167 | ### Patch Changes 168 | 169 | - [#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 170 | 171 | ## 2.5.1 172 | 173 | ### Patch Changes 174 | 175 | - [#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 176 | 177 | ## 2.5.0 178 | 179 | ### Minor Changes 180 | 181 | - [#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. 182 | 183 | ## 2.4.1 184 | 185 | ### Patch Changes 186 | 187 | - [#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 188 | 189 | ## 2.4.0 190 | 191 | ### Minor Changes 192 | 193 | - [#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 194 | 195 | ### Patch Changes 196 | 197 | - [#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 198 | 199 | - [#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 200 | 201 | ## 2.3.4 202 | 203 | ### Patch Changes 204 | 205 | - [#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 206 | 207 | ## 2.3.3 208 | 209 | ### Patch Changes 210 | 211 | - [#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 212 | 213 | ## 2.3.2 214 | 215 | ### Patch Changes 216 | 217 | - [#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 218 | 219 | ## 2.3.1 220 | 221 | ### Patch Changes 222 | 223 | - [#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 224 | 225 | - [#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 226 | 227 | ## 2.3.0 228 | 229 | ### Minor Changes 230 | 231 | - [#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 232 | 233 | ### Patch Changes 234 | 235 | - [#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 236 | 237 | ## 2.2.7 238 | 239 | ### Patch Changes 240 | 241 | - [#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 242 | 243 | ## 2.2.6 244 | 245 | ### Patch Changes 246 | 247 | - [#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 248 | 249 | ## 2.2.5 250 | 251 | ### Patch Changes 252 | 253 | - [#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 254 | 255 | ## 2.2.4 256 | 257 | ### Patch Changes 258 | 259 | - [#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 260 | 261 | - [#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 262 | 263 | ## 2.2.3 264 | 265 | ### Patch Changes 266 | 267 | - [#69](https://github.com/TenKeyLabs/dappwright/pull/69) [`88e2281`](https://github.com/TenKeyLabs/dappwright/commit/88e22815707d2cc6be46be22fe33554366cdc8ac) Thanks [@osis](https://github.com/osis)! 268 | - Support for Coinbase Wallet 3.6.0 269 | - Fixes static token symbol reference for Coinbase Wallet when using getTokenBalance 270 | 271 | ## 2.2.2 272 | 273 | ### Patch Changes 274 | 275 | - [#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 276 | 277 | ## 2.2.1 278 | 279 | ### Minor Changes 280 | 281 | - [#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 282 | 283 | ### Patch Changes 284 | 285 | - [#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 286 | 287 | - [#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 288 | 289 | - [#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 290 | 291 | - [#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 292 | 293 | - [#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 294 | 295 | ## 2.2.0 296 | 297 | ### Minor Changes 298 | 299 | - [#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 300 | 301 | ### Patch Changes 302 | 303 | - [#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 304 | 305 | - [#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 306 | 307 | - [#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 308 | 309 | ## 2.1.0 310 | 311 | ### Minor Changes 312 | 313 | - [#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 314 | Adds `hasNetwork` action for all wallets 315 | Adds `confirmNetworkSwitch` action for MetaMask 316 | 317 | ### Patch Changes 318 | 319 | - [#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 320 | 321 | - [#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 322 | Adds wallet context to chromium session/download paths 323 | Changes popup actions behaviour to wait for a natural close event instead of potentially closing immaturely 324 | Able to handle when the extension doesn't pop up automatically on re-launches 325 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @TenKeyLabs/ten-key-labs-coders 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | - [addToken](#addToken) 18 | - [confirmTransaction](#confirmTransaction) 19 | - [sign](#sign) 20 | - [approve](#approve) 21 | - [helpers](#helpers) 22 | - [getTokenBalance](#getTokenBalance) 23 | - [deleteAccount](#deleteAccount) 24 | - [deleteNetwork](#deleteNetwork) 25 | - [page](#page) 26 | 27 | # dAppwright setup methods 28 | 29 | 30 | 31 | ## `dappwright.launch(browserName: string, options: OfficialOptions | CustomOptions): Promise` 32 | 33 | ```typescript 34 | interface OfficialOptions { 35 | metamaskVersion: 'latest' | string; 36 | metamaskLocation?: Path; 37 | } 38 | 39 | type Path = string | { download: string; extract: string }; 40 | ``` 41 | 42 | or 43 | 44 | ```typescript 45 | interface CustomOptions { 46 | metamaskPath: string; 47 | } 48 | ``` 49 | 50 | 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) 51 | 52 | 53 | 54 | ## `dappwright.setupMetamask(browser: BrowserContext, options: MetamaskOptions = {}, steps: Step[]): Promise` 55 | 56 | ```typescript 57 | interface MetamaskOptions { 58 | seed?: string; 59 | password?: string; 60 | showTestNets?: boolean; 61 | } 62 | ``` 63 | 64 | ```typescript 65 | type Step = (page: Page, options?: Options) => void; 66 | ``` 67 | 68 | 69 | 70 | ## `dappwright.bootstrap(puppeteerLib: typeof puppeteer, options: OfficialOptions & MetamaskOptions): Promise<[Dappwright, Page, Browser]>` 71 | 72 | ```typescript 73 | interface OfficialOptions { 74 | metamaskVersion: 'latest' | string; 75 | metamaskLocation?: Path; 76 | } 77 | ``` 78 | 79 | it runs `dappwright.launch` and `dappwright.setup` and return array with dappwright, page and browser 80 | 81 | 82 | 83 | ## `dappwright.getMetamaskWindow(browser: Browser, version?: string): Promise` 84 | 85 | 86 | 87 | # dAppwright methods 88 | 89 | `metamask` is used as placeholder for dAppwright returned by [`setupMetamask`](setup) or [`getMetamaskWindow`](getMetamask) 90 | 91 | 92 | 93 | ## `metamask.switchAccount(accountNumber: number): Promise` 94 | 95 | it commands MetaMask to switch to a different account, by passing the index/position of the account in the accounts list. 96 | 97 | 98 | 99 | ## `metamask.importPK(privateKey: string): Promise` 100 | 101 | it commands MetaMask to import an private key. It can only be used while you haven't signed in yet, otherwise it throws. 102 | 103 | 104 | 105 | ## `metamask.lock(): Promise` 106 | 107 | signs out from MetaMask. It can only be used if you arelady signed it, otherwise it throws. 108 | 109 | 110 | 111 | ## `metamask.unlock(password: string): Promise` 112 | 113 | 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`. 114 | 115 | 116 | 117 | ## `metamask.switchNetwork(network: string): Promise` 118 | 119 | it changes the current selected network. `networkName` can take the following values: `"main"`, `"ropsten"`, `"rinkeby"`, `"kovan"`, `"localhost"`. 120 | 121 | 122 | 123 | ## `metamask.addNetwork(options: AddNetwork): Promise` 124 | 125 | ```typescript 126 | interface AddNetwork { 127 | networkName: string; 128 | rpc: string; 129 | chainId: number; 130 | symbol: string; 131 | } 132 | ``` 133 | 134 | it adds a custom network to MetaMask. 135 | 136 | 137 | 138 | ## `metamask.addToken(tokenAddress: string): Promise` 139 | 140 | ```typescript 141 | interface AddToken { 142 | tokenAddress: string; 143 | symbol?: string; 144 | decimals?: number; 145 | } 146 | ``` 147 | 148 | it adds a custom token to MetaMask. 149 | 150 | 151 | 152 | ## `metamask.confirmTransaction(options?: TransactionOptions): Promise` 153 | 154 | ```typescript 155 | interface TransactionOptions { 156 | gas?: number; 157 | gasLimit?: number; 158 | priority?: number; 159 | } 160 | ``` 161 | 162 | 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. 163 | 164 | 165 | 166 | ## `metamask.sign(): Promise` 167 | 168 | commands MetaMask to sign a message. For this to work MetaMask must be in a sign confirmation state. 169 | 170 | 171 | 172 | ## `metamask.approve(): Promise` 173 | 174 | enables the app to connect to MetaMask account in privacy mode 175 | 176 | 177 | 178 | ## `metamask.helpers` 179 | 180 | 181 | 182 | ### `metamask.helpers.getTokenBalance(tokenSymbol: string): Promise` 183 | 184 | get balance of specific token 185 | 186 | 187 | 188 | ### `metamask.helpers.deleteAccount(accountNumber: number): Promise` 189 | 190 | deletes account containing name with specified number 191 | 192 | 193 | 194 | ### `metamask.helpers.deleteNetwork(): Promise` 195 | 196 | deletes custom network from metamask 197 | 198 | 199 | 200 | ## `metamask.page` is Metamask plugin `Page` 201 | 202 | **for advanced usages** in case you need custom features. 203 | -------------------------------------------------------------------------------- /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 | 65 | // // Naming convention rules 66 | '@typescript-eslint/naming-convention': [ 67 | 'error', 68 | { 69 | selector: ['classProperty', 'parameterProperty', 'objectLiteralProperty', 'classMethod', 'parameter'], 70 | format: ['camelCase'], 71 | leadingUnderscore: 'allow', 72 | }, 73 | // Variable must be in camel or upper case 74 | { 75 | selector: 'variable', 76 | format: ['camelCase', 'UPPER_CASE'], 77 | leadingUnderscore: 'allow', 78 | filter: { 79 | regex: '^_', 80 | match: false, 81 | }, 82 | }, 83 | // Classes and types must be in PascalCase 84 | { selector: ['typeLike', 'enum'], format: ['PascalCase'] }, 85 | { selector: 'enumMember', format: null }, 86 | { selector: 'typeProperty', format: ['PascalCase', 'camelCase'] }, 87 | // Ignore rules on destructured params 88 | { 89 | selector: 'variable', 90 | modifiers: ['destructured'], 91 | format: null, 92 | }, 93 | ], 94 | } 95 | }, 96 | // Override for test files 97 | { 98 | files: ['**/test/**/*.ts'], 99 | rules: { 100 | 'no-console': 'off', 101 | }, 102 | } 103 | ]); 104 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tenkeylabs/dappwright", 3 | "version": "2.11.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", 25 | "dev": "microbundle --tsconfig tsconfig.build.json --external os,https,zlib,stream watch", 26 | "lint": "eslint", 27 | "lint:fix": "yarn run lint --fix", 28 | "test": "playwright test", 29 | "test:ci": "playwright test --headed --timeout 60000", 30 | "test:debug": "playwright test --debug --timeout 0 test/", 31 | "test:metamask:debug": "playwright test --debug --timeout 0 --project metamask", 32 | "test:coinbase:debug": "playwright test --debug --timeout 0 --project coinbase", 33 | "test:dapp": "node --require ts-node/register test/dapp/start.ts", 34 | "changeset:publish": "yarn build && changeset publish" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/TenKeyLabs/dappwright.git" 39 | }, 40 | "keywords": [ 41 | "e2e", 42 | "testing", 43 | "metamask", 44 | "playwright", 45 | "dapp", 46 | "ethereum" 47 | ], 48 | "contributors": [ 49 | "Dwayne Forde " 50 | ], 51 | "license": "MIT", 52 | "dependencies": { 53 | "node-stream-zip": "^1.13.0" 54 | }, 55 | "devDependencies": { 56 | "@changesets/changelog-github": "^0.5.0", 57 | "@changesets/cli": "^2.26.0", 58 | "@playwright/test": "^1.51.0", 59 | "@types/jest": "^29.2.4", 60 | "@typescript-eslint/eslint-plugin": "^8.29.0", 61 | "@typescript-eslint/parser": "^8.29.0", 62 | "eslint": "^9.24.0", 63 | "eslint-config-prettier": "^10.1.0", 64 | "eslint-plugin-import": "^2.31.0", 65 | "eslint-plugin-prettier": "^5.2.0", 66 | "ganache": "^7.4.3", 67 | "microbundle": "^0.15.1", 68 | "prettier": "^3.0.3", 69 | "rimraf": "^6.0.1", 70 | "serve-handler": "6.1.6", 71 | "solc": "0.8.29", 72 | "ts-node": "10.9.2", 73 | "typescript": "^5.0", 74 | "web3": "4.16.0" 75 | }, 76 | "peerDependencies": { 77 | "playwright-core": ">1.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /playwright.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@playwright/test'; 2 | import { CoinbaseWallet, MetaMaskWallet } from './src'; 3 | 4 | export default defineConfig({ 5 | retries: process.env.CI ? 1 : 0, 6 | use: { 7 | trace: process.env.CI ? 'retain-on-first-failure' : 'on', 8 | headless: false, 9 | }, 10 | maxFailures: process.env.CI ? 0 : 1, 11 | reporter: [['list'], ['html', { open: 'on-failure' }]], 12 | webServer: { 13 | command: 'yarn test:dapp', 14 | url: 'http://localhost:8080', 15 | timeout: 120 * 1000, 16 | reuseExistingServer: false, 17 | }, 18 | projects: [ 19 | { 20 | name: 'MetaMask', 21 | metadata: { 22 | wallet: 'metamask', 23 | version: MetaMaskWallet.recommendedVersion, 24 | seed: 'pioneer casual canoe gorilla embrace width fiction bounce spy exhibit another dog', 25 | password: 'password1234!@#$', 26 | }, 27 | }, 28 | { 29 | name: 'Coinbase', 30 | metadata: { 31 | wallet: 'coinbase', 32 | version: CoinbaseWallet.recommendedVersion, 33 | seed: 'pioneer casual canoe gorilla embrace width fiction bounce spy exhibit another dog', 34 | password: 'password1234!@#$', 35 | }, 36 | dependencies: ["MetaMask"], 37 | }, 38 | ], 39 | }); 40 | -------------------------------------------------------------------------------- /src/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { BrowserContext, Page } from 'playwright-core'; 2 | import { launch } from './launch'; 3 | import { Dappwright, OfficialOptions } from './types'; 4 | import { closeWalletSetupPopup, getWallet, 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 } = await launch(browserName, launchOptions); 11 | closeWalletSetupPopup(launchOptions.wallet, browserContext); 12 | const wallet = await getWallet(launchOptions.wallet, browserContext); 13 | await wallet.setup({ seed, password, showTestNets }); 14 | 15 | return [wallet, wallet.page, browserContext]; 16 | }; 17 | -------------------------------------------------------------------------------- /src/downloader/downloader.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | import { OfficialOptions } from '../types'; 4 | import { WalletIdOptions } from '../wallets/wallets'; 5 | import { downloadDir, editExtensionPubKey, extractZip, isEmpty } from './file'; 6 | import { downloadGithubRelease, getGithubRelease } from './github'; 7 | import { printVersion } from './version'; 8 | 9 | // Overrides for consistent navigation experience across wallets extensions 10 | export const EXTENSION_ID = 'gadekpdjmpjjnnemgnhkbjgnjpdaakgh'; 11 | export const EXTENSION_PUB_KEY = 12 | 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnpiOcYGaEp02v5On5luCk/4g9j+ujgWeGlpZVibaSz6kUlyiZvcVNIIUXR568uv5NrEi5+j9+HbzshLALhCn9S43E7Ha6Xkdxs3kOEPBu8FRNwFh2S7ivVr6ixnl2FCGwfkP1S1r7k665eC1/xYdJKGCc8UByfSw24Rtl5odUqZX1SaE6CsQEMymCFcWhpE3fV+LZ6RWWJ63Zm1ac5KmKzXdj7wZzN3onI0Csc8riBZ0AujkThJmCR8tZt2PkVUDX9exa0XkJb79pe0Ken5Bt2jylJhmQB7R3N1pVNhNQt17Sytnwz6zG2YsB2XNd/1VYJe52cPNJc7zvhQJpHjh5QIDAQAB'; 13 | 14 | export type Path = 15 | | string 16 | | { 17 | download: string; 18 | extract: string; 19 | }; 20 | 21 | export default (walletId: WalletIdOptions, releasesUrl: string, recommendedVersion: string) => 22 | async (options: OfficialOptions): Promise => { 23 | const { version } = options; 24 | const downloadPath = downloadDir(walletId, version); 25 | 26 | if (!version) { 27 | // eslint-disable-next-line no-console 28 | console.info(`Running tests on local ${walletId} build`); 29 | return downloadPath; 30 | } 31 | 32 | if (process.env.TEST_PARALLEL_INDEX === '0') { 33 | printVersion(walletId, version, recommendedVersion); 34 | await download(walletId, version, releasesUrl, downloadPath); 35 | } else { 36 | while (!fs.existsSync(downloadPath) || isEmpty(downloadPath)) { 37 | // eslint-disable-next-line no-console 38 | console.info(`Waiting for primary worker to download ${walletId}...`); 39 | await new Promise((resolve) => setTimeout(resolve, 5000)); 40 | } 41 | } 42 | 43 | return downloadPath; 44 | }; 45 | 46 | const download = async ( 47 | walletId: WalletIdOptions, 48 | version: string, 49 | releasesUrl: string, 50 | downloadPath: string, 51 | ): Promise => { 52 | if (version !== 'latest' && fs.existsSync(downloadPath) && !isEmpty(downloadPath)) return; 53 | 54 | // eslint-disable-next-line no-console 55 | console.info(`Downloading ${walletId}...`); 56 | 57 | const { filename, downloadUrl } = await getGithubRelease(releasesUrl, `v${version}`); 58 | 59 | if (!fs.existsSync(downloadPath) || isEmpty(downloadPath)) { 60 | const walletFolder = downloadPath.split('/').slice(0, -1).join('/'); 61 | const zipData = await downloadGithubRelease(filename, downloadUrl, walletFolder); 62 | await extractZip(zipData, downloadPath); 63 | 64 | editExtensionPubKey(downloadPath); 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /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 './downloader'; 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 isEmpty = (folderPath: string): boolean => { 13 | const items = fs.readdirSync(folderPath, { withFileTypes: true }); 14 | const files = items.filter((item) => item.isFile() && !item.name.startsWith('.')); 15 | return files.length === 0; 16 | }; 17 | 18 | export const extractZip = async (zipData: string, destination: string): Promise => { 19 | const zip = new StreamZip.async({ file: zipData }); 20 | fs.mkdirSync(destination, { recursive: true }); 21 | await zip.extract(null, destination); 22 | }; 23 | 24 | // Set the chrome extension public key 25 | export const editExtensionPubKey = (extensionPath: string): void => { 26 | const manifestPath = path.resolve(extensionPath, 'manifest.json'); 27 | const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); 28 | manifest.key = EXTENSION_PUB_KEY; 29 | fs.writeFileSync(manifestPath, JSON.stringify(manifest)); 30 | }; 31 | -------------------------------------------------------------------------------- /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 | export const getGithubRelease = (releasesUrl: string, version: string): Promise => 9 | new Promise((resolve, reject) => { 10 | // eslint-disable-next-line @typescript-eslint/naming-convention 11 | const options = { headers: { 'User-Agent': 'Mozilla/5.0' } }; 12 | if (process.env.GITHUB_TOKEN) options.headers['Authorization'] = `Bearer ${process.env.GITHUB_TOKEN}`; 13 | const request = get(releasesUrl, options, (response) => { 14 | let body = ''; 15 | response.on('data', (chunk) => { 16 | body += chunk; 17 | }); 18 | 19 | response.on('end', () => { 20 | const data = JSON.parse(body); 21 | if (data.message) 22 | return reject( 23 | `There was a problem connecting to github API to get the extension release (URL: ${releasesUrl}). Error: ${data.message}`, 24 | ); 25 | for (const result of data) { 26 | if (result.draft) continue; 27 | if (version === 'latest' || result.name.includes(version) || result.tag_name.includes(version)) { 28 | for (const asset of result.assets) { 29 | if (asset.name.includes('chrome')) 30 | resolve({ 31 | downloadUrl: asset.browser_download_url, 32 | filename: asset.name, 33 | tag: result.tag_name, 34 | }); 35 | } 36 | } 37 | } 38 | reject(`Version ${version} not found!`); 39 | }); 40 | }); 41 | request.on('error', (error) => { 42 | // eslint-disable-next-line no-console 43 | console.warn('getGithubRelease error:', error.message); 44 | throw error; 45 | }); 46 | }); 47 | 48 | export const downloadGithubRelease = (name: string, url: string, location: string): Promise => 49 | new Promise(async (resolve) => { 50 | if (!fs.existsSync(location)) { 51 | fs.mkdirSync(location, { recursive: true }); 52 | } 53 | const fileLocation = path.join(location, name); 54 | const file = fs.createWriteStream(fileLocation); 55 | const stream = await request(url); 56 | stream.pipe(file); 57 | stream.on('end', () => { 58 | resolve(fileLocation); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | `It is not recommended to run ${walletId} with "latest" version. Use it at your own risk or set to the recommended version "${recommendedVersion}".`, 10 | ); 11 | else if (isNewerVersion(recommendedVersion, version)) 12 | console.warn( 13 | '\x1b[33m%s\x1b[0m', 14 | `Seems you are running a newer version (${version}) of ${walletId} than recommended by the Dappwright team. 15 | Use it at your own risk or set to the recommended version "${recommendedVersion}".`, 16 | ); 17 | else if (isNewerVersion(version, recommendedVersion)) 18 | console.warn( 19 | '\x1b[33m%s\x1b[0m', 20 | `Seems you are running an older version (${version}) of ${walletId} than recommended by the Dappwright team. 21 | Use it at your own risk or set the recommended version "${recommendedVersion}".`, 22 | ); 23 | else console.log(`Using ${walletId} v${version}`); 24 | 25 | console.log(''); // new line 26 | }; 27 | 28 | const isNewerVersion = (current: string, comparingWith: string): boolean => { 29 | if (current === comparingWith) return false; 30 | 31 | const currentFragments = current.replace(/[^\d.-]/g, '').split('.'); 32 | const comparingWithFragments = comparingWith.replace(/[^\d.-]/g, '').split('.'); 33 | 34 | const length = 35 | currentFragments.length > comparingWithFragments.length ? currentFragments.length : comparingWithFragments.length; 36 | for (let i = 0; i < length; i++) { 37 | if ((Number(currentFragments[i]) || 0) === (Number(comparingWithFragments[i]) || 0)) continue; 38 | return (Number(comparingWithFragments[i]) || 0) > (Number(currentFragments[i]) || 0); 39 | } 40 | return true; 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/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions'; 2 | export * from './selectors'; 3 | -------------------------------------------------------------------------------- /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 | 28 | export const getInputByLabelSelector = (text: string, excludeSpan = false): string => 29 | [ 30 | `//label[contains(.,'${text}')]/following-sibling::textarea`, 31 | `//label[contains(.,'${text}')]/following-sibling::*//input`, 32 | `//h6[contains(.,'${text}')]/parent::node()/parent::node()/following-sibling::input`, 33 | `//h6[contains(.,'${text}')]/parent::node()/parent::node()/following-sibling::*//input`, 34 | ...(!excludeSpan 35 | ? [ 36 | `//span[contains(.,'${text}')]/parent::node()/parent::node()/following-sibling::*//input`, 37 | `//span[contains(.,'${text}')]/following-sibling::*//input`, 38 | ] 39 | : []), 40 | ].join('|'); 41 | -------------------------------------------------------------------------------- /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/jest/config.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'node:fs'; 2 | import { cwd } from 'node:process'; 3 | import path from 'path'; 4 | import { DappwrightConfig, LaunchOptions } from '../types'; 5 | import { MetaMaskWallet } from '../wallets/metamask/metamask'; 6 | 7 | export const DAPPWRIGHT_DEFAULT_CONFIG: LaunchOptions = { metamaskVersion: MetaMaskWallet.recommendedVersion }; 8 | 9 | export async function getDappwrightConfig(): Promise { 10 | const configPath = 'dappwright.config.js'; 11 | const filePath = path.resolve(cwd(), configPath); 12 | 13 | if (!existsSync(filePath)) 14 | return { 15 | dappwright: DAPPWRIGHT_DEFAULT_CONFIG, 16 | }; 17 | 18 | // eslint-disable-next-line @typescript-eslint/no-require-imports 19 | const config = await require(filePath); 20 | 21 | return { 22 | dappwright: { 23 | ...DAPPWRIGHT_DEFAULT_CONFIG, 24 | ...config.dappwright, 25 | }, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /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 { getWallet, getWalletType } from './wallets/wallets'; 8 | 9 | /** 10 | * Launch Playwright chromium instance with wallet plugin installed 11 | * */ 12 | export 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 | return { 41 | wallet: await getWallet(wallet.id, browserContext), 42 | browserContext, 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { BrowserContext, Page } from 'playwright-core'; 2 | import Wallet from './wallets/wallet'; 3 | import { 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 AddToken = { 36 | tokenAddress: string; 37 | symbol?: string; 38 | decimals?: number; 39 | }; 40 | 41 | export type TransactionOptions = { 42 | gas?: number; 43 | gasLimit?: number; 44 | priority: number; 45 | }; 46 | 47 | export type Dappwright = { 48 | addNetwork: (options: AddNetwork) => Promise; 49 | addToken: (options: AddToken) => Promise; 50 | approve: () => Promise; 51 | confirmNetworkSwitch: () => Promise; 52 | confirmTransaction: (options?: TransactionOptions) => Promise; 53 | createAccount: (name?: string) => Promise; 54 | deleteAccount: (name: string) => Promise; 55 | deleteNetwork: (name: string) => Promise; 56 | getTokenBalance: (tokenSymbol: string) => Promise; 57 | hasNetwork: (name: string) => Promise; 58 | importPK: (pk: string) => Promise; 59 | lock: () => Promise; 60 | reject: () => Promise; 61 | sign: () => Promise; 62 | signin: () => Promise; 63 | switchAccount: (name: string) => Promise; 64 | switchNetwork: (network: string) => Promise; 65 | unlock: (password?: string) => Promise; 66 | 67 | page: Page; 68 | }; 69 | -------------------------------------------------------------------------------- /src/wallets/coinbase/actions.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from 'playwright-core'; 2 | import { waitForChromeState } from '../../helpers'; 3 | import { AddNetwork, AddToken } 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 | -------------------------------------------------------------------------------- /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 | } from './actions'; 28 | 29 | export class CoinbaseWallet extends Wallet { 30 | static id = 'coinbase' as WalletIdOptions; 31 | static recommendedVersion = '3.109.0'; 32 | static releasesUrl = 'https://api.github.com/repos/TenKeyLabs/coinbase-wallet-archive/releases'; 33 | static homePath = '/index.html'; 34 | 35 | options: WalletOptions; 36 | 37 | // Extension Downloader 38 | static download = downloader(this.id, this.releasesUrl, this.recommendedVersion); 39 | 40 | // Setup 41 | defaultSetupSteps: Step[] = [getStarted, navigateHome]; 42 | setup = setup(this.page, this.defaultSetupSteps); 43 | 44 | // Actions 45 | addNetwork = addNetwork(this.page); 46 | addToken = addToken; 47 | approve = approve(this.page); 48 | createAccount = createAccount(this.page); 49 | confirmNetworkSwitch = confirmNetworkSwitch; 50 | confirmTransaction = confirmTransaction(this.page); 51 | countAccounts = countAccounts(this.page); 52 | deleteAccount = deleteAccount; 53 | deleteNetwork = deleteNetwork(this.page); 54 | getTokenBalance = getTokenBalance(this.page); 55 | hasNetwork = hasNetwork(this.page); 56 | importPK = importPK; 57 | lock = lock(this.page); 58 | reject = reject(this.page); 59 | sign = sign(this.page); 60 | signin = signin; 61 | switchAccount = switchAccount(this.page); 62 | switchNetwork = switchNetwork; 63 | unlock = unlock(this.page); 64 | } 65 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/addToken.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { clickOnButton } from '../../../helpers'; 3 | import { AddToken } from '../../../types'; 4 | import { clickOnLogo } from './helpers'; 5 | 6 | export const addToken = 7 | (page: Page) => 8 | async ({ tokenAddress, symbol, decimals = 0 }: AddToken): Promise => { 9 | await page.bringToFront(); 10 | 11 | await page.getByTestId('import-token-button').click(); 12 | await page.getByTestId('importTokens__button').click(); 13 | await clickOnButton(page, 'Custom token'); 14 | await page.getByTestId('import-tokens-modal-custom-address').fill(tokenAddress); 15 | 16 | await page.waitForTimeout(500); 17 | 18 | if (symbol) { 19 | await page.getByTestId('import-tokens-modal-custom-symbol').fill(symbol); 20 | } 21 | 22 | if (decimals) { 23 | await page.getByTestId('import-tokens-modal-custom-decimals').fill(decimals.toString()); 24 | } 25 | 26 | await clickOnButton(page, 'Next'); 27 | await page.getByTestId('import-tokens-modal-import-button').click(); 28 | await clickOnLogo(page); 29 | }; 30 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | 25 | export const clickOnLogo = async (page: Page): Promise => { 26 | const header = await page.waitForSelector('.app-header__logo-container'); 27 | await header.click(); 28 | }; 29 | -------------------------------------------------------------------------------- /src/wallets/metamask/actions/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions'; 2 | export * from './selectors'; 3 | -------------------------------------------------------------------------------- /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 | 15 | export const getAccountMenuButton = (page: Page): Promise => 16 | page.waitForSelector(`button.menu-bar__account-options`); 17 | -------------------------------------------------------------------------------- /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/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 './util'; 18 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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/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/metamask.ts: -------------------------------------------------------------------------------- 1 | import downloader from '../../downloader/downloader'; 2 | import Wallet from '../wallet'; 3 | import { 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 | } from './actions'; 22 | import { confirmNetworkSwitch } from './actions/confirmNetworkSwitch'; 23 | import { countAccounts } from './actions/countAccounts'; 24 | import { hasNetwork } from './actions/hasNetwork'; 25 | import { setup } from './setup'; 26 | import { 27 | adjustSettings, 28 | clearOnboardingHelp, 29 | closePopup, 30 | createPassword, 31 | goToSettings, 32 | importAccount, 33 | } from './setup/setupActions'; 34 | 35 | export class MetaMaskWallet extends Wallet { 36 | static id = 'metamask' as WalletIdOptions; 37 | static recommendedVersion = '12.16.0'; 38 | static releasesUrl = 'https://api.github.com/repos/metamask/metamask-extension/releases'; 39 | static homePath = '/home.html'; 40 | 41 | options: WalletOptions; 42 | 43 | // Extension Downloader 44 | static download = downloader(this.id, this.releasesUrl, this.recommendedVersion); 45 | 46 | // Setup 47 | defaultSetupSteps: Step[] = [ 48 | importAccount, 49 | createPassword, 50 | clearOnboardingHelp, 51 | closePopup, 52 | goToSettings, 53 | adjustSettings, 54 | ]; 55 | setup = setup(this.page, this.defaultSetupSteps); 56 | 57 | // Actions 58 | addNetwork = addNetwork(this.page); 59 | addToken = addToken(this.page); 60 | approve = approve(this.page); 61 | createAccount = createAccount(this.page); 62 | confirmNetworkSwitch = confirmNetworkSwitch(this.page); 63 | confirmTransaction = confirmTransaction(this.page); 64 | countAccounts = countAccounts(this.page); 65 | deleteAccount = deleteAccount(this.page); 66 | deleteNetwork = deleteNetwork(this.page); 67 | getTokenBalance = getTokenBalance(this.page); 68 | hasNetwork = hasNetwork(this.page); 69 | importPK = importPk(this.page); 70 | lock = lock(this.page); 71 | reject = reject(this.page); 72 | sign = sign(this.page); 73 | signin = signin(this.page); 74 | switchAccount = switchAccount(this.page); 75 | switchNetwork = switchNetwork(this.page); 76 | unlock = unlock(this.page); 77 | } 78 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/wallets/metamask/setup/index.ts: -------------------------------------------------------------------------------- 1 | import { BrowserContext, Page } from 'playwright-core'; 2 | 3 | import { Dappwright, OfficialOptions } from '../../../types'; 4 | 5 | import fs from 'fs'; 6 | import { launch } from '../../../launch'; 7 | import { WalletOptions } from '../metamask'; 8 | import { setupMetamask } from '../setup'; 9 | 10 | export * from '../../../launch'; 11 | export * from '../setup'; 12 | 13 | export const bootstrap = async ( 14 | browserName: string, 15 | { seed, password, showTestNets, ...launchOptions }: OfficialOptions & WalletOptions, 16 | ): Promise<[Dappwright, Page, BrowserContext]> => { 17 | fs.rmSync('./metamaskSession', { recursive: true, force: true }); 18 | const browserContext = await launch(browserName, launchOptions); 19 | const dappwright = await setupMetamask(browserContext, { seed, password, showTestNets }); 20 | const pages = await browserContext.pages(); 21 | 22 | return [dappwright, pages[0], browserContext]; 23 | }; 24 | -------------------------------------------------------------------------------- /src/wallets/metamask/setup/setupActions.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | 3 | import { clickOnButton, typeOnInputField, waitForChromeState } from '../../../helpers'; 4 | import { WalletOptions } from '../../wallets'; 5 | import { clickOnLogo, 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 clickOnLogo(metamaskPage); 18 | 19 | await waitForChromeState(metamaskPage); 20 | } 21 | 22 | export async function importWallet(metamaskPage: Page): Promise { 23 | await metamaskPage.getByTestId('onboarding-import-wallet').click(); 24 | await metamaskPage.getByTestId('import-srp-confirm').click(); 25 | await metamaskPage.getByTestId('create-password-new').fill('sdfsdf'); 26 | await metamaskPage.getByTestId('create-password-confirm').click(); 27 | await metamaskPage.getByTestId('create-password-confirm').fill('sdfsdfs'); 28 | await metamaskPage.getByTestId('create-password-new').dblclick(); 29 | await metamaskPage.getByTestId('create-password-new').fill('10keylabs'); 30 | await metamaskPage.getByTestId('create-password-new').press('Tab'); 31 | await metamaskPage.getByTestId('create-password-confirm').fill('10keylabs'); 32 | await metamaskPage.getByTestId('create-password-import').click(); 33 | await metamaskPage.getByTestId('onboarding-complete-done').click(); 34 | await metamaskPage.getByTestId('pin-extension-next').click(); 35 | await metamaskPage.getByTestId('pin-extension-done').click(); 36 | } 37 | 38 | export async function noThanksTelemetry(metamaskPage: Page): Promise { 39 | await clickOnButton(metamaskPage, 'No thanks'); 40 | } 41 | 42 | export async function importAccount( 43 | metamaskPage: Page, 44 | { seed = 'already turtle birth enroll since owner keep patch skirt drift any dinner' }: WalletOptions, 45 | ): Promise { 46 | await metamaskPage.getByTestId('onboarding-terms-checkbox').click(); 47 | await metamaskPage.getByTestId('onboarding-import-wallet').click(); 48 | await metamaskPage.getByTestId('metametrics-i-agree').click(); 49 | 50 | for (const [index, seedPart] of seed.split(' ').entries()) 51 | await typeOnInputField(metamaskPage, `${index + 1}.`, seedPart); 52 | 53 | await metamaskPage.getByTestId('import-srp-confirm').click(); 54 | } 55 | 56 | export async function createPassword(metamaskPage: Page, { password = 'password1234' }: WalletOptions): Promise { 57 | await metamaskPage.getByTestId('create-password-new').fill(password); 58 | await metamaskPage.getByTestId('create-password-confirm').fill(password); 59 | await metamaskPage.getByTestId('create-password-terms').click(); 60 | await metamaskPage.getByTestId('create-password-import').click(); 61 | } 62 | 63 | export async function clearOnboardingHelp(metamaskPage: Page): Promise { 64 | await metamaskPage.getByTestId('onboarding-complete-done').click(); 65 | await metamaskPage.getByTestId('pin-extension-next').click(); 66 | await metamaskPage.getByTestId('pin-extension-done').click(); 67 | } 68 | 69 | export const closePopup = async (page: Page): Promise => { 70 | /* For some reason popup deletes close button and then create new one (react stuff) 71 | * hacky solution can be found here => https://github.com/puppeteer/puppeteer/issues/3496 */ 72 | await new Promise((resolve) => setTimeout(resolve, 1000)); 73 | if (await page.getByTestId('popover-close').isVisible()) { 74 | await page.getByTestId('popover-close').click(); 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /src/wallets/wallet.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { AddNetwork, AddToken, Dappwright, OfficialOptions, TransactionOptions } from '../types'; 3 | import { 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 | } 47 | -------------------------------------------------------------------------------- /src/wallets/wallets.ts: -------------------------------------------------------------------------------- 1 | import { BrowserContext, Page } from 'playwright-core'; 2 | import { EXTENSION_ID } from '../downloader/downloader'; 3 | import { CoinbaseWallet } from './coinbase/coinbase'; 4 | import { MetaMaskWallet } from './metamask/metamask'; 5 | 6 | export type Step = (page: Page, options?: Options) => void; 7 | export type WalletIdOptions = 'metamask' | 'coinbase'; 8 | export type WalletTypes = typeof CoinbaseWallet | typeof MetaMaskWallet; 9 | export type WalletOptions = { 10 | seed?: string; 11 | password?: string; 12 | showTestNets?: boolean; 13 | }; 14 | 15 | export const WALLETS: WalletTypes[] = [CoinbaseWallet, MetaMaskWallet]; 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | // TODO: Come back to this since metamask doesn't consider this to be an error anymore but blocks 121 | // test('should fail to add network with wrong chain ID', async ({ wallet }) => { 122 | // await expect( 123 | // metamask.addNetwork({ 124 | // networkName: 'Optimistic Ethereum Testnet Kovan', 125 | // rpc: 'https://kovan.optimism.io/', 126 | // chainId: 99999, 127 | // symbol: 'KUR', 128 | // }), 129 | // ).rejects.toThrowError(SyntaxError); 130 | // await metamask.page.pause(); 131 | // }); 132 | }); 133 | 134 | // Metamask only 135 | test.describe('private keys', () => { 136 | test.describe('importPK', () => { 137 | test('should import private key', async ({ wallet }) => { 138 | await forMetaMask(wallet, async () => { 139 | const beforeImport = await countAccounts(wallet); 140 | await wallet.importPK('4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b10'); 141 | const afterImport = await countAccounts(wallet); 142 | 143 | expect(beforeImport + 1).toEqual(afterImport); 144 | }); 145 | }); 146 | 147 | test('should throw error on duplicated private key', async ({ wallet }) => { 148 | await forMetaMask(wallet, async () => { 149 | await expect( 150 | wallet.importPK('4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b10'), 151 | ).rejects.toThrowError(SyntaxError); 152 | }); 153 | }); 154 | 155 | test('should throw error on wrong key', async ({ wallet }) => { 156 | await forMetaMask(wallet, async () => { 157 | await expect( 158 | wallet.importPK('4f3edf983ac636a65a$@!ce7c78d9aa706d3b113bce9c46f30d7d21715b23b10'), 159 | ).rejects.toThrowError(SyntaxError); 160 | }); 161 | }); 162 | 163 | test('should throw error on to short key', async ({ wallet }) => { 164 | await forMetaMask(wallet, async () => { 165 | await expect( 166 | wallet.importPK('4f3edf983ac636a65ace7c78d9aa706d3b113bce9c46f30d7d21715b23b10'), 167 | ).rejects.toThrowError(SyntaxError); 168 | }); 169 | }); 170 | }); 171 | 172 | test('should be able to delete an imported account', async ({ wallet }) => { 173 | await forMetaMask(wallet, async () => { 174 | const beforeDelete = await countAccounts(wallet); 175 | await wallet.deleteAccount(`Account ${beforeDelete}`); 176 | const afterDelete = await countAccounts(wallet); 177 | 178 | expect(beforeDelete - 1).toEqual(afterDelete); 179 | }); 180 | }); 181 | }); 182 | 183 | test.describe('getTokenBalance', () => { 184 | test.beforeEach(async ({ wallet }) => { 185 | if (wallet instanceof MetaMaskWallet) await wallet.switchNetwork('GoChain Testnet'); 186 | }); 187 | 188 | test('should return token balance', async ({ wallet }) => { 189 | let tokenBalance: number; 190 | 191 | await forMetaMask(wallet, async () => { 192 | tokenBalance = await wallet.getTokenBalance('GO'); 193 | expect(tokenBalance).toBeLessThanOrEqual(1000); 194 | expect(tokenBalance).toBeGreaterThanOrEqual(999.999); 195 | }); 196 | 197 | // Unable to get local balance from Coinbase wallet. This is Sepolia value for now. 198 | await forCoinbase(wallet, async () => { 199 | tokenBalance = await wallet.getTokenBalance('ETH'); 200 | // expect(tokenBalance).toEqual(999.999); 201 | }); 202 | }); 203 | 204 | test('should return 0 token balance when token not found', async ({ wallet }) => { 205 | await expect(wallet.getTokenBalance('TKLBUCKS')).rejects.toThrowError(new Error('Token TKLBUCKS not found')); 206 | }); 207 | }); 208 | 209 | test.describe('when working with tokens', () => { 210 | test('should add token', async ({ wallet }) => { 211 | await forMetaMask(wallet, async () => { 212 | await wallet.addToken({ 213 | tokenAddress: '0x4f96fe3b7a6cf9725f59d353f723c1bdb64ca6aa', 214 | symbol: 'KAKI', 215 | }); 216 | }); 217 | }); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/dapp/start.ts: -------------------------------------------------------------------------------- 1 | import { start } from './server'; 2 | 3 | start(); 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "lib": ["es2021", "dom"], 7 | "typeRoots": ["./node_modules/@types", "./types"], 8 | "declaration": true, 9 | "esModuleInterop": true, 10 | "outDir": "dist" 11 | }, 12 | "include": ["src/**/*", "test/**/*"], 13 | "exclude": ["node_modules"] 14 | } 15 | -------------------------------------------------------------------------------- /types/solc/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'solc' { 2 | export function version(): string; 3 | export function semver(): string; 4 | export function license(): string; 5 | 6 | export let lowlevel: { 7 | compileSingle: (input: string) => string; 8 | compileMulti: (input: string) => string; 9 | compileCallback: (input: string) => string; 10 | compileStandard: (input: string) => string; 11 | }; 12 | 13 | export let features: { 14 | legacySingleInput: boolean; 15 | multipleInputs: boolean; 16 | importCallback: boolean; 17 | nativeStandardJSON: boolean; 18 | }; 19 | 20 | export type ReadCallbackResult = { contents: string } | { error: string }; 21 | export type ReadCallback = (path: string) => ReadCallbackResult; 22 | export type Callbacks = { import: ReadCallback }; 23 | export function compile(input: string, readCallback?: Callbacks): string; 24 | } 25 | --------------------------------------------------------------------------------