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..104c262a8 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) @@ -47,7 +50,14 @@ 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( + { + 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..1e42d5679 --- /dev/null +++ b/wallets/phantom/src/cypress/Phantom.ts @@ -0,0 +1,300 @@ +import { type BrowserContext, type Page, expect } from '@playwright/test' +import { Phantom as PhantomPlaywright } from '../playwright/Phantom' +import { waitFor } from '../playwright/utils/waitFor' +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 context */ + readonly phantomContext: BrowserContext + /** 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.phantomContext = context + 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.accountName) + .innerText() + } + + /** + * Gets the current account address. + * @returns The current account address + */ + async getAccountAddress(network: Networks): Promise { + // Grant clipboard permissions to browser context + await this.phantomContext.grantPermissions(['clipboard-read']) + return await this.phantomPlaywright.getAccountAddress(network) + } + + /** + * Connects Phantom to a dApp. + * @param accounts - Optional array of account addresses to connect + * @returns True if the connection was successful + */ + async connectToDapp(account?: string): Promise { + await this.phantomPlaywright.connectToDapp(account) + 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.accountName) + ).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.accountName) + ).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) + return true + } + + /** + * Resets the current account. + * @returns True if the reset was successful + */ + async resetApp(): Promise { + await this.phantomPlaywright.resetApp() + 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.unlockPage.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.accountMenu.accountName) + ).toBeVisible() + return true + } + + /** + * 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 + } + + /** + * Toggles the display of test networks. + * @returns True if the toggle was successful + */ + async toggleTestnetMode(): Promise { + await this.phantomPlaywright.toggleTestnetMode() + return true + } + + /** + * Navigates back to the home page. + * @returns True if the navigation was successful + */ + async goToHomePage(): Promise { + await this.phantomPlaywright.goToHomePage() + return true + } + + /** + * Navigates back to the home page. + * @returns True if the navigation was successful + */ + async goBackToHomePage(): Promise { + await this.phantomPlaywright.goBackToHomePage() + return true + } + + /** + * Opens the settings page. + * @returns True if the settings page was opened successfully + */ + async openSettings(): Promise { + await this.phantomPlaywright.openSettings() + return true + } + + /** + * Gets an element in Phantpm page + * @returns Element in Phantom page + */ + async shouldHavePhantomPageElement({ + selector, + visible, + options + }: { + selector: string + visible: boolean + options?: { timeout: number } | undefined + }): Promise { + if (visible) { + await expect(this.phantomExtensionPage.locator(selector)).toBeVisible({ + timeout: options?.timeout ?? 5_000 + }) + } else { + await expect(this.phantomExtensionPage.locator(selector)).not.toBeVisible({ + timeout: options?.timeout ?? 5_000 + }) + } + + return true + } +} diff --git a/wallets/phantom/src/cypress/configureSynpress.ts b/wallets/phantom/src/cypress/configureSynpress.ts new file mode 100644 index 000000000..81d3cb863 --- /dev/null +++ b/wallets/phantom/src/cypress/configureSynpress.ts @@ -0,0 +1,167 @@ +import type { BrowserContext, Page } from '@playwright/test' +import { ensureRdpPort } from '@synthetixio/synpress-core' +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') // 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 }), + resetApp: () => phantom?.resetApp(), + + // Token + approveTokenPermission: (options?: { + spendLimit?: number | 'max' + gasSetting?: GasSettings + }) => phantom?.approveTokenPermission(options), + rejectTokenPermission: () => phantom?.rejectTokenPermission(), + + // Transactions + confirmSignature: () => phantom?.confirmSignature(), + rejectSignature: () => phantom?.rejectSignature(), + confirmTransaction: (options?: { gasSetting?: GasSettings }) => phantom?.confirmTransaction(options), + rejectTransaction: () => phantom?.rejectTransaction(), + + // Lock/Unlock + lock: () => phantom?.lock(), + unlock: () => phantom?.unlock(), + + // Toggles + toggleTestnetMode: () => phantom?.toggleTestnetMode(), + + // Others + goToHomePage: () => phantom?.goToHomePage(), + goBackToHomePage: () => phantom?.goBackToHomePage(), + openSettings: () => phantom?.openSettings(), + shouldHavePhantomPageElement: ({ + selector, + visible, + options + }: { + selector: string + visible: boolean + options?: { timeout: number } + }) => phantom?.shouldHavePhantomPageElement({ selector, visible, options }) + }) + + 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..b98042f8d --- /dev/null +++ b/wallets/phantom/src/cypress/support/importPhantomWallet.ts @@ -0,0 +1,45 @@ +import { type BrowserContext, type Page, chromium } from '@playwright/test' +import { getExtensionIdPhantom } 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 getExtensionIdPhantom(context, 'Phantom') + + const phantom = getPlaywrightPhantom(context, extensionPage, phantomExtensionId) + + if (importDefaultWallet) await phantom.importWallet(SEED_PHRASE) + + await phantom.goToHomePage() + + 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..4385afe52 --- /dev/null +++ b/wallets/phantom/src/cypress/support/synpressCommands.ts @@ -0,0 +1,276 @@ +/// +// *********************************************** +// 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 { GasSettings } from '../../type/GasSettings' +import type { Networks } from '../../type/Networks' + +declare global { + namespace Cypress { + interface Chainable { + importWallet(seedPhrase: string): Chainable + importWalletFromPrivateKey(network: Networks, privateKey: string, walletName?: string): Chainable + + getAccount(): Chainable + + connectToDapp(accounts?: string[]): Chainable + + addNewAccount(accountName: string): Chainable + switchAccount(accountName: string): Chainable + renameAccount(currentAccountName: string, newAccountName: string): Chainable + getAccountAddress(network: Networks): Chainable + resetApp(): Chainable + + approveTokenPermission(options?: { + spendLimit?: number | 'max' + gasSetting?: GasSettings + }): Chainable + rejectTokenPermission(): Chainable + + confirmSignature(): Chainable + rejectSignature(): Chainable + confirmTransaction(options?: { + gasSetting?: GasSettings + }): Chainable + rejectTransaction(): Chainable + + lock(): Chainable + unlock(): Chainable + + toggleTestnetMode(): Chainable + + goToHomePage(): Chainable + goBackToHomePage(): Chainable + openSettings(): Chainable + shouldHavePhantomPageElement(selector: string, visible: boolean, options?: { timeout: number }): 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', (network: Networks, privateKey: string, walletName?: string) => { + return cy.task('importWalletFromPrivateKey', { + network, + privateKey, + walletName + }) + }) + + /** + * 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 for a specific network + * @returns The account address + */ + Cypress.Commands.add('getAccountAddress', (network: Networks) => { + return cy.task('getAccountAddress', network) + }) + + /** + * Resets the current account + */ + Cypress.Commands.add('resetApp', () => { + return cy.task('resetApp') + }) + + // Token + + /** + * 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('toggleTestnetMode', () => { + return cy.task('toggleTestnetMode') + }) + + // Others + + /** + * 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') + }) + + /** + * Navigates to the Phantom extension home page + */ + Cypress.Commands.add('goToHomePage', () => { + return cy.task('goToHomePage') + }) + + /** + * Goes back to the home page + */ + Cypress.Commands.add('goBackToHomePage', () => { + return cy.task('goBackToHomePage') + }) + + /** + * Opens settings + */ + Cypress.Commands.add('openSettings', () => { + return cy.task('openSettings') + }) + + /** + * Verifies if an element is visible (or not) in Phantom page + * @param selector - Selector of the element to be asserted + * @param selector - Whether the element should be visible or not + * @param options.timeout - Optional custom timeout + */ + Cypress.Commands.add( + 'shouldHavePhantomPageElement', + (selector: string, visible: boolean, options?: { timeout: number }) => { + return cy.task('shouldHavePhantomPageElement', { + selector, + visible, + options + }) + } + ) +} 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..d23dcd10c --- /dev/null +++ b/wallets/phantom/src/playwright/Phantom.ts @@ -0,0 +1,308 @@ +import type { BrowserContext, Page } from '@playwright/test' +import type { GasSettings } from '../type/GasSettings' +import type { Networks } from '../type/Networks' +import { PhantomAbstract } from '../type/PhantomAbstract' +import { CrashPage, HomePage, NotificationPage, OnboardingPage, UnlockPage } from './pages' + +const NO_EXTENSION_ID_ERROR = new Error('Phantom extensionId is not set') + +/** + * Phantom class for interacting with the Phantom extension in Playwright tests. + * + * This class provides methods to perform various operations on the Phantom extension, + * such as importing wallets, switching networks, confirming transactions, and more. + * + * @class + * @extends PhantomAbstract + */ +export class Phantom extends PhantomAbstract { + /** + * This property can be used to access selectors for the 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 unlockPage: UnlockPage + + /** + * This property can be used to access selectors for the home page. + * + * @public + * @readonly + */ + readonly homePage: HomePage + + /** + * This property can be used to access selectors for the notification page. + * + * @public + * @readonly + */ + readonly notificationPage: NotificationPage + + /** + * Creates an instance of Phantom. + * + * @param context - The Playwright BrowserContext in which the Phantom extension is running. + * @param page - The Playwright Page object representing the Phantom extension's main page. + * @param password - The password for the Phantom wallet. + * @param extensionId - The ID of the Phantom extension. Optional if no interaction with dapps is required. + */ + constructor( + readonly context: BrowserContext, + readonly page: Page, + override readonly password: string, + override readonly extensionId?: string + ) { + super(password, extensionId) + + this.crashPage = new CrashPage() + this.onboardingPage = new OnboardingPage(page) + this.unlockPage = new UnlockPage(page) + this.homePage = new HomePage(page) + this.notificationPage = new NotificationPage(page) + } + + /** + * Imports a wallet using the given seed phrase. + * + * @param seedPhrase - The seed phrase to import. + */ + async importWallet(seedPhrase: string): Promise { + await this.onboardingPage.importWallet(seedPhrase, this.password) + } + + /** + * Adds a new account with the given name. + * + * @param accountName - The name for the new account. + */ + async addNewAccount(accountName: string): Promise { + await this.homePage.addNewAccount(accountName) + } + + /** + * Renames the currently selected account. + * + * @param currentAccountName - The current account name. + * @param newAccountName - The new name for the account. + */ + async renameAccount(currentAccountName: string, newAccountName: string): Promise { + await this.homePage.renameAccount(currentAccountName, newAccountName) + } + + /** + * Imports a wallet using the given private key. + * + * @param network - Network that the wallet belongs to. + * @param privateKey - The private key to import. + * @param privateKey - Name given to the new wallet/account. + */ + async importWalletFromPrivateKey( + network: 'solana' | 'ethereum' | 'base' | 'polygon' | 'bitcoin', + privateKey: string, + walletName?: string + ): Promise { + await this.homePage.importWalletFromPrivateKey(network, privateKey, walletName) + } + + /** + * Switches to the account with the given name. + * + * @param accountName - The name of the account to switch to. + */ + async switchAccount(accountName: string): Promise { + await this.homePage.switchAccount(accountName) + } + + /** + * Gets the address of the currently selected account. + * + * @param network - Network that the address belongs to. + * @returns The account address. + */ + async getAccountAddress(network: Networks): Promise { + return await this.homePage.getAccountAddress(network) + } + + /** + * Connects Phantom to a dapp. + * + * @param accounts - Optional array of account addresses to connect. + * @throws {Error} If extensionId is not set. + */ + async connectToDapp(account?: string): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.connectToDapp(this.extensionId, account) + } + + /** + * Locks the Phantom wallet. + */ + async lock(): Promise { + await this.homePage.lock() + } + + /** + * Unlocks the Phantom wallet. + */ + async unlock(): Promise { + await this.unlockPage.unlock(this.password) + } + + /** + * Confirms a signature request. + * + * @throws {Error} If extensionId is not set. + */ + async confirmSignature(): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.signMessage(this.extensionId) + } + + /** + * Confirms a signature request with risk. + * + * @throws {Error} If extensionId is not set. + */ + async confirmSignatureWithRisk(): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.signMessageWithRisk(this.extensionId) + } + + /** + * Rejects a signature request. + * + * @throws {Error} If extensionId is not set. + */ + async rejectSignature(): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.rejectMessage(this.extensionId) + } + + /** + * Confirms a transaction. + * + * @param options - Optional gas settings for the transaction. + * @throws {Error} If extensionId is not set. + */ + async confirmTransaction(options?: { + gasSetting?: GasSettings + }): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.confirmTransaction(this.extensionId, options) + } + + /** + * Rejects a transaction. + * + * @throws {Error} If extensionId is not set. + */ + async rejectTransaction(): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.rejectTransaction(this.extensionId) + } + + /** + * Approves a token permission request. + * + * @param options - Optional settings for the approval. + * @throws {Error} If extensionId is not set. + */ + async approveTokenPermission(options?: { + spendLimit?: 'max' | number + gasSetting?: GasSettings + }): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.approveTokenPermission(this.extensionId, options) + } + + /** + * Rejects a token permission request. + * + * @throws {Error} If extensionId is not set. + */ + async rejectTokenPermission(): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.notificationPage.rejectTokenPermission(this.extensionId) + } + + /** + * Navigates to the home page or wallet dashboard. + */ + async goToHomePage(): Promise { + if (!this.extensionId) { + throw NO_EXTENSION_ID_ERROR + } + + await this.homePage.goToHomePage(this.extensionId) + } + + /** + * Navigates back to the home page. + */ + async goBackToHomePage(): Promise { + await this.homePage.goBackToHomePage() + } + + /** + * Opens the settings page. + */ + async openSettings(): Promise { + await this.homePage.openSettings() + } + + /** + * Toggles the display of test networks. + */ + async toggleTestnetMode(): Promise { + await this.homePage.toggleTestnetMode() + } + + /** + * Resets the account. + */ + async resetApp(): Promise { + await this.homePage.resetApp() + } +} diff --git a/wallets/phantom/src/playwright/fixture-actions/getExtensionId.ts b/wallets/phantom/src/playwright/fixture-actions/getExtensionId.ts new file mode 100644 index 000000000..e570080d6 --- /dev/null +++ b/wallets/phantom/src/playwright/fixture-actions/getExtensionId.ts @@ -0,0 +1,46 @@ +import type { BrowserContext } from '@playwright/test' +import { z } from 'zod' + +const Extension = z.object({ + id: z.string(), + name: z.string() +}) + +const Extensions = z.array(Extension) + +/** + * Returns the extension ID for the given extension name. The ID is fetched from the `chrome://extensions` page. + * + * ::: tip + * This function soon will be removed to improve the developer experience! 😇 + * ::: + * + * @param context - The browser context. + * @param extensionName - The name of the extension, e.g., `Phantom`. + * + * @returns The extension ID. + */ +export async function getExtensionIdPhantom(context: BrowserContext, extensionName: string) { + const page = await context.newPage() + await page.goto('chrome://extensions') + + const unparsedExtensions = await page.evaluate('chrome.management.getAll()') + + const allExtensions = Extensions.parse(unparsedExtensions) + const targetExtension = allExtensions.find( + (extension) => extension.name.toLowerCase() === extensionName.toLowerCase() + ) + + if (!targetExtension) { + throw new Error( + [ + `[GetExtensionId] Extension with name ${extensionName} not found.`, + `Available extensions: ${allExtensions.map((extension) => extension.name).join(', ')}` + ].join('\n') + ) + } + + await page.close() + + return targetExtension.id +} diff --git a/wallets/phantom/src/playwright/fixture-actions/index.ts b/wallets/phantom/src/playwright/fixture-actions/index.ts new file mode 100644 index 000000000..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..54b760dd0 --- /dev/null +++ b/wallets/phantom/src/playwright/fixture-actions/unlockForFixture.ts @@ -0,0 +1,34 @@ +import type { Page } from '@playwright/test' +import { errors as playwrightErrors } from '@playwright/test' +import { Phantom } from '..' +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 unlockForFixturePhantom(page: Page, password: string) { + const phantom = new Phantom(page.context(), page, password) + + await unlockWalletButReloadIfSpinnerDoesNotVanish(phantom) +} + +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 + } + } +} diff --git a/wallets/phantom/src/playwright/fixtures/phantomFixtures.ts b/wallets/phantom/src/playwright/fixtures/phantomFixtures.ts new file mode 100644 index 000000000..8e4693719 --- /dev/null +++ b/wallets/phantom/src/playwright/fixtures/phantomFixtures.ts @@ -0,0 +1,149 @@ +import path from 'node:path' +import { type Page, chromium, expect } from '@playwright/test' +import { test as base } from '@playwright/test' +import { + CACHE_DIR_NAME, + createTempContextDir, + defineWalletSetup, + removeTempContextDir +} from '@synthetixio/synpress-cache' +import fs from 'fs-extra' +import { prepareExtensionPhantom } from '../../prepareExtensionPhantom' +import { Phantom } from '../Phantom' +import { getExtensionIdPhantom, unlockForFixturePhantom } from '../fixture-actions' +import { persistLocalStorage } from '../fixture-actions/persistLocalStorage' +import { waitForPhantomWindowToBeStable } from '../utils/waitFor' + +type PhantomFixtures = { + _contextPath: string + phantom: Phantom + extensionId: string + phantomPage: Page + aavePage: Page + solanaSandboxPage: Page +} + +// If setup phantomPage in a fixture, browser does not handle it properly (even if ethereum.isConnected() is true, it's not reflected on the page). +let _phantomPage: Page + +export const phantomFixtures = (walletSetup: ReturnType, slowMo = 0) => { + return base.extend({ + _contextPath: async ({ browserName }, use, testInfo) => { + const contextPath = await createTempContextDir(browserName, testInfo.testId) + + await use(contextPath) + + const error = await removeTempContextDir(contextPath) + if (error) { + console.error(error) + } + }, + context: async ({ context: currentContext, _contextPath }, use) => { + const cacheDirPath = path.join(process.cwd(), CACHE_DIR_NAME, walletSetup.hash) + if (!(await fs.exists(cacheDirPath))) { + throw new Error(`Cache for ${walletSetup.hash} does not exist. Create it first!`) + } + + // Copying the cache to the temporary context directory. + await fs.copy(cacheDirPath, _contextPath) + + const phantomPath = await prepareExtensionPhantom() + + // We don't need the `--load-extension` arg since the extension is already loaded in the cache. + const browserArgs = [`--disable-extensions-except=${phantomPath}`] + + if (process.env.HEADLESS) { + browserArgs.push('--headless=new') + + if (slowMo > 0) { + console.warn('[WARNING] Slow motion makes no sense in headless mode. It will be ignored!') + } + } + + const context = await chromium.launchPersistentContext(_contextPath, { + headless: false, + args: browserArgs, + slowMo: process.env.HEADLESS ? 0 : slowMo + }) + + const { cookies, origins } = await currentContext.storageState() + + if (cookies) { + await context.addCookies(cookies) + } + if (origins && origins.length > 0) { + await persistLocalStorage(origins, context) + } + + const extensionId = await getExtensionIdPhantom(context, 'Phantom') + + _phantomPage = context.pages()[0] as Page + + await _phantomPage.goto(`chrome-extension://${extensionId}/popup.html`) + + await waitForPhantomWindowToBeStable(_phantomPage) + + await unlockForFixturePhantom(_phantomPage, walletSetup.walletPassword) + + await use(context) + + await context.close() + }, + phantomPage: async ({ context: _ }, use) => { + await use(_phantomPage) + }, + extensionId: async ({ context }, use) => { + const extensionId = await getExtensionIdPhantom(context, 'Phantom') + + await use(extensionId) + }, + phantom: async ({ context, extensionId }, use) => { + const phantom = new Phantom(context, _phantomPage, walletSetup.walletPassword, extensionId) + + await use(phantom) + }, + page: async ({ page }, use) => { + await page.goto('/') + + await use(page) + }, + aavePage: async ({ page, phantom }, use) => { + await page.goto('https://app.aave.com') + + await phantom.toggleTestnetMode() + + await page.locator('button#settings-button').click() + await page.locator('li:has-text("Testnet mode")').click() + await expect(page.getByRole('button', { name: 'TESTNET' })).toBeVisible() + + await page.getByRole('button', { name: 'Connect wallet' }).click() + await page.getByRole('button', { name: 'Browser wallet browser wallet' }).click() + + await phantom.connectToDapp() + + await expect(page.locator('button[aria-label="wallet"]')).toContainText('0xf3') + + await use(page) + }, + solanaSandboxPage: async ({ page, phantom }, use) => { + await phantom.importWalletFromPrivateKey( + 'solana', + 'XQaKFLLSKbzpVzmfJrj4yUjAyFy2Eu7JcNdbPdnLuod2Uw3yf3tjGd4ha1DBfFdjkZFX1PZg3knth2Tz2tvd8C4' + ) + + await phantom.toggleTestnetMode() + + await page.goto('https://r3byv.csb.app/') + await page.locator('a:has-text("Yes, proceed to preview")').click() + await page.getByRole('button', { name: 'Connect to Phantom' }).click() + + await phantom.connectToDapp() + + await page.getByRole('button', { name: 'Clear Logs' }).click() + + await expect(page.getByText('Click a button and watch magic happen...')).toBeVisible() + + 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..71155737a --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/index.ts @@ -0,0 +1,9 @@ +export * from './popups' +export * from './lock' +export * from './importWalletFromPrivateKey' +export * from './switchAccount' +export * from './settings' +export * from './toggleTestnetMode' +export * from './addNewAccount' +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..07b28874b --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/lock.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test' + +import Selectors from '../../../../selectors/pages/HomePage' + +export async function lock(page: Page) { + await page.locator(Selectors.accountMenu.accountButton).click() + await page.locator(Selectors.accountMenu.settings).click() + + await page.locator(Selectors.settings.lockWallet).click() +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/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..e9ad22b5f --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/settings.ts @@ -0,0 +1,23 @@ +import type { Page } from '@playwright/test' +import { homePage, notificationPage } from '../../../../selectors' + +async function openSettings(page: Page) { + await page.locator(homePage.accountMenu.accountButton).click() + await page.locator(homePage.accountMenu.settings).click() +} + +async function resetApp(page: Page) { + await openSettings(page) + + await page.locator(homePage.settings.securityAndPrivacyButton).click() + + await page.waitForSelector(homePage.settings.securityAndPrivacy.resetApp) + await page.locator(homePage.settings.securityAndPrivacy.resetApp).click() + + await page.locator(notificationPage.ActionFooter.continueActionButton).click() +} + +export const settings = { + openSettings, + resetApp +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/switchAccount.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/switchAccount.ts new file mode 100644 index 000000000..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..f72df6096 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/switchNetwork.ts @@ -0,0 +1,11 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/HomePage' + +export 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() + } +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/actions/toggleTestnetMode.ts b/wallets/phantom/src/playwright/pages/HomePage/actions/toggleTestnetMode.ts new file mode 100644 index 000000000..cf40f4d58 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/actions/toggleTestnetMode.ts @@ -0,0 +1,14 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/HomePage' + +// Toggling this through the network dropdown instead of the settings page is a better approach. +// This is in most cases the faster approach, but it's also more reliable. +export async function toggleTestnetMode(page: Page) { + await page.locator(Selectors.accountMenu.accountButton).click() + + await page.locator(Selectors.accountMenu.settings).click() + + await page.locator(Selectors.settings.developerSettingsButton).click() + + await page.locator(Selectors.settings.devSettings.toggleTestnetMode).click() +} diff --git a/wallets/phantom/src/playwright/pages/HomePage/page.ts b/wallets/phantom/src/playwright/pages/HomePage/page.ts new file mode 100644 index 000000000..7fc9b3ff1 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/HomePage/page.ts @@ -0,0 +1,72 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../selectors/pages/HomePage' +import type { Networks } from '../../../type/Networks' +import { + addNewAccount, + getAccountAddress, + importWalletFromPrivateKey, + lock, + renameAccount, + settings, + switchAccount, + toggleTestnetMode +} from './actions' + +export class HomePage { + static readonly selectors = Selectors + readonly selectors = Selectors + + readonly page: Page + + constructor(page: Page) { + this.page = page + } + + async goToHomePage(extensionId: string) { + await this.page.goto(`chrome-extension://${extensionId}/popup.html`) + } + + async goBackToHomePage() { + await this.page.locator(Selectors.settings.closeSettingsButton).click() + } + + async lock() { + await lock(this.page) + } + + async addNewAccount(accountName: string) { + await addNewAccount(this.page, accountName) + } + + async renameAccount(currentAccountName: string, newAccountName: string) { + await renameAccount(this.page, currentAccountName, newAccountName) + } + + async getAccountAddress(network: Networks) { + return await getAccountAddress(network, this.page) + } + + async importWalletFromPrivateKey( + network: 'solana' | 'ethereum' | 'base' | 'polygon' | 'bitcoin', + privateKey: string, + walletName?: string + ) { + await importWalletFromPrivateKey(this.page, network, privateKey, walletName) + } + + async switchAccount(accountName: string) { + await switchAccount(this.page, accountName) + } + + async openSettings() { + await settings.openSettings(this.page) + } + + async toggleTestnetMode() { + await toggleTestnetMode(this.page) + } + + async resetApp() { + await settings.resetApp(this.page) + } +} diff --git a/wallets/phantom/src/playwright/pages/NotificationPage/actions/approvePermission.ts b/wallets/phantom/src/playwright/pages/NotificationPage/actions/approvePermission.ts new file mode 100644 index 000000000..303883003 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/approvePermission.ts @@ -0,0 +1,30 @@ +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) => { + // Approve flow is identical to the confirm transaction flow after we click the "Next" button. + await transaction.confirm(notificationPage, gasSetting) +} + +const rejectTokenPermission = async (notificationPage: Page) => { + await notificationPage.locator(Selectors.ActionFooter.cancelActionButton).click() +} + +export const approvePermission = { + 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..eee302c44 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/connectToDapp.ts @@ -0,0 +1,16 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/NotificationPage' +import { switchAccount } from '../../HomePage/actions' + +async function confirmConnection(notificationPage: Page) { + await notificationPage.locator(Selectors.ActionFooter.connectActionButton).click() +} + +// By default, the last account will be selected. If you want to select a specific account, pass `account` parameter. +export async function connectToDapp(notificationPage: Page, account?: string) { + if (account) { + await switchAccount(notificationPage, account) + } + + await confirmConnection(notificationPage) +} diff --git a/wallets/phantom/src/playwright/pages/NotificationPage/actions/index.ts b/wallets/phantom/src/playwright/pages/NotificationPage/actions/index.ts new file mode 100644 index 000000000..367d7471f --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/index.ts @@ -0,0 +1,6 @@ +export * from './connectToDapp' +export * from './signSimpleMessage' +export * from './signStructuredMessage' +export * from './approvePermission' +export * from './transaction' +export * from './token' diff --git a/wallets/phantom/src/playwright/pages/NotificationPage/actions/signSimpleMessage.ts b/wallets/phantom/src/playwright/pages/NotificationPage/actions/signSimpleMessage.ts new file mode 100644 index 000000000..9f2a73e47 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/signSimpleMessage.ts @@ -0,0 +1,23 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/NotificationPage' + +const signMessage = async (notificationPage: Page) => { + await notificationPage.locator(Selectors.ActionFooter.confirmActionButton).click() +} + +const rejectMessage = async (notificationPage: Page) => { + await notificationPage.locator(Selectors.ActionFooter.cancelActionButton).click() +} + +const signMessageWithRisk = async (notificationPage: Page) => { + await notificationPage.locator(Selectors.SignaturePage.riskModal.proceedAnyway).click() + await notificationPage.locator(Selectors.SignaturePage.riskModal.confirmUnsafe).click() + await notificationPage.locator(Selectors.SignaturePage.riskModal.acknowledgeRisks).click() + await notificationPage.locator(Selectors.SignaturePage.riskModal.reconfirmUnsafe).click() +} + +export const signSimpleMessage = { + sign: signMessage, + reject: rejectMessage, + signWithRisk: signMessageWithRisk +} diff --git a/wallets/phantom/src/playwright/pages/NotificationPage/actions/signStructuredMessage.ts b/wallets/phantom/src/playwright/pages/NotificationPage/actions/signStructuredMessage.ts new file mode 100644 index 000000000..0283b9c41 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/signStructuredMessage.ts @@ -0,0 +1,25 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/NotificationPage' + +const signMessage = async (notificationPage: Page) => { + const scrollDownButton = notificationPage.locator(Selectors.SignaturePage.structuredMessage.scrollDownButton) + const signButton = notificationPage.locator(Selectors.ActionFooter.confirmActionButton) + + while (await signButton.isDisabled()) { + await scrollDownButton.click() + } + + await signButton.click() +} + +const rejectMessage = async (notificationPage: Page) => { + await notificationPage.locator(Selectors.ActionFooter.cancelActionButton).click() +} + +// Used for: +// - `eth_signTypedData_v3` +// - `eth_signTypedData_v4` +export const signStructuredMessage = { + sign: signMessage, + reject: rejectMessage +} diff --git a/wallets/phantom/src/playwright/pages/NotificationPage/actions/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..55e81bacd --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/actions/transaction.ts @@ -0,0 +1,64 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/NotificationPage' +import { GasSettingValidation, type GasSettings } from '../../../../type/GasSettings' +import { waitFor } from '../../../utils/waitFor' + +const confirmTransaction = async (notificationPage: Page, options: GasSettings) => { + const gasSetting = GasSettingValidation.parse(options) + + const handleNftSetApprovalForAll = async (page: Page) => { + try { + const nftApproveButtonLocator = page.locator( + Selectors.TransactionPage.nftApproveAllConfirmationPopup.approveButton + ) + const isNfTPopupHidden = await waitFor(() => nftApproveButtonLocator.isHidden(), 3_000, false) + + if (!isNfTPopupHidden) { + await nftApproveButtonLocator.click() + } + } catch (e) { + if (page.isClosed()) { + return + } + + throw new Error(`Failed to handle NFT setApprovalForAll popup: ${e}`) + } + } + + // By default, the `Average` gas setting is used. + if (gasSetting === 'Average') { + await notificationPage.locator(Selectors.ActionFooter.confirmActionButton).click() + + await handleNftSetApprovalForAll(notificationPage) + + return + } + + // TODO: This button can be invisible in case of a network issue. Verify this, and handle in the future. + await notificationPage.locator(Selectors.TransactionPage.editGasFeeMenu.editGasFeeButton).click() + + const handleSlowOrFastGasSetting = async (selector: string) => { + await notificationPage.locator(selector).click() + } + + if (gasSetting === 'Slow') { + await handleSlowOrFastGasSetting(Selectors.TransactionPage.editGasFeeMenu.slowGasFeeButton) + } else if (gasSetting === 'Fast') { + await handleSlowOrFastGasSetting(Selectors.TransactionPage.editGasFeeMenu.fastGasFeeButton) + } + + await notificationPage.locator(Selectors.TransactionPage.editGasFeeMenu.saveButton).click() + + await notificationPage.locator(Selectors.ActionFooter.confirmActionButton).click() + + await handleNftSetApprovalForAll(notificationPage) +} + +const rejectTransaction = async (notificationPage: Page) => { + await notificationPage.locator(Selectors.ActionFooter.cancelActionButton).click() +} + +export const transaction = { + confirm: confirmTransaction, + reject: rejectTransaction +} diff --git a/wallets/phantom/src/playwright/pages/NotificationPage/page.ts b/wallets/phantom/src/playwright/pages/NotificationPage/page.ts new file mode 100644 index 000000000..f8fea3eee --- /dev/null +++ b/wallets/phantom/src/playwright/pages/NotificationPage/page.ts @@ -0,0 +1,91 @@ +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, signSimpleMessage, signStructuredMessage, transaction } from './actions' + +export class NotificationPage { + static readonly selectors = Selectors + readonly selectors = Selectors + + readonly page: Page + + constructor(page: Page) { + this.page = page + } + + async connectToDapp(extensionId: string, account?: string) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + await connectToDapp(notificationPage, account) + } + + // TODO: Revisit this logic in the future to see if we can increase the performance by utilizing `Promise.race`. + private async beforeMessageSignature(extensionId: string) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + const scrollButton = notificationPage.locator(Selectors.SignaturePage.structuredMessage.scrollDownButton) + const isScrollButtonPresent = (await scrollButton.count()) > 0 + + let isScrollButtonVisible = false + if (isScrollButtonPresent) { + await scrollButton.waitFor({ state: 'visible' }) + isScrollButtonVisible = true + } + + return { + notificationPage, + isScrollButtonVisible + } + } + + async signMessage(extensionId: string) { + const { notificationPage, isScrollButtonVisible } = 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 ?? 'Average') + } + + async rejectTransaction(extensionId: string) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + await transaction.reject(notificationPage) + } + + async approveTokenPermission(extensionId: string, options?: { gasSetting?: GasSettings }) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + await approvePermission.approve(notificationPage, options?.gasSetting ?? 'Average') + } + + async rejectTokenPermission(extensionId: string) { + const notificationPage = await getNotificationPageAndWaitForLoad(this.page.context(), extensionId) + + await approvePermission.reject(notificationPage) + } +} 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..2af16c1fa --- /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: 30_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/UnlockPage/actions/index.ts b/wallets/phantom/src/playwright/pages/UnlockPage/actions/index.ts new file mode 100644 index 000000000..b0dce8ac2 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/UnlockPage/actions/index.ts @@ -0,0 +1 @@ +export * from './unlock' diff --git a/wallets/phantom/src/playwright/pages/UnlockPage/actions/unlock.ts b/wallets/phantom/src/playwright/pages/UnlockPage/actions/unlock.ts new file mode 100644 index 000000000..7130b3fbe --- /dev/null +++ b/wallets/phantom/src/playwright/pages/UnlockPage/actions/unlock.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../../selectors/pages/UnlockPage' +import { waitForSpinnerToVanish } from '../../../utils/waitForSpinnerToVanish' + +export async function unlock(page: Page, password: string) { + await page.locator(Selectors.passwordInput).fill(password) + await page.locator(Selectors.submitButton).click() + + await waitForSpinnerToVanish(page) +} diff --git a/wallets/phantom/src/playwright/pages/UnlockPage/page.ts b/wallets/phantom/src/playwright/pages/UnlockPage/page.ts new file mode 100644 index 000000000..85e197742 --- /dev/null +++ b/wallets/phantom/src/playwright/pages/UnlockPage/page.ts @@ -0,0 +1,18 @@ +import type { Page } from '@playwright/test' +import Selectors from '../../../selectors/pages/UnlockPage' +import { unlock } from './actions' + +export class UnlockPage { + static readonly selectors = Selectors + readonly selectors = Selectors + + readonly page: Page + + constructor(page: Page) { + this.page = page + } + + async unlock(password: string) { + await unlock(this.page, password) + } +} diff --git a/wallets/phantom/src/playwright/pages/index.ts b/wallets/phantom/src/playwright/pages/index.ts new file mode 100644 index 000000000..78f98ea2c --- /dev/null +++ b/wallets/phantom/src/playwright/pages/index.ts @@ -0,0 +1,5 @@ +export * from './OnboardingPage/page' +export * from './CrashPage/page' +export * from './UnlockPage/page' +export * from './HomePage/page' +export * from './NotificationPage/page' diff --git a/wallets/phantom/src/playwright/utils/allTextContents.ts b/wallets/phantom/src/playwright/utils/allTextContents.ts new file mode 100644 index 000000000..09fa95be2 --- /dev/null +++ b/wallets/phantom/src/playwright/utils/allTextContents.ts @@ -0,0 +1,10 @@ +import type { Locator } from '@playwright/test' +import { z } from 'zod' + +// Custom implementation of `locator.allTextContents()` that is not utilizing `.map` which is not accessible under Phantom's scuttling mode. +export async function allTextContents(locators: Locator[]) { + const names = await Promise.all(locators.map((locator) => locator.textContent())) + + // We're making sure that the return type is `string[]` same as `locator.allTextContents()`. + return names.map((name) => z.string().parse(name)) +} diff --git a/wallets/phantom/src/playwright/utils/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..e98e7d7de --- /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 unlockPage } from './pages/UnlockPage' +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..f57d78653 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/HomePage/index.ts @@ -0,0 +1,115 @@ +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'), + settings: createDataTestSelector('sidebar_menu-button-settings'), + 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'), + copyAccountAddressButton: createDataTestSelector('address-copy-button-text'), + currentNetwork: `${createDataTestSelector('network-display')} span:nth-of-type(1)`, + headerBackButton: createDataTestSelector('header--back'), + 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..2ae518506 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/HomePage/settings.ts @@ -0,0 +1,26 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +const advanced = { + showTestNetworksToggle: `${createDataTestSelector('advanced-setting-show-testnet-conversion')} .toggle-button`, + dismissSecretRecoveryPhraseReminderToggle: '.settings-page__content-row:nth-of-type(11) .toggle-button' +} + +const devSettings = { + toggleTestnetMode: createDataTestSelector('toggleTestNetwork'), + toggleEnableCopyTransaction: createDataTestSelector('solana-copy-transaction') +} + +const securityAndPrivacy = { + resetApp: 'button:has-text("Reset App")' +} + +export default { + securityAndPrivacyButton: createDataTestSelector('settings-item-security-and-privacy'), + lockWallet: createDataTestSelector('lock-menu-item'), + unlocWallet: createDataTestSelector('data-testid="unlock-form-submit-button"'), + developerSettingsButton: createDataTestSelector('settings-item-developer-settings'), + closeSettingsButton: createDataTestSelector('settings-menu-close-button'), + advanced, + devSettings, + securityAndPrivacy +} diff --git a/wallets/phantom/src/selectors/pages/NotificationPage/actionFooter.ts b/wallets/phantom/src/selectors/pages/NotificationPage/actionFooter.ts new file mode 100644 index 000000000..c193b737b --- /dev/null +++ b/wallets/phantom/src/selectors/pages/NotificationPage/actionFooter.ts @@ -0,0 +1,8 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +export default { + connectActionButton: `button${createDataTestSelector('primary-button')}:has-text("Connect")`, + confirmActionButton: `button${createDataTestSelector('primary-button')}:has-text("Confirm")`, + continueActionButton: `button${createDataTestSelector('primary-button')}:has-text("Continue")`, + cancelActionButton: `button${createDataTestSelector('secondary-button')}:has-text("Cancel")` +} 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..66c074de2 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/NotificationPage/index.ts @@ -0,0 +1,13 @@ +import ActionFooter from './actionFooter' +import ConnectPage from './connectPage' +import PermissionPage from './permissionPage' +import SignaturePage from './signaturePage' +import TransactionPage from './transactionPage' + +export default { + ActionFooter, + ConnectPage, + PermissionPage, + SignaturePage, + TransactionPage +} 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..32e35e013 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/NotificationPage/signaturePage.ts @@ -0,0 +1,25 @@ +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 = { + proceedAnyway: 'text=Proceed anyway (unsafe)', + confirmUnsafe: 'text=Confirm (unsafe)', + acknowledgeRisks: createDataTestSelector('acknowledge--button'), + reconfirmUnsafe: 'text=Yes, confirm (unsafe)' +} + +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..1748add70 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/NotificationPage/transactionPage.ts @@ -0,0 +1,15 @@ +const editGasFeeMenu = { + editGasFeeButton: 'div > div p:has-text("Network Fee")', + slowGasFeeButton: 'div > div p:has-text("Slow")', + fastGasFeeButton: 'div > div p:has-text("Fast")', + saveButton: 'button:has-text("Save")' +} + +const nftApproveAllConfirmationPopup = { + approveButton: '.set-approval-for-all-warning__content button.set-approval-for-all-warning__footer__approve-button' +} + +export default { + editGasFeeMenu, + nftApproveAllConfirmationPopup +} diff --git a/wallets/phantom/src/selectors/pages/OnboardingPage/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..1fe9a3ad9 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/OnboardingPage/getStartedPage.ts @@ -0,0 +1,8 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +export default { + termsOfServiceCheckbox: createDataTestSelector('onboarding-terms-checkbox'), + createNewWallet: `button:has-text("Create a new 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/selectors/pages/UnlockPage/index.ts b/wallets/phantom/src/selectors/pages/UnlockPage/index.ts new file mode 100644 index 000000000..680748a28 --- /dev/null +++ b/wallets/phantom/src/selectors/pages/UnlockPage/index.ts @@ -0,0 +1,6 @@ +import { createDataTestSelector } from '../../createDataTestSelector' + +export default { + passwordInput: createDataTestSelector('unlock-form-password-input'), + submitButton: createDataTestSelector('unlock-form-submit-button') +} diff --git a/wallets/phantom/src/type/GasSettings.ts b/wallets/phantom/src/type/GasSettings.ts new file mode 100644 index 000000000..e1ecd9870 --- /dev/null +++ b/wallets/phantom/src/type/GasSettings.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' + +export const GasSettingValidation = z.union([ + z.literal('Slow'), + z.literal('Fast'), + z.literal('Average'), + z + .object({ + maxBaseFee: z.number(), + priorityFee: z.number(), + // TODO: Add gasLimit range validation. + gasLimit: z.number().optional() + }) + .superRefine(({ maxBaseFee, priorityFee }, ctx) => { + if (priorityFee > maxBaseFee) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Max base fee cannot be lower than priority fee', + path: ['Phantom', 'confirmTransaction', 'gasSetting', 'maxBaseFee'] + }) + } + }) +]) + +export type GasSettings = z.input diff --git a/wallets/phantom/src/type/Networks.ts b/wallets/phantom/src/type/Networks.ts new file mode 100644 index 000000000..372d8e67b --- /dev/null +++ b/wallets/phantom/src/type/Networks.ts @@ -0,0 +1 @@ +export type Networks = 'solana' | 'ethereum' | 'base' | 'polygon' | 'bitcoin' diff --git a/wallets/phantom/src/type/PhantomAbstract.ts b/wallets/phantom/src/type/PhantomAbstract.ts new file mode 100644 index 000000000..21f388c7a --- /dev/null +++ b/wallets/phantom/src/type/PhantomAbstract.ts @@ -0,0 +1,158 @@ +import type { GasSettings } from './GasSettings' +import type { Networks } from './Networks' + +export abstract class PhantomAbstract { + /** + * @param password - The password of the Phantom wallet. + * @param extensionId - The extension ID of the Phantom extension. Optional if no interaction with the dapp is required. + * + * @returns A new instance of the Phantom class. + */ + constructor( + /** + * The password of the Phantom wallet. + */ + readonly password: string, + /** + * The extension ID of the Phantom extension. Optional if no interaction with the dapp is required. + */ + readonly extensionId?: string + ) { + this.password = password + this.extensionId = extensionId + } + + /** + * Imports a wallet using the given seed phrase. + * + * @param seedPhrase - The seed phrase to import. + */ + abstract importWallet(seedPhrase: string): void + + /** + * Adds a new account with the given name. This account is based on the initially imported seed phrase. + * + * @param accountName - The name of the new account. + */ + abstract addNewAccount(accountName: string): void + + /** + * Imports a wallet using the given private key. + * + * @param privateKey - The private key to import. + */ + abstract importWalletFromPrivateKey(network: Networks, privateKey: string, walletName?: string): void + + /** + * Switches to the account with the given name. + * + * @param accountName - The name of the account to switch to. + */ + abstract switchAccount(accountName: string): void + + /** + * Retrieves the current account address. + */ + abstract getAccountAddress(network: Networks): void + + /** + * Connects to the dapp using the currently selected account. + */ + abstract connectToDapp(account?: string): void + + /** + * Locks Phantom. + */ + abstract lock(): void + + /** + * Unlocks Phantom. + */ + abstract unlock(): void + + /** + * Confirms a signature request. This function supports all types of commonly used signatures. + */ + abstract confirmSignature(): void + + /** + * Confirms a signature request with potential risk. + */ + abstract confirmSignatureWithRisk(): void + + /** + * Rejects a signature request. This function supports all types of commonly used signatures. + */ + abstract rejectSignature(): void + + /** + * Confirms a transaction request. + * + * @param options - The transaction options. + * @param options.gasSetting - The gas setting to use for the transaction. + */ + abstract confirmTransaction(options?: { gasSetting?: GasSettings }): void + + /** + * Rejects a transaction request. + */ + abstract rejectTransaction(): void + + /** + * Approves a permission request to spend tokens. + * + * ::: warning + * For NFT approvals, use `confirmTransaction` method. + * ::: + * + * @param options - The permission options. + * @param options.spendLimit - The spend limit to use for the permission. + * @param options.gasSetting - The gas setting to use for the approval transaction. + */ + abstract approveTokenPermission(options?: { + spendLimit?: 'max' | number + gasSetting?: GasSettings + }): void + + /** + * Rejects a permission request to spend tokens. + * + * ::: warning + * For NFT approvals, use `confirmTransaction` method. + * ::: + */ + abstract rejectTokenPermission(): void + + /** + * Navigates to the home page of Phantom tab. + */ + abstract goToHomePage(): void + + /** + * Goes back to the home page of Phantom tab. + */ + abstract goBackToHomePage(): void + + /** + * Opens the settings page. + */ + abstract openSettings(): void + + /** + * Toggles the "Show Test Networks" setting. + * + * ::: warning + * This function requires the correct menu to be already opened. + * ::: + */ + abstract toggleTestnetMode(): void + + /** + * Resets the account. + * + * ::: warning + * This function requires the correct menu to be already opened. + * ::: + */ + abstract resetApp(): void +} diff --git a/wallets/phantom/test/cypress/addNewAccount.cy.ts b/wallets/phantom/test/cypress/addNewAccount.cy.ts new file mode 100644 index 000000000..f5bc382ca --- /dev/null +++ b/wallets/phantom/test/cypress/addNewAccount.cy.ts @@ -0,0 +1,7 @@ +it('should add a new account with a specified name', () => { + const accountName = 'Test Account 2' + + cy.addNewAccount(accountName).then(() => { + cy.getAccount().should('eq', accountName) + }) +}) diff --git a/wallets/phantom/test/cypress/getAccountAddress.cy.ts b/wallets/phantom/test/cypress/getAccountAddress.cy.ts new file mode 100644 index 000000000..6c93cbd64 --- /dev/null +++ b/wallets/phantom/test/cypress/getAccountAddress.cy.ts @@ -0,0 +1,3 @@ +it('should should get account address for Solana network', () => { + cy.getAccountAddress('solana').should('eq', 'oeYf6KAJkLYhBuR8CiGc6L4D4Xtfepr85fuDgA9kq96') +}) diff --git a/wallets/phantom/test/cypress/goBackToHomepage.cy.ts b/wallets/phantom/test/cypress/goBackToHomepage.cy.ts new file mode 100644 index 000000000..a43b66e86 --- /dev/null +++ b/wallets/phantom/test/cypress/goBackToHomepage.cy.ts @@ -0,0 +1,11 @@ +import * as selectors from '../../src/selectors/' + +it('should open settings and go back to homepage', () => { + cy.openSettings() + + cy.shouldHavePhantomPageElement(selectors.homePage.settings.lockWallet, true) + + cy.goBackToHomePage() + + cy.shouldHavePhantomPageElement(selectors.homePage.settings.lockWallet, false) +}) diff --git a/wallets/phantom/test/cypress/importWalletFromPrivateKey.cy.ts b/wallets/phantom/test/cypress/importWalletFromPrivateKey.cy.ts new file mode 100644 index 000000000..a1a0da5f0 --- /dev/null +++ b/wallets/phantom/test/cypress/importWalletFromPrivateKey.cy.ts @@ -0,0 +1,5 @@ +it('should import a new wallet from private key', () => { + cy.importWalletFromPrivateKey('ethereum', 'ea084c575a01e2bbefcca3db101eaeab1d8af15554640a510c73692db24d0a6a') + + cy.getAccountAddress('ethereum').should('eq', '0xa2ce797cA71d0EaE1be5a7EffD27Fd6C38126801') +}) diff --git a/wallets/phantom/test/cypress/lock.cy.ts b/wallets/phantom/test/cypress/lock.cy.ts new file mode 100644 index 000000000..37c083d7e --- /dev/null +++ b/wallets/phantom/test/cypress/lock.cy.ts @@ -0,0 +1,13 @@ +import * as selectors from '../../src/selectors/' + +it('should lock & unlock the wallet without any errors', () => { + cy.shouldHavePhantomPageElement(selectors.homePage.accountMenu.accountName, true) + + cy.lock() + + cy.shouldHavePhantomPageElement(selectors.unlockPage.submitButton, true) + + cy.unlock() + + cy.shouldHavePhantomPageElement(selectors.homePage.accountMenu.accountName, true) +}) diff --git a/wallets/phantom/test/cypress/renameAccount.cy.ts b/wallets/phantom/test/cypress/renameAccount.cy.ts new file mode 100644 index 000000000..fc17f21ba --- /dev/null +++ b/wallets/phantom/test/cypress/renameAccount.cy.ts @@ -0,0 +1,10 @@ +const newAccountName = 'New Name' + +it('should rename currently connected account with specified name', () => { + cy.addNewAccount(newAccountName).then(() => { + cy.renameAccount(newAccountName, 'Renaming test').then(() => { + cy.goToHomePage() + cy.getAccount().should('eq', 'Renaming test') + }) + }) +}) diff --git a/wallets/phantom/test/cypress/switchAccount.cy.ts b/wallets/phantom/test/cypress/switchAccount.cy.ts new file mode 100644 index 000000000..c582fee4c --- /dev/null +++ b/wallets/phantom/test/cypress/switchAccount.cy.ts @@ -0,0 +1,13 @@ +import { defaultAccount } from '../../src/cypress/constans' + +it('should switch back to the `Account 1` account', () => { + const accountName = 'New Name' + + cy.addNewAccount(accountName).then(() => { + cy.getAccount().should('eq', accountName) + }) + + cy.switchAccount(defaultAccount).then(() => { + cy.getAccount().should('eq', defaultAccount) + }) +}) diff --git a/wallets/phantom/test/cypress/toggleTestnetMode.cy.ts b/wallets/phantom/test/cypress/toggleTestnetMode.cy.ts new file mode 100644 index 000000000..f5d5f099f --- /dev/null +++ b/wallets/phantom/test/cypress/toggleTestnetMode.cy.ts @@ -0,0 +1,13 @@ +it('should toggle Testnet mode', () => { + // Turn Testnet mode ON + cy.toggleTestnetMode() + + cy.goToHomePage() + cy.shouldHavePhantomPageElement('text=You are currently in Testnet Mode', true) + + // Turn Testnet mode OFF + cy.toggleTestnetMode() + + cy.goToHomePage() + cy.shouldHavePhantomPageElement('text=You are currently in Testnet Mode', false) +}) diff --git a/wallets/phantom/test/playwright/commonSteps/connectPhantomToTestDapp.ts b/wallets/phantom/test/playwright/commonSteps/connectPhantomToTestDapp.ts new file mode 100644 index 000000000..ebc7b47c1 --- /dev/null +++ b/wallets/phantom/test/playwright/commonSteps/connectPhantomToTestDapp.ts @@ -0,0 +1,10 @@ +import { type Page, expect } from '@playwright/test' +import type { Phantom } from '../../../src/playwright' + +export const connectPhantomToTestDapp = async (page: Page, phantom: Phantom) => { + await page.locator('#connectButton').click() + + await phantom.connectToDapp() + + await expect(page.locator('#accounts')).toHaveText('0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266') +} diff --git a/wallets/phantom/test/playwright/e2e/addNewAccount.spec.ts b/wallets/phantom/test/playwright/e2e/addNewAccount.spec.ts new file mode 100644 index 000000000..9dfc05948 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/addNewAccount.spec.ts @@ -0,0 +1,23 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should add a new account with specified name', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + const accountName = 'Test Account' + await phantom.addNewAccount(accountName) + + await expect(phantomPage.locator(phantom.homePage.selectors.accountMenu.accountName)).toHaveText(accountName) +}) + +test('should throw an error if an empty account name is passed', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await expect(phantom.addNewAccount('')).rejects.toThrowError('[AddNewAccount] Account name cannot be an empty string') +}) diff --git a/wallets/phantom/test/playwright/e2e/approveTokenPermission.spec.ts b/wallets/phantom/test/playwright/e2e/approveTokenPermission.spec.ts new file mode 100644 index 000000000..4399e1110 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/approveTokenPermission.spec.ts @@ -0,0 +1,27 @@ +import synpress from '../synpress' + +const test = synpress + +const { expect } = test + +test('should approve token with the default limit', async ({ aavePage, phantom }) => { + test.setTimeout(80_000) + + await aavePage.goto( + 'https://app.aave.com/reserve-overview/?underlyingAsset=0xff34b3d4aee8ddcd6f9afffb6fe49bd371b8a357&marketName=proto_sepolia_v3' + ) + + await aavePage.getByRole('button', { name: 'Supply' }).click() + await aavePage.locator('input[aria-label="amount input"]').fill('1') + + const supplyDaiLocator = aavePage.getByRole('button', { + name: 'Supply DAI' + }) + await expect(supplyDaiLocator).toBeDisabled() + + await aavePage.locator('button:has-text("Approve DAI to continue")').click() + + await phantom.approveTokenPermission() + + await expect(supplyDaiLocator).toBeEnabled({ timeout: 10_000 }) +}) diff --git a/wallets/phantom/test/playwright/e2e/confirmSignature.spec.ts b/wallets/phantom/test/playwright/e2e/confirmSignature.spec.ts new file mode 100644 index 000000000..6d7c55d6b --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/confirmSignature.spec.ts @@ -0,0 +1,78 @@ +import { connectPhantomToTestDapp } from '../commonSteps/connectPhantomToTestDapp' +import synpress from '../synpress' + +const test = synpress + +const { expect } = test + +test('should confirm `personal_sign`', async ({ page, phantom }) => { + connectPhantomToTestDapp(page, phantom) + + await page.locator('#personalSign').click() + + await phantom.confirmSignature() + + await expect(page.locator('#personalSignResult')).toHaveText( + '0xf95b3efc808585303e20573e960993cde30c7f5a0f1c25cfab0379d5a14311d17898199814c8ebe66ec80b2b11690f840bde539f862ff4f04468d2a40f15178a1b' + ) +}) + +test('should confirm `eth_signTypedData`', async ({ page, phantom }) => { + connectPhantomToTestDapp(page, phantom) + + await page.locator('#signTypedData').click() + + await phantom.confirmSignature() + + await expect(page.locator('#signTypedDataResult')).toHaveText( + '0xd75eece0d337f4e425f87bd112c849561956afe4f154cdd07d1d4cba7a979b481ba6ceede5c0eb9daa66bec4eea6e7ecfee5496274ef2a93b69abd97531519b21c' + ) + + await page.locator('#signTypedDataVerify').click() + + await expect(page.locator('#signTypedDataVerifyResult')).toHaveText('0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266') +}) + +test('should confirm `eth_signTypedData_v3`', async ({ page, phantom }) => { + connectPhantomToTestDapp(page, phantom) + + await page.locator('#signTypedDataV3').click() + + await phantom.confirmSignature() + + await expect(page.locator('#signTypedDataV3Result')).toHaveText( + '0x6ea8bb309a3401225701f3565e32519f94a0ea91a5910ce9229fe488e773584c0390416a2190d9560219dab757ecca2029e63fa9d1c2aebf676cc25b9f03126a1b' + ) + + await page.locator('#signTypedDataV3Verify').click() + + await expect(page.locator('#signTypedDataV3VerifyResult')).toHaveText('0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266') +}) + +test('should confirm `eth_signTypedData_v4`', async ({ page, phantom }) => { + connectPhantomToTestDapp(page, phantom) + + await page.locator('#signTypedDataV4').click() + + await phantom.confirmSignature() + + await expect(page.locator('#signTypedDataV4Result')).toHaveText( + '0x1cf422c4a319c19ecb89c960e7c296810278fa2bef256c7e9419b285c8216c547b3371fa1ec3987ce08561d3ed779845393d8d3e4311376d0bc0846f37d1b2821c' + ) + + await page.locator('#signTypedDataV4Verify').click() + + await expect(page.locator('#signTypedDataV4VerifyResult')).toHaveText('0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266') +}) + +test('should confirm `eth_sign`', async ({ page, phantom }) => { + connectPhantomToTestDapp(page, phantom) + + await page.locator('#ethSign').click() + + await phantom.confirmSignatureWithRisk() + + await expect(page.locator('#ethSignResult')).toContainText( + '0x369af83ed2fcee757c12d59d921289722d7c6bc11f484f667f2ef8b74b068a0e281ffff7099fc5a4af534f5dbaaab92238b715dd887e3a9f8e2298dc99f554e81b' + ) +}) diff --git a/wallets/phantom/test/playwright/e2e/confirmTransaction.spec.ts b/wallets/phantom/test/playwright/e2e/confirmTransaction.spec.ts new file mode 100644 index 000000000..da8212b6d --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/confirmTransaction.spec.ts @@ -0,0 +1,44 @@ +import { connectPhantomToTestDapp } from '../commonSteps/connectPhantomToTestDapp' +import synpress from '../synpress' + +const test = synpress + +const { expect } = test + +test('should Sign Transaction ', async ({ solanaSandboxPage, phantom }) => { + await solanaSandboxPage.getByRole('button', { name: 'Sign Transaction' }).click() + await phantom.confirmTransaction() + + await expect(solanaSandboxPage.getByText('> success')).toBeVisible() +}) + +test('should Sign All Transactions ', async ({ solanaSandboxPage, phantom }) => { + await solanaSandboxPage.getByRole('button', { name: 'Sign All Transaction' }).click() + await phantom.confirmTransaction() + + await expect(solanaSandboxPage.getByText('> success')).toBeVisible() +}) + +test('should confirm contract deployment with default gas setting', async ({ page, phantom }) => { + connectPhantomToTestDapp(page, phantom) + + await expect(page.locator('#tokenAddresses')).toBeEmpty() + await page.locator('#createToken').click() + + await phantom.confirmTransaction() + + await expect(page.locator('#tokenAddresses')).toContainText('Creation Failed') +}) + +;(['Slow', 'Fast'] as const).forEach((gasSetting) => { + test(`should confirm contract deployment with ${gasSetting} gas setting`, async ({ page, phantom }) => { + connectPhantomToTestDapp(page, phantom) + + await expect(page.locator('#tokenAddresses')).toBeEmpty() + await page.locator('#createToken').click() + + await phantom.confirmTransaction({ gasSetting }) + + await expect(page.locator('#tokenAddresses')).toContainText('Creation Failed') + }) +}) diff --git a/wallets/phantom/test/playwright/e2e/connectToDapp.spec.ts b/wallets/phantom/test/playwright/e2e/connectToDapp.spec.ts new file mode 100644 index 000000000..cce4a8abc --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/connectToDapp.spec.ts @@ -0,0 +1,49 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should connect wallet to dapp', async ({ context, page, extensionId }) => { + const phantom = new Phantom(context, page, basicSetup.walletPassword, extensionId) + + await page.goto('/') + + await page.locator('#connectButton').click() + + await phantom.connectToDapp() + + await expect(page.locator('#accounts')).toHaveText('0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266') +}) + +test('should connect multiple wallets to dapp', async ({ context, page, phantomPage, extensionId }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword, extensionId) + + await phantom.addNewAccount('NewAccount1') + await phantom.addNewAccount('NewAccount2') + + await page.goto('/') + await page.locator('#connectButton').click() + + await phantom.connectToDapp('NewAccount1') + + // Get address for account connected to testdapp + await expect(page.locator('#accounts')).toContainText('0x') + const testDappAccountAddress = await page.locator('#accounts').innerText() + + // Verify that active account in Phantom extension is 'NewAccount2' + await expect(phantomPage.locator(phantom.homePage.selectors.accountMenu.accountName)).toHaveText('NewAccount2') + // Verify that address for active account in Phantom extension is not connected to test dapp + const phantomActiveAccountAddress = await phantom.getAccountAddress('ethereum') + expect(testDappAccountAddress).not.toEqual(phantomActiveAccountAddress) + + // Switch to 'NewAccount1' in Phantom extension and verify that this is the one connected to test dapp + await phantom.switchAccount('NewAccount1') + const phantomNewAccount1Address = await phantom.getAccountAddress('ethereum') + + // Two accounts connected + expect(testDappAccountAddress.toLowerCase()).toEqual(phantomNewAccount1Address.toLowerCase()) +}) diff --git a/wallets/phantom/test/playwright/e2e/getAccountAddress.spec.ts b/wallets/phantom/test/playwright/e2e/getAccountAddress.spec.ts new file mode 100644 index 000000000..289e45b89 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/getAccountAddress.spec.ts @@ -0,0 +1,27 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should get account address for all available networks', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + const solanaAccountAddress = await phantom.getAccountAddress('solana') + expect(solanaAccountAddress).toEqual('oeYf6KAJkLYhBuR8CiGc6L4D4Xtfepr85fuDgA9kq96') + + const ethereumAccountAddress = await phantom.getAccountAddress('ethereum') + expect(ethereumAccountAddress).toEqual('0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266') + + const baseAccountAddress = await phantom.getAccountAddress('base') + expect(baseAccountAddress).toEqual('0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266') + + const polygonAccountAddress = await phantom.getAccountAddress('polygon') + expect(polygonAccountAddress).toEqual('0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266') + + const bitcoinAccountAddress = await phantom.getAccountAddress('bitcoin') + expect(bitcoinAccountAddress).toEqual('bc1q4qw42stdzjqs59xvlrlxr8526e3nunw7mp73te') +}) diff --git a/wallets/phantom/test/playwright/e2e/goBackToHomePage.spec.ts b/wallets/phantom/test/playwright/e2e/goBackToHomePage.spec.ts new file mode 100644 index 000000000..5818741e7 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/goBackToHomePage.spec.ts @@ -0,0 +1,22 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should go back to the home page', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await expect(phantomPage.locator(phantom.homePage.selectors.settings.lockWallet)).not.toBeVisible() + + await phantom.openSettings() + + await expect(phantomPage.locator(phantom.homePage.selectors.settings.lockWallet)).toBeVisible() + + await phantom.goBackToHomePage() + + await expect(phantomPage.locator(phantom.homePage.selectors.settings.lockWallet)).not.toBeVisible() +}) diff --git a/wallets/phantom/test/playwright/e2e/importWalletFromPrivateKey.spec.ts b/wallets/phantom/test/playwright/e2e/importWalletFromPrivateKey.spec.ts new file mode 100644 index 000000000..e6cafef2a --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/importWalletFromPrivateKey.spec.ts @@ -0,0 +1,41 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +const privateKey = 'ea084c575a01e2bbefcca3db101eaeab1d8af15554640a510c73692db24d0a6a' + +test('should import a new wallet from private key', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await phantom.importWalletFromPrivateKey('ethereum', privateKey) + + await phantomPage.locator(phantom.homePage.selectors.accountMenu.accountName).hover() + await expect(phantomPage.locator(phantom.homePage.selectors.ethereumWalletAddress)).toContainText('0xa2ce...6801') +}) + +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) + + 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/lock.spec.ts b/wallets/phantom/test/playwright/e2e/lock.spec.ts new file mode 100644 index 000000000..e6b748038 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/lock.spec.ts @@ -0,0 +1,16 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should lock the wallet', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await phantom.lock() + + await expect(phantomPage.locator(phantom.unlockPage.selectors.submitButton)).toBeVisible() +}) diff --git a/wallets/phantom/test/playwright/e2e/openSettings.spec.ts b/wallets/phantom/test/playwright/e2e/openSettings.spec.ts new file mode 100644 index 000000000..f99d2ab53 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/openSettings.spec.ts @@ -0,0 +1,15 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should open settings', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await phantom.openSettings() + + await expect(phantomPage.locator(phantom.homePage.selectors.settings.lockWallet)).toBeVisible() +}) diff --git a/wallets/phantom/test/playwright/e2e/rejectSignature.spec.ts b/wallets/phantom/test/playwright/e2e/rejectSignature.spec.ts new file mode 100644 index 000000000..8abd7f670 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/rejectSignature.spec.ts @@ -0,0 +1,47 @@ +import { connectPhantomToTestDapp } from '../commonSteps/connectPhantomToTestDapp' +import synpress from '../synpress' + +const test = synpress + +const { expect } = test + +test('should reject `personal_sign`', async ({ page, phantom }) => { + connectPhantomToTestDapp(page, phantom) + + await page.locator('#personalSign').click() + + await phantom.rejectSignature() + + await expect(page.locator('#personalSign')).toHaveText('Error: User rejected the request.') + await expect(page.locator('#personalSignResult')).toHaveText('') +}) + +test('should reject `eth_signTypedData`', async ({ page, phantom }) => { + connectPhantomToTestDapp(page, phantom) + + await page.locator('#signTypedData').click() + + await phantom.rejectSignature() + + await expect(page.locator('#signTypedDataResult')).toHaveText('Error: User rejected the request.') +}) + +test('should reject `eth_signTypedData_v3`', async ({ page, phantom }) => { + connectPhantomToTestDapp(page, phantom) + + await page.locator('#signTypedDataV3').click() + + await phantom.rejectSignature() + + await expect(page.locator('#signTypedDataV3Result')).toHaveText('Error: User rejected the request.') +}) + +test('should reject `eth_signTypedData_v4`', async ({ page, phantom }) => { + connectPhantomToTestDapp(page, phantom) + + await page.locator('#signTypedDataV4').click() + + await phantom.rejectSignature() + + await expect(page.locator('#signTypedDataV4Result')).toHaveText('Error: User rejected the request.') +}) diff --git a/wallets/phantom/test/playwright/e2e/rejectTokenPermission.spec.ts b/wallets/phantom/test/playwright/e2e/rejectTokenPermission.spec.ts new file mode 100644 index 000000000..a83b39768 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/rejectTokenPermission.spec.ts @@ -0,0 +1,32 @@ +import synpress from '../synpress' + +const test = synpress + +const { expect } = test + +test('should reject approve token request', async ({ aavePage, phantom }) => { + test.setTimeout(80_000) + + await aavePage.goto( + 'https://app.aave.com/reserve-overview/?underlyingAsset=0xff34b3d4aee8ddcd6f9afffb6fe49bd371b8a357&marketName=proto_sepolia_v3' + ) + + await aavePage.getByRole('button', { name: 'Supply' }).click() + await aavePage.locator('input[aria-label="amount input"]').fill('1') + + const supplyDaiLocator = aavePage.getByRole('button', { + name: 'Supply DAI' + }) + await expect(supplyDaiLocator).toBeDisabled() + + await aavePage.locator('button:has-text("Approve DAI to continue")').click() + + await expect(aavePage.locator('button:has-text("Approve DAI to continue")')).not.toBeVisible() + await expect(aavePage.locator('button:has-text("Approving DAI...")')).toBeVisible() + + await phantom.rejectTokenPermission() + + await expect(aavePage.locator('button:has-text("Approving DAI...")')).not.toBeVisible({ timeout: 10_000 }) + await expect(aavePage.locator('button:has-text("Approve DAI to continue")')).toBeVisible() + await expect(aavePage.getByText('There was some error. Please try changing the parameters or')).toBeVisible() +}) diff --git a/wallets/phantom/test/playwright/e2e/rejectTransaction.spec.ts b/wallets/phantom/test/playwright/e2e/rejectTransaction.spec.ts new file mode 100644 index 000000000..6cb2e61c2 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/rejectTransaction.spec.ts @@ -0,0 +1,31 @@ +import { connectPhantomToTestDapp } from '../commonSteps/connectPhantomToTestDapp' +import synpress from '../synpress' + +const test = synpress + +const { expect } = test + +test('should Reject Transaction ', async ({ solanaSandboxPage, phantom }) => { + await solanaSandboxPage.getByRole('button', { name: 'Sign Transaction' }).click() + await phantom.rejectTransaction() + + await expect(solanaSandboxPage.getByText('User rejected the request.')).toBeVisible() +}) + +test('should Reject All Transactions ', async ({ solanaSandboxPage, phantom }) => { + await solanaSandboxPage.getByRole('button', { name: 'Sign All Transaction' }).click() + await phantom.rejectTransaction() + + await expect(solanaSandboxPage.getByText('User rejected the request.')).toBeVisible() +}) + +test('should reject contract deployment', async ({ page, phantom }) => { + connectPhantomToTestDapp(page, phantom) + + await expect(page.locator('#tokenAddresses')).toBeEmpty() + await page.locator('#createToken').click() + + await phantom.rejectTransaction() + + await expect(page.locator('#tokenAddresses')).toContainText('Creation Failed') +}) diff --git a/wallets/phantom/test/playwright/e2e/renameAccount.spec.ts b/wallets/phantom/test/playwright/e2e/renameAccount.spec.ts new file mode 100644 index 000000000..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/resetApp.spec.ts b/wallets/phantom/test/playwright/e2e/resetApp.spec.ts new file mode 100644 index 000000000..1ae740e22 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/resetApp.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('reset the app', async ({ context, phantomPage }) => { + test.setTimeout(40_000) + + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await expect(phantomPage.locator(phantom.homePage.selectors.accountMenu.accountName)).toHaveText('Account 1') + + await phantom.resetApp() + + await expect(async () => { + const newPhantomPage = context.pages()[1] + + if (newPhantomPage) { + const newPhantomPageUrl = newPhantomPage?.url() + expect(newPhantomPageUrl).toContain('onboarding') + } + }).toPass() +}) diff --git a/wallets/phantom/test/playwright/e2e/switchAccount.spec.ts b/wallets/phantom/test/playwright/e2e/switchAccount.spec.ts new file mode 100644 index 000000000..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/e2e/toggleTestnetMode.spec.ts b/wallets/phantom/test/playwright/e2e/toggleTestnetMode.spec.ts new file mode 100644 index 000000000..3470f7653 --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/toggleTestnetMode.spec.ts @@ -0,0 +1,20 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import Selectors from '../../../src/selectors/pages/HomePage' +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should toggle the "Testnet Mode" option from Developer Settings menu', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await phantom.toggleTestnetMode() + + await phantomPage.locator(Selectors.headerBackButton).click() + await phantom.goBackToHomePage() + + await expect(phantomPage.getByText('You are currently in Testnet Mode')).toBeVisible() +}) diff --git a/wallets/phantom/test/playwright/e2e/unlock.spec.ts b/wallets/phantom/test/playwright/e2e/unlock.spec.ts new file mode 100644 index 000000000..ebafc283d --- /dev/null +++ b/wallets/phantom/test/playwright/e2e/unlock.spec.ts @@ -0,0 +1,18 @@ +import { testWithSynpress } from '@synthetixio/synpress-core' +import { Phantom, phantomFixtures } from '../../../src/playwright' + +import basicSetup from '../wallet-setup/basic.setup' + +const test = testWithSynpress(phantomFixtures(basicSetup)) + +const { expect } = test + +test('should unlock the wallet', async ({ context, phantomPage }) => { + const phantom = new Phantom(context, phantomPage, basicSetup.walletPassword) + + await phantom.lock() + + await phantom.unlock() + + await expect(phantomPage.locator(phantom.homePage.selectors.accountMenu.accountName)).toHaveText('Account 1') +}) 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' + } +})