diff --git a/docs/api/cypress/functions/initPhantom.md b/docs/api/cypress/functions/initPhantom.md new file mode 100644 index 000000000..60437302c --- /dev/null +++ b/docs/api/cypress/functions/initPhantom.md @@ -0,0 +1,29 @@ +# Function: initPhantom() + +```ts +function initPhantom(): Promise<{ + "browserArgs": string[]; + "extensions": string[]; +}> +``` + +Initializes Phantom for Cypress tests. + +This function prepares the Phantom extension for use in Cypress tests. +It sets up the necessary browser arguments and extension paths. + +## Returns + +`Promise`\<\{ + `"browserArgs"`: `string`[]; + `"extensions"`: `string`[]; + \}\> + +An object containing the extension path and browser arguments. + +| Member | Type | +| :------ | :------ | +| `browserArgs` | `string`[] | +| `extensions` | `string`[] | + +## Async diff --git a/docs/api/cypress/index.md b/docs/api/cypress/index.md index 30ad88589..d7dc8992c 100644 --- a/docs/api/cypress/index.md +++ b/docs/api/cypress/index.md @@ -5,6 +5,9 @@ | Member | Description | | :------ | :------ | | [MetaMask](classes/MetaMask.md) | MetaMask class for interacting with the MetaMask extension in Cypress tests. | +| [Phantom](classes/Phantom.md) | Phantom class for interacting with the Phantom extension in Cypress tests. | | [configureSynpressForEthereumWalletMock](functions/configureSynpressForEthereumWalletMock.md) | Configures Synpress for use with the Ethereum Wallet Mock. | | [configureSynpressForMetaMask](functions/configureSynpressForMetaMask.md) | Configures Synpress for use with MetaMask. | +| [configureSynpressForPhantom](functions/configureSynpressForPhantom.md) | Configures Synpress for use with Phantom. | | [initMetaMask](functions/initMetaMask.md) | Initializes MetaMask for Cypress tests. | +| [initPhantom](functions/initPhantom.md) | Initializes Phantom for Cypress tests. | diff --git a/docs/api/typedoc-sidebar.json b/docs/api/typedoc-sidebar.json index 30282d5dc..472e7a536 100644 --- a/docs/api/typedoc-sidebar.json +++ b/docs/api/typedoc-sidebar.json @@ -7,7 +7,10 @@ { "text": "Classes", "collapsed": true, - "items": [{ "text": "MetaMask", "link": "/api/cypress/classes/MetaMask.md" }] + "items": [ + { "text": "MetaMask", "link": "/api/cypress/classes/MetaMask.md" }, + { "text": "Phantom", "link": "/api/cypress/classes/Phantom.md" } + ] }, { "text": "Functions", @@ -17,8 +20,22 @@ "text": "configureSynpressForEthereumWalletMock", "link": "/api/cypress/functions/configureSynpressForEthereumWalletMock.md" }, - { "text": "configureSynpressForMetaMask", "link": "/api/cypress/functions/configureSynpressForMetaMask.md" }, - { "text": "initMetaMask", "link": "/api/cypress/functions/initMetaMask.md" } + { + "text": "configureSynpressForMetaMask", + "link": "/api/cypress/functions/configureSynpressForMetaMask.md" + }, + { + "text": "configureSynpressForPhantom", + "link": "/api/cypress/functions/configureSynpressForPhantom.md" + }, + { + "text": "initMetaMask", + "link": "/api/cypress/functions/initMetaMask.md" + }, + { + "text": "initPhanton", + "link": "/api/cypress/functions/initPhantom.md" + } ] }, { @@ -30,7 +47,10 @@ "text": "Functions", "collapsed": true, "items": [ - { "text": "mockEthereum", "link": "/api/cypress/support/functions/mockEthereum.md" }, + { + "text": "mockEthereum", + "link": "/api/cypress/support/functions/mockEthereum.md" + }, { "text": "synpressCommandsForEthereumWalletMock", "link": "/api/cypress/support/functions/synpressCommandsForEthereumWalletMock.md" @@ -38,6 +58,10 @@ { "text": "synpressCommandsForMetaMask", "link": "/api/cypress/support/functions/synpressCommandsForMetaMask.md" + }, + { + "text": "synpressCommandsForPhantom", + "link": "/api/cypress/support/functions/synpressCommandsForPhantom.md" } ] } @@ -54,8 +78,14 @@ "text": "Functions", "collapsed": true, "items": [ - { "text": "defineWalletSetup", "link": "/api/index/functions/defineWalletSetup.md" }, - { "text": "testWithSynpress", "link": "/api/index/functions/testWithSynpress.md" } + { + "text": "defineWalletSetup", + "link": "/api/index/functions/defineWalletSetup.md" + }, + { + "text": "testWithSynpress", + "link": "/api/index/functions/testWithSynpress.md" + } ] } ] @@ -69,28 +99,60 @@ "text": "Classes", "collapsed": true, "items": [ - { "text": "EthereumWalletMock", "link": "/api/playwright/classes/EthereumWalletMock.md" }, - { "text": "MetaMask", "link": "/api/playwright/classes/MetaMask.md" } + { + "text": "EthereumWalletMock", + "link": "/api/playwright/classes/EthereumWalletMock.md" + }, + { "text": "MetaMask", "link": "/api/playwright/classes/MetaMask.md" }, + { "text": "Phantom", "link": "/api/playwright/classes/Phantom.md" } ] }, { "text": "Variables", "collapsed": true, "items": [ - { "text": "DEFAULT_NETWORK_ID", "link": "/api/playwright/variables/DEFAULT_NETWORK_ID.md" }, - { "text": "PRIVATE_KEY", "link": "/api/playwright/variables/PRIVATE_KEY.md" }, - { "text": "web3MockPath", "link": "/api/playwright/variables/web3MockPath.md" } + { + "text": "DEFAULT_NETWORK_ID", + "link": "/api/playwright/variables/DEFAULT_NETWORK_ID.md" + }, + { + "text": "PRIVATE_KEY", + "link": "/api/playwright/variables/PRIVATE_KEY.md" + }, + { + "text": "web3MockPath", + "link": "/api/playwright/variables/web3MockPath.md" + } ] }, { "text": "Functions", "collapsed": true, "items": [ - { "text": "ethereumWalletMockFixtures", "link": "/api/playwright/functions/ethereumWalletMockFixtures.md" }, - { "text": "getExtensionId", "link": "/api/playwright/functions/getExtensionId.md" }, - { "text": "metaMaskFixtures", "link": "/api/playwright/functions/metaMaskFixtures.md" }, - { "text": "mockEthereum", "link": "/api/playwright/functions/mockEthereum.md" }, - { "text": "unlockForFixture", "link": "/api/playwright/functions/unlockForFixture.md" } + { + "text": "ethereumWalletMockFixtures", + "link": "/api/playwright/functions/ethereumWalletMockFixtures.md" + }, + { + "text": "getExtensionId", + "link": "/api/playwright/functions/getExtensionId.md" + }, + { + "text": "metaMaskFixtures", + "link": "/api/playwright/functions/metaMaskFixtures.md" + }, + { + "text": "phantomFixtures", + "link": "/api/playwright/functions/phantomFixtures.md" + }, + { + "text": "mockEthereum", + "link": "/api/playwright/functions/mockEthereum.md" + }, + { + "text": "unlockForFixture", + "link": "/api/playwright/functions/unlockForFixture.md" + } ] } ] diff --git a/package.json b/package.json index 704740877..2ed39536d 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "scripts": { "build": "turbo build", "build:cache": "turbo build:cache --filter=@synthetixio/synpress-metamask", + "build:cache:phantom": "turbo build:cache --filter=@synthetixio/synpress-phantom", "docs:build": "turbo docs:build --filter=docs", "format": "biome format . --write", "format:check": "biome format . --error-on-warnings", @@ -15,8 +16,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-metamask --filter=@synthetixio/ethereum-wallet-mock --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 bb3d9a78c..0c340e507 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -38,6 +38,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 9851573f4..8e5d5b969 100644 --- a/packages/cache/src/cli/cliEntrypoint.ts +++ b/packages/cache/src/cli/cliEntrypoint.ts @@ -6,6 +6,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' @@ -13,6 +14,7 @@ interface CliFlags { headless: boolean force: boolean debug: boolean + phantom: boolean } // TODO: Add unit tests for the CLI! @@ -30,6 +32,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) @@ -46,8 +49,15 @@ export const cliEntrypoint = async () => { } if (flags.debug) { - console.log('[DEBUG] Running with the following options:') - console.log({ cacheDir: walletSetupDir, ...flags, headless: Boolean(process.env.HEADLESS) ?? false }, '\n') + console.log('[DEBUG] Running with the following options ===:') + console.log( + { + cacheDir: walletSetupDir, + ...flags, + headless: Boolean(process.env.HEADLESS) ?? false + }, + '\n' + ) } if (os.platform() === 'win32') { @@ -64,8 +74,12 @@ export const cliEntrypoint = async () => { const compiledWalletSetupDirPath = await compileWalletSetupFunctions(walletSetupDir, flags.debug) - // TODO: We should be using `prepareExtension` function from the wallet itself! - await createCache(compiledWalletSetupDirPath, prepareExtension, flags.force) + // TODO: We should be using `prepareExtension` functions from the wallet itself! + if (flags.phantom) { + await createCache(compiledWalletSetupDirPath, prepareExtensionPhantom, flags.force) + } else { + await createCache(compiledWalletSetupDirPath, 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..0cc7a4de7 100644 --- a/packages/cache/tsconfig.json +++ b/packages/cache/tsconfig.json @@ -8,5 +8,5 @@ "src", "test" ], - "files": ["environment.d.ts"] + "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 aada3eba7..a3d0ff6a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,6 +151,9 @@ importers: tsup: specifier: 8.0.2 version: 8.0.2(postcss@8.4.41)(typescript@5.3.3) + unzip-crx-3: + specifier: 0.2.0 + version: 0.2.0 unzipper: specifier: 0.10.14 version: 0.10.14 @@ -242,6 +245,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.4 @@ -351,6 +357,55 @@ 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: link:../../packages/tsconfig + '@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)) + cypress: + specifier: 13.15.1 + version: 13.15.1 + rimraf: + specifier: 5.0.5 + version: 5.0.5 + tsup: + specifier: 8.0.2 + version: 8.0.2(postcss@8.4.41)(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': @@ -2902,6 +2957,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'} @@ -3230,6 +3288,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==} @@ -3253,6 +3314,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'} @@ -4737,6 +4804,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==} @@ -5000,6 +5070,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==} @@ -8045,6 +8118,8 @@ snapshots: ignore@5.3.2: {} + immediate@3.0.6: {} + import-lazy@4.0.0: {} imurmurhash@0.1.4: {} @@ -8316,6 +8391,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 @@ -8334,6 +8416,10 @@ snapshots: dependencies: readable-stream: 2.3.8 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lilconfig@3.0.0: {} lilconfig@3.1.2: {} @@ -8946,6 +9032,8 @@ snapshots: - bluebird - supports-color + pako@1.0.11: {} + parse-github-url@1.0.3: {} parse-json@5.2.0: @@ -9955,6 +10043,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 @@ -10277,6 +10371,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 5ea548b9f..59b684e8c 100644 --- a/release/package.json +++ b/release/package.json @@ -42,7 +42,8 @@ "@synthetixio/ethereum-wallet-mock": "workspace:*", "@synthetixio/synpress-cache": "0.0.4", "@synthetixio/synpress-core": "0.0.4", - "@synthetixio/synpress-metamask": "workspace:*" + "@synthetixio/synpress-metamask": "workspace:*", + "@synthetixio/synpress-phantom": "workspace:*" }, "devDependencies": { "@synthetixio/synpress-tsconfig": "0.0.4", diff --git a/release/src/cypress/index.ts b/release/src/cypress/index.ts index 93cf88871..626887f7b 100644 --- a/release/src/cypress/index.ts +++ b/release/src/cypress/index.ts @@ -4,3 +4,8 @@ export { initMetaMask, MetaMask } from '@synthetixio/synpress-metamask/cypress' +export { + configureSynpress as configureSynpressForPhantom, + initPhantom, + Phantom +} from '@synthetixio/synpress-phantom/cypress' diff --git a/release/src/cypress/support/index.ts b/release/src/cypress/support/index.ts index 6cf85364c..c8fcb379b 100644 --- a/release/src/cypress/support/index.ts +++ b/release/src/cypress/support/index.ts @@ -3,3 +3,4 @@ export { synpressCommands as synpressCommandsForEthereumWalletMock } from '@synthetixio/ethereum-wallet-mock/cypress/support' export { synpressCommands as synpressCommandsForMetaMask } from '@synthetixio/synpress-metamask/cypress/support' +export { synpressCommands as synpressCommandsForPhantom } from '@synthetixio/synpress-phantom/cypress/support' 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/wallets/phantom/cypress.config.ts b/wallets/phantom/cypress.config.ts new file mode 100644 index 000000000..18b0c5dd6 --- /dev/null +++ b/wallets/phantom/cypress.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'cypress' +import configureSynpress from './src/cypress/configureSynpress' + +export default defineConfig({ + userAgent: 'synpress', + chromeWebSecurity: true, + e2e: { + baseUrl: 'http://localhost:9999', + specPattern: 'test/cypress/**/*.cy.{js,jsx,ts,tsx}', + supportFile: 'src/cypress/support/e2e.{js,jsx,ts,tsx}', + testIsolation: false, + async setupNodeEvents(on, config) { + return configureSynpress(on, config) + } + }, + + defaultCommandTimeout: 12_000, + taskTimeout: 15_000 +}) 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..f8d94e32a --- /dev/null +++ b/wallets/phantom/package.json @@ -0,0 +1,65 @@ +{ + "name": "@synthetixio/synpress-phantom", + "version": "0.0.4", + "type": "module", + "exports": { + "./cypress": { + "types": "./types/cypress/index.d.ts", + "default": "./dist/cypress/index.js" + }, + "./cypress/support": { + "types": "./types/cypress/support/index.d.ts", + "default": "./dist/cypress/support/index.js" + }, + "./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:cypress:headful": "cypress run --browser chrome --headed", + "test:cypress:headful:no-wallet": "cypress run --browser chrome --headed --config-file ./cypress-no-wallet.config.ts", + "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", + "cypress": "13.15.1", + "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..86ddab65a --- /dev/null +++ b/wallets/phantom/playwright.config.ts @@ -0,0 +1,60 @@ +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 Phantom Test Dapp. + 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', + // 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/cypress/Phantom.ts b/wallets/phantom/src/cypress/Phantom.ts new file mode 100644 index 000000000..a3714d7aa --- /dev/null +++ b/wallets/phantom/src/cypress/Phantom.ts @@ -0,0 +1,411 @@ +import { type BrowserContext, type Page, expect } from '@playwright/test' +import { Phantom as PhantomPlaywright } from '../playwright/Phantom' +import { waitFor } from '../playwright/utils/waitFor' +import HomePageSelectors from '../selectors/pages/HomePage' +import Selectors from '../selectors/pages/HomePage' +import type { SettingsSidebarMenus } from '../selectors/pages/HomePage/settings' +import TransactionPage from '../selectors/pages/NotificationPage/transactionPage' +import type { GasSettings } from '../type/GasSettings' +import type { Networks } from '../type/Networks' +import getPlaywrightPhantom from './getPlaywrightPhantom' + +/** + * Phantom class for interacting with the Phantom extension in Cypress tests. + */ +export default class Phantom { + /** The Phantom instance for Playwright */ + readonly phantomPlaywright: PhantomPlaywright + /** The Phantom extension page */ + readonly phantomExtensionPage: Page + + /** + * Creates an instance of Phantom. + * @param context - The browser context + * @param phantomExtensionPage - The Phantom extension page + * @param phantomExtensionId - The Phantom extension ID + */ + constructor(context: BrowserContext, phantomExtensionPage: Page, phantomExtensionId: string) { + this.phantomPlaywright = getPlaywrightPhantom(context, phantomExtensionPage, phantomExtensionId) + this.phantomExtensionPage = phantomExtensionPage + } + + /** + * Gets the current account name. + * @returns The current account name + */ + async getAccount(): Promise { + return await this.phantomExtensionPage + .locator(this.phantomPlaywright.homePage.selectors.accountMenu.accountButton) + .innerText() + } + + /** + * Gets the current account address. + * @returns The current account address + */ + async getAccountAddress(network: Networks): Promise { + return await this.phantomPlaywright.getAccountAddress(network) + } + + /** + * Gets the current network name. + * @returns The current network name + */ + async getNetwork(): Promise { + return await this.phantomExtensionPage.locator(this.phantomPlaywright.homePage.selectors.currentNetwork).innerText() + } + + /** + * Connects Phantom to a dApp. + * @param accounts - Optional array of account addresses to connect + * @returns True if the connection was successful + */ + async connectToDapp(accounts?: string[]): Promise { + await this.phantomPlaywright.connectToDapp(accounts) + return true + } + + /** + * Imports a wallet using a seed phrase. + * @param seedPhrase - The seed phrase to import + * @returns True if the import was successful + */ + async importWallet(seedPhrase: string): Promise { + await this.phantomPlaywright.importWallet(seedPhrase) + return true + } + + /** + * Imports a wallet using a private key. + * @param privateKey - The private key to import + * @returns True if the import was successful + */ + async importWalletFromPrivateKey(network: Networks, privateKey: string, walletName?: string): Promise { + await this.phantomPlaywright.importWalletFromPrivateKey(network, privateKey, walletName) + return true + } + + /** + * Adds a new account with the given name. + * @param accountName - The name for the new account + * @returns True if the account was added successfully + */ + async addNewAccount(accountName: string): Promise { + await this.phantomPlaywright.addNewAccount(accountName) + await expect( + this.phantomExtensionPage.locator(this.phantomPlaywright.homePage.selectors.accountMenu.accountButton) + ).toHaveText(accountName) + return true + } + + /** + * Switches to the account with the given name. + * @param accountName - The name of the account to switch to + * @returns True if the switch was successful + */ + async switchAccount(accountName: string): Promise { + await this.phantomPlaywright.switchAccount(accountName) + await expect( + this.phantomExtensionPage.locator(this.phantomPlaywright.homePage.selectors.accountMenu.accountButton) + ).toHaveText(accountName) + return true + } + + /** + * Renames an account. + * @param options - Object containing the current and new account names + * @param options.currentAccountName - The current name of the account + * @param options.newAccountName - The new name for the account + * @returns True if the rename was successful + */ + async renameAccount({ + currentAccountName, + newAccountName + }: { + currentAccountName: string + newAccountName: string + }): Promise { + await this.phantomPlaywright.renameAccount(currentAccountName, newAccountName) + await this.phantomExtensionPage.locator(HomePageSelectors.threeDotsMenu.accountDetailsCloseButton).click() + await expect( + this.phantomExtensionPage.locator(this.phantomPlaywright.homePage.selectors.accountMenu.accountButton) + ).toHaveText(newAccountName) + return true + } + + /** + * Resets the current account. + * @returns True if the reset was successful + */ + async resetAccount(): Promise { + await this.phantomPlaywright.resetAccount() + return true + } + + /** + * Switches to the specified network. + * @param options - Object containing the network name and testnet flag + * @param options.networkName - The name of the network to switch to + * @param options.isTestnet - Whether the network is a testnet (default: false) + * @returns True if the switch was successful, false otherwise + */ + async switchNetwork({ + networkName, + isTestnet = false + }: { + networkName: string + isTestnet?: boolean + }): Promise { + return await this.phantomPlaywright + .switchNetwork(networkName, isTestnet) + .then(() => { + return true + }) + .catch(() => { + return false + }) + } + + /** + * Adds a new token to Phantom. + * @returns True if the token was added successfully + */ + async addNewToken(): Promise { + await this.phantomPlaywright.addNewToken() + await expect(this.phantomExtensionPage.locator(Selectors.portfolio.singleToken).nth(1)).toContainText('TST') + return true + } + + /** + * Approves token permission. + * @param options - Optional settings for token approval + * @param options.spendLimit - The spend limit for the token (number or 'max') + * @param options.gasSetting - Gas settings for the transaction + * @returns True if the permission was approved, false otherwise + */ + async approveTokenPermission(options?: { + spendLimit?: number | 'max' + gasSetting?: GasSettings + }): Promise { + return await this.phantomPlaywright + .approveTokenPermission(options) + .then(() => { + return true + }) + .catch(() => { + return false + }) + } + + /** + * Rejects token permission. + * @returns True if the permission was rejected successfully + */ + async rejectTokenPermission(): Promise { + await this.phantomPlaywright.rejectTokenPermission() + return true + } + + /** + * Locks the Phantom wallet. + * @returns True if the wallet was locked successfully + */ + async lock(): Promise { + await this.phantomPlaywright.lock() + await expect( + this.phantomExtensionPage.locator(this.phantomPlaywright.lockPage.selectors.submitButton) + ).toBeVisible() + return true + } + + /** + * Unlocks the Phantom wallet. + * @returns True if the wallet was unlocked successfully + */ + async unlock(): Promise { + await this.phantomPlaywright.unlock() + await expect(this.phantomExtensionPage.locator(this.phantomPlaywright.homePage.selectors.logo)).toBeVisible() + return true + } + + /** + * Provides a public encryption key. + * @returns True if the key was provided successfully, false otherwise + */ + async providePublicEncryptionKey(): Promise { + return await this.phantomPlaywright + .providePublicEncryptionKey() + .then(() => { + return true + }) + .catch(() => { + return false + }) + } + + /** + * Decrypts a message. + * @returns True if the message was decrypted successfully, false otherwise + */ + async decrypt(): Promise { + return await this.phantomPlaywright + .decrypt() + .then(() => { + return true + }) + .catch(() => { + return false + }) + } + + /** + * Confirms a signature request. + * @returns True if the signature was confirmed successfully, false otherwise + */ + async confirmSignature(): Promise { + return await this.phantomPlaywright + .confirmSignature() + .then(() => { + return true + }) + .catch(() => { + return false + }) + } + + /** + * Rejects a signature request. + * @returns True if the signature was rejected successfully + */ + async rejectSignature(): Promise { + await this.phantomPlaywright.rejectSignature() + return true + } + + /** + * Confirms a transaction. + * @param options - Optional gas settings for the transaction + * @returns True if the transaction was confirmed successfully + */ + async confirmTransaction(options?: { + gasSetting?: GasSettings + }): Promise { + await waitFor( + () => this.phantomExtensionPage.locator(TransactionPage.nftApproveAllConfirmationPopup.approveButton).isVisible(), + 5_000, + false + ) + await this.phantomPlaywright.confirmTransaction(options) + return true + } + + /** + * Rejects a transaction. + * @returns True if the transaction was rejected successfully + */ + async rejectTransaction(): Promise { + await this.phantomPlaywright.rejectTransaction() + return true + } + + /** + * Confirms a transaction and waits for it to be mined. + * @returns True if the transaction was confirmed and mined successfully, false otherwise + */ + async confirmTransactionAndWaitForMining(): Promise { + await waitFor( + () => this.phantomExtensionPage.locator(TransactionPage.nftApproveAllConfirmationPopup.approveButton).isVisible(), + 5_000, + false + ) + return this.phantomPlaywright + .confirmTransactionAndWaitForMining() + .then(() => { + return true + }) + .catch(() => { + return false + }) + } + + /** + * Opens the details of a specific transaction. + * @param txIndex - The index of the transaction to open + * @returns True if the transaction details were opened successfully, false otherwise + */ + async openTransactionDetails(txIndex: number): Promise { + return this.phantomPlaywright + .openTransactionDetails(txIndex) + .then(() => { + return true + }) + .catch(() => { + return false + }) + } + + /** + * Closes the transaction details view. + * @returns True if the transaction details were closed successfully, false otherwise + */ + async closeTransactionDetails(): Promise { + return this.phantomPlaywright + .closeTransactionDetails() + .then(() => { + return true + }) + .catch(() => { + return false + }) + } + + /** + * Toggles the display of test networks. + * @returns True if the toggle was successful + */ + async toggleShowTestNetworks(): Promise { + await this.phantomPlaywright.toggleShowTestNetworks() + return true + } + + /** + * Toggles the dismissal of the secret recovery phrase reminder. + * @returns True if the toggle was successful + */ + async toggleDismissSecretRecoveryPhraseReminder(): Promise { + await this.phantomPlaywright.toggleDismissSecretRecoveryPhraseReminder() + return true + } + + /** + * Navigates back to the home page. + * @returns True if the navigation was successful + */ + async goBackToHomePage(): Promise { + await this.phantomPlaywright.openSettings() + await expect(this.phantomExtensionPage.locator(HomePageSelectors.copyAccountAddressButton)).not.toBeVisible() + await this.phantomPlaywright.goBackToHomePage() + await expect(this.phantomExtensionPage.locator(HomePageSelectors.copyAccountAddressButton)).toBeVisible() + return true + } + + /** + * Opens the settings page. + * @returns True if the settings page was opened successfully + */ + async openSettings(): Promise { + await this.phantomPlaywright.openSettings() + return true + } + + /** + * Opens a specific sidebar menu in the settings. + * @param menu - The menu to open + * @returns True if the menu was opened successfully + */ + async openSidebarMenu(menu: SettingsSidebarMenus): Promise { + await this.phantomPlaywright.openSidebarMenu(menu) + await expect(this.phantomExtensionPage.locator(HomePageSelectors.settings.sidebarMenu(menu))).toBeVisible() + return true + } +} diff --git a/wallets/phantom/src/cypress/configureSynpress.ts b/wallets/phantom/src/cypress/configureSynpress.ts new file mode 100644 index 000000000..2a0a26977 --- /dev/null +++ b/wallets/phantom/src/cypress/configureSynpress.ts @@ -0,0 +1,182 @@ +import type { BrowserContext, Page } from '@playwright/test' +import { ensureRdpPort } from '@synthetixio/synpress-core' +import type { SettingsSidebarMenus } from '../selectors/pages/HomePage/settings' +import type { GasSettings } from '../type/GasSettings' +import type { Networks } from '../type/Networks' +import Phantom from './Phantom' +import importPhantomWallet from './support/importPhantomWallet' +import { initPhantom } from './support/initPhantom' + +let phantom: Phantom + +let rdpPort: number + +let context: BrowserContext +let phantomExtensionId: string + +let phantomExtensionPage: Page + +// TODO: Implement if needed to change the focus between pages +// let cypressPage: Page + +/** + * Configures Synpress for use with Phantom. + * + * This function sets up the necessary configurations and hooks for running + * Cypress tests with Phantom. + * + * @param on - Cypress plugin event handler + * @param config - Cypress plugin configuration options + * @param importDefaultWallet - Whether to import the default wallet + * @returns Modified Cypress configuration + * @throws Error If no Chrome browser is found in the configuration + * + * @remarks + * This function performs the following tasks: + * + * 1. Filters the available browsers to ensure only Chrome is used. + * 2. Sets up a 'before:browser:launch' hook to enable debug mode, establish + * a Playwright connection, and initialize Phantom. + * 3. Sets up a 'before:spec' hook to import the Phantom wallet before + * each test spec runs. + * 4. Provides task handlers for various Phantom-related operations. + * + * @example + * ```typescript + * import { configureSynpress } from './configureSynpress'; + * + * export default (on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions) => { + * return configureSynpress(on, config); + * }; + * ``` + */ + +export default function configureSynpress( + on: Cypress.PluginEvents, + config: Cypress.PluginConfigOptions, + importDefaultWallet = true +) { + const browsers = config.browsers.filter((b) => b.name === 'chrome') + if (browsers.length === 0) { + throw new Error('No Chrome browser found in the configuration') + } + + on('before:browser:launch', async (browser, launchOptions) => { + // Enable debug mode to establish playwright connection + const args = Array.isArray(launchOptions) ? launchOptions : launchOptions.args + rdpPort = ensureRdpPort(args) + + if (browser.family === 'chromium') { + const { extensions, browserArgs } = await initPhantom() + + launchOptions.extensions.push(...extensions) + args.push(...browserArgs) + } + + return launchOptions + }) + + on('before:spec', async () => { + if (!phantom) { + const { + context: _context, + phantomExtensionId: _phantomExtensionId, + extensionPage: _extensionPage, + cypressPage: _cypressPage + } = await importPhantomWallet(rdpPort, importDefaultWallet) + if (_extensionPage && _phantomExtensionId) { + context = _context + phantomExtensionId = _phantomExtensionId + phantomExtensionPage = _extensionPage + } + // TODO: Implement if needed to change the focus between pages + // if (_cypressPage) { + // cypressPage = _cypressPage + // } + phantom = new Phantom(context, phantomExtensionPage, phantomExtensionId) + } + }) + + // Synpress API + on('task', { + // Wallet + connectToDapp: () => phantom?.connectToDapp(), + importWallet: (seedPhrase: string) => phantom?.importWallet(seedPhrase), + importWalletFromPrivateKey: ({ + network, + privateKey, + walletName + }: { + network: Networks + privateKey: string + walletName?: string + }) => phantom?.importWalletFromPrivateKey(network, privateKey, walletName), + + // Account + getAccount: () => phantom?.getAccount(), + getAccountAddress: (network: Networks) => phantom?.getAccountAddress(network), + addNewAccount: (accountName: string) => phantom?.addNewAccount(accountName), + switchAccount: (accountName: string) => phantom?.switchAccount(accountName), + renameAccount: ({ + currentAccountName, + newAccountName + }: { + currentAccountName: string + newAccountName: string + }) => phantom?.renameAccount({ currentAccountName, newAccountName }), + resetAccount: () => phantom?.resetAccount(), + + // Network + getNetwork: () => phantom?.getNetwork(), + switchNetwork: ({ + networkName, + isTestnet = false + }: { + networkName: string + isTestnet?: boolean + }) => + phantom?.switchNetwork({ + networkName, + isTestnet + }), + + // Token + addNewToken: () => phantom?.addNewToken(), + approveTokenPermission: (options?: { + spendLimit?: number | 'max' + gasSetting?: GasSettings + }) => phantom?.approveTokenPermission(options), + rejectTokenPermission: () => phantom?.rejectTokenPermission(), + + // Encryption + providePublicEncryptionKey: () => phantom?.providePublicEncryptionKey(), + decrypt: () => phantom?.decrypt(), + + // Transactions + confirmSignature: () => phantom?.confirmSignature(), + rejectSignature: () => phantom?.rejectSignature(), + confirmTransaction: (options?: { gasSetting?: GasSettings }) => phantom?.confirmTransaction(options), + rejectTransaction: () => phantom?.rejectTransaction(), + confirmTransactionAndWaitForMining: () => phantom?.confirmTransactionAndWaitForMining(), + openTransactionDetails: (txIndex: number) => phantom?.openTransactionDetails(txIndex), + closeTransactionDetails: () => phantom?.closeTransactionDetails(), + + // Lock/Unlock + lock: () => phantom?.lock(), + unlock: () => phantom?.unlock(), + + // Toggles + toggleShowTestNetworks: () => phantom?.toggleShowTestNetworks(), + toggleDismissSecretRecoveryPhraseReminder: () => phantom?.toggleDismissSecretRecoveryPhraseReminder(), + + // Others + goBackToHomePage: () => phantom?.goBackToHomePage(), + openSettings: () => phantom?.openSettings(), + openSidebarMenu: (menu: SettingsSidebarMenus) => phantom?.openSidebarMenu(menu) + }) + + return { + ...config, + browsers + } +} diff --git a/wallets/phantom/src/cypress/constans.ts b/wallets/phantom/src/cypress/constans.ts new file mode 100644 index 000000000..d94424e0b --- /dev/null +++ b/wallets/phantom/src/cypress/constans.ts @@ -0,0 +1 @@ +export const defaultAccount = 'Account 1' diff --git a/wallets/phantom/src/cypress/getPlaywrightPhantom.ts b/wallets/phantom/src/cypress/getPlaywrightPhantom.ts new file mode 100644 index 000000000..adc8fe255 --- /dev/null +++ b/wallets/phantom/src/cypress/getPlaywrightPhantom.ts @@ -0,0 +1,15 @@ +import type { BrowserContext, Page } from '@playwright/test' +import { Phantom } from '../playwright' + +let phantom: Phantom | undefined + +export default function getPlaywrightPhantom( + context: BrowserContext, + phantomExtensionPage: Page, + phantomExtensionId: string +) { + if (!phantom) { + phantom = new Phantom(context, phantomExtensionPage, 'password', phantomExtensionId) + } + return phantom +} diff --git a/wallets/phantom/src/cypress/index.ts b/wallets/phantom/src/cypress/index.ts new file mode 100644 index 000000000..22d083b3d --- /dev/null +++ b/wallets/phantom/src/cypress/index.ts @@ -0,0 +1,4 @@ +export { default as Phantom } from './Phantom' +export { default as configureSynpress } from './configureSynpress' +export { default as synpressCommands } from './support/synpressCommands' +export { initPhantom } from './support/initPhantom' diff --git a/wallets/phantom/src/cypress/support/e2e.ts b/wallets/phantom/src/cypress/support/e2e.ts new file mode 100644 index 000000000..643103165 --- /dev/null +++ b/wallets/phantom/src/cypress/support/e2e.ts @@ -0,0 +1,27 @@ +// *********************************************************** +// This example support/e2e.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +import synpressCommands from './synpressCommands' + +Cypress.on('uncaught:exception', () => { + // failing the test + return false +}) + +synpressCommands() + +before(() => { + cy.visit('/') +}) diff --git a/wallets/phantom/src/cypress/support/importPhantomWallet.ts b/wallets/phantom/src/cypress/support/importPhantomWallet.ts new file mode 100644 index 000000000..8800fbfd2 --- /dev/null +++ b/wallets/phantom/src/cypress/support/importPhantomWallet.ts @@ -0,0 +1,43 @@ +import { type BrowserContext, type Page, chromium } from '@playwright/test' +import { getExtensionId } from '../../playwright' +import getPlaywrightPhantom from '../getPlaywrightPhantom' + +const SEED_PHRASE = 'test test test test test test test test test test test junk' + +export default async function importPhantomWallet(port: number, importDefaultWallet = true) { + const debuggerDetails = await fetch(`http://127.0.0.1:${port}/json/version`) + + const debuggerDetailsConfig = (await debuggerDetails.json()) as { + webSocketDebuggerUrl: string + } + + const browser = await chromium.connectOverCDP(debuggerDetailsConfig.webSocketDebuggerUrl) + + const context = browser.contexts()[0] as BrowserContext + + await context.waitForEvent('response') + + let phantomExtensionId: string | undefined + let extensionPage: Page | undefined + let cypressPage: Page | undefined + + const extensionPageIndex = context.pages().findIndex((page) => page.url().includes('chrome-extension://')) + if (extensionPageIndex !== -1) { + extensionPage = context.pages()[extensionPageIndex] as Page + phantomExtensionId = await getExtensionId(context, 'Phantom') + + const phantom = getPlaywrightPhantom(context, extensionPage, phantomExtensionId) + + if (importDefaultWallet) await phantom.importWallet(SEED_PHRASE) + + cypressPage = context.pages()[extensionPageIndex === 1 ? 0 : 1] as Page + await cypressPage.bringToFront() + } + + return { + context, + extensionPage, + cypressPage, + phantomExtensionId + } +} diff --git a/wallets/phantom/src/cypress/support/index.ts b/wallets/phantom/src/cypress/support/index.ts new file mode 100644 index 000000000..843e719fe --- /dev/null +++ b/wallets/phantom/src/cypress/support/index.ts @@ -0,0 +1 @@ +export { default as synpressCommands } from './synpressCommands' diff --git a/wallets/phantom/src/cypress/support/initPhantom.ts b/wallets/phantom/src/cypress/support/initPhantom.ts new file mode 100644 index 000000000..33b27a1fb --- /dev/null +++ b/wallets/phantom/src/cypress/support/initPhantom.ts @@ -0,0 +1,23 @@ +import { prepareExtensionPhantom } from '../../prepareExtensionPhantom' + +/** + * Initializes Phantom for Cypress tests. + * + * This function prepares the Phantom extension for use in Cypress tests. + * It sets up the necessary browser arguments and extension paths. + * + * @async + * @returns {Promise<{extensions: string[], browserArgs: string[]}>} An object containing the extension path and browser arguments. + */ +export async function initPhantom(): Promise<{ extensions: string[]; browserArgs: string[] }> { + const phantomPath = await prepareExtensionPhantom(false) + + const extensions = [phantomPath] + const browserArgs: string[] = [] + + if (process.env.HEADLESS) { + browserArgs.push('--headless=new') + } + + return { extensions, browserArgs } +} diff --git a/wallets/phantom/src/cypress/support/synpressCommands.ts b/wallets/phantom/src/cypress/support/synpressCommands.ts new file mode 100644 index 000000000..65eb66c1c --- /dev/null +++ b/wallets/phantom/src/cypress/support/synpressCommands.ts @@ -0,0 +1,333 @@ +/// +// *********************************************** +// This example commands.ts shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** + +import type { SettingsSidebarMenus } from '../../selectors/pages/HomePage/settings' +import type { GasSettings } from '../../type/GasSettings' + +declare global { + namespace Cypress { + interface Chainable { + importWallet(seedPhrase: string): Chainable + importWalletFromPrivateKey(privateKey: string): Chainable + + getAccount(): Chainable + getNetwork(): Chainable + + connectToDapp(accounts?: string[]): Chainable + + addNewAccount(accountName: string): Chainable + switchAccount(accountName: string): Chainable + renameAccount(currentAccountName: string, newAccountName: string): Chainable + getAccountAddress(): Chainable + resetAccount(): Chainable + + switchNetwork(networkName: string, isTestnet?: boolean): Chainable + + addNewToken(): Chainable + approveTokenPermission(options?: { + spendLimit?: number | 'max' + gasSetting?: GasSettings + }): Chainable + rejectTokenPermission(): Chainable + + providePublicEncryptionKey(): Chainable + decrypt(): Chainable + confirmSignature(): Chainable + rejectSignature(): Chainable + confirmTransaction(options?: { + gasSetting?: GasSettings + }): Chainable + rejectTransaction(): Chainable + confirmTransactionAndWaitForMining(): Chainable + openTransactionDetails(txIndex: number): Chainable + closeTransactionDetails(): Chainable + + lock(): Chainable + unlock(): Chainable + + toggleShowTestNetworks(): Chainable + toggleDismissSecretRecoveryPhraseReminder(): Chainable + + goBackToHomePage(): Chainable + openSettings(): Chainable + openSidebarMenu(menu: SettingsSidebarMenus): Chainable + } + } +} + +/** + * Synpress Commands for Phantom + * + * This module extends Cypress with custom commands for interacting with Phantom and Ethereum networks. + * It provides a wide range of functionalities including wallet management, account operations, + * network interactions, token handling, transaction management, and Phantom UI interactions. + * + * @module SynpressCommandsForPhantom + * + * Key features: + * - Wallet: Import wallet, connect to dApps + * - Account: Add, switch, rename, reset accounts + * - Network: Switch networks + * - Tokens: Deploy tokens, add new tokens, approve token permissions + * - Transactions: Confirm, reject, and view transaction details + * - Phantom UI: Lock/unlock, toggle settings, navigate UI + * + * These commands enhance the testing capabilities for Ethereum-based applications, + * allowing for comprehensive end-to-end testing of dApps integrated with Phantom. + */ + +/** + * Initializes Synpress commands for Phantom + */ +export default function synpressCommandsForPhantom(): void { + // Wallet + + /** + * Imports a wallet using a seed phrase + * @param seedPhrase - The seed phrase to import + */ + Cypress.Commands.add('importWallet', (seedPhrase: string) => { + return cy.task('importWallet', seedPhrase) + }) + + /** + * Imports a wallet using a private key + * @param privateKey - The private key to import + */ + Cypress.Commands.add('importWalletFromPrivateKey', (privateKey: string) => { + return cy.task('importWalletFromPrivateKey', privateKey) + }) + + /** + * Connects to a dApp + */ + Cypress.Commands.add('connectToDapp', () => { + return cy.task('connectToDapp') + }) + + // Account + + /** + * Gets the current account + */ + Cypress.Commands.add('getAccount', () => { + return cy.task('getAccount') + }) + + /** + * Adds a new account + * @param accountName - The name of the new account + */ + Cypress.Commands.add('addNewAccount', (accountName: string) => { + return cy.task('addNewAccount', accountName) + }) + + /** + * Switches to a different account + * @param accountName - The name of the account to switch to + */ + Cypress.Commands.add('switchAccount', (accountName: string) => { + return cy.task('switchAccount', accountName) + }) + + /** + * Renames an account + * @param currentAccountName - The current name of the account + * @param newAccountName - The new name for the account + */ + Cypress.Commands.add('renameAccount', (currentAccountName: string, newAccountName: string) => { + return cy.task('renameAccount', { currentAccountName, newAccountName }) + }) + + /** + * Gets the address of the current account + * @returns The account address + */ + Cypress.Commands.add('getAccountAddress', () => { + return cy.task('getAccountAddress') + }) + + /** + * Resets the current account + */ + Cypress.Commands.add('resetAccount', () => { + return cy.task('resetAccount') + }) + + // Network + + /** + * Gets the current network + */ + Cypress.Commands.add('getNetwork', () => { + return cy.task('getNetwork') + }) + + /** + * Switches to a different network + * @param networkName - The name of the network to switch to + * @param isTestnet - Whether the network is a testnet + */ + Cypress.Commands.add('switchNetwork', (networkName: string, isTestnet = false) => { + return cy.task('switchNetwork', { networkName, isTestnet }) + }) + + // Token + + /** + * Adds a new token + */ + Cypress.Commands.add('addNewToken', () => { + return cy.task('addNewToken') + }) + + /** + * Approves token permission + * @param options - Options for approving token permission + * @param options.spendLimit - The spend limit for the token + * @param options.gasSetting - Gas settings for the transaction + */ + Cypress.Commands.add( + 'approveTokenPermission', + (options?: { spendLimit?: number | 'max'; gasSetting?: GasSettings }) => { + return cy.task('approveTokenPermission', options) + } + ) + + /** + * Rejects token permission + */ + Cypress.Commands.add('rejectTokenPermission', () => { + return cy.task('rejectTokenPermission') + }) + + // Lock/Unlock + + /** + * Locks Phantom + */ + Cypress.Commands.add('lock', () => { + return cy.task('lock') + }) + + /** + * Unlocks Phantom + */ + Cypress.Commands.add('unlock', () => { + return cy.task('unlock') + }) + + // Toggles + + /** + * Toggles showing test networks + */ + Cypress.Commands.add('toggleShowTestNetworks', () => { + return cy.task('toggleShowTestNetworks') + }) + + /** + * Toggles dismissing the secret recovery phrase reminder + */ + Cypress.Commands.add('toggleDismissSecretRecoveryPhraseReminder', () => { + return cy.task('toggleDismissSecretRecoveryPhraseReminder') + }) + + // Others + + /** + * Provides a public encryption key + */ + Cypress.Commands.add('providePublicEncryptionKey', () => { + return cy.task('providePublicEncryptionKey') + }) + + /** + * Decrypts a message + */ + Cypress.Commands.add('decrypt', () => { + return cy.task('decrypt') + }) + + /** + * Confirms a signature + */ + Cypress.Commands.add('confirmSignature', () => { + return cy.task('confirmSignature') + }) + + /** + * Rejects a signature + */ + Cypress.Commands.add('rejectSignature', () => { + return cy.task('rejectSignature') + }) + + /** + * Confirms a transaction + * @param options - Options for confirming the transaction + * @param options.gasSetting - Gas settings for the transaction + */ + Cypress.Commands.add('confirmTransaction', (options?: { gasSetting?: GasSettings }) => { + return cy.task('confirmTransaction', options) + }) + + /** + * Rejects a transaction + */ + Cypress.Commands.add('rejectTransaction', () => { + return cy.task('rejectTransaction') + }) + + /** + * Confirms a transaction and waits for mining + */ + Cypress.Commands.add('confirmTransactionAndWaitForMining', () => { + return cy.task('confirmTransactionAndWaitForMining') + }) + + /** + * Opens transaction details + * @param txIndex - The index of the transaction to open + */ + Cypress.Commands.add('openTransactionDetails', (txIndex = 0) => { + return cy.task('openTransactionDetails', txIndex) + }) + + /** + * Closes transaction details + */ + Cypress.Commands.add('closeTransactionDetails', () => { + return cy.task('closeTransactionDetails') + }) + + /** + * Goes back to the home page + */ + Cypress.Commands.add('goBackToHomePage', () => { + return cy.task('goBackToHomePage') + }) + + /** + * Opens settings + */ + Cypress.Commands.add('openSettings', () => { + return cy.task('openSettings') + }) + + /** + * Opens a sidebar menu + * @param menu - The menu to open + */ + Cypress.Commands.add('openSidebarMenu', (menu: SettingsSidebarMenus) => { + return cy.task('openSidebarMenu', menu) + }) +} 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..150704419 --- /dev/null +++ b/wallets/phantom/src/playwright/Phantom.ts @@ -0,0 +1,418 @@ +import type { BrowserContext, Page } from '@playwright/test' +import { SettingsSidebarMenus } from '../selectors/pages/HomePage/settings' +import type { GasSettings } from '../type/GasSettings' +import type { Networks } from '../type/Networks' +import { PhantomAbstract } from '../type/PhantomAbstract' +import { CrashPage, HomePage, LockPage, NotificationPage, OnboardingPage } from './pages' +import { SettingsPage } from './pages/SettingsPage/page' + +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 crash page. + * + * @public + * @readonly + */ + readonly crashPage: CrashPage + + /** + * 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 lockPage: LockPage + + /** + * 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 + + /** + * This property can be used to access selectors for the settings page. + * + * @public + * @readonly + */ + readonly settingsPage: SettingsPage + + /** + * 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.crashPage = new CrashPage() + this.onboardingPage = new OnboardingPage(page) + this.lockPage = new LockPage(page) + this.homePage = new HomePage(page) + this.notificationPage = new NotificationPage(page) + this.settingsPage = new SettingsPage(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 privateKey - The private key to import. + */ + 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. + * + * @returns The account address. + */ + async getAccountAddress(network: Networks): Promise { + return await this.homePage.getAccountAddress(network) + } + + /** + * Switches to the specified network. + * + * @param networkName - The name of the network to switch to. + * @param isTestnet - Whether the network is a testnet. Default is false. + */ + async switchNetwork(networkName: string, isTestnet = false): Promise { + await this.homePage.switchNetwork(networkName, isTestnet) + } + + /** + * Connects Phantom to a dapp. + * + * @param accounts - Optional array of account addresses to connect. + * @throws {Error} If extensionId is not set. + */ + async connectToDapp(accounts?: string[]): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.connectToDapp(this.extensionId, accounts) + } + + /** + * Locks the Phantom wallet. + */ + async lock(): Promise { + await this.homePage.lock() + } + + /** + * Unlocks the Phantom wallet. + */ + async unlock(): Promise { + await this.lockPage.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 back to the home page. + */ + async goBackToHomePage(): Promise { + await this.homePage.goBackToHomePage() + } + + /** + * Opens the settings page. + */ + async openSettings(): Promise { + await this.homePage.openSettings() + } + + /** + * Opens a specific sidebar menu in the settings. + * + * @param menu - The menu to open. + */ + async openSidebarMenu(menu: SettingsSidebarMenus): Promise { + await this.homePage.openSidebarMenu(menu) + } + + /** + * Toggles the display of test networks. + */ + async toggleShowTestNetworks(): Promise { + await this.homePage.toggleShowTestNetworks() + } + + /** + * Toggles the dismissal of the secret recovery phrase reminder. + */ + async toggleDismissSecretRecoveryPhraseReminder(): Promise { + await this.homePage.toggleDismissSecretRecoveryPhraseReminder() + } + + /** + * Resets the account. + */ + async resetAccount(): Promise { + await this.homePage.resetAccount() + } + + /** + * Enables eth_sign (unsafe). + */ + async unsafe_enableEthSign(): Promise { + await this.homePage.openSettings() + await this.settingsPage.enableEthSign() + } + + /** + * Disables eth_sign. + */ + async disableEthSign(): Promise { + await this.homePage.openSettings() + await this.settingsPage.disableEthSign() + } + + /** + * Adds a new token. + * + * @throws {Error} If extensionId is not set. + */ + async addNewToken(): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.addNewToken(this.extensionId) + } + + /** + * Provides a public encryption key. + * + * @throws {Error} If extensionId is not set. + */ + async providePublicEncryptionKey(): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.providePublicEncryptionKey(this.extensionId) + } + + /** + * Decrypts a message. + * + * @throws {Error} If extensionId is not set. + */ + async decrypt(): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.decryptMessage(this.extensionId) + } + + /** + * Confirms a transaction and waits for it to be mined. + * + * @param options - Optional gas settings for the transaction. + * @throws {Error} If extensionId is not set. + */ + async confirmTransactionAndWaitForMining(options?: { + gasSetting?: GasSettings + }): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.confirmTransactionAndWaitForMining(this.extensionId, options) + } + + /** + * Opens the details of a specific transaction. + * + * @param txIndex - The index of the transaction to open. + */ + async openTransactionDetails(txIndex: number): Promise { + await this.homePage.openTransactionDetails(txIndex) + } + + /** + * Closes the transaction details view. + */ + async closeTransactionDetails(): Promise { + await this.homePage.closeTransactionDetails() + } +} 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..fc2575c21 --- /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 getExtensionId(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..b9c4f13e6 --- /dev/null +++ b/wallets/phantom/src/playwright/fixture-actions/index.ts @@ -0,0 +1,3 @@ +export * from './unlockForFixture' +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/fixture-actions/unlockForFixture.ts b/wallets/phantom/src/playwright/fixture-actions/unlockForFixture.ts new file mode 100644 index 000000000..4bb86f82d --- /dev/null +++ b/wallets/phantom/src/playwright/fixture-actions/unlockForFixture.ts @@ -0,0 +1,73 @@ +import type { Page } from '@playwright/test' +import { errors as playwrightErrors } from '@playwright/test' +import { Phantom } from '..' +import { CrashPage, HomePage } from '../pages' +import { waitForSpinnerToVanish } from '../utils/waitForSpinnerToVanish' + +/** + * A more advanced version of the `Phantom.unlock()` function that incorporates various workarounds for Phantom issues, among other things. + * This function should be used instead of the `Phantom.unlock()` when passing it to the `testWithSynpress` function. + * + * @param page - The Phantom tab page. + * @param password - The password of the Phantom wallet. + */ +export async function unlockForFixture(page: Page, password: string) { + const phantom = new Phantom(page.context(), page, password) + + await unlockWalletButReloadIfSpinnerDoesNotVanish(phantom) + + await retryIfPhantomCrashAfterUnlock(page) +} + +async function unlockWalletButReloadIfSpinnerDoesNotVanish(phantom: Phantom) { + try { + await phantom.unlock() + } catch (e) { + if (e instanceof playwrightErrors.TimeoutError) { + console.warn('[UnlockWalletButReloadIfSpinnerDoesNotVanish] Unlocking Phantom timed out. Reloading page...') + + const page = phantom.page + + await page.reload() + await waitForSpinnerToVanish(page) + } else { + throw e + } + } +} + +async function retryIfPhantomCrashAfterUnlock(page: Page) { + const homePageLogoLocator = page.locator(HomePage.selectors.logo) + + const isHomePageLogoVisible = await homePageLogoLocator.isVisible() + const isPopoverVisible = await page.locator(HomePage.selectors.popover.closeButton).isVisible() + + if (!isHomePageLogoVisible && !isPopoverVisible) { + if (await page.locator(CrashPage.selectors.header).isVisible()) { + const errors = await page.locator(CrashPage.selectors.errors).allTextContents() + + console.warn(['[RetryIfPhantomCrashAfterUnlock] Phantom crashed due to:', ...errors].join('\n')) + + console.log('[RetryIfPhantomCrashAfterUnlock] Reloading page...') + await page.reload() + + try { + await homePageLogoLocator.waitFor({ + state: 'visible', + timeout: 10_000 // TODO: Extract & Make this timeout configurable. + }) + console.log('[RetryIfPhantomCrashAfterUnlock] Successfully restored Phantom!') + } catch (e) { + if (e instanceof playwrightErrors.TimeoutError) { + throw new Error( + ['[RetryIfPhantomCrashAfterUnlock] Reload did not help. Throwing with the crash cause:', ...errors].join( + '\n' + ) + ) + } + + throw e + } + } + } +} diff --git a/wallets/phantom/src/playwright/fixtures/phantomFixtures.ts b/wallets/phantom/src/playwright/fixtures/phantomFixtures.ts new file mode 100644 index 000000000..f1a372070 --- /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 { getExtensionId, unlockForFixture } from '../fixture-actions' +import { persistLocalStorage } from '../fixture-actions/persistLocalStorage' +import { waitForPhantomWindowToBeStable } from '../utils/waitFor' + +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 getExtensionId(context, 'Phantom') + + _phantomPage = context.pages()[0] as Page + + await _phantomPage.goto(`chrome-extension://${extensionId}/popup.html`) + + await waitForPhantomWindowToBeStable(_phantomPage) + + await unlockForFixture(_phantomPage, walletSetup.walletPassword) + + await use(context) + + await context.close() + }, + phantomPage: async ({ context: _ }, use) => { + await use(_phantomPage) + }, + extensionId: async ({ context }, use) => { + const extensionId = await getExtensionId(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/CrashPage/page.ts b/wallets/phantom/src/playwright/pages/CrashPage/page.ts new file mode 100644 index 000000000..e731a6e94 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/CrashPage/page.ts @@ -0,0 +1,6 @@ +import Selectors from '../../../selectors/pages/CrashPage' + +export class CrashPage { + static readonly selectors = Selectors + readonly selectors = Selectors +} 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/getAccountAddress.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/getAccountAddress.ts new file mode 100644 index 000000000..827cd60d9 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/getAccountAddress.ts @@ -0,0 +1,16 @@ +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`]).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..297917351 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/importWalletFromPrivateKey.ts @@ -0,0 +1,47 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/HomePage' +import type { Networks } from '../../../../type/Networks' +import { waitFor } from '../../../utils/waitFor' + +export async function importWalletFromPrivateKey( + page: Page, + network: Networks, + privateKey: string, + walletName?: string +) { + const extensionUrl = page.url() + await page.goto(extensionUrl.replace('onboarding', 'popup')) + + await page.locator(Selectors.accountMenu.accountButton).click() + + 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() +} 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..edb5c4508 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/index.ts @@ -0,0 +1,11 @@ +export * from './popups' +export * from './lock' +export * from './importWalletFromPrivateKey' +export * from './switchAccount' +export * from './settings' +export * from './switchNetwork' +export * from './toggleShowTestNetworks' +export * from './addNewAccount' +export * from './transactionDetails' +export * from './renameAccount' +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..010ec8c01 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/lock.ts @@ -0,0 +1,8 @@ +import type { Page } from '@playwright/test' + +import Selectors from '../../../../selectors/pages/HomePage' + +export async function lock(page: Page) { + await page.locator(Selectors.threeDotsMenu.threeDotsButton).click() + await page.locator(Selectors.threeDotsMenu.lockButton).click() +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/popups/closePopover.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/popups/closePopover.ts new file mode 100644 index 000000000..36378580b --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/popups/closePopover.ts @@ -0,0 +1,12 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../../selectors/pages/HomePage' +import { clickLocatorIfCondition } from '../../../../utils/clickLocatorIfCondition' + +// Closes the popover with news, rainbows, unicorns, and other stuff. +export async function closePopover(page: Page) { + // We're using `first()` here just in case there are multiple popovers, which happens sometimes. + const closeButtonLocator = page.locator(Selectors.popover.closeButton).first() + + // TODO: Extract & make configurable + await clickLocatorIfCondition(closeButtonLocator, () => closeButtonLocator.isVisible(), 1_000) +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/popups/closeRecoveryPhraseReminder.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/popups/closeRecoveryPhraseReminder.ts new file mode 100644 index 000000000..1664221e0 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/popups/closeRecoveryPhraseReminder.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../../selectors/pages/HomePage' +import { clickLocatorIfCondition } from '../../../../utils/clickLocatorIfCondition' + +export async function closeRecoveryPhraseReminder(page: Page) { + const closeButtonLocator = page.locator(Selectors.recoveryPhraseReminder.gotItButton) + + // TODO: Extract & make configurable + await clickLocatorIfCondition(closeButtonLocator, () => closeButtonLocator.isVisible(), 1_000) +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/popups/index.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/popups/index.ts new file mode 100644 index 000000000..2c83dc704 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/popups/index.ts @@ -0,0 +1,2 @@ +export * from './closePopover' +export * from './closeRecoveryPhraseReminder' 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..11cd4e7fd --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/renameAccount.ts @@ -0,0 +1,37 @@ +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() + + const accountNamesLocators = await page.locator(Selectors.accountMenu.accountNames).all() + + const accountNames = await allTextContents(accountNamesLocators) + + 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..0ba074619 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/settings.ts @@ -0,0 +1,37 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/HomePage' +import type { SettingsSidebarMenus } from '../../../../selectors/pages/HomePage/settings' +import { toggle } from '../../../utils/toggle' + +async function openSettings(page: Page) { + await page.locator(Selectors.threeDotsMenu.threeDotsButton).click() + await page.locator(Selectors.threeDotsMenu.settingsButton).click() +} + +async function openSidebarMenu(page: Page, menu: SettingsSidebarMenus) { + await page.locator(Selectors.settings.sidebarMenu(menu)).click() +} + +async function resetAccount(page: Page) { + const buttonSelector = `[data-testid="advanced-setting-reset-account"] button` + const confirmButtonSelector = '.modal .modal-container__footer button.btn-danger-primary' + + await page.locator(buttonSelector).click() + await page.locator(confirmButtonSelector).click() +} + +async function toggleDismissSecretRecoveryPhraseReminder(page: Page) { + const toggleLocator = page.locator(Selectors.settings.advanced.dismissSecretRecoveryPhraseReminderToggle) + await toggle(toggleLocator) +} + +const advanced = { + resetAccount, + toggleDismissSecretRecoveryPhraseReminder +} + +export const settings = { + openSettings, + openSidebarMenu, + advanced +} 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..ad303290b --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/switchAccount.ts @@ -0,0 +1,23 @@ +import type { Page } 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() + + const accountNamesLocators = await page.locator(Selectors.accountMenu.accountNames).all() + + const accountNames = await allTextContents(accountNamesLocators) + + 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/switchNetwork.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/switchNetwork.ts new file mode 100644 index 000000000..07e63bdc4 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/switchNetwork.ts @@ -0,0 +1,39 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/HomePage' +import { allTextContents } from '../../../utils/allTextContents' +import { closeRecoveryPhraseReminder } from './popups' + +async function openTestnetSection(page: Page) { + const toggleButtonLocator = page.locator(Selectors.networkDropdown.showTestNetworksToggle) + const classes = await toggleButtonLocator.getAttribute('class') + if (classes?.includes('toggle-button--off')) { + await toggleButtonLocator.click() + await page.locator(Selectors.networkDropdown.toggleOn).isChecked() + } +} + +export async function switchNetwork(page: Page, networkName: string, includeTestNetworks: boolean) { + await page.locator(Selectors.networkDropdown.dropdownButton).click() + + if (includeTestNetworks) { + await openTestnetSection(page) + } + + const networkLocators = await page.locator(Selectors.networkDropdown.networks).all() + const networkNames = await allTextContents(networkLocators) + + const seekedNetworkNameIndex = networkNames.findIndex( + (name) => name.toLocaleLowerCase() === networkName.toLocaleLowerCase() + ) + + const seekedNetworkLocator = seekedNetworkNameIndex >= 0 && networkLocators[seekedNetworkNameIndex] + + if (!seekedNetworkLocator) { + throw new Error(`[SwitchNetwork] Network with name ${networkName} not found`) + } + + await seekedNetworkLocator.click() + + // TODO: This is not really needed if we do `phantom.toggleDismissSecretRecoveryPhraseReminder()` by default. Figure this out! + await closeRecoveryPhraseReminder(page) +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/toggleShowTestNetworks.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/toggleShowTestNetworks.ts new file mode 100644 index 000000000..b6bde5537 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/toggleShowTestNetworks.ts @@ -0,0 +1,13 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/HomePage' +import { toggle } from '../../../utils/toggle' + +// 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 toggleShowTestNetworks(page: Page) { + await page.locator(Selectors.networkDropdown.dropdownButton).click() + + await toggle(page.locator(Selectors.networkDropdown.showTestNetworksToggle)) + + await page.locator(Selectors.networkDropdown.closeNetworkPopupButton).click() +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/transactionDetails.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/transactionDetails.ts new file mode 100644 index 000000000..979e67979 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/transactionDetails.ts @@ -0,0 +1,29 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/HomePage' +import { waitFor } from '../../../utils/waitFor' + +const openTransactionDetails = async (page: Page, txIndex: number) => { + await page.locator(Selectors.activityTab.activityTabButton).click() + + const visibleTxs = await page.locator(Selectors.activityTab.completedTransactions).count() + + if (txIndex >= visibleTxs) { + throw new Error( + `[OpenTransactionDetails] Transaction with index ${txIndex} is not visible. There are only ${visibleTxs} transactions visible.` + ) + } + + await page.locator(Selectors.activityTab.completedTransactions).nth(txIndex).click() + + // TODO: Extract timeout. + await waitFor(() => page.locator(Selectors.popover.closeButton).isVisible(), 3_000) +} + +const closeTransactionDetails = async (page: Page) => { + await page.locator(Selectors.popover.closeButton).click() +} + +export const transactionDetails = { + open: openTransactionDetails, + close: closeTransactionDetails +} 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..79567530c --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/page.ts @@ -0,0 +1,91 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../selectors/pages/HomePage' +import type { SettingsSidebarMenus } from '../../../selectors/pages/HomePage/settings' +import type { Networks } from '../../../type/Networks' +import { + addNewAccount, + getAccountAddress, + importWalletFromPrivateKey, + lock, + renameAccount, + settings, + switchAccount, + switchNetwork, + toggleShowTestNetworks, + transactionDetails +} from './actions' + +export class HomePage { + static readonly selectors = Selectors + readonly selectors = Selectors + + readonly page: Page + + constructor(page: Page) { + this.page = page + } + + async goBackToHomePage() { + await this.page.locator(Selectors.logo).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 openSidebarMenu(menu: SettingsSidebarMenus) { + await settings.openSidebarMenu(this.page, menu) + } + + async toggleShowTestNetworks() { + await toggleShowTestNetworks(this.page) + } + + async resetAccount() { + await settings.advanced.resetAccount(this.page) + } + + async toggleDismissSecretRecoveryPhraseReminder() { + await settings.advanced.toggleDismissSecretRecoveryPhraseReminder(this.page) + } + + async switchNetwork(networkName: string, isTestnet: boolean) { + await switchNetwork(this.page, networkName, isTestnet) + } + + async openTransactionDetails(txIndex: number) { + await transactionDetails.open(this.page, txIndex) + } + + async closeTransactionDetails() { + await transactionDetails.close(this.page) + } +} diff --git a/wallets/phantom/src/playwright/pages/LockPage/actions/index.ts b/wallets/phantom/src/playwright/pages/LockPage/actions/index.ts new file mode 100644 index 000000000..b0dce8ac2 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/LockPage/actions/index.ts @@ -0,0 +1 @@ +export * from './unlock' diff --git a/wallets/phantom/src/playwright/pages/LockPage/actions/unlock.ts b/wallets/phantom/src/playwright/pages/LockPage/actions/unlock.ts new file mode 100644 index 000000000..79e3956df --- /dev/null +++ b/wallets/phantom/src/playwright/pages/LockPage/actions/unlock.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/LockPage' +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/LockPage/page.ts b/wallets/phantom/src/playwright/pages/LockPage/page.ts new file mode 100644 index 000000000..1b14d06e6 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/LockPage/page.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../selectors/pages/LockPage' +import { unlock } from './actions' + +export class LockPage { + 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/NotificationPage/actions/approvePermission.ts b/wallets/phantom/src/playwright/pages/NotificationPage/actions/approvePermission.ts new file mode 100644 index 000000000..bba71a048 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/approvePermission.ts @@ -0,0 +1,33 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/NotificationPage' +import type { GasSettings } from '../../../../type/GasSettings' +import { transaction } from './transaction' + +const editTokenPermission = async (notificationPage: Page, customSpendLimit: 'max' | number) => { + if (customSpendLimit === 'max') { + await notificationPage.locator(Selectors.PermissionPage.approve.maxButton).click() + return + } + + await notificationPage + .locator(Selectors.PermissionPage.approve.customSpendingCapInput) + .fill(customSpendLimit.toString()) +} + +const approveTokenPermission = async (notificationPage: Page, gasSetting: GasSettings) => { + // Click the "Next" button. + await notificationPage.locator(Selectors.ActionFooter.confirmActionButton).click() + + // 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.rejectActionButton).click() +} + +export const approvePermission = { + editTokenPermission, + approve: approveTokenPermission, + reject: rejectTokenPermission +} 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..63c8a4887 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/connectToDapp.ts @@ -0,0 +1,42 @@ +import type { Locator, Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/NotificationPage' +import { allTextContents } from '../../../utils/allTextContents' + +async function selectAccounts(accountsToSelect: string[], accountLocators: Locator[], availableAccountNames: string[]) { + for (const account of accountsToSelect) { + const accountNameIndex = availableAccountNames.findIndex((name) => name.startsWith(account)) + if (accountNameIndex < 0) throw new Error(`[ConnectToDapp] Account with name ${account} not found`) + await accountLocators[accountNameIndex]?.locator(Selectors.ConnectPage.accountCheckbox).check() + } +} + +async function connectMultipleAccounts(notificationPage: Page, accounts: string[]) { + // Wait for the accounts to be loaded as 'all()' doesnt not wait for the results - https://playwright.dev/docs/api/class-locator#locator-all + // Additionally disable default account to reuse necessary delay + await notificationPage + .locator(Selectors.ConnectPage.accountOption) + .locator(Selectors.ConnectPage.accountCheckbox) + .last() + .setChecked(false) + + const accountLocators = await notificationPage.locator(Selectors.ConnectPage.accountOption).all() + const accountNames = await allTextContents(accountLocators) + + await selectAccounts(accounts, accountLocators, accountNames) +} + +async function confirmConnection(notificationPage: Page) { + // Click `Next` + await notificationPage.locator(Selectors.ActionFooter.confirmActionButton).click() + // Click `Connect` + await notificationPage.locator(Selectors.ActionFooter.confirmActionButton).click() +} + +// By default, only the last account will be selected. If you want to select a specific account, pass `accounts` parameter. +export async function connectToDapp(notificationPage: Page, accounts?: string[]) { + if (accounts && accounts.length > 0) { + await connectMultipleAccounts(notificationPage, accounts) + } + + await confirmConnection(notificationPage) +} diff --git a/wallets/phantom/src/playwright/pages/NotificationPage/actions/encryption.ts b/wallets/phantom/src/playwright/pages/NotificationPage/actions/encryption.ts new file mode 100644 index 000000000..77a1fd957 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/encryption.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/NotificationPage' + +export async function providePublicEncryptionKey(notificationPage: Page) { + await notificationPage.locator(Selectors.ActionFooter.confirmActionButton).click() +} + +export async function decryptMessage(notificationPage: Page) { + await notificationPage.locator(Selectors.ActionFooter.confirmActionButton).click() +} 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..d7348e721 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/index.ts @@ -0,0 +1,7 @@ +export * from './connectToDapp' +export * from './signSimpleMessage' +export * from './signStructuredMessage' +export * from './approvePermission' +export * from './transaction' +export * from './token' +export * from './encryption' 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..e6b193084 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/signSimpleMessage.ts @@ -0,0 +1,22 @@ +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.rejectActionButton).click() +} + +const signMessageWithRisk = async (notificationPage: Page) => { + await signMessage(notificationPage) + + await notificationPage.locator(Selectors.SignaturePage.riskModal.signButton).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..60f03bada --- /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.rejectActionButton).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/token.ts b/wallets/phantom/src/playwright/pages/NotificationPage/actions/token.ts new file mode 100644 index 000000000..b909eff66 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/token.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/NotificationPage' + +async function addNew(notificationPage: Page) { + await notificationPage.locator(Selectors.ActionFooter.confirmActionButton).click() +} + +export const token = { + addNew +} 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..672429366 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/transaction.ts @@ -0,0 +1,164 @@ +import type { Page } from '@playwright/test' +import HomePageSelectors from '../../../../selectors/pages/HomePage' +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 `site` gas setting is used. + if (gasSetting === 'site') { + 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 estimationNotAvailableErrorMessage = (gasSetting: string) => + `[ConfirmTransaction] Estimated fee is not available for the "${gasSetting}" gas setting. By default, Phantom would use the "site" gas setting in this case, however, this is not YOUR intention.` + + const handleLowMediumOrAggressiveGasSetting = async ( + gasSetting: string, + selectors: { button: string; maxFee: string } + ) => { + if ((await notificationPage.locator(selectors.maxFee).textContent()) === '--') { + throw new Error(estimationNotAvailableErrorMessage(gasSetting)) + } + + await notificationPage.locator(selectors.button).click() + } + + if (gasSetting === 'low') { + await handleLowMediumOrAggressiveGasSetting(gasSetting, Selectors.TransactionPage.editGasFeeMenu.lowGasFee) + } else if (gasSetting === 'market') { + await handleLowMediumOrAggressiveGasSetting(gasSetting, Selectors.TransactionPage.editGasFeeMenu.marketGasFee) + } else if (gasSetting === 'aggressive') { + await handleLowMediumOrAggressiveGasSetting(gasSetting, Selectors.TransactionPage.editGasFeeMenu.aggressiveGasFee) + } else { + await notificationPage.locator(Selectors.TransactionPage.editGasFeeMenu.advancedGasFeeButton).click() + + await notificationPage.locator(Selectors.TransactionPage.editGasFeeMenu.advancedGasFeeMenu.maxBaseFeeInput).fill('') + await notificationPage + .locator(Selectors.TransactionPage.editGasFeeMenu.advancedGasFeeMenu.maxBaseFeeInput) + .fill(gasSetting.maxBaseFee.toString()) + + await notificationPage + .locator(Selectors.TransactionPage.editGasFeeMenu.advancedGasFeeMenu.priorityFeeInput) + .fill('') + await notificationPage + .locator(Selectors.TransactionPage.editGasFeeMenu.advancedGasFeeMenu.priorityFeeInput) + .fill(gasSetting.priorityFee.toString()) + + if (gasSetting.gasLimit) { + await notificationPage + .locator(Selectors.TransactionPage.editGasFeeMenu.advancedGasFeeMenu.gasLimitEditButton) + .click() + + await notificationPage.locator(Selectors.TransactionPage.editGasFeeMenu.advancedGasFeeMenu.gasLimitInput).fill('') + await notificationPage + .locator(Selectors.TransactionPage.editGasFeeMenu.advancedGasFeeMenu.gasLimitInput) + .fill(gasSetting.gasLimit.toString()) + + const gasLimitErrorLocator = notificationPage.locator( + Selectors.TransactionPage.editGasFeeMenu.advancedGasFeeMenu.gasLimitError + ) + const isGasLimitErrorHidden = await waitFor(() => gasLimitErrorLocator.isHidden(), 1_000, false) // TODO: Extract & make configurable + + if (!isGasLimitErrorHidden) { + const errorText = await gasLimitErrorLocator.textContent({ + timeout: 1_000 // TODO: Extract & make configurable + }) + + throw new Error(`[ConfirmTransaction] Invalid gas limit: ${errorText}`) + } + } + + await notificationPage.locator(Selectors.TransactionPage.editGasFeeMenu.advancedGasFeeMenu.saveButton).click() + } + + // We wait until the tooltip is not visible anymore. This indicates a gas setting was changed. + // Ideally, we would wait until the edit button changes its text, i.e., "Site" -> "Aggressive", however, this is not possible right now. + // For some unknown reason, if the manual gas setting is too high (>1 ETH), the edit button displays "Site" instead of "Advanced" ¯\_(ツ)_/¯ + const waitForAction = async () => { + const isTooltipVisible = await notificationPage + .locator(Selectors.TransactionPage.editGasFeeMenu.editGasFeeButtonToolTip) + .isVisible() + + return !isTooltipVisible + } + + // TODO: Extract & make configurable + await waitFor(waitForAction, 3_000, true) + + await notificationPage.locator(Selectors.ActionFooter.confirmActionButton).click() + + await handleNftSetApprovalForAll(notificationPage) +} + +const confirmTransactionAndWaitForMining = async (walletPage: Page, notificationPage: Page, options: GasSettings) => { + await walletPage.locator(HomePageSelectors.activityTab.activityTabButton).click() + + const waitForUnapprovedTxs = async () => { + const unapprovedTxs = await walletPage.locator(HomePageSelectors.activityTab.pendingUnapprovedTransactions).count() + + return unapprovedTxs !== 0 + } + + // TODO: Extract timeout. + const newTxsFound = await waitFor(waitForUnapprovedTxs, 30_000, false) + + if (!newTxsFound) { + throw new Error('No new pending transactions found in 30s') + } + + await confirmTransaction(notificationPage, options) + + const waitForMining = async () => { + const unapprovedTxs = await walletPage.locator(HomePageSelectors.activityTab.pendingUnapprovedTransactions).count() + const pendingTxs = await walletPage.locator(HomePageSelectors.activityTab.pendingApprovedTransactions).count() + const queuedTxs = await walletPage.locator(HomePageSelectors.activityTab.pendingQueuedTransactions).count() + + return unapprovedTxs === 0 && pendingTxs === 0 && queuedTxs === 0 + } + + // TODO: Extract timeout. + const allTxsMined = await waitFor(waitForMining, 120_000, false) + + if (!allTxsMined) { + throw new Error('All pending and queued transactions were not mined in 120s') + } +} + +const rejectTransaction = async (notificationPage: Page) => { + await notificationPage.locator(Selectors.ActionFooter.rejectActionButton).click() +} + +export const transaction = { + confirm: confirmTransaction, + reject: rejectTransaction, + confirmAndWaitForMining: confirmTransactionAndWaitForMining +} 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..94d39481c --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/page.ts @@ -0,0 +1,131 @@ +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, + connectToDapp, + decryptMessage, + providePublicEncryptionKey, + signSimpleMessage, + signStructuredMessage, + token, + 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, accounts?: string[]) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + await connectToDapp(notificationPage, accounts) + } + + // 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 } = await this.beforeMessageSignature(extensionId) + + 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 } = await this.beforeMessageSignature(extensionId) + + 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 ?? 'site') + } + + async rejectTransaction(extensionId: string) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + await transaction.reject(notificationPage) + } + + async confirmTransactionAndWaitForMining(extensionId: string, options?: { gasSetting?: GasSettings }) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + await transaction.confirmAndWaitForMining(this.page, notificationPage, options?.gasSetting ?? 'site') + } + + async approveTokenPermission( + extensionId: string, + options?: { spendLimit?: 'max' | number; gasSetting?: GasSettings } + ) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + if (options?.spendLimit !== undefined) { + await approvePermission.editTokenPermission(notificationPage, options.spendLimit) + } + + await approvePermission.approve(notificationPage, options?.gasSetting ?? 'site') + } + + async rejectTokenPermission(extensionId: string) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + await approvePermission.reject(notificationPage) + } + + async addNewToken(extensionId: string) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + await token.addNew(notificationPage) + } + + async providePublicEncryptionKey(extensionId: string) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + await providePublicEncryptionKey(notificationPage) + } + + async decryptMessage(extensionId: string) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + await decryptMessage(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..303677042 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/OnboardingPage/actions/helpers/confirmSecretRecoveryPhrase.ts @@ -0,0 +1,27 @@ +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(' ') + // const seedPhraseLength = seedPhraseWords.length; + + // // TODO: This should be validated! + // await page + // .locator(StepSelectors.selectNumberOfWordsDropdown) + // .selectOption(StepSelectors.selectNumberOfWordsOption(seedPhraseLength)) + + 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..590dbc276 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/OnboardingPage/actions/importWallet.ts @@ -0,0 +1,52 @@ +// import assert from "node:assert"; +import { type Page, expect } from '@playwright/test' + +// import HomePageSelectors from "../../../../selectors/pages/HomePage"; +import Selectors from '../../../../selectors/pages/OnboardingPage' + +// import { closePopover } from "../../HomePage/actions"; +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: 10_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 }) + + // TO DO !!!!!! + // await verifyImportedWallet(page); +} + +// // Checks if the wallet was imported successfully. +// // On rare occasions, the Phantom hangs during the onboarding process. +// async function verifyImportedWallet(page: Page) { +// const accountAddress = await page +// .locator(HomePageSelectors.copyAccountAddressButton) +// .textContent(); + +// assert.strictEqual( +// accountAddress?.startsWith("0x"), +// true, +// new Error( +// [ +// `Incorrect state after importing the seed phrase. Account address is expected to start with "0x", but got "${accountAddress}" instead.`, +// "Note: Try to re-run the cache creation. This is a known but rare error where Phantom hangs during the onboarding process. If it persists, please file an issue on GitHub.", +// ].join("\n") +// ) +// ); +// } 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/SettingsPage/actions/disableEthSign.ts b/wallets/phantom/src/playwright/pages/SettingsPage/actions/disableEthSign.ts new file mode 100644 index 000000000..8c917c25b --- /dev/null +++ b/wallets/phantom/src/playwright/pages/SettingsPage/actions/disableEthSign.ts @@ -0,0 +1,7 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/SettingsPage' + +export default async function disableEthSign(page: Page) { + await page.locator(Selectors.settings.advancedSettings).click() + await page.locator(Selectors.settings.ethSignToggle).click() +} diff --git a/wallets/phantom/src/playwright/pages/SettingsPage/actions/enableEthSign.ts b/wallets/phantom/src/playwright/pages/SettingsPage/actions/enableEthSign.ts new file mode 100644 index 000000000..cd9a05c09 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/SettingsPage/actions/enableEthSign.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/SettingsPage' + +export default async function enableEthSign(page: Page) { + // Settings + await page.locator(Selectors.settings.advancedSettings).click() + await page.locator(Selectors.settings.ethSignToggle).click() + + // Confirmation modal + await page.locator(Selectors.confirmationModal.confirmationCheckbox).click() + await page.locator(Selectors.confirmationModal.continueButton).click() + await page.locator(Selectors.confirmationModal.manualConfirmationInput).focus() + await page.locator(Selectors.confirmationModal.manualConfirmationInput).fill('I only sign what I understand') + await page.locator(Selectors.confirmationModal.enableButton).click() + + // Wait for warning + await page.locator(Selectors.settings.ethSignWarning).isVisible() +} diff --git a/wallets/phantom/src/playwright/pages/SettingsPage/actions/index.ts b/wallets/phantom/src/playwright/pages/SettingsPage/actions/index.ts new file mode 100644 index 000000000..9dc1575eb --- /dev/null +++ b/wallets/phantom/src/playwright/pages/SettingsPage/actions/index.ts @@ -0,0 +1,2 @@ +export { default as enableEthSign } from './enableEthSign' +export { default as disableEthSign } from './disableEthSign' diff --git a/wallets/phantom/src/playwright/pages/SettingsPage/page.ts b/wallets/phantom/src/playwright/pages/SettingsPage/page.ts new file mode 100644 index 000000000..71bcf29d1 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/SettingsPage/page.ts @@ -0,0 +1,22 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../selectors/pages/SettingsPage' +import { enableEthSign } from './actions' +import disableEthSign from './actions/disableEthSign' + +export class SettingsPage { + static readonly selectors = Selectors + + readonly page: Page + + constructor(page: Page) { + this.page = page + } + + async enableEthSign() { + await enableEthSign(this.page) + } + + async disableEthSign() { + await disableEthSign(this.page) + } +} diff --git a/wallets/phantom/src/playwright/pages/index.ts b/wallets/phantom/src/playwright/pages/index.ts new file mode 100644 index 000000000..9388f7d49 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/index.ts @@ -0,0 +1,5 @@ +export * from './OnboardingPage/page' +export * from './CrashPage/page' +export * from './LockPage/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/clickLocatorIfCondition.ts b/wallets/phantom/src/playwright/utils/clickLocatorIfCondition.ts new file mode 100644 index 000000000..ce919a455 --- /dev/null +++ b/wallets/phantom/src/playwright/utils/clickLocatorIfCondition.ts @@ -0,0 +1,10 @@ +import type { Locator } from '@playwright/test' +import { waitFor } from './waitFor' + +// TODO: Extract & make configurable +export async function clickLocatorIfCondition(locator: Locator, condition: () => Promise, timeout = 3_000) { + const shouldClick = await waitFor(condition, timeout, false) + if (shouldClick) { + await locator.click() + } +} diff --git a/wallets/phantom/src/playwright/utils/getNotificationPageAndWaitForLoad.ts b/wallets/phantom/src/playwright/utils/getNotificationPageAndWaitForLoad.ts new file mode 100644 index 000000000..7fb17137c --- /dev/null +++ b/wallets/phantom/src/playwright/utils/getNotificationPageAndWaitForLoad.ts @@ -0,0 +1,27 @@ +import type { BrowserContext, Page } from '@playwright/test' +import { waitForPhantomLoad, waitUntilStable } 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 + }) + } + + await waitUntilStable(notificationPage as Page) + + // Set pop-up window viewport size to resemble the actual Phantom pop-up window. + await notificationPage.setViewportSize({ + width: 360, + height: 592 + }) + + return await waitForPhantomLoad(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..f9378161e --- /dev/null +++ b/wallets/phantom/src/playwright/utils/waitFor.ts @@ -0,0 +1,138 @@ +import type { Page } from '@playwright/test' +import { errors } from '@playwright/test' +import { LoadingSelectors } from '../../selectors' +import { ErrorSelectors } from '../../selectors' + +const DEFAULT_TIMEOUT = 2000 + +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 }) + await page.waitForLoadState('networkidle', { 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 + } + } +} + +export const waitForPhantomLoad = async (page: Page) => { + await Promise.all( + LoadingSelectors.loadingIndicators.map(async (selector) => { + await waitForSelector(selector, page, DEFAULT_TIMEOUT) + }) + ) + .then(() => { + return true + }) + .catch((error) => { + console.error('Error: ', error) + }) + + return page +} + +export const waitForPhantomWindowToBeStable = async (page: Page) => { + await waitForPhantomLoad(page) + if ((await page.locator(ErrorSelectors.loadingOverlayErrorButtons).count()) > 0) { + const retryButton = await page.locator(ErrorSelectors.loadingOverlayErrorButtonsRetryButton) + await retryButton.click() + await waitForSelector(LoadingSelectors.loadingOverlay, page, DEFAULT_TIMEOUT) + } + await fixCriticalError(page) +} + +export const fixCriticalError = async (page: Page) => { + for (let times = 0; times < 5; times++) { + if ((await page.locator(ErrorSelectors.criticalError).count()) > 0) { + console.log('[fixCriticalError] Phantom crashed with critical error, refreshing..') + if (times <= 3) { + await page.reload() + await waitForPhantomWindowToBeStable(page) + } else if (times === 4) { + const restartButton = await page.locator(ErrorSelectors.criticalErrorRestartButton) + await restartButton.click() + await waitForPhantomWindowToBeStable(page) + } else { + throw new Error('[fixCriticalError] Max amount of retries to fix critical phantom error has been reached.') + } + } else if ((await page.locator(ErrorSelectors.errorPage).count()) > 0) { + console.log('[fixCriticalError] Phantom crashed with error, refreshing..') + if (times <= 4) { + await page.reload() + await waitForPhantomWindowToBeStable(page) + } else { + throw new Error('[fixCriticalError] Max amount of retries to fix critical phantom error has been reached.') + } + } else { + break + } + } +} + +// 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/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/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..dcba70893 --- /dev/null +++ b/wallets/phantom/src/selectors/index.ts @@ -0,0 +1,9 @@ +export * from './loading' +export * from './error' + +export { default as crashPage } from './pages/CrashPage' +export { default as homePage } from './pages/HomePage' +export { default as lockPage } from './pages/LockPage' +export { default as notificationPage } from './pages/NotificationPage' +export { default as onboardingPage } from './pages/OnboardingPage' +export { default as settingsPage } from './pages/SettingsPage' 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/CrashPage/index.ts b/wallets/phantom/src/selectors/pages/CrashPage/index.ts new file mode 100644 index 000000000..0c94db8ba --- /dev/null +++ b/wallets/phantom/src/selectors/pages/CrashPage/index.ts @@ -0,0 +1,6 @@ +const container = 'section.error-page' + +export default { + header: `${container} > .error-page__header`, + errors: `${container} > .error-page__details li` +} 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..316216240 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/HomePage/index.ts @@ -0,0 +1,114 @@ +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")`, + confirmRenameButton: 'div.editable-label button.mm-button-icon', + renameInput: '.mm-text-field .mm-box--padding-right-4' +} + +const importAccountMenu = { + networkOpenMenu: '#button--listbox-input--1', + ethereumNetwork: `[data-label="Ethereum"]`, + baseNetwork: `[data-label="Base"]`, + polygonNetwork: `[data-label="Polygon"]`, + bitcoinNetwork: `[data-label="Bitcoin"]`, + 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 [role="button"] > p`, + manageAccountsButton: createDataTestSelector('sidebar_menu-button-manage_accounts'), + addAccountMenu, + renameAccountMenu +} + +const manageAccountButton = (accountName: string) => + `[role="button"][data-testid="manage-accounts-sortable-${accountName}"]` + +const threeDotsMenu = { + threeDotsButton: createDataTestSelector('account-options-menu-button'), + settingsButton: createDataTestSelector('global-menu-settings'), + lockButton: createDataTestSelector('global-menu-lock'), + accountDetailsButton: createDataTestSelector('account-list-menu-details'), + accountDetailsCloseButton: '.mm-modal-content .mm-modal-header button.mm-button-icon.mm-button-icon--size-sm' +} + +const popoverContainer = '.popover-container' +const popover = { + closeButton: `${popoverContainer} ${createDataTestSelector('popover-close')}` +} + +const recoveryPhraseReminder = { + gotItButton: '.recovery-phrase-reminder button.btn-primary' +} + +const networkDropdownContainer = '.multichain-network-list-menu-content-wrapper' +const networkDropdown = { + dropdownButton: createDataTestSelector('network-display'), + closeDropdownButton: `${networkDropdownContainer} > section > div:nth-child(1) button`, + networksList: `${networkDropdownContainer} .multichain-network-list-menu`, + networks: `${networkDropdownContainer} .multichain-network-list-item p`, + showTestNetworksToggle: `${networkDropdownContainer} > section > div > label.toggle-button`, + toggleOff: `${networkDropdownContainer} label.toggle-button.toggle-button--off`, + toggleOn: `${networkDropdownContainer} label.toggle-button.toggle-button--on`, + closeNetworkPopupButton: + '.mm-modal-header button.mm-button-icon.mm-box--color-icon-default.mm-box--background-color-transparent.mm-box--rounded-lg' +} + +const tabContainer = '.tabs__content' +const activityTab = { + activityTabButton: `${createDataTestSelector('home__activity-tab')}`, + transactionsList: `${tabContainer} .transaction-list__transactions`, + pendingQueuedTransactions: `${tabContainer} .transaction-list__pending-transactions .transaction-list-item .transaction-status-label--queued`, + pendingUnapprovedTransactions: `${tabContainer} .transaction-list__pending-transactions .transaction-list-item .transaction-status-label--unapproved`, + pendingApprovedTransactions: `${tabContainer} .transaction-list__pending-transactions .transaction-list-item .transaction-status-label--pending`, + completedTransactions: `${tabContainer} .transaction-list__completed-transactions .transaction-list-item` +} + +const singleToken = '.multichain-token-list-item' + +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'), + logo: `button${createDataTestSelector('app-header-logo')}`, + copyAccountAddressButton: createDataTestSelector('address-copy-button-text'), + currentNetwork: `${createDataTestSelector('network-display')} span:nth-of-type(1)`, + threeDotsMenu, + settings, + activityTab, + networkDropdown, + accountMenu, + editAccountMenu, + recoveryPhraseReminder, + popover, + portfolio: { + singleToken + }, + 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..43ba4a738 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/HomePage/settings.ts @@ -0,0 +1,35 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +export enum SettingsSidebarMenus { + General = 1, + Advanced = 2 + + /// ---- Unused Selectors ---- + // Contacts = 3, + // SecurityAndPrivacy = 4, + // Alerts = 5, + // Networks = 6, + // Experimental = 7, + // About = 8 +} +const sidebarMenu = (menu: SettingsSidebarMenus) => + `.settings-page__content__tabs .tab-bar__tab.pointer:nth-of-type(${menu})` + +const resetAccount = { + button: `${createDataTestSelector('advanced-setting-reset-account')} button`, + confirmButton: '.modal .modal-container__footer button.btn-danger-primary' +} + +const advanced = { + // locator(showTestNetworksToggle).nth(0) -> Show conversion on test networks + // locator(showTestNetworksToggle).nth(1) -> Show test networks + resetAccount, + showTestNetworksToggle: `${createDataTestSelector('advanced-setting-show-testnet-conversion')} .toggle-button`, + dismissSecretRecoveryPhraseReminderToggle: '.settings-page__content-row:nth-of-type(11) .toggle-button' +} + +export default { + SettingsSidebarMenus, + sidebarMenu, + advanced +} diff --git a/wallets/phantom/src/selectors/pages/LockPage/index.ts b/wallets/phantom/src/selectors/pages/LockPage/index.ts new file mode 100644 index 000000000..680748a28 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/LockPage/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/selectors/pages/NotificationPage/actionFooter.ts b/wallets/phantom/src/selectors/pages/NotificationPage/actionFooter.ts new file mode 100644 index 000000000..815fafd87 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/NotificationPage/actionFooter.ts @@ -0,0 +1,6 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +export default { + confirmActionButton: `.page-container__footer ${createDataTestSelector('page-container-footer-next')}`, + rejectActionButton: `.page-container__footer ${createDataTestSelector('page-container-footer-cancel')}` +} diff --git a/wallets/phantom/src/selectors/pages/NotificationPage/connectPage.ts b/wallets/phantom/src/selectors/pages/NotificationPage/connectPage.ts new file mode 100644 index 000000000..3e50f4349 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/NotificationPage/connectPage.ts @@ -0,0 +1,4 @@ +export default { + accountOption: '.choose-account-list .choose-account-list__list .choose-account-list__account', + accountCheckbox: 'input.choose-account-list__list-check-box' +} diff --git a/wallets/phantom/src/selectors/pages/NotificationPage/ethereumRpcPage.ts b/wallets/phantom/src/selectors/pages/NotificationPage/ethereumRpcPage.ts new file mode 100644 index 000000000..56e2fe8cd --- /dev/null +++ b/wallets/phantom/src/selectors/pages/NotificationPage/ethereumRpcPage.ts @@ -0,0 +1,4 @@ +export default { + approveNewRpc: '.confirmation-warning-modal__content .mm-button-primary--type-danger', + rejectNewRpc: '.confirmation-warning-modal__content .mm-button-secondary' +} 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..a26d3856f --- /dev/null +++ b/wallets/phantom/src/selectors/pages/NotificationPage/index.ts @@ -0,0 +1,15 @@ +import ActionFooter from './actionFooter' +import ConnectPage from './connectPage' +import NetworkPage from './networkPage' +import PermissionPage from './permissionPage' +import SignaturePage from './signaturePage' +import TransactionPage from './transactionPage' + +export default { + ActionFooter, + ConnectPage, + NetworkPage, + PermissionPage, + SignaturePage, + TransactionPage +} diff --git a/wallets/phantom/src/selectors/pages/NotificationPage/networkPage.ts b/wallets/phantom/src/selectors/pages/NotificationPage/networkPage.ts new file mode 100644 index 000000000..3e052b77c --- /dev/null +++ b/wallets/phantom/src/selectors/pages/NotificationPage/networkPage.ts @@ -0,0 +1,8 @@ +const switchNetwork = { + switchNetworkButton: '.confirmation-footer__actions button.btn-primary', + cancelButton: '.confirmation-footer__actions button.btn-secondary' +} + +export default { + switchNetwork +} diff --git a/wallets/phantom/src/selectors/pages/NotificationPage/permissionPage.ts b/wallets/phantom/src/selectors/pages/NotificationPage/permissionPage.ts new file mode 100644 index 000000000..70fe9b90f --- /dev/null +++ b/wallets/phantom/src/selectors/pages/NotificationPage/permissionPage.ts @@ -0,0 +1,10 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +const approve = { + maxButton: createDataTestSelector('custom-spending-cap-max-button'), + customSpendingCapInput: createDataTestSelector('custom-spending-cap-input') +} + +export default { + approve +} 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..7b133cc4f --- /dev/null +++ b/wallets/phantom/src/selectors/pages/NotificationPage/signaturePage.ts @@ -0,0 +1,22 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +const simpleMessage = { + signButton: `.request-signature__footer ${createDataTestSelector('request-signature__sign')}`, + rejectButton: '.request-signature__footer button.btn-secondary' +} + +const structuredMessage = { + scrollDownButton: `.signature-request-message ${createDataTestSelector('signature-request-scroll-button')}`, + signButton: `.signature-request-footer ${createDataTestSelector('signature-sign-button')}`, + rejectButton: `.signature-request-footer ${createDataTestSelector('signature-cancel-button')}` +} + +const riskModal = { + signButton: createDataTestSelector('signature-warning-sign-button') +} + +export default { + simpleMessage, + 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..ed4a9c9c0 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/NotificationPage/transactionPage.ts @@ -0,0 +1,45 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +const advancedGasFeeMenu = { + maxBaseFeeInput: createDataTestSelector('base-fee-input'), + priorityFeeInput: createDataTestSelector('priority-fee-input'), + gasLimitEditButton: createDataTestSelector('advanced-gas-fee-edit'), + gasLimitInput: createDataTestSelector('gas-limit-input'), + gasLimitError: `div:has(> ${createDataTestSelector('gas-limit-input')}) + .form-field__error`, + saveButton: '.popover-footer > button.btn-primary' +} + +const lowGasFee = { + button: createDataTestSelector('edit-gas-fee-item-low'), + maxFee: `${createDataTestSelector('edit-gas-fee-item-low')} .edit-gas-item__fee-estimate` +} + +const marketGasFee = { + button: createDataTestSelector('edit-gas-fee-item-medium'), + maxFee: `${createDataTestSelector('edit-gas-fee-item-medium')} .edit-gas-item__fee-estimate` +} + +const aggressiveGasFee = { + button: createDataTestSelector('edit-gas-fee-item-high'), + maxFee: `${createDataTestSelector('edit-gas-fee-item-high')} .edit-gas-item__fee-estimate` +} + +const editGasFeeMenu = { + editGasFeeButton: createDataTestSelector('edit-gas-fee-icon'), + editGasFeeButtonToolTip: '.edit-gas-fee-button .info-tooltip', + lowGasFee, + marketGasFee, + aggressiveGasFee, + siteSuggestedGasFeeButton: createDataTestSelector('edit-gas-fee-item-dappSuggested'), + advancedGasFeeButton: createDataTestSelector('edit-gas-fee-item-custom'), + advancedGasFeeMenu +} + +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/analyticsPage.ts b/wallets/phantom/src/selectors/pages/OnboardingPage/analyticsPage.ts new file mode 100644 index 000000000..7738bd2ed --- /dev/null +++ b/wallets/phantom/src/selectors/pages/OnboardingPage/analyticsPage.ts @@ -0,0 +1,6 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +export default { + optIn: createDataTestSelector('metametrics-i-agree'), + optOut: createDataTestSelector('metametrics-no-thanks') +} 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..d39013153 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/OnboardingPage/getStartedPage.ts @@ -0,0 +1,9 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +export default { + termsOfServiceCheckbox: createDataTestSelector('onboarding-terms-checkbox'), + createNewWallet: createDataTestSelector('onboarding-create-wallet'), + // importWallet: createDataTestSelector('onboarding-import-wallet') + importWallet: 'text=I already have a wallet', + importRecoveryPhraseButton: 'text=Import Secret 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..c681569c8 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/OnboardingPage/index.ts @@ -0,0 +1,25 @@ +import AnalyticsPageSelectors from './analyticsPage' +import GetStartedPageSelectors from './getStartedPage' +import PinExtensionPageSelectors from './pinExtensionPage' +import SecretRecoveryPhrasePageSelectors from './secretRecoveryPhrasePage' +import WalletCreationSuccessPageSelectors from './walletCreationSuccessPage' + +// biome-ignore format: empty lines should be preserved +export default { + // Initial Welcome Page + GetStartedPageSelectors, + + // 2nd Page + AnalyticsPageSelectors, + + // 3rd Page with two steps: + // - Input Secret Recovery Phrase + // - Create Password + SecretRecoveryPhrasePageSelectors, + + // 4th Page + WalletCreationSuccessPageSelectors, + + // 5th Page + PinExtensionPageSelectors, +}; diff --git a/wallets/phantom/src/selectors/pages/OnboardingPage/pinExtensionPage.ts b/wallets/phantom/src/selectors/pages/OnboardingPage/pinExtensionPage.ts new file mode 100644 index 000000000..64a9b9634 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/OnboardingPage/pinExtensionPage.ts @@ -0,0 +1,6 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +export default { + nextButton: createDataTestSelector('pin-extension-next'), + confirmButton: createDataTestSelector('pin-extension-done') +} 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..92d8b9f23 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/OnboardingPage/secretRecoveryPhrasePage.ts @@ -0,0 +1,36 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +const recoveryStep = { + selectNumberOfWordsDropdown: '.import-srp__number-of-words-dropdown > .dropdown__select', + selectNumberOfWordsOption: (option: number | string) => `${option}`, + // secretRecoveryPhraseWord: (index: number) => createDataTestSelector(`import-srp__srp-word-${index}`), + secretRecoveryPhraseWord: (index: number) => createDataTestSelector(`secret-recovery-phrase-word-input-${index}`), + // confirmSecretRecoveryPhraseButton: + // createDataTestSelector("import-srp-confirm"), + confirmSecretRecoveryPhraseButton: createDataTestSelector('onboarding-form-submit-button'), + // error: ".mm-banner-alert.import-srp__srp-error div", + 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'), + // importWalletButton: createDataTestSelector("onboarding-form-submit-button"), + 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/SettingsPage/index.ts b/wallets/phantom/src/selectors/pages/SettingsPage/index.ts new file mode 100644 index 000000000..57e201a10 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/SettingsPage/index.ts @@ -0,0 +1,23 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +const menuOption = '.settings-page__content__tabs .tab-bar .tab-bar__tab' + +const settings = { + menuOption, + advancedSettings: `${menuOption}:nth-child(2)`, + ethSignToggle: `${createDataTestSelector('advanced-setting-toggle-ethsign')} .eth-sign-toggle`, + ethSignWarning: + '.settings-page__content-row .mm-banner-alert.mm-banner-alert--severity-danger.mm-box--background-color-error-muted' +} + +const confirmationModal = { + confirmationCheckbox: createDataTestSelector('eth-sign__checkbox'), + continueButton: '.modal__content button.mm-button-primary', + manualConfirmationInput: '#enter-eth-sign-text', + enableButton: '.modal__content button.mm-button-primary.mm-button-primary--type-danger' +} + +export default { + settings, + confirmationModal +} diff --git a/wallets/phantom/src/type/GasSettings.ts b/wallets/phantom/src/type/GasSettings.ts new file mode 100644 index 000000000..7ffb8a370 --- /dev/null +++ b/wallets/phantom/src/type/GasSettings.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' + +export const GasSettingValidation = z.union([ + z.literal('low'), + z.literal('market'), + z.literal('aggressive'), + z.literal('site'), + 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..9ce46bf79 --- /dev/null +++ b/wallets/phantom/src/type/PhantomAbstract.ts @@ -0,0 +1,230 @@ +import { SettingsSidebarMenus } from '../selectors/pages/HomePage/settings' +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 + + /** + * Switches to the network with the given name. + * + * @param networkName - The name of the network to switch to. + * @param isTestnet - If switch to a test network. + */ + abstract switchNetwork(networkName: string, isTestnet: boolean): void + + /** + * Connects to the dapp using the currently selected account. + */ + abstract connectToDapp(accounts?: 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 + + /** + * Goes back to the home page of Phantom tab. + */ + abstract goBackToHomePage(): void + + /** + * Opens the settings page. + */ + abstract openSettings(): void + + /** + * Opens a given menu in the sidebar. + * + * @param menu - The menu to open. + */ + abstract openSidebarMenu(menu: SettingsSidebarMenus): void + /** + * Toggles the "Show Test Networks" setting. + * + * ::: warning + * This function requires the correct menu to be already opened. + * ::: + */ + abstract toggleShowTestNetworks(): void + + /** + * Toggles the "Dismiss Secret Recovery Phrase Reminder" setting. + * + * ::: warning + * This function requires the correct menu to be already opened. + * ::: + */ + abstract toggleDismissSecretRecoveryPhraseReminder(): void + + /** + * Resets the account. + * + * ::: warning + * This function requires the correct menu to be already opened. + * ::: + */ + abstract resetAccount(): void + + /** + * Enables the eth_sign feature in Phantom advanced settings. + * This method is marked as unsafe because enabling eth_sign can have security implications. + */ + abstract unsafe_enableEthSign(): void + + /** + * Disables the eth_sign feature in Phantom advanced settings. + */ + abstract disableEthSign(): void + + abstract addNewToken(): void + + abstract providePublicEncryptionKey(): void + + abstract decrypt(): void + + /// ------------------------------------------- + /// ---------- EXPERIMENTAL FEATURES ---------- + /// ------------------------------------------- + + /** + * Confirms a transaction request and waits for the transaction to be mined. + * This function utilizes the "Activity" tab of the Phantom tab. + * + * @param options - The transaction options. + * @param options.gasSetting - The gas setting to use for the transaction. + * + * @experimental + * @group Experimental Methods + */ + abstract confirmTransactionAndWaitForMining(options?: { + gasSetting?: GasSettings + }): void + + /** + * Opens the transaction details. + * + * @param txIndex - The index of the transaction in the "Activity" tab. Starts from `0`. + * + * @experimental + * @group Experimental Methods + */ + abstract openTransactionDetails(txIndex: number): void + + /** + * Closes the currently opened transaction details. + * + * @experimental + * @group Experimental Methods + */ + abstract closeTransactionDetails(): void +} 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/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/importWalletFromPrivateKey.spec.ts b/wallets/phantom/test/playwright/e2e/importWalletFromPrivateKey.spec.ts new file mode 100644 index 000000000..e004dbddf --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/importWalletFromPrivateKey.spec.ts @@ -0,0 +1,44 @@ +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 import a new wallet from private key', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await phantom.importWalletFromPrivateKey( + 'ethereum', + 'ea084c575a01e2bbefcca3db101eaeab1d8af15554640a510c73692db24d0a6a' + ) + + 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 trying to import private key for the 2nd time', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + const privateKey = 'ea084c575a01e2bbefcca3db101eaeab1d8af15554640a510c73692db24d0a6a' + + await phantom.importWalletFromPrivateKey('ethereum', privateKey) + + 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/renameAccount.spec.ts b/wallets/phantom/test/playwright/e2e/renameAccount.spec.ts new file mode 100644 index 000000000..a63d3e46c --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/renameAccount.spec.ts @@ -0,0 +1,19 @@ +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 }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + const accountName = 'Test Account' + await phantom.renameAccount('Account 1', accountName) + + await phantomPage.reload() + + await expect(phantomPage.locator(phantom.homePage.selectors.accountMenu.accountName)).toHaveText(accountName) +}) 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..ea5fc5d87 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/switchAccount.spec.ts @@ -0,0 +1,38 @@ +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' + ) + + await phantom.importWalletFromPrivateKey( + 'ethereum', + '7dd4aab86170c0edbdcf97600eff0ae319fdc94149c5e8c33d5439f8417a40bf' + ) + + await phantom.switchAccount('Account 1') + + await expect(phantomPage.getByText('Account 1')).toBeVisible() + + await phantomPage.locator(phantom.homePage.selectors.accountMenu.accountName).hover() + await expect(phantomPage.locator(phantom.homePage.selectors.ethereumWalletAddress)).toContainText('0xf39F...2266') +}) + +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/synpress.ts b/wallets/phantom/test/playwright/synpress.ts new file mode 100644 index 000000000..43bbf7507 --- /dev/null +++ b/wallets/phantom/test/playwright/synpress.ts @@ -0,0 +1,8 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { phantomFixtures } from '../../src/playwright' +// import connectedSetup from './wallet-setup/connected.setup' +import basicSetup from './wallet-setup/basic.setup' + +// TO DO -- Add 'connected.setup' file +// export default testWithSynpress(phantomFixtures(connectedSetup)) +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' + } +})