diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3ae6dcbdf..f650c1c61 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,79 +1,92 @@ -name: 🧑‍🔧 Test - -on: - push: - branches: - - dev - pull_request: - paths: - - '.github/actions/**/*.yml' - - '.github/workflows/**/*.yml' - - 'packages/**' - - 'wallets/**' - -jobs: - test-unit: - name: Run unit tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4.2.2 - - - name: Setup Node & Install dependencies - uses: ./.github/actions/setup - - - name: Build project - run: pnpm run build - - - name: Run tests - run: pnpm run test - - test-e2e-headful: - name: Run E2E tests (headful) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4.2.2 - - - name: Setup Node & Install dependencies - uses: ./.github/actions/setup - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@de808b1eea699e761c404bda44ba8f21aba30b2c # pin@v1.3.1 - - - name: Install Playwright dependencies - run: pnpm dlx playwright@1.48.2 install-deps - - # For now, we only need Chromium. - - name: Install browsers for Playwright - run: pnpm dlx playwright@1.48.2 install chromium - - - name: Build project - run: pnpm run build - - - name: Install linux dependencies - run: | - sudo apt-get install --no-install-recommends -y \ - xvfb - - - name: Build project - run: pnpm run build - - - name: Serve MetaMask Test Dapp - run: | - pnpm run serve:test-dapp & - - - name: Build cache - run: | - xvfb-run pnpm run build:cache - - - name: Run E2E tests (headful) - run: | - xvfb-run pnpm run test:playwright:headful - - - name: Archive Playwright report - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # pin@v4.6.0 - if: success() || failure() - with: - name: playwright-report-headful - path: | - wallets/metamask/playwright-report-headful/ - if-no-files-found: error +# name: 🧑‍🔧 Test + +# on: +# push: +# branches: +# - dev +# pull_request: +# paths: +# - '.github/actions/**/*.yml' +# - '.github/workflows/**/*.yml' +# - 'packages/**' +# - 'wallets/**' + +# jobs: +# test-unit: +# name: Run unit tests +# runs-on: ubuntu-latest +# steps: +# - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4.2.2 + +# - name: Setup Node & Install dependencies +# uses: ./.github/actions/setup + +# - name: Build project +# run: pnpm run build + +# - name: Run tests +# run: pnpm run test + +# test-e2e-headful: +# name: Run E2E tests (headful) +# runs-on: ubuntu-latest +# steps: +# - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4.2.2 + +# - name: Setup Node & Install dependencies +# uses: ./.github/actions/setup + +# - name: Install Foundry +# uses: foundry-rs/foundry-toolchain@de808b1eea699e761c404bda44ba8f21aba30b2c # pin@v1.3.1 + +# - name: Install Playwright dependencies +# run: pnpm dlx playwright@1.48.2 install-deps + +# # For now, we only need Chromium. +# - name: Install browsers for Playwright +# run: pnpm dlx playwright@1.48.2 install chromium + +# - name: Build project +# run: pnpm run build + +# - name: Install linux dependencies +# run: | +# sudo apt-get install --no-install-recommends -y \ +# xvfb + +# - name: Build project +# run: pnpm run build + +# - name: Serve MetaMask Test Dapp +# run: | +# pnpm run serve:test-dapp & + +# - name: Build cache (metamask) +# run: | +# xvfb-run pnpm run build:cache:metamask + +# - name: Build cache (phantom) +# run: | +# xvfb-run pnpm run build:cache:phantom + +# - name: Run E2E tests (headful) +# run: | +# xvfb-run pnpm run test:playwright:headful + +# - name: Archive Playwright report Phantom +# uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # pin@v4.6.0 +# if: success() || failure() +# with: +# name: playwright-report-headful-phantom +# path: | +# wallets/phantom/playwright-report-headful/ +# if-no-files-found: error + +# - name: Archive Playwright report Metamask +# uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # pin@v4.6.0 +# if: success() || failure() +# with: +# name: playwright-report-headful-metamask +# path: | +# wallets/metamask/playwright-report-headful/ +# if-no-files-found: error diff --git a/.github/workflows/testjj2.yml b/.github/workflows/testjj2.yml new file mode 100644 index 000000000..b3ae449e7 --- /dev/null +++ b/.github/workflows/testjj2.yml @@ -0,0 +1,65 @@ +name: 🧑‍🔧 Test - Phantom + +on: + push: + branches: + - dev + pull_request: + paths: + - '.github/actions/**/*.yml' + - '.github/workflows/**/*.yml' + - 'packages/**' + - 'wallets/**' + +jobs: + test-e2e-headful: + name: Run E2E tests (headful) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4.2.2 + + - name: Setup Node & Install dependencies + uses: ./.github/actions/setup + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@de808b1eea699e761c404bda44ba8f21aba30b2c # pin@v1.3.1 + + - name: Install Playwright dependencies + run: pnpm dlx playwright@1.48.2 install-deps + + # For now, we only need Chromium. + - name: Install browsers for Playwright + run: pnpm dlx playwright@1.48.2 install chromium + + - name: Build project + run: pnpm run build + + - name: Install linux dependencies + run: | + sudo apt-get install --no-install-recommends -y \ + xvfb + + - name: Build project + run: pnpm run build + + - name: Serve MetaMask Test Dapp + run: | + pnpm run serve:test-dapp & + + - name: Build cache (phantom) + run: | + xvfb-run pnpm run build:cache:phantom + + - name: Run E2E tests (headful) + run: | + xvfb-run pnpm run test:playwright:headful + + - name: Archive Playwright report Phantom + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # pin@v4.6.0 + if: success() || failure() + with: + name: playwright-report-headful-phantom + path: | + wallets/phantom/playwright-report-headful/ + if-no-files-found: error + diff --git a/.gitignore b/.gitignore index d4a4b71df..1ef33535e 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,5 @@ playwright/.cache output synpress-source.txt + +playwright-report* diff --git a/package.json b/package.json index 333f5d8c0..9f9dd0637 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "license": "MIT", "scripts": { "build": "turbo build", - "build:cache": "turbo build:cache --filter=@synthetixio/synpress-metamask", - "docs:build": "turbo docs:build --filter=docs", + "build:cache:metamask": "turbo build:cache --filter=@synthetixio/synpress-metamask", + "build:cache:phantom": "turbo build:cache --filter=@synthetixio/synpress-phantom", "format": "biome format . --write", "format:check": "biome format . --error-on-warnings", "preinstall": "npx only-allow pnpm", @@ -21,8 +21,8 @@ "sort-package-json": "sort-package-json 'package.json' '{packages,wallets,examples}/*/package.json'", "sort-package-json:check": "sort-package-json 'package.json' '{packages,wallets,examples}/*/package.json' --check", "test": "turbo test", - "test:playwright:headful": "turbo test:playwright:headful --filter=@synthetixio/synpress-metamask --filter=@synthetixio/ethereum-wallet-mock", - "test:playwright:headless": "turbo test:playwright:headless --filter=@synthetixio/synpress-metamask --filter=@synthetixio/ethereum-wallet-mock", + "test:playwright:headful": "turbo test:playwright:headful --filter=@synthetixio/synpress-phantom", + "test:playwright:headless": "turbo test:playwright:headless --filter=@synthetixio/synpress-metamask --filter=@synthetixio/ethereum-wallet-mock --filter=@synthetixio/synpress-phantom", "update:deps": "ncu -u -ws --root" }, "lint-staged": { diff --git a/packages/cache/package.json b/packages/cache/package.json index 392932456..5ab9d8929 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -44,6 +44,7 @@ "gradient-string": "2.0.2", "progress": "2.0.3", "tsup": "8.0.2", + "unzip-crx-3": "0.2.0", "unzipper": "0.10.14", "zod": "3.22.4" }, diff --git a/packages/cache/src/cli/cliEntrypoint.ts b/packages/cache/src/cli/cliEntrypoint.ts index 7ce093b65..84e0e2c5d 100644 --- a/packages/cache/src/cli/cliEntrypoint.ts +++ b/packages/cache/src/cli/cliEntrypoint.ts @@ -7,6 +7,7 @@ import { rimraf } from 'rimraf' import { WALLET_SETUP_DIR_NAME } from '../constants' import { createCache } from '../createCache' import { prepareExtension } from '../prepareExtension' +import { prepareExtensionPhantom } from '../prepareExtensionPhantom' import { compileWalletSetupFunctions } from './compileWalletSetupFunctions' import { footer } from './footer' @@ -14,6 +15,7 @@ interface CliFlags { headless: boolean force: boolean debug: boolean + phantom: boolean } // Helper function to check if running in WSL @@ -41,6 +43,7 @@ export const cliEntrypoint = async () => { ) .option('-f, --force', 'Force the creation of cache even if it already exists', false) .option('-d, --debug', 'If this flag is present, the compilation files are not going to be deleted', false) + .option('-p, --phantom', 'If this flag is present, Phantom extension will be installed instead of Metamask', false) .helpOption(undefined, 'Display help for command') .addHelpText('afterAll', `\n${footer}\n`) .parse(process.argv) @@ -88,8 +91,12 @@ export const cliEntrypoint = async () => { flags.debug ) - // TODO: We should be using `prepareExtension` function from the wallet itself! - await createCache(compiledWalletSetupDirPath, setupFunctionHashes, prepareExtension, flags.force) + // TODO: We should be using `prepareExtension` functions from the wallet itself! + if (flags.phantom) { + await createCache(compiledWalletSetupDirPath, setupFunctionHashes, prepareExtensionPhantom, flags.force) + } else { + await createCache(compiledWalletSetupDirPath, setupFunctionHashes, prepareExtension, flags.force) + } if (!flags.debug) { await rimraf(compiledWalletSetupDirPath) diff --git a/packages/cache/src/index.ts b/packages/cache/src/index.ts index 027dbc751..c1fdff25a 100644 --- a/packages/cache/src/index.ts +++ b/packages/cache/src/index.ts @@ -8,3 +8,4 @@ export * from './utils/createTempContextDir' export * from './utils/removeTempContextDir' export * from './prepareExtension' export * from './cli/cliEntrypoint' +export * from './prepareExtensionPhantom' diff --git a/packages/cache/src/prepareExtensionPhantom.ts b/packages/cache/src/prepareExtensionPhantom.ts new file mode 100644 index 000000000..575f0e8f6 --- /dev/null +++ b/packages/cache/src/prepareExtensionPhantom.ts @@ -0,0 +1,21 @@ +import { downloadFile, ensureCacheDirExists, unzipArchivePhantom } from '.' + +export const DEFAULT_PHANTOM_VERSION = 'latest' +export const PHANTOM_EXTENSION_DOWNLOAD_URL = 'https://crx-backup.phantom.dev/latest.crx' + +// NOTE: This function is copied from `wallets/phantom/src/prepareExtensionPhantom.ts` only TEMPORARILY! +export async function prepareExtensionPhantom() { + const cacheDirPath = ensureCacheDirExists() + + const downloadResult = await downloadFile({ + url: PHANTOM_EXTENSION_DOWNLOAD_URL, + outputDir: cacheDirPath, + fileName: 'phantom-chrome-latest.crx' + }) + + const unzipResult = await unzipArchivePhantom({ + archivePath: downloadResult.filePath + }) + + return unzipResult.outputPath +} diff --git a/packages/cache/src/unzipArchive.ts b/packages/cache/src/unzipArchive.ts index ab4af5052..dfb8c1955 100644 --- a/packages/cache/src/unzipArchive.ts +++ b/packages/cache/src/unzipArchive.ts @@ -1,5 +1,6 @@ import path from 'node:path' import fs from 'fs-extra' +import unzipCrx from 'unzip-crx-3' import unzippper from 'unzipper' type UnzipArchiveOptions = { @@ -73,3 +74,27 @@ export async function unzipArchive(options: UnzipArchiveOptions) { throw new Error(`[UnzipFile] Error unzipping the file - ${error.message}`) }) } + +export async function unzipArchivePhantom(options: UnzipArchiveOptions) { + const { archivePath, overwrite } = options + + const archiveFileExtension = archivePath.split('.').slice(-1) + const outputPath = archivePath.replace(`.${archiveFileExtension}`, '') + + const fileExists = fs.existsSync(outputPath) + if (fileExists && !overwrite) { + return { + outputPath, + unzipSkipped: true + } + } + + // Creates the output directory + fs.mkdirSync(outputPath, { recursive: true }) + + await unzipCrx(archivePath, outputPath) + + // TODO: Handle errors + + return { outputPath } +} diff --git a/packages/cache/tsconfig.build.json b/packages/cache/tsconfig.build.json index 9af73f809..a0d6937e9 100644 --- a/packages/cache/tsconfig.build.json +++ b/packages/cache/tsconfig.build.json @@ -8,5 +8,5 @@ "declarationMap": true }, "include": ["src"], - "files": ["environment.d.ts"] + "files": ["environment.d.ts", "unzip-crx-3.d.ts"] } diff --git a/packages/cache/tsconfig.json b/packages/cache/tsconfig.json index 14c18c50e..9f68e10a6 100644 --- a/packages/cache/tsconfig.json +++ b/packages/cache/tsconfig.json @@ -4,9 +4,6 @@ "rootDir": ".", "lib": ["DOM"] }, - "include": [ - "src", - "test" - ], - "files": ["environment.d.ts"] + "include": ["src", "test"], + "files": ["environment.d.ts", "unzip-crx-3.d.ts"] } diff --git a/packages/cache/unzip-crx-3.d.ts b/packages/cache/unzip-crx-3.d.ts new file mode 100644 index 000000000..bee17e7ac --- /dev/null +++ b/packages/cache/unzip-crx-3.d.ts @@ -0,0 +1 @@ +declare module 'unzip-crx-3' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0214c15d7..ac2b3a160 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,9 @@ importers: tsup: specifier: 8.0.2 version: 8.0.2(postcss@8.5.3)(typescript@5.3.3) + unzip-crx-3: + specifier: 0.2.0 + version: 0.2.0 unzipper: specifier: 0.10.14 version: 0.10.14 @@ -218,6 +221,9 @@ importers: '@synthetixio/synpress-metamask': specifier: workspace:* version: link:../wallets/metamask + '@synthetixio/synpress-phantom': + specifier: workspace:* + version: link:../wallets/phantom devDependencies: '@synthetixio/synpress-tsconfig': specifier: 0.0.9 @@ -349,6 +355,52 @@ importers: specifier: 1.2.2 version: 1.2.2(@types/node@20.11.17) + wallets/phantom: + dependencies: + '@playwright/test': + specifier: 1.48.2 + version: 1.48.2 + '@synthetixio/synpress-cache': + specifier: workspace:* + version: link:../../packages/cache + '@synthetixio/synpress-core': + specifier: workspace:* + version: link:../../packages/core + '@viem/anvil': + specifier: 0.0.7 + version: 0.0.7 + fs-extra: + specifier: 11.2.0 + version: 11.2.0 + zod: + specifier: 3.22.4 + version: 3.22.4 + devDependencies: + '@synthetixio/synpress-tsconfig': + specifier: 0.0.4 + version: 0.0.4 + '@types/fs-extra': + specifier: 11.0.4 + version: 11.0.4 + '@types/node': + specifier: 20.11.17 + version: 20.11.17 + '@vitest/coverage-v8': + specifier: 1.2.2 + version: 1.2.2(vitest@1.2.2(@types/node@20.11.17)) + rimraf: + specifier: 5.0.5 + version: 5.0.5 + tsup: + specifier: 8.0.2 + version: 8.0.2(postcss@8.5.3)(typescript@5.3.3) + typescript: + specifier: 5.3.3 + version: 5.3.3 + vitest: + specifier: 1.2.2 + version: 1.2.2(@types/node@20.11.17) + packages: '@adraffy/ens-normalize@1.10.0': @@ -1500,6 +1552,9 @@ packages: peerDependencies: '@playwright/test': '*' + '@synthetixio/synpress-tsconfig@0.0.4': + resolution: {integrity: sha512-hbCj7Tr3fFt1oJ1ceu8aQeUgG8I8SHOUxM6V2sHHSK9+2caEKxg1ehqVBObY7Tt2X4WyZerJa92WPVpX/TKSRA==} + '@synthetixio/synpress-tsconfig@0.0.9': resolution: {integrity: sha512-wLwdoHVg2lPAMcm4m36BnJyaxEXZmIfZEMLHAE0eWN16ObqidxzfwFjAVK26GqhVlngjhKTUSUWt3XB9GFNjTQ==} @@ -2881,6 +2936,9 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-lazy@4.0.0: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} engines: {node: '>=8'} @@ -3228,6 +3286,9 @@ packages: resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} engines: {'0': node >=0.6.0} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3251,6 +3312,9 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lilconfig@3.0.0: resolution: {integrity: sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==} engines: {node: '>=14'} @@ -3750,6 +3814,9 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} hasBin: true + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse-github-url@1.0.3: resolution: {integrity: sha512-tfalY5/4SqGaV/GIGzWyHnFjlpTPTNpENR9Ea2lLldSJ8EWXMsvacWucqY3m3I4YPtas15IxTLQVQ5NSYXPrww==} engines: {node: '>= 0.10'} @@ -4740,6 +4807,9 @@ packages: resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} engines: {node: '>=8'} + unzip-crx-3@0.2.0: + resolution: {integrity: sha512-0+JiUq/z7faJ6oifVB5nSwt589v1KCduqIJupNVDoWSXZtWDmjDGO3RAEOvwJ07w90aoXoP4enKsR7ecMrJtWQ==} + unzipper@0.10.14: resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} @@ -4975,6 +5045,9 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yaku@0.16.7: + resolution: {integrity: sha512-Syu3IB3rZvKvYk7yTiyl1bo/jiEFaaStrgv1V2TIJTqYPStSMQVO8EQjg/z+DRzLq/4LIIharNT3iH1hylEIRw==} + yallist@2.1.2: resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==} @@ -6182,6 +6255,8 @@ snapshots: dependencies: '@playwright/test': 1.48.2 + '@synthetixio/synpress-tsconfig@0.0.4': {} + '@synthetixio/synpress-tsconfig@0.0.9': {} '@szmarczak/http-timer@5.0.1': @@ -7881,6 +7956,8 @@ snapshots: ignore@5.3.2: {} + immediate@3.0.6: {} + import-lazy@4.0.0: {} imurmurhash@0.1.4: {} @@ -8186,6 +8263,13 @@ snapshots: json-schema: 0.4.0 verror: 1.10.0 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -8204,6 +8288,10 @@ snapshots: dependencies: readable-stream: 2.3.8 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lilconfig@3.0.0: {} lilconfig@3.1.3: {} @@ -8822,6 +8910,8 @@ snapshots: - bluebird - supports-color + pako@1.0.11: {} + parse-github-url@1.0.3: {} parse-json@5.2.0: @@ -9860,6 +9950,12 @@ snapshots: untildify@4.0.0: {} + unzip-crx-3@0.2.0: + dependencies: + jszip: 3.10.1 + mkdirp: 0.5.6 + yaku: 0.16.7 + unzipper@0.10.14: dependencies: big-integer: 1.6.52 @@ -10132,6 +10228,8 @@ snapshots: y18n@5.0.8: {} + yaku@0.16.7: {} + yallist@2.1.2: {} yallist@4.0.0: {} diff --git a/release/package.json b/release/package.json index 31db35440..b05a2072b 100644 --- a/release/package.json +++ b/release/package.json @@ -48,7 +48,8 @@ "@synthetixio/ethereum-wallet-mock": "workspace:*", "@synthetixio/synpress-cache": "workspace:*", "@synthetixio/synpress-core": "workspace:*", - "@synthetixio/synpress-metamask": "workspace:*" + "@synthetixio/synpress-metamask": "workspace:*", + "@synthetixio/synpress-phantom": "workspace:*" }, "devDependencies": { "@synthetixio/synpress-tsconfig": "0.0.9", diff --git a/release/src/playwright/index.ts b/release/src/playwright/index.ts index f97810c9b..a83beb951 100644 --- a/release/src/playwright/index.ts +++ b/release/src/playwright/index.ts @@ -1,2 +1,3 @@ export * from '@synthetixio/ethereum-wallet-mock/playwright' export * from '@synthetixio/synpress-metamask/playwright' +export * from '@synthetixio/synpress-phantom/playwright' diff --git a/turbo.json b/turbo.json index 58c44315a..17ed57630 100644 --- a/turbo.json +++ b/turbo.json @@ -16,9 +16,6 @@ }, "test:playwright:headless": { "dependsOn": ["build", "build:cache"] - }, - "docs:build": { - "outputs": [".vitepress/dist/**"] } } } diff --git a/wallets/phantom/environment.d.ts b/wallets/phantom/environment.d.ts new file mode 100644 index 000000000..e214b5b09 --- /dev/null +++ b/wallets/phantom/environment.d.ts @@ -0,0 +1,10 @@ +declare global { + namespace NodeJS { + interface ProcessEnv { + CI: boolean + HEADLESS: boolean + } + } +} + +export {} diff --git a/wallets/phantom/package.json b/wallets/phantom/package.json new file mode 100644 index 000000000..bb2b32058 --- /dev/null +++ b/wallets/phantom/package.json @@ -0,0 +1,54 @@ +{ + "name": "@synthetixio/synpress-phantom", + "version": "0.0.4", + "type": "module", + "exports": { + "./playwright": { + "types": "./types/playwright/index.d.ts", + "default": "./dist/playwright/index.js" + } + }, + "main": "./dist/index.js", + "types": "./types/index.d.ts", + "files": [ + "dist", + "src", + "types" + ], + "scripts": { + "build": "pnpm run clean && pnpm run build:dist && pnpm run build:types", + "build:cache": "synpress-cache test/playwright/wallet-setup --phantom --debug", + "build:cache:headless": "synpress-cache test/playwright/wallet-setup --phantom --headless", + "build:cache:headless:force": "synpress-cache test/playwright/wallet-setup --phantom --headless --force", + "build:dist": "tsup --tsconfig tsconfig.build.json", + "build:types": "tsc --emitDeclarationOnly --project tsconfig.build.json", + "clean": "rimraf dist types", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:playwright:headful": "playwright test", + "test:playwright:headless": "HEADLESS=true playwright test", + "test:playwright:headless:ui": "HEADLESS=true playwright test --ui", + "test:watch": "vitest watch", + "types:check": "tsc --noEmit" + }, + "dependencies": { + "@synthetixio/synpress-cache": "workspace:*", + "@synthetixio/synpress-core": "workspace:*", + "@viem/anvil": "0.0.7", + "fs-extra": "11.2.0", + "zod": "3.22.4" + }, + "devDependencies": { + "@synthetixio/synpress-tsconfig": "0.0.4", + "@types/fs-extra": "11.0.4", + "@types/node": "20.11.17", + "@vitest/coverage-v8": "1.2.2", + "rimraf": "5.0.5", + "tsup": "8.0.2", + "typescript": "5.3.3", + "vitest": "1.2.2" + }, + "peerDependencies": { + "@playwright/test": "1.48.2" + } +} diff --git a/wallets/phantom/playwright.config.ts b/wallets/phantom/playwright.config.ts new file mode 100644 index 000000000..ff9a9018d --- /dev/null +++ b/wallets/phantom/playwright.config.ts @@ -0,0 +1,62 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + // Look for test files in the "test/e2e" directory, relative to this configuration file. + testDir: './test/playwright/e2e', + + // We're increasing the timeout to 60 seconds to allow all traces to be recorded. + // Sometimes it threw an error saying that traces were not recorded in the 30 seconds timeout limit. + timeout: 60_000, + + // Run all tests in parallel. + fullyParallel: true, + + // Fail the build on CI if you accidentally left test.only in the source code. + forbidOnly: !!process.env.CI, + + // Fail all remaining tests on CI after the first failure. We want to reduce the feedback loop on CI to minimum. + maxFailures: process.env.CI ? 1 : 0, + + // Opt out of parallel tests on CI since it supports only 1 worker. + workers: process.env.CI ? 1 : undefined, + + // Concise 'dot' for CI, default 'html' when running locally. + // See https://playwright.dev/docs/test-reporters. + reporter: process.env.CI + ? [ + [ + 'html', + { + open: 'never', + outputFolder: `playwright-report-${process.env.HEADLESS ? 'headless' : 'headful'}` + } + ] + ] + : 'html', + + // Shared settings for all the projects below. + // See https://playwright.dev/docs/api/class-testoptions. + use: { + // We are using locally deployed Metamask Test Dapp for somePhantom tests. + baseURL: 'http://localhost:9999', + + // Collect all traces on CI, and only traces for failed tests when running locally. + // See https://playwright.dev/docs/trace-viewer. + // trace: process.env.CI ? 'on' : 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + // Added for getting account address + permissions: ['clipboard-read'] + }, + + // Configure projects for major browsers. + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + } + ] +}) diff --git a/wallets/phantom/src/index.ts b/wallets/phantom/src/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/wallets/phantom/src/playwright/Phantom.ts b/wallets/phantom/src/playwright/Phantom.ts new file mode 100644 index 000000000..557e8d06d --- /dev/null +++ b/wallets/phantom/src/playwright/Phantom.ts @@ -0,0 +1,313 @@ +import type { BrowserContext, Page } from '@playwright/test' +import type { GasSettings } from '../type/GasSettings' +import type { Networks } from '../type/Networks' +import { PhantomAbstract } from '../type/PhantomAbstract' +import { HomePage, NotificationPage, OnboardingPage, UnlockPage } from './pages' + +const NO_EXTENSION_ID_ERROR = new Error('Phantom extensionId is not set') + +/** + * Phantom class for interacting with the Phantom extension in Playwright tests. + * + * This class provides methods to perform various operations on the Phantom extension, + * such as importing wallets, switching networks, confirming transactions, and more. + * + * @class + * @extends PhantomAbstract + */ +export class Phantom extends PhantomAbstract { + /** + * This property can be used to access selectors for the onboarding page. + * + * @public + * @readonly + */ + readonly onboardingPage: OnboardingPage + + /** + * This property can be used to access selectors for the lock page. + * + * @public + * @readonly + */ + readonly unlockPage: UnlockPage + + /** + * This property can be used to access selectors for the home page. + * + * @public + * @readonly + */ + readonly homePage: HomePage + + /** + * This property can be used to access selectors for the notification page. + * + * @public + * @readonly + */ + readonly notificationPage: NotificationPage + + /** + * Creates an instance of Phantom. + * + * @param context - The Playwright BrowserContext in which the Phantom extension is running. + * @param page - The Playwright Page object representing the Phantom extension's main page. + * @param password - The password for the Phantom wallet. + * @param extensionId - The ID of the Phantom extension. Optional if no interaction with dapps is required. + */ + constructor( + readonly context: BrowserContext, + readonly page: Page, + override readonly password: string, + override readonly extensionId?: string + ) { + super(password, extensionId) + + this.onboardingPage = new OnboardingPage(page) + this.unlockPage = new UnlockPage(page) + this.homePage = new HomePage(page) + this.notificationPage = new NotificationPage(page) + } + + /** + * Imports a wallet using the given seed phrase. + * + * @param seedPhrase - The seed phrase to import. + */ + async importWallet(seedPhrase: string): Promise { + await this.onboardingPage.importWallet(seedPhrase, this.password) + } + + /** + * Adds a new account with the given name. + * + * @param accountName - The name for the new account. + */ + async addNewAccount(accountName: string): Promise { + await this.homePage.addNewAccount(accountName) + } + + /** + * Renames the currently selected account. + * + * @param currentAccountName - The current account name. + * @param newAccountName - The new name for the account. + */ + async renameAccount(currentAccountName: string, newAccountName: string): Promise { + await this.homePage.renameAccount(currentAccountName, newAccountName) + } + + /** + * Imports a wallet using the given private key. + * + * @param network - Network that the wallet belongs to. + * @param privateKey - The private key to import. + * @param privateKey - Name given to the new wallet/account. + */ + async importWalletFromPrivateKey( + network: 'solana' | 'ethereum' | 'base' | 'polygon' | 'bitcoin', + privateKey: string, + walletName?: string + ): Promise { + await this.homePage.importWalletFromPrivateKey(network, privateKey, walletName) + } + + /** + * Switches to the account with the given name. + * + * @param accountName - The name of the account to switch to. + */ + async switchAccount(accountName: string): Promise { + await this.homePage.switchAccount(accountName) + } + + /** + * Gets the address of the currently selected account. + * + * @param network - Network that the address belongs to. + * @returns The account address. + */ + async getAccountAddress(network: Networks): Promise { + return await this.homePage.getAccountAddress(network) + } + + /** + * Connects Phantom to a dapp. + * + * @param accounts - Optional array of account addresses to connect. + * @throws {Error} If extensionId is not set. + */ + async connectToDapp(account?: string): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.connectToDapp(this.extensionId, account) + } + + /** + * Locks the Phantom wallet. + */ + async lock(): Promise { + await this.homePage.lock() + } + + /** + * Unlocks the Phantom wallet. + */ + async unlock(): Promise { + await this.unlockPage.unlock(this.password) + } + + /** + * Confirms a signature request. + * + * @throws {Error} If extensionId is not set. + */ + async confirmSignature(): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.signMessage(this.extensionId) + } + + /** + * Confirms a signature request with risk. + * + * @throws {Error} If extensionId is not set. + */ + async confirmSignatureWithRisk(): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.signMessageWithRisk(this.extensionId) + } + + /** + * Rejects a signature request. + * + * @throws {Error} If extensionId is not set. + */ + async rejectSignature(): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.rejectMessage(this.extensionId) + } + + /** + * Confirms a transaction. + * + * @param options - Optional gas settings for the transaction. + * @throws {Error} If extensionId is not set. + */ + async confirmTransaction(options?: { + gasSetting?: GasSettings + }): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.confirmTransaction(this.extensionId, options) + } + + /** + * Rejects a transaction. + * + * @throws {Error} If extensionId is not set. + */ + async rejectTransaction(): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.rejectTransaction(this.extensionId) + } + + /** + * Approves a token permission request. + * + * @param options - Optional settings for the approval. + * @throws {Error} If extensionId is not set. + */ + async approveTokenPermission(options?: { + spendLimit?: 'max' | number + gasSetting?: GasSettings + }): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.approveTokenPermission(this.extensionId, options) + } + + /** + * Rejects a token permission request. + * + * @throws {Error} If extensionId is not set. + */ + async rejectTokenPermission(): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.rejectTokenPermission(this.extensionId) + } + + /** + * Navigates to the home page or wallet dashboard. + */ + async goToHomePage(): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.homePage.goToHomePage(this.extensionId) + } + + /** + * Navigates back to the home page. + */ + async goBackToHomePage(): Promise { + await this.homePage.goBackToHomePage() + } + + /** + * Opens the settings page. + */ + async openSettings(): Promise { + await this.homePage.openSettings() + } + + /** + * Toggles the display of test networks. + */ + async toggleTestnetMode(): Promise { + await this.homePage.toggleTestnetMode() + } + + /** + * Resets the account. + */ + async resetApp(): Promise { + await this.homePage.resetApp() + } + + /** + * Connects Phantom to a dapp. + * + * @param accounts - Optional array of account addresses to connect. + * @throws {Error} If extensionId is not set. + */ + async closeUnsupportedNetworkWarning(): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.closeUnsupportedNetworkWarning(this.extensionId) + } +} diff --git a/wallets/phantom/src/playwright/fixture-actions/getExtensionId.ts b/wallets/phantom/src/playwright/fixture-actions/getExtensionId.ts new file mode 100644 index 000000000..e570080d6 --- /dev/null +++ b/wallets/phantom/src/playwright/fixture-actions/getExtensionId.ts @@ -0,0 +1,46 @@ +import type { BrowserContext } from '@playwright/test' +import { z } from 'zod' + +const Extension = z.object({ + id: z.string(), + name: z.string() +}) + +const Extensions = z.array(Extension) + +/** + * Returns the extension ID for the given extension name. The ID is fetched from the `chrome://extensions` page. + * + * ::: tip + * This function soon will be removed to improve the developer experience! 😇 + * ::: + * + * @param context - The browser context. + * @param extensionName - The name of the extension, e.g., `Phantom`. + * + * @returns The extension ID. + */ +export async function getExtensionIdPhantom(context: BrowserContext, extensionName: string) { + const page = await context.newPage() + await page.goto('chrome://extensions') + + const unparsedExtensions = await page.evaluate('chrome.management.getAll()') + + const allExtensions = Extensions.parse(unparsedExtensions) + const targetExtension = allExtensions.find( + (extension) => extension.name.toLowerCase() === extensionName.toLowerCase() + ) + + if (!targetExtension) { + throw new Error( + [ + `[GetExtensionId] Extension with name ${extensionName} not found.`, + `Available extensions: ${allExtensions.map((extension) => extension.name).join(', ')}` + ].join('\n') + ) + } + + await page.close() + + return targetExtension.id +} diff --git a/wallets/phantom/src/playwright/fixture-actions/index.ts b/wallets/phantom/src/playwright/fixture-actions/index.ts new file mode 100644 index 000000000..6d7fc97d1 --- /dev/null +++ b/wallets/phantom/src/playwright/fixture-actions/index.ts @@ -0,0 +1,2 @@ +export * from './getExtensionId' +export * from './prepareExtensionPhantom' diff --git a/wallets/phantom/src/playwright/fixture-actions/persistLocalStorage.ts b/wallets/phantom/src/playwright/fixture-actions/persistLocalStorage.ts new file mode 100644 index 000000000..e24deb66d --- /dev/null +++ b/wallets/phantom/src/playwright/fixture-actions/persistLocalStorage.ts @@ -0,0 +1,24 @@ +import type { BrowserContext } from '@playwright/test' + +export async function persistLocalStorage( + origins: { + origin: string + localStorage: { name: string; value: string }[] + }[], + context: BrowserContext +) { + const newPage = await context.newPage() + + for (const { origin, localStorage } of origins) { + const frame = newPage.mainFrame() + await frame.goto(origin) + + await frame.evaluate((localStorageData) => { + localStorageData.forEach(({ name, value }) => { + window.localStorage.setItem(name, value) + }) + }, localStorage) + } + + await newPage.close() +} diff --git a/wallets/phantom/src/playwright/fixture-actions/prepareExtensionPhantom.ts b/wallets/phantom/src/playwright/fixture-actions/prepareExtensionPhantom.ts new file mode 100644 index 000000000..e69de29bb diff --git a/wallets/phantom/src/playwright/fixtures/phantomFixtures.ts b/wallets/phantom/src/playwright/fixtures/phantomFixtures.ts new file mode 100644 index 000000000..062a47aa3 --- /dev/null +++ b/wallets/phantom/src/playwright/fixtures/phantomFixtures.ts @@ -0,0 +1,109 @@ +import path from 'node:path' +import { type Page, chromium } from '@playwright/test' +import { test as base } from '@playwright/test' +import { + CACHE_DIR_NAME, + createTempContextDir, + defineWalletSetup, + removeTempContextDir +} from '@synthetixio/synpress-cache' +import fs from 'fs-extra' +import { prepareExtensionPhantom } from '../../prepareExtensionPhantom' +import { Phantom } from '../Phantom' +import { getExtensionIdPhantom } from '../fixture-actions' +import { persistLocalStorage } from '../fixture-actions/persistLocalStorage' +import { closeSuiAndMonadIfPresent } from '../pages/HomePage/actions' +import { unlock } from '../pages/UnlockPage/actions' +import { loadAndWaitForPopupPage } from '../utils/waitForPopupPageLoad' + +type PhantomFixtures = { + _contextPath: string + phantom: Phantom + extensionId: string + phantomPage: Page +} + +// If setup phantomPage in a fixture, browser does not handle it properly (even if ethereum.isConnected() is true, it's not reflected on the page). +let _phantomPage: Page + +export const phantomFixtures = (walletSetup: ReturnType, slowMo = 0) => { + return base.extend({ + _contextPath: async ({ browserName }, use, testInfo) => { + const contextPath = await createTempContextDir(browserName, testInfo.testId) + + await use(contextPath) + + const error = await removeTempContextDir(contextPath) + if (error) { + console.error(error) + } + }, + context: async ({ context: currentContext, _contextPath }, use) => { + const cacheDirPath = path.join(process.cwd(), CACHE_DIR_NAME, walletSetup.hash) + if (!(await fs.exists(cacheDirPath))) { + throw new Error(`Cache for ${walletSetup.hash} does not exist. Create it first!`) + } + + // Copying the cache to the temporary context directory. + await fs.copy(cacheDirPath, _contextPath) + + const phantomPath = await prepareExtensionPhantom() + + // We don't need the `--load-extension` arg since the extension is already loaded in the cache. + const browserArgs = [`--disable-extensions-except=${phantomPath}`] + + if (process.env.HEADLESS) { + browserArgs.push('--headless=new') + + if (slowMo > 0) { + console.warn('[WARNING] Slow motion makes no sense in headless mode. It will be ignored!') + } + } + + const context = await chromium.launchPersistentContext(_contextPath, { + headless: false, + args: browserArgs, + slowMo: process.env.HEADLESS ? 0 : slowMo + }) + + const { cookies, origins } = await currentContext.storageState() + + if (cookies) { + await context.addCookies(cookies) + } + if (origins && origins.length > 0) { + await persistLocalStorage(origins, context) + } + + const extensionId = await getExtensionIdPhantom(context, 'Phantom') + + _phantomPage = await loadAndWaitForPopupPage(context, extensionId) + + await unlock(_phantomPage, walletSetup.walletPassword) + + await use(context) + + await context.close() + }, + phantomPage: async ({ context: _ }, use) => { + await closeSuiAndMonadIfPresent(_phantomPage) + + await use(_phantomPage) + }, + extensionId: async ({ context }, use) => { + const extensionId = await getExtensionIdPhantom(context, 'Phantom') + + await use(extensionId) + }, + phantom: async ({ context, extensionId }, use) => { + const phantom = new Phantom(context, _phantomPage, walletSetup.walletPassword, extensionId) + + await use(phantom) + }, + page: async ({ page }, use) => { + await page.goto('/') + + await use(page) + } + }) +} diff --git a/wallets/phantom/src/playwright/index.ts b/wallets/phantom/src/playwright/index.ts new file mode 100644 index 000000000..fe0d3a347 --- /dev/null +++ b/wallets/phantom/src/playwright/index.ts @@ -0,0 +1,3 @@ +export * from './Phantom' +export * from './fixtures/phantomFixtures' +export * from './fixture-actions' diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/addNewAccount.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/addNewAccount.ts new file mode 100644 index 000000000..305b7d9e2 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/addNewAccount.ts @@ -0,0 +1,19 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/HomePage' + +export async function addNewAccount(page: Page, accountName: string) { + // TODO: Use zod to validate this. + if (accountName.length === 0) { + throw new Error('[AddNewAccount] Account name cannot be an empty string') + } + + await page.locator(Selectors.accountMenu.accountButton).click() + + await page.locator(Selectors.accountMenu.addAccountMenu.addAccountButton).click() + + await page.locator(Selectors.accountMenu.addAccountMenu.createNewAccountButton).click() + + await page.locator(Selectors.accountMenu.addAccountMenu.addNewAccountMenu.accountNameInput).fill(accountName) + + await page.locator(Selectors.accountMenu.addAccountMenu.addNewAccountMenu.createButton).click() +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/closeSuiAndMonadScreen.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/closeSuiAndMonadScreen.ts new file mode 100644 index 000000000..56bb26993 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/closeSuiAndMonadScreen.ts @@ -0,0 +1,34 @@ +import { type Page, expect } from '@playwright/test' + +export async function closeSuiAndMonadIfPresent(page: Page) { + // Wait for Phantom page to fully load + const walletValueUsdRegExp = new RegExp('\\$.*[0-9].[0-9]{1,2}.*[0-9]{1,2}\\%') + await expect(page.getByText(walletValueUsdRegExp), 'Wallet value should be visible').toBeVisible({ timeout: 15_000 }) + + // Reload page to trigger Sui and/or Monad screens + await page.reload() + // Wait for Phantom page to fully load + await expect(page.getByText(walletValueUsdRegExp), 'Wallet value should be visible').toBeVisible({ timeout: 15_000 }) + // Wait for Sui / Monad screen to load (if it does) + await page.waitForTimeout(2_000) + + // Loop until Sui/Monad screens have been closed and Phantompage is ready for testing + // => 'ready for testing' = top 'fungible token' row is clickable + await expect(async () => { + const suiIsVisible = await page.getByRole('button', { name: 'Enable Sui' }).isVisible() + + if (suiIsVisible) { + await page.getByRole('button', { name: 'Not Now' }).click() + // Wait for Nomad page to load (if it does) + await page.waitForTimeout(1_000) + } + + const monadIsVisible = await page.getByRole('button', { name: 'Enable Monad' }).isVisible() + + if (monadIsVisible) { + await page.getByRole('button', { name: 'Not Now' }).click() + } + + await page.locator('[data-testid*="fungible-token-row-"]').first().click({ timeout: 3_000 }) + }).toPass() +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/getAccountAddress.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/getAccountAddress.ts new file mode 100644 index 000000000..26dbac6cb --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/getAccountAddress.ts @@ -0,0 +1,19 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/HomePage' +import type { Networks } from '../../../../type/Networks' + +// TODO - .getAccountAddress() to be updated for all networks +export default async function getAccountAddress(network: Networks, page: Page): Promise { + // Copy account address to clipboard + await page.locator(Selectors.accountMenu.accountName).hover() + await page + .locator(Selectors[`${network}WalletAddress`]) + .first() + .click() + + // Get clipboard content + const handle = await page.evaluateHandle(() => navigator.clipboard.readText()) + const account = await handle.jsonValue() + + return account +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/importWalletFromPrivateKey.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/importWalletFromPrivateKey.ts new file mode 100644 index 000000000..d93b67045 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/importWalletFromPrivateKey.ts @@ -0,0 +1,52 @@ +import { type Page, expect } from '@playwright/test' +import Selectors from '../../../../selectors/pages/HomePage' +import type { Networks } from '../../../../type/Networks' +import { waitFor } from '../../../utils/waitFor' +import { closeSuiAndMonadIfPresent } from './closeSuiAndMonadScreen' + +export async function importWalletFromPrivateKey( + page: Page, + network: Networks, + privateKey: string, + walletName?: string +) { + await closeSuiAndMonadIfPresent(page) + + await expect(page.locator(Selectors.accountMenu.accountButton)).toBeVisible() + await page.locator(Selectors.accountMenu.accountButton).click() + + await expect(page.locator(Selectors.accountMenu.addAccountMenu.addAccountButton)).toBeVisible() + + await page.locator(Selectors.accountMenu.addAccountMenu.addAccountButton).click() + + await page.locator(Selectors.accountMenu.addAccountMenu.importAccountPrivateKeyButton).click() + + // SELECT NETWORK + if (network !== 'solana') { + await page.locator(Selectors.accountMenu.addAccountMenu.importAccountMenu.networkOpenMenu).click() + await page.locator(Selectors.accountMenu.addAccountMenu.importAccountMenu[`${network}Network`]).click() + } + + await page + .locator(Selectors.accountMenu.addAccountMenu.importAccountMenu.nameInput) + .fill(walletName ?? 'ImportedWallet') + + await page.locator(Selectors.accountMenu.addAccountMenu.importAccountMenu.privateKeyInput).fill(privateKey) + + const importButton = page.locator(Selectors.accountMenu.addAccountMenu.importAccountMenu.importButton) + + // TODO: Extract & make configurable + const isImportButtonEnabled = await waitFor(() => importButton.isEnabled(), 1_000, false) + + if (!isImportButtonEnabled) { + const errorText = await page.locator(Selectors.accountMenu.addAccountMenu.importAccountMenu.error).textContent({ + timeout: 1_000 // TODO: Extract & make configurable + }) + + throw new Error(`[ImportWalletFromPrivateKey] Importing failed due to error: ${errorText}`) + } + + await importButton.click() + + await expect(page.locator('[data-testid*="fungible-token-row-"]').first()).toBeVisible({ timeout: 10_000 }) +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/index.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/index.ts new file mode 100644 index 000000000..5a24acd3c --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/index.ts @@ -0,0 +1,9 @@ +export * from './addNewAccount' +export * from './closeSuiAndMonadScreen' +export * from './importWalletFromPrivateKey' +export * from './lock' +export * from './renameAccount' +export * from './settings' +export * from './switchAccount' +export * from './toggleTestnetMode' +export { default as getAccountAddress } from './getAccountAddress' diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/lock.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/lock.ts new file mode 100644 index 000000000..07b28874b --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/lock.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test' + +import Selectors from '../../../../selectors/pages/HomePage' + +export async function lock(page: Page) { + await page.locator(Selectors.accountMenu.accountButton).click() + await page.locator(Selectors.accountMenu.settings).click() + + await page.locator(Selectors.settings.lockWallet).click() +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/renameAccount.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/renameAccount.ts new file mode 100644 index 000000000..d6fc5d4be --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/renameAccount.ts @@ -0,0 +1,43 @@ +import { type Page, expect } from '@playwright/test' +import Selectors from '../../../../selectors/pages/HomePage' +import { allTextContents } from '../../../utils/allTextContents' + +export async function renameAccount(page: Page, currentAccountName: string, newAccountName: string) { + // TODO: Use zod to validate this. + if (newAccountName.length === 0) { + throw new Error('[RenameAccount] Account name cannot be an empty string') + } + + await page.locator(Selectors.accountMenu.accountButton).click() + + let accountNames: string[] = [] + + await expect(async () => { + const accountNamesLocators = await page.locator(Selectors.accountMenu.accountNames).all() + + accountNames = await allTextContents(accountNamesLocators) + + expect(accountNames.length).toBeGreaterThan(0) + }).toPass() + + const seekedAccountNames = accountNames.filter( + (name) => name.toLocaleLowerCase() === currentAccountName.toLocaleLowerCase() + ) + + if (seekedAccountNames.length === 0) { + throw new Error(`[SwitchAccount] Account with name ${currentAccountName} not found`) + } + + await page.locator(Selectors.accountMenu.manageAccountsButton).click() + + await page.locator(Selectors.manageAccountButton(currentAccountName)).click() + + await page.locator(Selectors.editAccountMenu.accountNameButton).click() + + await page.locator(Selectors.accountMenu.addAccountMenu.addNewAccountMenu.accountNameInput).fill(newAccountName) + + await page.locator(Selectors.accountMenu.renameAccountMenu.saveButton).click() + + // Verify that account has been renamed + await expect(page.locator(Selectors.editAccountMenu.accountNameButton)).toContainText(newAccountName) +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/settings.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/settings.ts new file mode 100644 index 000000000..e9ad22b5f --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/settings.ts @@ -0,0 +1,23 @@ +import type { Page } from '@playwright/test' +import { homePage, notificationPage } from '../../../../selectors' + +async function openSettings(page: Page) { + await page.locator(homePage.accountMenu.accountButton).click() + await page.locator(homePage.accountMenu.settings).click() +} + +async function resetApp(page: Page) { + await openSettings(page) + + await page.locator(homePage.settings.securityAndPrivacyButton).click() + + await page.waitForSelector(homePage.settings.securityAndPrivacy.resetApp) + await page.locator(homePage.settings.securityAndPrivacy.resetApp).click() + + await page.locator(notificationPage.ActionFooter.continueActionButton).click() +} + +export const settings = { + openSettings, + resetApp +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/switchAccount.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/switchAccount.ts new file mode 100644 index 000000000..07a93ae80 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/switchAccount.ts @@ -0,0 +1,30 @@ +import { type Locator, type Page, expect } from '@playwright/test' +import Selectors from '../../../../selectors/pages/HomePage' +import { allTextContents } from '../../../utils/allTextContents' + +export async function switchAccount(page: Page, accountName: string) { + await page.locator(Selectors.accountMenu.accountButton).click() + + let accountNamesLocators: Locator[] = [] + let accountNames: string[] = [] + + await expect(async () => { + accountNamesLocators = await page.locator(Selectors.accountMenu.accountNames).all() + + accountNames = await allTextContents(accountNamesLocators) + + expect(accountNames.length).toBeGreaterThan(0) + }).toPass() + + const seekedAccountNames = accountNames.filter((name) => name.toLocaleLowerCase() === accountName.toLocaleLowerCase()) + + if (seekedAccountNames.length === 0) { + throw new Error(`[SwitchAccount] Account with name ${accountName} not found`) + } + + // biome-ignore lint/style/noNonNullAssertion: this non-null assertion is intentional + const accountIndex = accountNames.indexOf(seekedAccountNames[0]!) // TODO: handle the undefined here better + + // biome-ignore lint/style/noNonNullAssertion: this non-null assertion is intentional + await accountNamesLocators[accountIndex]!.click() // TODO: handle the undefined here better +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/toggleTestnetMode.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/toggleTestnetMode.ts new file mode 100644 index 000000000..cf40f4d58 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/toggleTestnetMode.ts @@ -0,0 +1,14 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/HomePage' + +// Toggling this through the network dropdown instead of the settings page is a better approach. +// This is in most cases the faster approach, but it's also more reliable. +export async function toggleTestnetMode(page: Page) { + await page.locator(Selectors.accountMenu.accountButton).click() + + await page.locator(Selectors.accountMenu.settings).click() + + await page.locator(Selectors.settings.developerSettingsButton).click() + + await page.locator(Selectors.settings.devSettings.toggleTestnetMode).click() +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/page.ts b/wallets/phantom/src/playwright/pages/HomePage/page.ts new file mode 100644 index 000000000..7fc9b3ff1 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/page.ts @@ -0,0 +1,72 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../selectors/pages/HomePage' +import type { Networks } from '../../../type/Networks' +import { + addNewAccount, + getAccountAddress, + importWalletFromPrivateKey, + lock, + renameAccount, + settings, + switchAccount, + toggleTestnetMode +} from './actions' + +export class HomePage { + static readonly selectors = Selectors + readonly selectors = Selectors + + readonly page: Page + + constructor(page: Page) { + this.page = page + } + + async goToHomePage(extensionId: string) { + await this.page.goto(`chrome-extension://${extensionId}/popup.html`) + } + + async goBackToHomePage() { + await this.page.locator(Selectors.settings.closeSettingsButton).click() + } + + async lock() { + await lock(this.page) + } + + async addNewAccount(accountName: string) { + await addNewAccount(this.page, accountName) + } + + async renameAccount(currentAccountName: string, newAccountName: string) { + await renameAccount(this.page, currentAccountName, newAccountName) + } + + async getAccountAddress(network: Networks) { + return await getAccountAddress(network, this.page) + } + + async importWalletFromPrivateKey( + network: 'solana' | 'ethereum' | 'base' | 'polygon' | 'bitcoin', + privateKey: string, + walletName?: string + ) { + await importWalletFromPrivateKey(this.page, network, privateKey, walletName) + } + + async switchAccount(accountName: string) { + await switchAccount(this.page, accountName) + } + + async openSettings() { + await settings.openSettings(this.page) + } + + async toggleTestnetMode() { + await toggleTestnetMode(this.page) + } + + async resetApp() { + await settings.resetApp(this.page) + } +} diff --git a/wallets/phantom/src/playwright/pages/NotificationPage/actions/approvePermission.ts b/wallets/phantom/src/playwright/pages/NotificationPage/actions/approvePermission.ts new file mode 100644 index 000000000..2e26c1766 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/approvePermission.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/NotificationPage' +import type { GasSettings } from '../../../../type/GasSettings' +import { transaction } from './transaction' + +const approveTokenPermission = async (notificationPage: Page, gasSetting: GasSettings) => { + // Approve flow is identical to the confirm transaction flow after we click the "Next" button. + await transaction.confirm(notificationPage, gasSetting) +} + +const rejectTokenPermission = async (notificationPage: Page) => { + await notificationPage.locator(Selectors.ActionFooter.cancelActionButton).click() +} + +export const approvePermission = { + approve: approveTokenPermission, + reject: rejectTokenPermission +} diff --git a/wallets/phantom/src/playwright/pages/NotificationPage/actions/closeUnsupportedNetworkWarning.ts b/wallets/phantom/src/playwright/pages/NotificationPage/actions/closeUnsupportedNetworkWarning.ts new file mode 100644 index 000000000..783dde53c --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/closeUnsupportedNetworkWarning.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/NotificationPage' + +async function closeWarning(notificationPage: Page) { + await notificationPage.locator(Selectors.ActionFooter.closeActionButton).click() +} + +export async function closeUnsupportedNetworkWarning(notificationPage: Page) { + await closeWarning(notificationPage) +} diff --git a/wallets/phantom/src/playwright/pages/NotificationPage/actions/connectToDapp.ts b/wallets/phantom/src/playwright/pages/NotificationPage/actions/connectToDapp.ts new file mode 100644 index 000000000..eee302c44 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/connectToDapp.ts @@ -0,0 +1,16 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/NotificationPage' +import { switchAccount } from '../../HomePage/actions' + +async function confirmConnection(notificationPage: Page) { + await notificationPage.locator(Selectors.ActionFooter.connectActionButton).click() +} + +// By default, the last account will be selected. If you want to select a specific account, pass `account` parameter. +export async function connectToDapp(notificationPage: Page, account?: string) { + if (account) { + await switchAccount(notificationPage, account) + } + + await confirmConnection(notificationPage) +} diff --git a/wallets/phantom/src/playwright/pages/NotificationPage/actions/index.ts b/wallets/phantom/src/playwright/pages/NotificationPage/actions/index.ts new file mode 100644 index 000000000..35d32209a --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/index.ts @@ -0,0 +1,6 @@ +export * from './approvePermission' +export * from './closeUnsupportedNetworkWarning' +export * from './connectToDapp' +export * from './signSimpleMessage' +export * from './signStructuredMessage' +export * from './transaction' diff --git a/wallets/phantom/src/playwright/pages/NotificationPage/actions/signSimpleMessage.ts b/wallets/phantom/src/playwright/pages/NotificationPage/actions/signSimpleMessage.ts new file mode 100644 index 000000000..9f2a73e47 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/signSimpleMessage.ts @@ -0,0 +1,23 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/NotificationPage' + +const signMessage = async (notificationPage: Page) => { + await notificationPage.locator(Selectors.ActionFooter.confirmActionButton).click() +} + +const rejectMessage = async (notificationPage: Page) => { + await notificationPage.locator(Selectors.ActionFooter.cancelActionButton).click() +} + +const signMessageWithRisk = async (notificationPage: Page) => { + await notificationPage.locator(Selectors.SignaturePage.riskModal.proceedAnyway).click() + await notificationPage.locator(Selectors.SignaturePage.riskModal.confirmUnsafe).click() + await notificationPage.locator(Selectors.SignaturePage.riskModal.acknowledgeRisks).click() + await notificationPage.locator(Selectors.SignaturePage.riskModal.reconfirmUnsafe).click() +} + +export const signSimpleMessage = { + sign: signMessage, + reject: rejectMessage, + signWithRisk: signMessageWithRisk +} diff --git a/wallets/phantom/src/playwright/pages/NotificationPage/actions/signStructuredMessage.ts b/wallets/phantom/src/playwright/pages/NotificationPage/actions/signStructuredMessage.ts new file mode 100644 index 000000000..0283b9c41 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/signStructuredMessage.ts @@ -0,0 +1,25 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/NotificationPage' + +const signMessage = async (notificationPage: Page) => { + const scrollDownButton = notificationPage.locator(Selectors.SignaturePage.structuredMessage.scrollDownButton) + const signButton = notificationPage.locator(Selectors.ActionFooter.confirmActionButton) + + while (await signButton.isDisabled()) { + await scrollDownButton.click() + } + + await signButton.click() +} + +const rejectMessage = async (notificationPage: Page) => { + await notificationPage.locator(Selectors.ActionFooter.cancelActionButton).click() +} + +// Used for: +// - `eth_signTypedData_v3` +// - `eth_signTypedData_v4` +export const signStructuredMessage = { + sign: signMessage, + reject: rejectMessage +} diff --git a/wallets/phantom/src/playwright/pages/NotificationPage/actions/transaction.ts b/wallets/phantom/src/playwright/pages/NotificationPage/actions/transaction.ts new file mode 100644 index 000000000..55e81bacd --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/transaction.ts @@ -0,0 +1,64 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/NotificationPage' +import { GasSettingValidation, type GasSettings } from '../../../../type/GasSettings' +import { waitFor } from '../../../utils/waitFor' + +const confirmTransaction = async (notificationPage: Page, options: GasSettings) => { + const gasSetting = GasSettingValidation.parse(options) + + const handleNftSetApprovalForAll = async (page: Page) => { + try { + const nftApproveButtonLocator = page.locator( + Selectors.TransactionPage.nftApproveAllConfirmationPopup.approveButton + ) + const isNfTPopupHidden = await waitFor(() => nftApproveButtonLocator.isHidden(), 3_000, false) + + if (!isNfTPopupHidden) { + await nftApproveButtonLocator.click() + } + } catch (e) { + if (page.isClosed()) { + return + } + + throw new Error(`Failed to handle NFT setApprovalForAll popup: ${e}`) + } + } + + // By default, the `Average` gas setting is used. + if (gasSetting === 'Average') { + await notificationPage.locator(Selectors.ActionFooter.confirmActionButton).click() + + await handleNftSetApprovalForAll(notificationPage) + + return + } + + // TODO: This button can be invisible in case of a network issue. Verify this, and handle in the future. + await notificationPage.locator(Selectors.TransactionPage.editGasFeeMenu.editGasFeeButton).click() + + const handleSlowOrFastGasSetting = async (selector: string) => { + await notificationPage.locator(selector).click() + } + + if (gasSetting === 'Slow') { + await handleSlowOrFastGasSetting(Selectors.TransactionPage.editGasFeeMenu.slowGasFeeButton) + } else if (gasSetting === 'Fast') { + await handleSlowOrFastGasSetting(Selectors.TransactionPage.editGasFeeMenu.fastGasFeeButton) + } + + await notificationPage.locator(Selectors.TransactionPage.editGasFeeMenu.saveButton).click() + + await notificationPage.locator(Selectors.ActionFooter.confirmActionButton).click() + + await handleNftSetApprovalForAll(notificationPage) +} + +const rejectTransaction = async (notificationPage: Page) => { + await notificationPage.locator(Selectors.ActionFooter.cancelActionButton).click() +} + +export const transaction = { + confirm: confirmTransaction, + reject: rejectTransaction +} diff --git a/wallets/phantom/src/playwright/pages/NotificationPage/page.ts b/wallets/phantom/src/playwright/pages/NotificationPage/page.ts new file mode 100644 index 000000000..4355cd33e --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/page.ts @@ -0,0 +1,109 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../selectors/pages/NotificationPage' +import type { GasSettings } from '../../../type/GasSettings' +import { getNotificationPageAndWaitForLoad } from '../../utils/getNotificationPageAndWaitForLoad' +import { + approvePermission, + closeUnsupportedNetworkWarning, + connectToDapp, + signSimpleMessage, + // signStructuredMessage, + transaction +} from './actions' + +export class NotificationPage { + static readonly selectors = Selectors + readonly selectors = Selectors + + readonly page: Page + + constructor(page: Page) { + this.page = page + } + + async connectToDapp(extensionId: string, account?: string) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + await connectToDapp(notificationPage, account) + } + + // TODO: Revisit this logic in the future to see if we can increase the performance by utilizing `Promise.race`. + private async beforeMessageSignature(extensionId: string) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + // const scrollButton = notificationPage.locator(Selectors.SignaturePage.structuredMessage.scrollDownButton) + // const isScrollButtonPresent = (await scrollButton.count()) > 0 + + // let isScrollButtonVisible = false + // if (isScrollButtonPresent) { + // await scrollButton.waitFor({ state: 'visible' }) + // isScrollButtonVisible = true + // } + + return { + notificationPage + // isScrollButtonVisible, + } + } + + async signMessage(extensionId: string) { + // const { notificationPage, isScrollButtonVisible } = + const { notificationPage } = await this.beforeMessageSignature(extensionId) + + await signSimpleMessage.sign(notificationPage) + // if (isScrollButtonVisible) { + // await signStructuredMessage.sign(notificationPage); + // } else { + // await signSimpleMessage.sign(notificationPage); + // } + } + + async signMessageWithRisk(extensionId: string) { + const { notificationPage } = await this.beforeMessageSignature(extensionId) + + await signSimpleMessage.signWithRisk(notificationPage) + } + + async rejectMessage(extensionId: string) { + //const { notificationPage, isScrollButtonVisible } = + const { notificationPage } = await this.beforeMessageSignature(extensionId) + + await signSimpleMessage.reject(notificationPage) + + // if (isScrollButtonVisible) { + // await signStructuredMessage.reject(notificationPage); + // } else { + // await signSimpleMessage.reject(notificationPage); + // } + } + + async confirmTransaction(extensionId: string, options?: { gasSetting?: GasSettings }) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + await transaction.confirm(notificationPage, options?.gasSetting ?? 'Average') + } + + async rejectTransaction(extensionId: string) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + await transaction.reject(notificationPage) + } + + async approveTokenPermission(extensionId: string, options?: { gasSetting?: GasSettings }) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + await approvePermission.approve(notificationPage, options?.gasSetting ?? 'Average') + } + + async rejectTokenPermission(extensionId: string) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + await approvePermission.reject(notificationPage) + } + + async closeUnsupportedNetworkWarning(extensionId: string) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + await closeUnsupportedNetworkWarning(notificationPage) + } +} diff --git a/wallets/phantom/src/playwright/pages/OnboardingPage/actions/helpers/confirmSecretRecoveryPhrase.ts b/wallets/phantom/src/playwright/pages/OnboardingPage/actions/helpers/confirmSecretRecoveryPhrase.ts new file mode 100644 index 000000000..074187374 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/OnboardingPage/actions/helpers/confirmSecretRecoveryPhrase.ts @@ -0,0 +1,21 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../../selectors/pages/OnboardingPage' + +const StepSelectors = Selectors.SecretRecoveryPhrasePageSelectors.recoveryStep + +export async function confirmSecretRecoveryPhrase(page: Page, seedPhrase: string) { + const seedPhraseWords = seedPhrase.split(' ') + + for (const [index, word] of seedPhraseWords.entries()) { + await page.locator(StepSelectors.secretRecoveryPhraseWord(index)).fill(word) + } + + await page.locator(StepSelectors.confirmSecretRecoveryPhraseButton).click() + + if (await page.locator(StepSelectors.error).isVisible({ timeout: 2_000 })) { + const errorText = await page.locator(StepSelectors.error).textContent({ + timeout: 1000 + }) + throw new Error(`[ConfirmSecretRecoveryPhrase] Invalid seed phrase. Error from Phantom: ${errorText}`) + } +} diff --git a/wallets/phantom/src/playwright/pages/OnboardingPage/actions/helpers/createPassword.ts b/wallets/phantom/src/playwright/pages/OnboardingPage/actions/helpers/createPassword.ts new file mode 100644 index 000000000..cb6da9e9c --- /dev/null +++ b/wallets/phantom/src/playwright/pages/OnboardingPage/actions/helpers/createPassword.ts @@ -0,0 +1,14 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../../selectors/pages/OnboardingPage' + +const StepSelectors = Selectors.SecretRecoveryPhrasePageSelectors.passwordStep + +export async function createPassword(page: Page, password: string) { + await page.locator(StepSelectors.passwordInput).fill(password) + await page.locator(StepSelectors.confirmPasswordInput).fill(password) + + // Using `locator.click()` instead of `locator.check()` as a workaround due to dynamically appearing elements. + await page.locator(StepSelectors.acceptTermsCheckbox).click() + + await page.locator(StepSelectors.continue).click() +} diff --git a/wallets/phantom/src/playwright/pages/OnboardingPage/actions/helpers/index.ts b/wallets/phantom/src/playwright/pages/OnboardingPage/actions/helpers/index.ts new file mode 100644 index 000000000..79b348f20 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/OnboardingPage/actions/helpers/index.ts @@ -0,0 +1,2 @@ +export * from './confirmSecretRecoveryPhrase' +export * from './createPassword' diff --git a/wallets/phantom/src/playwright/pages/OnboardingPage/actions/importWallet.ts b/wallets/phantom/src/playwright/pages/OnboardingPage/actions/importWallet.ts new file mode 100644 index 000000000..0558703ff --- /dev/null +++ b/wallets/phantom/src/playwright/pages/OnboardingPage/actions/importWallet.ts @@ -0,0 +1,25 @@ +import { type Page, expect } from '@playwright/test' +import Selectors from '../../../../selectors/pages/OnboardingPage' +import { confirmSecretRecoveryPhrase, createPassword } from './helpers' + +export async function importWallet(page: Page, seedPhrase: string, password: string) { + await page.locator(Selectors.GetStartedPageSelectors.importWallet).click() + + await page.locator(Selectors.GetStartedPageSelectors.importRecoveryPhraseButton).click() + + await confirmSecretRecoveryPhrase(page, seedPhrase) + + await expect( + page.locator(Selectors.SecretRecoveryPhrasePageSelectors.viewAccountsButton), + 'Import accounts success screen should be visible' + ).toBeVisible({ timeout: 60_000 }) + + await page.locator(Selectors.SecretRecoveryPhrasePageSelectors.continueButton).click() + + await createPassword(page, password) + + await expect( + page.locator(Selectors.SecretRecoveryPhrasePageSelectors.allDone), + 'All Done success screen should be visible' + ).toBeVisible({ timeout: 10_000 }) +} diff --git a/wallets/phantom/src/playwright/pages/OnboardingPage/actions/index.ts b/wallets/phantom/src/playwright/pages/OnboardingPage/actions/index.ts new file mode 100644 index 000000000..2b7f14e22 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/OnboardingPage/actions/index.ts @@ -0,0 +1 @@ +export * from './importWallet' diff --git a/wallets/phantom/src/playwright/pages/OnboardingPage/page.ts b/wallets/phantom/src/playwright/pages/OnboardingPage/page.ts new file mode 100644 index 000000000..2c03651b4 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/OnboardingPage/page.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../selectors/pages/OnboardingPage' +import { importWallet } from './actions' + +export class OnboardingPage { + static readonly selectors = Selectors + readonly selectors = Selectors + + readonly page: Page + + constructor(page: Page) { + this.page = page + } + + async importWallet(seedPhrase: string, password: string) { + return await importWallet(this.page, seedPhrase, password) + } +} diff --git a/wallets/phantom/src/playwright/pages/UnlockPage/actions/index.ts b/wallets/phantom/src/playwright/pages/UnlockPage/actions/index.ts new file mode 100644 index 000000000..b0dce8ac2 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/UnlockPage/actions/index.ts @@ -0,0 +1 @@ +export * from './unlock' diff --git a/wallets/phantom/src/playwright/pages/UnlockPage/actions/unlock.ts b/wallets/phantom/src/playwright/pages/UnlockPage/actions/unlock.ts new file mode 100644 index 000000000..3c8c3edb7 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/UnlockPage/actions/unlock.ts @@ -0,0 +1,10 @@ +import { type Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/UnlockPage' +import { waitForSpinnerToVanish } from '../../../utils/waitForSpinnerToVanish' + +export async function unlock(page: Page, password: string) { + await page.locator(Selectors.passwordInput).fill(password) + await page.locator(Selectors.submitButton).click() + + await waitForSpinnerToVanish(page) +} diff --git a/wallets/phantom/src/playwright/pages/UnlockPage/page.ts b/wallets/phantom/src/playwright/pages/UnlockPage/page.ts new file mode 100644 index 000000000..85e197742 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/UnlockPage/page.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../selectors/pages/UnlockPage' +import { unlock } from './actions' + +export class UnlockPage { + static readonly selectors = Selectors + readonly selectors = Selectors + + readonly page: Page + + constructor(page: Page) { + this.page = page + } + + async unlock(password: string) { + await unlock(this.page, password) + } +} diff --git a/wallets/phantom/src/playwright/pages/index.ts b/wallets/phantom/src/playwright/pages/index.ts new file mode 100644 index 000000000..f421d035a --- /dev/null +++ b/wallets/phantom/src/playwright/pages/index.ts @@ -0,0 +1,4 @@ +export * from './OnboardingPage/page' +export * from './UnlockPage/page' +export * from './HomePage/page' +export * from './NotificationPage/page' diff --git a/wallets/phantom/src/playwright/utils/allTextContents.ts b/wallets/phantom/src/playwright/utils/allTextContents.ts new file mode 100644 index 000000000..09fa95be2 --- /dev/null +++ b/wallets/phantom/src/playwright/utils/allTextContents.ts @@ -0,0 +1,10 @@ +import type { Locator } from '@playwright/test' +import { z } from 'zod' + +// Custom implementation of `locator.allTextContents()` that is not utilizing `.map` which is not accessible under Phantom's scuttling mode. +export async function allTextContents(locators: Locator[]) { + const names = await Promise.all(locators.map((locator) => locator.textContent())) + + // We're making sure that the return type is `string[]` same as `locator.allTextContents()`. + return names.map((name) => z.string().parse(name)) +} diff --git a/wallets/phantom/src/playwright/utils/getNotificationPageAndWaitForLoad.ts b/wallets/phantom/src/playwright/utils/getNotificationPageAndWaitForLoad.ts new file mode 100644 index 000000000..7f6073fd2 --- /dev/null +++ b/wallets/phantom/src/playwright/utils/getNotificationPageAndWaitForLoad.ts @@ -0,0 +1,30 @@ +import type { BrowserContext, Page } from '@playwright/test' +import { waitUntilStableNotificationPage } from './waitFor' + +export async function getNotificationPageAndWaitForLoad(context: BrowserContext, extensionId: string) { + const notificationPageUrl = `chrome-extension://${extensionId}/notification.html` + + const isNotificationPage = (page: Page) => page.url().includes(notificationPageUrl) + + // Check if notification page is already open. + let notificationPage = context.pages().find(isNotificationPage) + + if (!notificationPage) { + notificationPage = await context.waitForEvent('page', { + predicate: isNotificationPage, + timeout: 10_000 + }) + } + + await waitUntilStableNotificationPage(notificationPage as Page) + + // Set pop-up window viewport size to resemble the actual Phantom pop-up window. + await notificationPage.setViewportSize({ + width: 360, + height: 592 + }) + + await waitUntilStableNotificationPage(notificationPage as Page) + + return notificationPage +} diff --git a/wallets/phantom/src/playwright/utils/toggle.ts b/wallets/phantom/src/playwright/utils/toggle.ts new file mode 100644 index 000000000..cfdcc8354 --- /dev/null +++ b/wallets/phantom/src/playwright/utils/toggle.ts @@ -0,0 +1,32 @@ +import type { Locator } from '@playwright/test' +import { waitFor } from './waitFor' + +export async function toggle(toggleLocator: Locator) { + // TODO: Extract timeout + const classes = await toggleLocator.getAttribute('class', { timeout: 3_000 }) + + if (!classes) { + throw new Error('[ToggleShowTestNetworks] Toggle class returned null') + } + + const isOn = classes.includes('toggle-button--on') + + await toggleLocator.click() + + const waitForAction = async () => { + const classes = await toggleLocator.getAttribute('class') + + if (!classes) { + throw new Error('[ToggleShowTestNetworks] Toggle class returned null inside waitFor') + } + + if (isOn) { + return classes.includes('toggle-button--off') + } + + return classes.includes('toggle-button--on') + } + + // TODO: Extract timeout + await waitFor(waitForAction, 3_000, true) +} diff --git a/wallets/phantom/src/playwright/utils/waitFor.ts b/wallets/phantom/src/playwright/utils/waitFor.ts new file mode 100644 index 000000000..8d88f2151 --- /dev/null +++ b/wallets/phantom/src/playwright/utils/waitFor.ts @@ -0,0 +1,96 @@ +import type { Page } from '@playwright/test' +import { errors, expect } from '@playwright/test' + +import Selectors from '../../selectors/pages/UnlockPage' + +let retries = 0 + +export const waitToBeHidden = async (selector: string, page: Page) => { + // info: waits for 60 seconds + const locator = page.locator(selector) + for (const element of await locator.all()) { + if ((await element.count()) > 0 && retries < 300) { + retries++ + await page.waitForTimeout(200) + await module.exports.waitToBeHidden(selector, page) + } else if (retries >= 300) { + retries = 0 + throw new Error(`[waitToBeHidden] Max amount of retries reached while waiting for ${selector} to disappear.`) + } + retries = 0 + } +} + +export const waitUntilStable = async (page: Page) => { + await page.waitForLoadState('load', { timeout: 10_000 }) + await page.waitForLoadState('domcontentloaded', { timeout: 10_000 }) +} + +export const waitUntilStableBeforeUnlock = async (page: Page) => { + await page.waitForLoadState('load', { timeout: 10_000 }) + await page.waitForLoadState('domcontentloaded', { timeout: 10_000 }) + + await expect(async () => { + await expect(page.locator(Selectors.submitButton), '"Unlock" buttonshouldbe visible').toBeVisible() + }).toPass({ timeout: 10_000 }) +} + +export const waitUntilStableNotificationPage = async (page: Page) => { + await page.waitForLoadState('load', { timeout: 10_000 }) + await page.waitForLoadState('domcontentloaded', { timeout: 10_000 }) + await page.locator('[data-testid="home-header-account-name"]').waitFor({ timeout: 10_000 }) +} + +export const waitForSelector = async (selector: string, page: Page, timeout: number) => { + await waitUntilStable(page) + + try { + await page.waitForSelector(selector, { state: 'hidden', timeout }) + } catch (error) { + if (error instanceof errors.TimeoutError) { + console.log(`Loading indicator '${selector}' not found - continuing.`) + } else { + console.log(`Error while waiting for loading indicator '${selector}' to disappear`) + throw error + } + } +} + +// Inlining the sleep function here cause this is one of the few places in the entire codebase where sleep should be used! +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +const timeouts = [0, 20, 50, 100, 100, 500] as const + +// TODO: Box this function. +// This functions mimics the one found in Playwright with a few small differences. +// Custom implementation is needed because Playwright lists errors in the report even if they are caught. +export async function waitFor(action: () => Promise, timeout: number, shouldThrow = true) { + let timeoutsSum = 0 + let timeoutIndex = 0 + + let reachedTimeout = false + + while (!reachedTimeout) { + let nextTimeout = timeouts.at(Math.min(timeoutIndex++, timeouts.length - 1)) as number + + if (timeoutsSum + nextTimeout > timeout) { + nextTimeout = timeout - timeoutsSum + reachedTimeout = true + } else { + timeoutsSum += nextTimeout + } + + await sleep(nextTimeout) + + const result = await action() + if (result) { + return result + } + } + + if (shouldThrow) { + throw new Error(`Timeout ${timeout}ms exceeded.`) + } + + return false +} diff --git a/wallets/phantom/src/playwright/utils/waitForPopupPageLoad.ts b/wallets/phantom/src/playwright/utils/waitForPopupPageLoad.ts new file mode 100644 index 000000000..c11718cc9 --- /dev/null +++ b/wallets/phantom/src/playwright/utils/waitForPopupPageLoad.ts @@ -0,0 +1,45 @@ +import { type BrowserContext, type Page, expect } from '@playwright/test' +import { waitUntilStableBeforeUnlock } from './waitFor' +import { waitForTestPageLoad } from './waitForTestPageLoad' + +export async function waitForPopupPageLoad(context: BrowserContext, extensionId: string) { + const popupPageUrl = `chrome-extension://${extensionId}/popup.html` + + const isPopupPage = (page: Page) => page.url().includes(popupPageUrl) + + // Check if popup page is already open. + let popupPage = context.pages().find(isPopupPage) + + if (!popupPage) { + popupPage = await context.waitForEvent('page', { + predicate: isPopupPage, + timeout: 10_000 + }) + } + + await waitUntilStableBeforeUnlock(popupPage as Page) +} + +export async function loadAndWaitForPopupPage2(page: Page, extensionId: string) { + await expect(async () => { + await page.goto(`chrome-extension://${extensionId}/popup.html`) + + await waitUntilStableBeforeUnlock(page) + }).toPass({ timeout: 35_000 }) +} + +export async function loadAndWaitForPopupPage(context: BrowserContext, extensionId: string) { + let popupPage: Page = context.pages()[0] as Page + + await expect(async () => { + popupPage = await context.newPage() + + await waitForTestPageLoad(context) + + await popupPage.goto(`chrome-extension://${extensionId}/popup.html`) + + await waitUntilStableBeforeUnlock(popupPage) + }).toPass() + + return popupPage +} diff --git a/wallets/phantom/src/playwright/utils/waitForSpinnerToVanish.ts b/wallets/phantom/src/playwright/utils/waitForSpinnerToVanish.ts new file mode 100644 index 000000000..77043750a --- /dev/null +++ b/wallets/phantom/src/playwright/utils/waitForSpinnerToVanish.ts @@ -0,0 +1,13 @@ +import type { Page } from '@playwright/test' +import { LoadingSelectors } from '../../selectors' + +// TODO: Should we decrease the timeout? +// TODO: Not sure if hard coding the timeout is a good idea but must be enough for now. +const DEFAULT_TIMEOUT = 10_000 + +export async function waitForSpinnerToVanish(page: Page) { + await page.locator(LoadingSelectors.spinner).waitFor({ + state: 'hidden', + timeout: DEFAULT_TIMEOUT + }) +} diff --git a/wallets/phantom/src/playwright/utils/waitForTestPageLoad.ts b/wallets/phantom/src/playwright/utils/waitForTestPageLoad.ts new file mode 100644 index 000000000..0cfbc7158 --- /dev/null +++ b/wallets/phantom/src/playwright/utils/waitForTestPageLoad.ts @@ -0,0 +1,20 @@ +import type { BrowserContext, Page } from '@playwright/test' +import { waitUntilStable } from './waitFor' + +export async function waitForTestPageLoad(context: BrowserContext) { + const testPageUrl = 'about:blank' + + const isTestPage = (page: Page) => page.url().includes(testPageUrl) + + // Check if test page is already open. + let testPage = context.pages().find(isTestPage) + + if (!testPage) { + testPage = await context.waitForEvent('page', { + predicate: isTestPage, + timeout: 10_000 + }) + } + + await waitUntilStable(testPage as Page) +} diff --git a/wallets/phantom/src/prepareExtensionPhantom.ts b/wallets/phantom/src/prepareExtensionPhantom.ts new file mode 100644 index 000000000..73556089a --- /dev/null +++ b/wallets/phantom/src/prepareExtensionPhantom.ts @@ -0,0 +1,31 @@ +import path from 'node:path' +import { downloadFile, ensureCacheDirExists, unzipArchivePhantom } from '@synthetixio/synpress-cache' +import fs from 'fs-extra' + +export const DEFAULT_PHANTOM_VERSION = 'latest' +export const PHANTOM_EXTENSION_DOWNLOAD_URL = 'https://crx-backup.phantom.dev/latest.crx' + +export async function prepareExtensionPhantom(forceCache = true) { + let outputDir = '' + if (forceCache) { + outputDir = ensureCacheDirExists() + } else { + outputDir = process.platform === 'win32' ? `file:\\\\\\${outputDir}` : path.resolve('./', 'downloads') + + if (!(await fs.exists(outputDir))) { + fs.mkdirSync(outputDir) + } + } + + const downloadResult = await downloadFile({ + url: PHANTOM_EXTENSION_DOWNLOAD_URL, + outputDir, + fileName: 'phantom-chrome-latest.crx' + }) + + const unzipResult = await unzipArchivePhantom({ + archivePath: downloadResult.filePath + }) + + return unzipResult.outputPath +} diff --git a/wallets/phantom/src/selectors/createDataTestSelector.ts b/wallets/phantom/src/selectors/createDataTestSelector.ts new file mode 100644 index 000000000..705c69420 --- /dev/null +++ b/wallets/phantom/src/selectors/createDataTestSelector.ts @@ -0,0 +1,7 @@ +export const createDataTestSelector = (dataTestId: string) => { + if (dataTestId.includes(' ')) { + throw new Error('[CreateDataTestSelector] dataTestId cannot contain spaces') + } + + return `[data-testid="${dataTestId}"]` +} diff --git a/wallets/phantom/src/selectors/error/index.ts b/wallets/phantom/src/selectors/error/index.ts new file mode 100644 index 000000000..b29b281c3 --- /dev/null +++ b/wallets/phantom/src/selectors/error/index.ts @@ -0,0 +1,7 @@ +export const ErrorSelectors = { + loadingOverlayErrorButtons: '.loading-overlay__error-buttons', + loadingOverlayErrorButtonsRetryButton: '.loading-overlay__error-buttons .btn-primary', + criticalError: '.critical-error', + criticalErrorRestartButton: '#critical-error-button', + errorPage: '.error-page' +} diff --git a/wallets/phantom/src/selectors/index.ts b/wallets/phantom/src/selectors/index.ts new file mode 100644 index 000000000..835505d07 --- /dev/null +++ b/wallets/phantom/src/selectors/index.ts @@ -0,0 +1,7 @@ +export * from './loading' +export * from './error' + +export { default as homePage } from './pages/HomePage' +export { default as unlockPage } from './pages/UnlockPage' +export { default as notificationPage } from './pages/NotificationPage' +export { default as onboardingPage } from './pages/OnboardingPage' diff --git a/wallets/phantom/src/selectors/loading/index.ts b/wallets/phantom/src/selectors/loading/index.ts new file mode 100644 index 000000000..02b70db37 --- /dev/null +++ b/wallets/phantom/src/selectors/loading/index.ts @@ -0,0 +1,17 @@ +export const LoadingSelectors = { + spinner: '.spinner', + loadingOverlay: '.loading-overlay', + loadingIndicators: [ + '.loading-logo', + '.loading-spinner', + '.loading-overlay', + '.loading-overlay__spinner', + '.loading-span', + '.loading-indicator', + '#loading__logo', + '#loading__spinner', + '.mm-button-base__icon-loading', + '.loading-swaps-quotes', + '.loading-heartbeat' + ] +} diff --git a/wallets/phantom/src/selectors/pages/HomePage/index.ts b/wallets/phantom/src/selectors/pages/HomePage/index.ts new file mode 100644 index 000000000..77ec0027e --- /dev/null +++ b/wallets/phantom/src/selectors/pages/HomePage/index.ts @@ -0,0 +1,64 @@ +import { createDataTestSelector } from '../../createDataTestSelector' +import settings from './settings' + +const addNewAccountMenu = { + accountNameInput: `input[placeholder="Name"]`, + createButton: `button${createDataTestSelector('primary-button')}:has-text("Create")` +} + +const renameAccountMenu = { + saveButton: `button${createDataTestSelector('primary-button')}:has-text("Save")` +} + +const importAccountMenu = { + networkOpenMenu: '[role="button"]:has-text("Solana")', // '#button--listbox-input--1', + ethereumNetwork: `[data-label="Ethereum"]`, + baseNetwork: `[data-label="Base"]`, + polygonNetwork: `[data-label="Polygon"]`, + bitcoinNetwork: `[data-label="Bitcoin"]`, + solanaNetwork: `[data-label="Solana"]`, + nameInput: `input[name="name"]`, + privateKeyInput: `textarea[placeholder="Private key"]`, + importButton: `button:has-text("Import")`, + error: `textarea[placeholder="Private key"] + div` +} + +const addAccountMenu = { + addAccountButton: createDataTestSelector('sidebar_menu-button-add_account'), + createNewAccountButton: createDataTestSelector('add-account-create-new-wallet-button'), + importAccountPrivateKeyButton: 'text=Import Private Key', + addNewAccountMenu, + importAccountMenu +} + +const editAccountMenu = { + accountNameButton: `button:has-text("Account Name")` +} + +const accountMenu = { + accountName: createDataTestSelector('home-header-account-name'), + accountButton: createDataTestSelector('settings-menu-open-button'), + accountNames: '#accounts button > div:nth-child(2)', + manageAccountsButton: createDataTestSelector('sidebar_menu-button-manage_accounts'), + settings: createDataTestSelector('sidebar_menu-button-settings'), + addAccountMenu, + renameAccountMenu +} + +const manageAccountButton = (accountName: string) => + `[role="button"][data-testid="manage-accounts-sortable-${accountName}"]` + +export default { + solanaWalletAddress: createDataTestSelector('account-header-chain-solana:101'), + ethereumWalletAddress: createDataTestSelector('account-header-chain-eip155:1'), + baseWalletAddress: createDataTestSelector('account-header-chain-eip155:8453'), + polygonWalletAddress: createDataTestSelector('account-header-chain-eip155:137'), + bitcoinWalletAddress: createDataTestSelector('account-header-chain-bip122:000000000019d6689c085ae165831e93'), + copyAccountAddressButton: createDataTestSelector('address-copy-button-text'), + currentNetwork: `${createDataTestSelector('network-display')} span:nth-of-type(1)`, + headerBackButton: `section:has-text("Developer Settings") ${createDataTestSelector('header--back')}`, + settings, + accountMenu, + editAccountMenu, + manageAccountButton +} diff --git a/wallets/phantom/src/selectors/pages/HomePage/settings.ts b/wallets/phantom/src/selectors/pages/HomePage/settings.ts new file mode 100644 index 000000000..8aaaaaaf3 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/HomePage/settings.ts @@ -0,0 +1,21 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +const devSettings = { + toggleTestnetMode: createDataTestSelector('toggleTestNetwork'), + toggleEnableCopyTransaction: createDataTestSelector('solana-copy-transaction') +} + +const securityAndPrivacy = { + resetApp: 'button:has-text("Reset App")' +} + +export default { + securityAndPrivacyButton: createDataTestSelector('settings-item-security-and-privacy'), + lockWallet: createDataTestSelector('lock-menu-item'), + unlocWallet: createDataTestSelector('data-testid="unlock-form-submit-button"'), + developerSettingsButton: createDataTestSelector('settings-item-developer-settings'), + closeSettingsButton: createDataTestSelector('settings-menu-close-button'), + + devSettings, + securityAndPrivacy +} diff --git a/wallets/phantom/src/selectors/pages/NotificationPage/actionFooter.ts b/wallets/phantom/src/selectors/pages/NotificationPage/actionFooter.ts new file mode 100644 index 000000000..d0af7cd76 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/NotificationPage/actionFooter.ts @@ -0,0 +1,9 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +export default { + connectActionButton: `button${createDataTestSelector('primary-button')}:has-text("Connect")`, + confirmActionButton: `button${createDataTestSelector('primary-button')}:has-text("Confirm")`, + continueActionButton: `button${createDataTestSelector('primary-button')}:has-text("Continue")`, + cancelActionButton: `button${createDataTestSelector('secondary-button')}:has-text("Cancel")`, + closeActionButton: createDataTestSelector('button-close') +} diff --git a/wallets/phantom/src/selectors/pages/NotificationPage/index.ts b/wallets/phantom/src/selectors/pages/NotificationPage/index.ts new file mode 100644 index 000000000..2764c386f --- /dev/null +++ b/wallets/phantom/src/selectors/pages/NotificationPage/index.ts @@ -0,0 +1,9 @@ +import ActionFooter from './actionFooter' +import SignaturePage from './signaturePage' +import TransactionPage from './transactionPage' + +export default { + ActionFooter, + SignaturePage, + TransactionPage +} diff --git a/wallets/phantom/src/selectors/pages/NotificationPage/signaturePage.ts b/wallets/phantom/src/selectors/pages/NotificationPage/signaturePage.ts new file mode 100644 index 000000000..fa3649553 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/NotificationPage/signaturePage.ts @@ -0,0 +1,17 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +const structuredMessage = { + scrollDownButton: `.signature-request-message ${createDataTestSelector('signature-request-scroll-button')}` +} + +const riskModal = { + proceedAnyway: 'text=Proceed anyway (unsafe)', + confirmUnsafe: 'text=Confirm (unsafe)', + acknowledgeRisks: createDataTestSelector('acknowledge--button'), + reconfirmUnsafe: 'text=Yes, confirm (unsafe)' +} + +export default { + structuredMessage, + riskModal +} diff --git a/wallets/phantom/src/selectors/pages/NotificationPage/transactionPage.ts b/wallets/phantom/src/selectors/pages/NotificationPage/transactionPage.ts new file mode 100644 index 000000000..9dd1ce5ea --- /dev/null +++ b/wallets/phantom/src/selectors/pages/NotificationPage/transactionPage.ts @@ -0,0 +1,15 @@ +const editGasFeeMenu = { + editGasFeeButton: 'button:has-text("Network Fee")', + slowGasFeeButton: 'div > div p:has-text("Slow")', + fastGasFeeButton: 'div > div p:has-text("Fast")', + saveButton: 'button:has-text("Save")' +} + +const nftApproveAllConfirmationPopup = { + approveButton: '.set-approval-for-all-warning__content button.set-approval-for-all-warning__footer__approve-button' +} + +export default { + editGasFeeMenu, + nftApproveAllConfirmationPopup +} diff --git a/wallets/phantom/src/selectors/pages/OnboardingPage/getStartedPage.ts b/wallets/phantom/src/selectors/pages/OnboardingPage/getStartedPage.ts new file mode 100644 index 000000000..fe065be96 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/OnboardingPage/getStartedPage.ts @@ -0,0 +1,4 @@ +export default { + importWallet: 'text=I already have a wallet', + importRecoveryPhraseButton: 'text=Import Recovery Phrase' +} diff --git a/wallets/phantom/src/selectors/pages/OnboardingPage/index.ts b/wallets/phantom/src/selectors/pages/OnboardingPage/index.ts new file mode 100644 index 000000000..75c77e41f --- /dev/null +++ b/wallets/phantom/src/selectors/pages/OnboardingPage/index.ts @@ -0,0 +1,10 @@ +import GetStartedPageSelectors from './getStartedPage' +import SecretRecoveryPhrasePageSelectors from './secretRecoveryPhrasePage' +import WalletCreationSuccessPageSelectors from './walletCreationSuccessPage' + +// biome-ignore format: empty lines should be preserved +export default { + GetStartedPageSelectors, + SecretRecoveryPhrasePageSelectors, + WalletCreationSuccessPageSelectors, +}; diff --git a/wallets/phantom/src/selectors/pages/OnboardingPage/secretRecoveryPhrasePage.ts b/wallets/phantom/src/selectors/pages/OnboardingPage/secretRecoveryPhrasePage.ts new file mode 100644 index 000000000..00c36632a --- /dev/null +++ b/wallets/phantom/src/selectors/pages/OnboardingPage/secretRecoveryPhrasePage.ts @@ -0,0 +1,29 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +const recoveryStep = { + secretRecoveryPhraseWord: (index: number) => createDataTestSelector(`secret-recovery-phrase-word-input-${index}`), + confirmSecretRecoveryPhraseButton: createDataTestSelector('onboarding-form-submit-button'), + error: createDataTestSelector('onboarding-import-secret-recovery-phrase-error-message') +} + +const viewAccountsButton = createDataTestSelector('onboarding-form-secondary-button') + +const continueButton = createDataTestSelector('onboarding-form-submit-button') + +const passwordStep = { + passwordInput: createDataTestSelector('onboarding-form-password-input'), + confirmPasswordInput: createDataTestSelector('onboarding-form-confirm-password-input'), + acceptTermsCheckbox: createDataTestSelector('onboarding-form-terms-of-service-checkbox'), + continue: continueButton, + error: `${createDataTestSelector('create-password-new')} + h6 > span > span` +} + +const allDone = `text=You're all done!` + +export default { + recoveryStep, + viewAccountsButton, + continueButton, + passwordStep, + allDone +} diff --git a/wallets/phantom/src/selectors/pages/OnboardingPage/walletCreationSuccessPage.ts b/wallets/phantom/src/selectors/pages/OnboardingPage/walletCreationSuccessPage.ts new file mode 100644 index 000000000..726626a15 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/OnboardingPage/walletCreationSuccessPage.ts @@ -0,0 +1,5 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +export default { + confirmButton: createDataTestSelector('onboarding-complete-done') +} diff --git a/wallets/phantom/src/selectors/pages/UnlockPage/index.ts b/wallets/phantom/src/selectors/pages/UnlockPage/index.ts new file mode 100644 index 000000000..680748a28 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/UnlockPage/index.ts @@ -0,0 +1,6 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +export default { + passwordInput: createDataTestSelector('unlock-form-password-input'), + submitButton: createDataTestSelector('unlock-form-submit-button') +} diff --git a/wallets/phantom/src/type/GasSettings.ts b/wallets/phantom/src/type/GasSettings.ts new file mode 100644 index 000000000..e1ecd9870 --- /dev/null +++ b/wallets/phantom/src/type/GasSettings.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' + +export const GasSettingValidation = z.union([ + z.literal('Slow'), + z.literal('Fast'), + z.literal('Average'), + z + .object({ + maxBaseFee: z.number(), + priorityFee: z.number(), + // TODO: Add gasLimit range validation. + gasLimit: z.number().optional() + }) + .superRefine(({ maxBaseFee, priorityFee }, ctx) => { + if (priorityFee > maxBaseFee) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Max base fee cannot be lower than priority fee', + path: ['Phantom', 'confirmTransaction', 'gasSetting', 'maxBaseFee'] + }) + } + }) +]) + +export type GasSettings = z.input diff --git a/wallets/phantom/src/type/Networks.ts b/wallets/phantom/src/type/Networks.ts new file mode 100644 index 000000000..372d8e67b --- /dev/null +++ b/wallets/phantom/src/type/Networks.ts @@ -0,0 +1 @@ +export type Networks = 'solana' | 'ethereum' | 'base' | 'polygon' | 'bitcoin' diff --git a/wallets/phantom/src/type/PhantomAbstract.ts b/wallets/phantom/src/type/PhantomAbstract.ts new file mode 100644 index 000000000..21f388c7a --- /dev/null +++ b/wallets/phantom/src/type/PhantomAbstract.ts @@ -0,0 +1,158 @@ +import type { GasSettings } from './GasSettings' +import type { Networks } from './Networks' + +export abstract class PhantomAbstract { + /** + * @param password - The password of the Phantom wallet. + * @param extensionId - The extension ID of the Phantom extension. Optional if no interaction with the dapp is required. + * + * @returns A new instance of the Phantom class. + */ + constructor( + /** + * The password of the Phantom wallet. + */ + readonly password: string, + /** + * The extension ID of the Phantom extension. Optional if no interaction with the dapp is required. + */ + readonly extensionId?: string + ) { + this.password = password + this.extensionId = extensionId + } + + /** + * Imports a wallet using the given seed phrase. + * + * @param seedPhrase - The seed phrase to import. + */ + abstract importWallet(seedPhrase: string): void + + /** + * Adds a new account with the given name. This account is based on the initially imported seed phrase. + * + * @param accountName - The name of the new account. + */ + abstract addNewAccount(accountName: string): void + + /** + * Imports a wallet using the given private key. + * + * @param privateKey - The private key to import. + */ + abstract importWalletFromPrivateKey(network: Networks, privateKey: string, walletName?: string): void + + /** + * Switches to the account with the given name. + * + * @param accountName - The name of the account to switch to. + */ + abstract switchAccount(accountName: string): void + + /** + * Retrieves the current account address. + */ + abstract getAccountAddress(network: Networks): void + + /** + * Connects to the dapp using the currently selected account. + */ + abstract connectToDapp(account?: string): void + + /** + * Locks Phantom. + */ + abstract lock(): void + + /** + * Unlocks Phantom. + */ + abstract unlock(): void + + /** + * Confirms a signature request. This function supports all types of commonly used signatures. + */ + abstract confirmSignature(): void + + /** + * Confirms a signature request with potential risk. + */ + abstract confirmSignatureWithRisk(): void + + /** + * Rejects a signature request. This function supports all types of commonly used signatures. + */ + abstract rejectSignature(): void + + /** + * Confirms a transaction request. + * + * @param options - The transaction options. + * @param options.gasSetting - The gas setting to use for the transaction. + */ + abstract confirmTransaction(options?: { gasSetting?: GasSettings }): void + + /** + * Rejects a transaction request. + */ + abstract rejectTransaction(): void + + /** + * Approves a permission request to spend tokens. + * + * ::: warning + * For NFT approvals, use `confirmTransaction` method. + * ::: + * + * @param options - The permission options. + * @param options.spendLimit - The spend limit to use for the permission. + * @param options.gasSetting - The gas setting to use for the approval transaction. + */ + abstract approveTokenPermission(options?: { + spendLimit?: 'max' | number + gasSetting?: GasSettings + }): void + + /** + * Rejects a permission request to spend tokens. + * + * ::: warning + * For NFT approvals, use `confirmTransaction` method. + * ::: + */ + abstract rejectTokenPermission(): void + + /** + * Navigates to the home page of Phantom tab. + */ + abstract goToHomePage(): void + + /** + * Goes back to the home page of Phantom tab. + */ + abstract goBackToHomePage(): void + + /** + * Opens the settings page. + */ + abstract openSettings(): void + + /** + * Toggles the "Show Test Networks" setting. + * + * ::: warning + * This function requires the correct menu to be already opened. + * ::: + */ + abstract toggleTestnetMode(): void + + /** + * Resets the account. + * + * ::: warning + * This function requires the correct menu to be already opened. + * ::: + */ + abstract resetApp(): void +} diff --git a/wallets/phantom/test/playwright/commonSteps/aaveSetup.ts b/wallets/phantom/test/playwright/commonSteps/aaveSetup.ts new file mode 100644 index 000000000..93ddc2b5a --- /dev/null +++ b/wallets/phantom/test/playwright/commonSteps/aaveSetup.ts @@ -0,0 +1,36 @@ +import { type Page, expect } from '@playwright/test' +import type { Phantom } from '../../../src/playwright' + +export const aaveSetup = async (page: Page, phantom: Phantom) => { + await page.goto('https://app.aave.com') + + await phantom.toggleTestnetMode() + + await page.locator('button#settings-button').click() + await page.locator('li:has-text("Testnet mode")').click() + await expect(page.getByRole('button', { name: 'TESTNET' })).toBeVisible() + + await page.getByRole('button', { name: 'Connect wallet' }).first().click() + await page.getByRole('button', { name: 'Phantom' }).click() + + await phantom.connectToDapp() + await phantom.page.waitForTimeout(1_000) + await phantom.closeUnsupportedNetworkWarning() + + await expect(page.getByText('0xf3...2266'), '"0xf3...2266" should be visible').toBeVisible() + + await page.goto( + 'https://app.aave.com/reserve-overview/?underlyingAsset=0xff34b3d4aee8ddcd6f9afffb6fe49bd371b8a357&marketName=proto_sepolia_v3' + ) + + await page.getByRole('button', { name: 'Supply' }).click() + await page.locator('input[aria-label="amount input"]').fill('1') + + await expect( + page.getByRole('button', { + name: 'Supply DAI' + }) + ).toBeDisabled() + + await page.locator('button:has-text("Approve DAI to continue")').click() +} diff --git a/wallets/phantom/test/playwright/commonSteps/connectPhantomToTestDapp.ts b/wallets/phantom/test/playwright/commonSteps/connectPhantomToTestDapp.ts new file mode 100644 index 000000000..ebc7b47c1 --- /dev/null +++ b/wallets/phantom/test/playwright/commonSteps/connectPhantomToTestDapp.ts @@ -0,0 +1,10 @@ +import { type Page, expect } from '@playwright/test' +import type { Phantom } from '../../../src/playwright' + +export const connectPhantomToTestDapp = async (page: Page, phantom: Phantom) => { + await page.locator('#connectButton').click() + + await phantom.connectToDapp() + + await expect(page.locator('#accounts')).toHaveText('0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266') +} diff --git a/wallets/phantom/test/playwright/commonSteps/solanaSandboxSetup.ts b/wallets/phantom/test/playwright/commonSteps/solanaSandboxSetup.ts new file mode 100644 index 000000000..d3de95472 --- /dev/null +++ b/wallets/phantom/test/playwright/commonSteps/solanaSandboxSetup.ts @@ -0,0 +1,25 @@ +import { type Page, expect } from '@playwright/test' +import type { Phantom } from '../../../src/playwright' + +export const solanaSandboxSetup = async (page: Page, phantom: Phantom) => { + // await phantom.page.waitForTimeout(1_000) + await phantom.importWalletFromPrivateKey( + 'solana', + 'XQaKFLLSKbzpVzmfJrj4yUjAyFy2Eu7JcNdbPdnLuod2Uw3yf3tjGd4ha1DBfFdjkZFX1PZg3knth2Tz2tvd8C4' + ) + + await phantom.toggleTestnetMode() + + await page.goto('https://r3byv.csb.app/') + await page.locator('a:has-text("Yes, proceed to preview")').click() + await page.getByRole('button', { name: 'Connect to Phantom' }).click() + + // Delay for avoiding random fails + await page.waitForTimeout(2_000) + + await phantom.connectToDapp() + + await page.getByRole('button', { name: 'Clear Logs' }).click() + + await expect(page.getByText('Click a button and watch magic happen...')).toBeVisible() +} diff --git a/wallets/phantom/test/playwright/e2e/addNewAccount.spec.ts b/wallets/phantom/test/playwright/e2e/addNewAccount.spec.ts new file mode 100644 index 000000000..9dfc05948 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/addNewAccount.spec.ts @@ -0,0 +1,23 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should add a new account with specified name', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + const accountName = 'Test Account' + await phantom.addNewAccount(accountName) + + await expect(phantomPage.locator(phantom.homePage.selectors.accountMenu.accountName)).toHaveText(accountName) +}) + +test('should throw an error if an empty account name is passed', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await expect(phantom.addNewAccount('')).rejects.toThrowError('[AddNewAccount] Account name cannot be an empty string') +}) diff --git a/wallets/phantom/test/playwright/e2e/approveTokenPermission.spec.ts b/wallets/phantom/test/playwright/e2e/approveTokenPermission.spec.ts new file mode 100644 index 000000000..938721984 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/approveTokenPermission.spec.ts @@ -0,0 +1,20 @@ +import { aaveSetup } from '../commonSteps/aaveSetup' +import synpress from '../synpress' + +const test = synpress + +const { expect } = test + +test('should approve token with the default limit', async ({ page, phantom }) => { + test.setTimeout(80_000) + + await aaveSetup(page, phantom) + + await phantom.approveTokenPermission() + + await expect( + page.getByRole('button', { + name: 'Supply DAI' + }) + ).toBeEnabled({ timeout: 10_000 }) +}) diff --git a/wallets/phantom/test/playwright/e2e/confirmSignature.spec.ts b/wallets/phantom/test/playwright/e2e/confirmSignature.spec.ts new file mode 100644 index 000000000..806ff8de4 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/confirmSignature.spec.ts @@ -0,0 +1,93 @@ +import { connectPhantomToTestDapp } from '../commonSteps/connectPhantomToTestDapp' +import synpress from '../synpress' + +const test = synpress + +const { expect } = test + +test('should confirm `personal_sign`', async ({ page, phantom }) => { + await connectPhantomToTestDapp(page, phantom) + + await page.locator('#personalSign').click() + + // Delay toavoid random fails + await page.waitForTimeout(2_000) + + await phantom.confirmSignature() + + await expect(page.locator('#personalSignResult')).toHaveText( + '0xf95b3efc808585303e20573e960993cde30c7f5a0f1c25cfab0379d5a14311d17898199814c8ebe66ec80b2b11690f840bde539f862ff4f04468d2a40f15178a1b' + ) +}) + +test('should confirm `eth_signTypedData`', async ({ page, phantom }) => { + await connectPhantomToTestDapp(page, phantom) + + await page.locator('#signTypedData').click() + + // Delay toavoid random fails + await page.waitForTimeout(2_000) + + await phantom.confirmSignature() + + await expect(page.locator('#signTypedDataResult')).toHaveText( + '0xd75eece0d337f4e425f87bd112c849561956afe4f154cdd07d1d4cba7a979b481ba6ceede5c0eb9daa66bec4eea6e7ecfee5496274ef2a93b69abd97531519b21c' + ) + + await page.locator('#signTypedDataVerify').click() + + await expect(page.locator('#signTypedDataVerifyResult')).toHaveText('0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266') +}) + +test('should confirm `eth_signTypedData_v3`', async ({ page, phantom }) => { + await connectPhantomToTestDapp(page, phantom) + + await page.locator('#signTypedDataV3').click() + + // Delay toavoid random fails + await page.waitForTimeout(2_000) + + await phantom.confirmSignature() + + await expect(page.locator('#signTypedDataV3Result')).toHaveText( + '0x6ea8bb309a3401225701f3565e32519f94a0ea91a5910ce9229fe488e773584c0390416a2190d9560219dab757ecca2029e63fa9d1c2aebf676cc25b9f03126a1b' + ) + + await page.locator('#signTypedDataV3Verify').click() + + await expect(page.locator('#signTypedDataV3VerifyResult')).toHaveText('0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266') +}) + +test('should confirm `eth_signTypedData_v4`', async ({ page, phantom }) => { + await connectPhantomToTestDapp(page, phantom) + + await page.locator('#signTypedDataV4').click() + + // Delay toavoid random fails + await page.waitForTimeout(2_000) + + await phantom.confirmSignature() + + await expect(page.locator('#signTypedDataV4Result')).toHaveText( + '0x1cf422c4a319c19ecb89c960e7c296810278fa2bef256c7e9419b285c8216c547b3371fa1ec3987ce08561d3ed779845393d8d3e4311376d0bc0846f37d1b2821c' + ) + + await page.locator('#signTypedDataV4Verify').click() + + await expect(page.locator('#signTypedDataV4VerifyResult')).toHaveText('0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266') +}) + +test('should confirm `eth_sign`', async ({ page, phantom }) => { + await connectPhantomToTestDapp(page, phantom) + + await page.locator('#ethSign').click() + + // To avoid random fails + await page.waitForTimeout(2_000) + + await phantom.confirmSignatureWithRisk() + + await expect(page.locator('#ethSignResult')).toContainText( + '0x369af83ed2fcee757c12d59d921289722d7c6bc11f484f667f2ef8b74b068a0e281ffff7099fc5a4af534f5dbaaab92238b715dd887e3a9f8e2298dc99f554e81b' + ) +}) diff --git a/wallets/phantom/test/playwright/e2e/confirmTransaction.spec.ts b/wallets/phantom/test/playwright/e2e/confirmTransaction.spec.ts new file mode 100644 index 000000000..d91154fae --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/confirmTransaction.spec.ts @@ -0,0 +1,66 @@ +import { connectPhantomToTestDapp } from '../commonSteps/connectPhantomToTestDapp' +import { solanaSandboxSetup } from '../commonSteps/solanaSandboxSetup' +import synpress from '../synpress' + +const test = synpress + +const { expect } = test + +test('should Sign Transaction - ', async ({ page, phantom }) => { + test.setTimeout(90_000) + + await solanaSandboxSetup(page, phantom) + + await page.getByRole('button', { name: 'Sign Transaction' }).click() + + // Delay toavoid random fails + await page.waitForTimeout(2_000) + + await phantom.confirmTransaction() + + await expect(page.getByText('> success')).toBeVisible() +}) + +test('should Sign All Transactions', async ({ page, phantom }) => { + test.setTimeout(90_000) + + await solanaSandboxSetup(page, phantom) + + await page.getByRole('button', { name: 'Sign All Transaction' }).click() + + // Delay toavoid random fails + await page.waitForTimeout(2_000) + + await phantom.confirmTransaction() + + await expect(page.getByText('> success')).toBeVisible() +}) + +test('should confirm contract deployment with default gas setting', async ({ page, phantom }) => { + await connectPhantomToTestDapp(page, phantom) + + await expect(page.locator('#tokenAddresses')).toBeEmpty() + await page.locator('#createToken').click() + + // Delay toavoid random fails + await page.waitForTimeout(2_000) + + await phantom.confirmTransaction() + + await expect(page.locator('#tokenAddresses')).toContainText('Creation Failed') +}) +;(['Slow', 'Fast'] as const).forEach((gasSetting) => { + test(`should confirm contract deployment with ${gasSetting} gas setting`, async ({ page, phantom }) => { + await connectPhantomToTestDapp(page, phantom) + + await expect(page.locator('#tokenAddresses')).toBeEmpty() + await page.locator('#createToken').click() + + // Delay toavoid random fails + await page.waitForTimeout(2_000) + + await phantom.confirmTransaction({ gasSetting }) + + await expect(page.locator('#tokenAddresses')).toContainText('Creation Failed') + }) +}) diff --git a/wallets/phantom/test/playwright/e2e/connectToDapp.spec.ts b/wallets/phantom/test/playwright/e2e/connectToDapp.spec.ts new file mode 100644 index 000000000..5e6f75a8e --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/connectToDapp.spec.ts @@ -0,0 +1,57 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should connect wallet to dapp', async ({ context, page, extensionId }) => { + const phantom = new Phantom(context, page, basicSetup.walletPassword, extensionId) + + await page.goto('/') + + await page.locator('#connectButton').click() + + // Delay toavoid random fails + await page.waitForTimeout(2_000) + + await phantom.connectToDapp() + + await expect(page.locator('#accounts')).toHaveText('0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266') +}) + +test('should connect multiple wallets to dapp', async ({ context, page, phantomPage, extensionId }) => { + test.setTimeout(90_000) + + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword, extensionId) + + await phantom.addNewAccount('NewAccount1') + await phantom.addNewAccount('NewAccount2') + + await page.goto('/') + await page.locator('#connectButton').click() + + // Delay toavoid random fails + await page.waitForTimeout(2_000) + + await phantom.connectToDapp('NewAccount1') + + // Get address for account connected to testdapp + await expect(page.locator('#accounts')).toContainText('0x') + const testDappAccountAddress = await page.locator('#accounts').innerText() + + // Verify that active account in Phantom extension is 'NewAccount2' + await expect(phantomPage.locator(phantom.homePage.selectors.accountMenu.accountName)).toHaveText('NewAccount2') + // Verify that address for active account in Phantom extension is not connected to test dapp + const phantomActiveAccountAddress = await phantom.getAccountAddress('ethereum') + expect(testDappAccountAddress).not.toEqual(phantomActiveAccountAddress) + + // Switch to 'NewAccount1' in Phantom extension and verify that this is the one connected to test dapp + await phantom.switchAccount('NewAccount1') + const phantomNewAccount1Address = await phantom.getAccountAddress('ethereum') + + // Two accounts connected + expect(testDappAccountAddress.toLowerCase()).toEqual(phantomNewAccount1Address.toLowerCase()) +}) diff --git a/wallets/phantom/test/playwright/e2e/getAccountAddress.spec.ts b/wallets/phantom/test/playwright/e2e/getAccountAddress.spec.ts new file mode 100644 index 000000000..289e45b89 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/getAccountAddress.spec.ts @@ -0,0 +1,27 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should get account address for all available networks', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + const solanaAccountAddress = await phantom.getAccountAddress('solana') + expect(solanaAccountAddress).toEqual('oeYf6KAJkLYhBuR8CiGc6L4D4Xtfepr85fuDgA9kq96') + + const ethereumAccountAddress = await phantom.getAccountAddress('ethereum') + expect(ethereumAccountAddress).toEqual('0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266') + + const baseAccountAddress = await phantom.getAccountAddress('base') + expect(baseAccountAddress).toEqual('0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266') + + const polygonAccountAddress = await phantom.getAccountAddress('polygon') + expect(polygonAccountAddress).toEqual('0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266') + + const bitcoinAccountAddress = await phantom.getAccountAddress('bitcoin') + expect(bitcoinAccountAddress).toEqual('bc1q4qw42stdzjqs59xvlrlxr8526e3nunw7mp73te') +}) diff --git a/wallets/phantom/test/playwright/e2e/goBackToHomePage.spec.ts b/wallets/phantom/test/playwright/e2e/goBackToHomePage.spec.ts new file mode 100644 index 000000000..5818741e7 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/goBackToHomePage.spec.ts @@ -0,0 +1,22 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should go back to the home page', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await expect(phantomPage.locator(phantom.homePage.selectors.settings.lockWallet)).not.toBeVisible() + + await phantom.openSettings() + + await expect(phantomPage.locator(phantom.homePage.selectors.settings.lockWallet)).toBeVisible() + + await phantom.goBackToHomePage() + + await expect(phantomPage.locator(phantom.homePage.selectors.settings.lockWallet)).not.toBeVisible() +}) diff --git a/wallets/phantom/test/playwright/e2e/importWalletFromPrivateKey.spec.ts b/wallets/phantom/test/playwright/e2e/importWalletFromPrivateKey.spec.ts new file mode 100644 index 000000000..d1feff425 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/importWalletFromPrivateKey.spec.ts @@ -0,0 +1,48 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +const privateKey = 'ea084c575a01e2bbefcca3db101eaeab1d8af15554640a510c73692db24d0a6a' + +test('should import a new wallet from private key', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await phantom.importWalletFromPrivateKey('ethereum', privateKey) + + await phantomPage.locator(phantom.homePage.selectors.accountMenu.accountName).hover() + await expect(phantomPage.locator(phantom.homePage.selectors.ethereumWalletAddress)).toContainText('0xa2ce...6801') +}) + +// Flaky test - To be improved +test.skip('should throw an error if trying to import private key for the 2nd time', async ({ + context, + phantomPage +}) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await phantom.importWalletFromPrivateKey('ethereum', privateKey) + + // To avoid random fails + await phantomPage.waitForTimeout(2_000) + + const importWalletPromise = phantom.importWalletFromPrivateKey('ethereum', privateKey) + + await expect(importWalletPromise).rejects.toThrowError( + '[ImportWalletFromPrivateKey] Importing failed due to error: This account already exists in your wallet' + ) +}) + +test('should throw an error if the private key is invalid', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + const importWalletPromise = phantom.importWalletFromPrivateKey('ethereum', '0xdeadbeef') + + await expect(importWalletPromise).rejects.toThrowError( + '[ImportWalletFromPrivateKey] Importing failed due to error: Incorrect format' + ) +}) diff --git a/wallets/phantom/test/playwright/e2e/lock.spec.ts b/wallets/phantom/test/playwright/e2e/lock.spec.ts new file mode 100644 index 000000000..e6b748038 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/lock.spec.ts @@ -0,0 +1,16 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should lock the wallet', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await phantom.lock() + + await expect(phantomPage.locator(phantom.unlockPage.selectors.submitButton)).toBeVisible() +}) diff --git a/wallets/phantom/test/playwright/e2e/openSettings.spec.ts b/wallets/phantom/test/playwright/e2e/openSettings.spec.ts new file mode 100644 index 000000000..f99d2ab53 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/openSettings.spec.ts @@ -0,0 +1,15 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should open settings', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await phantom.openSettings() + + await expect(phantomPage.locator(phantom.homePage.selectors.settings.lockWallet)).toBeVisible() +}) diff --git a/wallets/phantom/test/playwright/e2e/rejectSignature.spec.ts b/wallets/phantom/test/playwright/e2e/rejectSignature.spec.ts new file mode 100644 index 000000000..8df7a7b97 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/rejectSignature.spec.ts @@ -0,0 +1,53 @@ +import { connectPhantomToTestDapp } from '../commonSteps/connectPhantomToTestDapp' +import synpress from '../synpress' + +const test = synpress + +const { expect } = test + +test('should reject `personal_sign`', async ({ page, phantom }) => { + await connectPhantomToTestDapp(page, phantom) + + await page.locator('#personalSign').click() + + await phantom.rejectSignature() + + await expect(page.locator('#personalSign')).toHaveText('Error: User rejected the request.') + await expect(page.locator('#personalSignResult')).toHaveText('') +}) + +test('should reject `eth_signTypedData`', async ({ page, phantom }) => { + await connectPhantomToTestDapp(page, phantom) + + await page.locator('#signTypedData').click() + + await phantom.rejectSignature() + + await expect(page.locator('#signTypedDataResult')).toHaveText('Error: User rejected the request.') +}) + +test('should reject `eth_signTypedData_v3`', async ({ page, phantom }) => { + await connectPhantomToTestDapp(page, phantom) + + await page.locator('#signTypedDataV3').click() + + // Delay toavoid random fails + await page.waitForTimeout(2_000) + + await phantom.rejectSignature() + + await expect(page.locator('#signTypedDataV3Result')).toHaveText('Error: User rejected the request.') +}) + +test('should reject `eth_signTypedData_v4`', async ({ page, phantom }) => { + await connectPhantomToTestDapp(page, phantom) + + await page.locator('#signTypedDataV4').click() + + // Delay toavoid random fails + await page.waitForTimeout(2_000) + + await phantom.rejectSignature() + + await expect(page.locator('#signTypedDataV4Result')).toHaveText('Error: User rejected the request.') +}) diff --git a/wallets/phantom/test/playwright/e2e/rejectTokenPermission.spec.ts b/wallets/phantom/test/playwright/e2e/rejectTokenPermission.spec.ts new file mode 100644 index 000000000..350c6ebbd --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/rejectTokenPermission.spec.ts @@ -0,0 +1,21 @@ +import { aaveSetup } from '../commonSteps/aaveSetup' +import synpress from '../synpress' + +const test = synpress + +const { expect } = test + +test('should reject approve token request', async ({ page, phantom }) => { + test.setTimeout(80_000) + + await aaveSetup(page, phantom) + + await expect(page.locator('button:has-text("Approve DAI to continue")')).not.toBeVisible() + await expect(page.locator('button:has-text("Approving DAI...")')).toBeVisible() + + await phantom.rejectTokenPermission() + + await expect(page.locator('button:has-text("Approving DAI...")')).not.toBeVisible({ timeout: 10_000 }) + await expect(page.locator('button:has-text("Approve DAI to continue")')).toBeVisible() + await expect(page.getByText('There was some error. Please try changing the parameters or')).toBeVisible() +}) diff --git a/wallets/phantom/test/playwright/e2e/rejectTransaction.spec.ts b/wallets/phantom/test/playwright/e2e/rejectTransaction.spec.ts new file mode 100644 index 000000000..d3faccc08 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/rejectTransaction.spec.ts @@ -0,0 +1,53 @@ +import { connectPhantomToTestDapp } from '../commonSteps/connectPhantomToTestDapp' +import { solanaSandboxSetup } from '../commonSteps/solanaSandboxSetup' +import synpress from '../synpress' + +const test = synpress + +const { expect } = test + +test('should Reject Transaction ', async ({ page, phantom }) => { + test.setTimeout(90_000) + + await solanaSandboxSetup(page, phantom) + + await page.getByRole('button', { name: 'Sign Transaction' }).click() + + // Delay toavoid random fails + await page.waitForTimeout(2_000) + + await phantom.rejectTransaction() + + await expect(page.getByText('User rejected the request.')).toBeVisible() +}) + +test('should Reject All Transactions ', async ({ page, phantom }) => { + test.setTimeout(90_000) + + await solanaSandboxSetup(page, phantom) + + await page.getByRole('button', { name: 'Sign All Transaction' }).click() + + // Delay toavoid random fails + await page.waitForTimeout(2_000) + + await phantom.rejectTransaction() + + await expect(page.getByText('User rejected the request.')).toBeVisible() +}) + +test('should reject contract deployment', async ({ page, phantom }) => { + test.setTimeout(90_000) + + await connectPhantomToTestDapp(page, phantom) + + await expect(page.locator('#tokenAddresses')).toBeEmpty() + await page.locator('#createToken').click() + + // Delay toavoid random fails + await page.waitForTimeout(2_000) + + await phantom.rejectTransaction() + + await expect(page.locator('#tokenAddresses')).toContainText('Creation Failed') +}) diff --git a/wallets/phantom/test/playwright/e2e/renameAccount.spec.ts b/wallets/phantom/test/playwright/e2e/renameAccount.spec.ts new file mode 100644 index 000000000..c8bb33f8e --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/renameAccount.spec.ts @@ -0,0 +1,27 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should rename current account with specified name', async ({ context, phantomPage }) => { + test.setTimeout(100_000) + + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await phantom.importWalletFromPrivateKey( + 'ethereum', + 'ea084c575a01e2bbefcca3db101eaeab1d8af15554640a510c73692db24d0a6a', + 'Imp1' + ) + + const accountName = 'Test Account' + await phantom.renameAccount('Imp1', accountName) + + await phantomPage.reload() + + await expect(phantomPage.locator(phantom.homePage.selectors.accountMenu.accountName)).toHaveText(accountName) +}) diff --git a/wallets/phantom/test/playwright/e2e/resetApp.spec.ts b/wallets/phantom/test/playwright/e2e/resetApp.spec.ts new file mode 100644 index 000000000..5e2c10173 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/resetApp.spec.ts @@ -0,0 +1,28 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +// SKIPPING - Failing on CI becaue of After Hooks - No context +test.skip('reset the app', async ({ context, phantomPage }) => { + test.setTimeout(80_000) + + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await expect(phantomPage.locator(phantom.homePage.selectors.accountMenu.accountName)).toHaveText('Account 1') + + await phantom.resetApp() + + await expect(async () => { + const newPhantomPage = context.pages()[1] + + if (newPhantomPage) { + const newPhantomPageUrl = newPhantomPage?.url() + expect(newPhantomPageUrl).toContain('onboarding') + } + }).toPass() +}) diff --git a/wallets/phantom/test/playwright/e2e/switchAccount.spec.ts b/wallets/phantom/test/playwright/e2e/switchAccount.spec.ts new file mode 100644 index 000000000..15cfffedd --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/switchAccount.spec.ts @@ -0,0 +1,40 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should switch account', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await phantom.importWalletFromPrivateKey( + 'ethereum', + 'ea084c575a01e2bbefcca3db101eaeab1d8af15554640a510c73692db24d0a6a', + 'Imp1' + ) + + await phantom.importWalletFromPrivateKey( + 'ethereum', + '7dd4aab86170c0edbdcf97600eff0ae319fdc94149c5e8c33d5439f8417a40bf', + 'Imp2' + ) + + await phantom.switchAccount('Imp1') + + await expect(phantomPage.getByTestId('home-header-account-name')).toContainText('Imp1') + + await phantomPage.locator(phantom.homePage.selectors.accountMenu.accountName).hover() + await expect(phantomPage.locator(phantom.homePage.selectors.ethereumWalletAddress)).toContainText('0xa2ce...6801') +}) + +test('should throw an error if there is no account with target name', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + const accountName = 'Account 420' + const switchAccountPromise = phantom.switchAccount(accountName) + + await expect(switchAccountPromise).rejects.toThrowError(`[SwitchAccount] Account with name ${accountName} not found`) +}) diff --git a/wallets/phantom/test/playwright/e2e/toggleTestnetMode.spec.ts b/wallets/phantom/test/playwright/e2e/toggleTestnetMode.spec.ts new file mode 100644 index 000000000..3470f7653 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/toggleTestnetMode.spec.ts @@ -0,0 +1,20 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import Selectors from '../../../src/selectors/pages/HomePage' +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should toggle the "Testnet Mode" option from Developer Settings menu', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await phantom.toggleTestnetMode() + + await phantomPage.locator(Selectors.headerBackButton).click() + await phantom.goBackToHomePage() + + await expect(phantomPage.getByText('You are currently in Testnet Mode')).toBeVisible() +}) diff --git a/wallets/phantom/test/playwright/e2e/unlock.spec.ts b/wallets/phantom/test/playwright/e2e/unlock.spec.ts new file mode 100644 index 000000000..76b90f613 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/unlock.spec.ts @@ -0,0 +1,18 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should unlock the wallet', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await phantom.lock() + + await phantom.unlock() + + await expect(phantomPage.locator(phantom.homePage.selectors.accountMenu.accountName)).toContainText('Account') +}) diff --git a/wallets/phantom/test/playwright/synpress.ts b/wallets/phantom/test/playwright/synpress.ts new file mode 100644 index 000000000..48099bf3d --- /dev/null +++ b/wallets/phantom/test/playwright/synpress.ts @@ -0,0 +1,5 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { phantomFixtures } from '../../src/playwright' +import basicSetup from './wallet-setup/basic.setup' + +export default testWithSynpress(phantomFixtures(basicSetup)) diff --git a/wallets/phantom/test/playwright/wallet-setup/basic.setup.ts b/wallets/phantom/test/playwright/wallet-setup/basic.setup.ts new file mode 100644 index 000000000..56ce9f314 --- /dev/null +++ b/wallets/phantom/test/playwright/wallet-setup/basic.setup.ts @@ -0,0 +1,11 @@ +import { defineWalletSetup } from '@synthetixio/synpress-cache' +import { Phantom } from '../../../src/playwright' + +export const SEED_PHRASE = 'test test test test test test test test test test test junk' +export const PASSWORD = 'Tester@1234' + +export default defineWalletSetup(PASSWORD, async (context, walletPage) => { + const phantom = new Phantom(context, walletPage, PASSWORD) + + await phantom.importWallet(SEED_PHRASE) +}) diff --git a/wallets/phantom/tsconfig.build.json b/wallets/phantom/tsconfig.build.json new file mode 100644 index 000000000..9af73f809 --- /dev/null +++ b/wallets/phantom/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "@synthetixio/synpress-tsconfig/base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "types", + "declaration": true, + "sourceMap": true, + "declarationMap": true + }, + "include": ["src"], + "files": ["environment.d.ts"] +} diff --git a/wallets/phantom/tsconfig.json b/wallets/phantom/tsconfig.json new file mode 100644 index 000000000..dad11ada4 --- /dev/null +++ b/wallets/phantom/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "rootDir": ".", + "esModuleInterop": true, + "exactOptionalPropertyTypes": false, // Allows for `undefined` in `playwright.config.ts` + "types": ["cypress"], + "sourceMap": false + }, + "include": ["src", "test"], + "files": ["environment.d.ts", "playwright.config.ts", "vitest.config.ts"] +} diff --git a/wallets/phantom/tsup.config.ts b/wallets/phantom/tsup.config.ts new file mode 100644 index 000000000..20c19819d --- /dev/null +++ b/wallets/phantom/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + name: 'phantom', + entry: ['src/index.ts', 'src/playwright/index.ts', 'src/cypress/index.ts', 'src/cypress/support/index.ts'], + outDir: 'dist', + format: 'esm', + splitting: false, + treeshake: true, + sourcemap: true, + external: ['@playwright/test'] +}) diff --git a/wallets/phantom/vitest.config.ts b/wallets/phantom/vitest.config.ts new file mode 100644 index 000000000..42ec65bf9 --- /dev/null +++ b/wallets/phantom/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + dir: 'test/unit' + } +})