diff --git a/.circleci/config.yml b/.circleci/config.yml index 3836d5e4048e..29354572a778 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -234,6 +234,9 @@ workflows: - test-e2e-mmi-playwright: requires: - prep-build-test-mmi-playwright + - test-e2e-swap-playwright - OPTIONAL: + requires: + - prep-build - test-e2e-chrome-rpc-mmi: requires: - prep-build-test-mmi diff --git a/playwright.config.ts b/playwright.config.ts index 6632e1983e79..0145cd3dcc49 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -71,6 +71,7 @@ const config: PlaywrightTestConfig = { ...devices['Desktop Chrome'], headless: true, }, + fullyParallel: false, }, // Global: universal, common, shared, and non feature related tests { diff --git a/test/e2e/playwright/shared/pageObjects/network-controller-page.ts b/test/e2e/playwright/shared/pageObjects/network-controller-page.ts index aed0e7377b16..1986de15dd88 100644 --- a/test/e2e/playwright/shared/pageObjects/network-controller-page.ts +++ b/test/e2e/playwright/shared/pageObjects/network-controller-page.ts @@ -1,4 +1,5 @@ import { type Locator, type Page } from '@playwright/test'; +import { Tenderly } from '../../swap/tenderly-network'; export class NetworkController { readonly page: Page; @@ -7,18 +8,12 @@ export class NetworkController { readonly addNetworkButton: Locator; - readonly addNetworkManuallyButton: Locator; - readonly approveBtn: Locator; readonly saveBtn: Locator; - readonly switchToNetworkBtn: Locator; - readonly gotItBtn: Locator; - readonly networkSearch: Locator; - readonly networkName: Locator; readonly networkRpc: Locator; @@ -27,43 +22,72 @@ export class NetworkController { readonly networkTicker: Locator; + readonly dismissBtn: Locator; + + readonly networkList: Locator; + + readonly networkListEdit: Locator; + + readonly rpcName: Locator; + + readonly addRpcDropDown: Locator; + + readonly addRpcURLBtn: Locator; + + readonly addURLBtn: Locator; + constructor(page: Page) { this.page = page; this.networkDisplay = this.page.getByTestId('network-display'); - this.addNetworkButton = this.page.getByText('Add network'); - this.addNetworkManuallyButton = this.page.getByTestId( - 'add-network-manually', + this.networkList = this.page.getByTestId( + 'network-list-item-options-button-0x1', ); + this.networkListEdit = this.page.getByTestId( + 'network-list-item-options-edit', + ); + this.addNetworkButton = this.page.getByText('Add a custom network'); + this.addRpcDropDown = this.page.getByTestId('test-add-rpc-drop-down'); + this.addRpcURLBtn = this.page.getByRole('button', { name: 'Add RPC URL' }); + this.addURLBtn = this.page.getByRole('button', { name: 'Add URL' }); this.saveBtn = this.page.getByRole('button', { name: 'Save' }); this.approveBtn = this.page.getByTestId('confirmation-submit-button'); - this.switchToNetworkBtn = this.page.locator('button', { - hasText: 'Switch to', - }); this.gotItBtn = this.page.getByRole('button', { name: 'Got it' }); - this.networkSearch = this.page.locator('input[type="search"]'); this.networkName = this.page.getByTestId('network-form-network-name'); - this.networkRpc = this.page.getByTestId('network-form-rpc-url'); + this.rpcName = this.page.getByTestId('rpc-name-input-test'); + this.networkRpc = this.page.getByTestId('rpc-url-input-test'); this.networkChainId = this.page.getByTestId('network-form-chain-id'); this.networkTicker = this.page.getByTestId('network-form-ticker-input'); + this.dismissBtn = this.page.getByRole('button', { name: 'Dismiss' }); } async addCustomNetwork(options: { name: string; + rpcName: string; url: string; chainID: string; symbol: string; }) { + let rpcName = options.name; await this.networkDisplay.click(); - await this.addNetworkButton.click(); - await this.addNetworkManuallyButton.click(); - - await this.networkName.waitFor(); - await this.networkName.fill(options.name); + if (options.name === Tenderly.Mainnet.name) { + rpcName = options.rpcName; + await this.networkList.click(); + await this.networkListEdit.click(); + } else { + await this.addNetworkButton.click(); + await this.networkName.fill(rpcName); + } + await this.addRpcDropDown.click(); + await this.addRpcURLBtn.click(); await this.networkRpc.fill(options.url); - await this.networkChainId.fill(options.chainID); + await this.rpcName.fill(rpcName); + await this.addURLBtn.click(); + if (options.name !== Tenderly.Mainnet.name) { + await this.networkChainId.fill(options.chainID); + } await this.networkTicker.fill(options.symbol); - await this.saveBtn.click(); - await this.switchToNetworkBtn.click(); + await this.saveBtn.waitFor({ state: 'visible' }); + await this.saveBtn.click({ timeout: 60000 }); } async addPopularNetwork(options: { networkName: string }) { @@ -72,13 +96,25 @@ export class NetworkController { const addBtn = this.page.getByTestId(`add-network-${options.networkName}`); await addBtn.click(); await this.approveBtn.click(); - await this.switchToNetworkBtn.click(); await this.gotItBtn.click(); } - async selectNetwork(options: { networkName: string }) { - await this.networkDisplay.click(); - await this.networkSearch.fill(options.networkName); - await this.page.getByText(options.networkName).click(); + async selectNetwork(options: { + name: string; + rpcName: string; + url: string; + chainID: string; + symbol: string; + }) { + const currentNetwork = await this.networkDisplay.textContent(); + if (currentNetwork !== options.name) { + await this.networkDisplay.click(); + if (options.name === Tenderly.Mainnet.name) { + await this.page.getByText(options.rpcName).click(); + await this.page.getByText(options.rpcName).click(); + } else { + await this.page.getByTestId(options.name).click(); + } + } } } diff --git a/test/e2e/playwright/shared/pageObjects/signup-page.ts b/test/e2e/playwright/shared/pageObjects/signup-page.ts index e4165fc1ab89..28909f23ba20 100644 --- a/test/e2e/playwright/shared/pageObjects/signup-page.ts +++ b/test/e2e/playwright/shared/pageObjects/signup-page.ts @@ -11,6 +11,10 @@ export class SignUpPage { readonly importWalletBtn: Locator; + readonly createWalletBtn: Locator; + + readonly metametricsBtn: Locator; + readonly confirmSecretBtn: Locator; readonly agreeBtn: Locator; @@ -21,6 +25,8 @@ export class SignUpPage { readonly passwordConfirmTxt: Locator; + readonly createPasswordBtn: Locator; + readonly agreeCheck: Locator; readonly agreeTandCCheck: Locator; @@ -35,30 +41,42 @@ export class SignUpPage { readonly nextBtn: Locator; - readonly enableButton: Locator; + readonly enableBtn: Locator; + + readonly secureWalletBtn: Locator; + + readonly skipBackupBtn: Locator; + + readonly skipSrpBackupBtn: Locator; constructor(page: Page) { this.page = page; this.getStartedBtn = page.locator('button:has-text("Get started")'); + this.createWalletBtn = page.getByTestId('onboarding-create-wallet'); this.importWalletBtn = page.locator( 'button:has-text("Import an existing wallet")', ); this.confirmSecretBtn = page.locator( 'button:has-text("Confirm Secret Recovery Phrase")', ); + this.metametricsBtn = page.getByTestId('metametrics-no-thanks'); this.agreeBtn = page.locator('button:has-text("I agree")'); + this.createPasswordBtn = page.getByTestId('create-password-wallet'); this.noThanksBtn = page.locator('button:has-text("No thanks")'); this.passwordTxt = page.getByTestId('create-password-new'); this.passwordConfirmTxt = page.getByTestId('create-password-confirm'); this.agreeCheck = page.getByTestId('create-new-vault__terms-checkbox'); this.agreeTandCCheck = page.getByTestId('onboarding-terms-checkbox'); this.agreePasswordTermsCheck = page.getByTestId('create-password-terms'); + this.secureWalletBtn = page.getByTestId('secure-wallet-later'); + this.skipBackupBtn = page.getByTestId('skip-srp-backup-popover-checkbox'); + this.skipSrpBackupBtn = page.getByTestId('skip-srp-backup'); this.importBtn = page.getByTestId('create-password-import'); this.doneBtn = page.getByTestId('pin-extension-done'); this.gotItBtn = page.getByTestId('onboarding-complete-done'); this.nextBtn = page.getByTestId('pin-extension-next'); this.agreeBtn = page.locator('button:has-text("I agree")'); - this.enableButton = page.locator('button:has-text("Enable")'); + this.enableBtn = page.locator('button:has-text("Enable")'); } async importWallet() { @@ -81,4 +99,20 @@ export class SignUpPage { await this.nextBtn.click(); await this.doneBtn.click(); } + + async createWallet() { + await this.agreeTandCCheck.click(); + await this.createWalletBtn.click(); + await this.metametricsBtn.click(); + await this.passwordTxt.fill(ACCOUNT_PASSWORD as string); + await this.passwordConfirmTxt.fill(ACCOUNT_PASSWORD as string); + await this.agreePasswordTermsCheck.click(); + await this.createPasswordBtn.click(); + await this.secureWalletBtn.click(); + await this.skipBackupBtn.click(); + await this.skipSrpBackupBtn.click(); + await this.gotItBtn.click(); + await this.nextBtn.click(); + await this.doneBtn.click(); + } } diff --git a/test/e2e/playwright/shared/pageObjects/wallet-page.ts b/test/e2e/playwright/shared/pageObjects/wallet-page.ts index 47a9b667c96c..4e31441451bf 100644 --- a/test/e2e/playwright/shared/pageObjects/wallet-page.ts +++ b/test/e2e/playwright/shared/pageObjects/wallet-page.ts @@ -13,15 +13,31 @@ export class WalletPage { readonly tokenTab: Locator; + readonly accountMenu: Locator; + + readonly addAccountButton: Locator; + + readonly importAccountButton: Locator; + + readonly importAccountConfirmBtn: Locator; + constructor(page: Page) { this.page = page; this.swapButton = this.page.getByTestId('token-overview-button-swap'); this.importTokensButton = this.page.getByText('Import tokens').first(); + this.accountMenu = this.page.getByTestId('account-menu-icon'); + this.importAccountButton = this.page.getByText('Import account'); this.importButton = this.page.getByText('Import ('); this.tokenTab = this.page.getByTestId('account-overview__asset-tab'); + this.addAccountButton = this.page.getByTestId( + 'multichain-account-menu-popover-action-button', + ); this.activityListTab = this.page.getByTestId( 'account-overview__activity-tab', ); + this.importAccountConfirmBtn = this.page.getByTestId( + 'import-account-confirm-button', + ); } async importTokens() { @@ -31,11 +47,21 @@ export class WalletPage { await this.importButton.click(); } + async importAccount(accountPK: string) { + await this.accountMenu.waitFor({ state: 'visible' }); + await this.accountMenu.click(); + await this.addAccountButton.click(); + await this.importAccountButton.click(); + await this.page.fill('#private-key-box', accountPK); + await this.importAccountConfirmBtn.click(); + } + async selectTokenWallet() { await this.tokenTab.click(); } async selectSwapAction() { + await this.swapButton.waitFor({ state: 'visible' }); await this.swapButton.click(); } diff --git a/test/e2e/playwright/swap/pageObjects/swap-page.ts b/test/e2e/playwright/swap/pageObjects/swap-page.ts index 094d92b0d116..c10536d37f05 100644 --- a/test/e2e/playwright/swap/pageObjects/swap-page.ts +++ b/test/e2e/playwright/swap/pageObjects/swap-page.ts @@ -3,6 +3,8 @@ import { type Locator, type Page } from '@playwright/test'; export class SwapPage { private page: Page; + private swapQty: string; + readonly toggleSmartSwap: Locator; readonly updateSettingsButton: Locator; @@ -27,8 +29,11 @@ export class SwapPage { readonly closeButton: Locator; + readonly viewInActivityBtn: Locator; + constructor(page: Page) { this.page = page; + this.swapQty = ''; this.toggleSmartSwap = this.page.locator('text="On"'); this.updateSettingsButton = this.page.getByTestId( 'update-transaction-settings-button', @@ -53,44 +58,80 @@ export class SwapPage { this.swapTokenButton = this.page.locator('button', { hasText: 'Swap' }); this.closeButton = this.page.getByText('Close'); this.backButton = this.page.locator('[title="Cancel"]'); + this.viewInActivityBtn = this.page.getByTestId( + 'page-container-footer-next', + ); } - async fetchQuote(options: { from?: string; to: string; qty: string }) { - // Enter Swap Quantity - await this.tokenQty.fill(options.qty); - + async enterQuote(options: { from?: string; to: string; qty: string }) { // Enter source token - if (options.from) { + const native = await this.page.$(`text=/${options.from}/`); + if (!native && options.from) { this.swapFromDropDown.click(); - await this.tokenSearch.fill(options.from); await this.selectTokenFromList(options.from); } - // Enter destionation token + const balanceString = await this.page + .locator('[class*="balance"]') + .first() + .textContent(); + if (balanceString) { + if (parseFloat(balanceString.split(' ')[1]) <= parseFloat(options.qty)) { + await this.goBack(); + // not enough balance so cancel out + return false; + } + } + + // Enter Swap Quantity + await this.tokenQty.fill(options.qty); + this.swapQty = options.qty; + + // Enter destination token await this.swapToDropDown.click(); - await this.tokenSearch.fill(options.to); await this.selectTokenFromList(options.to); + return true; + } + + async waitForQuote() { + let quoteFound = false; + do { + // Clear Swap Anyway button if present + const swapAnywayButton = await this.page.$('text=/Swap anyway/'); + if (swapAnywayButton) { + await swapAnywayButton.click(); + } + + // No quotes available + const noQuotes = await this.page.$('text=/No quotes available/'); + if (noQuotes) { + await this.goBack(); + break; + } + + if (await this.page.$('text=/New quotes in/')) { + quoteFound = true; + break; + } + await this.page.waitForTimeout(1000); + } while (!quoteFound); + + return quoteFound; } async swap() { await this.waitForCountDown(); - - // Clear Swap Anyway button if present - const swapAnywayButton = await this.page.$('text=/Swap anyway/'); - if (swapAnywayButton) { - await swapAnywayButton.click(); - } await this.swapTokenButton.click(); } - async switchTokens() { + async switchTokenOrder() { // Wait for swap button to appear await this.swapTokenButton.waitFor(); await this.switchTokensButton.click(); await this.waitForCountDown(); } - async gotBack() { + async goBack() { await this.backButton.click(); } @@ -98,9 +139,25 @@ export class SwapPage { await this.page.waitForSelector(`text=/New quotes in 0:${second}/`); } - async waitForTransactionToComplete() { - await this.page.waitForSelector('text=/Transaction complete/'); - await this.closeButton.click(); // Close button + async waitForTransactionToComplete(options: { seconds: number }) { + let countSecond = options.seconds; + let transactionCompleted; + do { + transactionCompleted = await this.page.$('text=/Transaction complete/'); + if (transactionCompleted) { + await this.closeButton.click(); + break; + } + + await this.page.waitForTimeout(1000); + countSecond -= 1; + } while (countSecond); + + if (!transactionCompleted && !countSecond) { + await this.viewInActivityBtn.click(); + return false; + } + return true; } async waitForInsufficentBalance() { @@ -109,20 +166,10 @@ export class SwapPage { } async selectTokenFromList(symbol: string) { - let searchItem; - do { - searchItem = await this.tokenList.first().textContent(); - } while (searchItem !== symbol); - - await this.tokenList.first().click(); - } - - async waitForSearchListToPopulate(symbol: string): Promise { - let searchItem; - do { - searchItem = await this.tokenList.first().textContent(); - } while (searchItem !== symbol); - - return await this.tokenList.first().click(); + await this.tokenSearch.waitFor(); + await this.tokenSearch.fill(symbol); + const regex = new RegExp(`^${symbol}$`, 'u'); + const searchItem = await this.tokenList.filter({ hasText: regex }); + await searchItem.click({ timeout: 5000 }); } } diff --git a/test/e2e/playwright/swap/specs/swap.spec.ts b/test/e2e/playwright/swap/specs/swap.spec.ts index a795f67b2753..73a85620eabf 100644 --- a/test/e2e/playwright/swap/specs/swap.spec.ts +++ b/test/e2e/playwright/swap/specs/swap.spec.ts @@ -1,4 +1,6 @@ +import { ethers } from 'ethers'; import { test } from '@playwright/test'; +import log from 'loglevel'; import { ChromeExtensionPage } from '../../shared/pageObjects/extension-page'; import { SignUpPage } from '../../shared/pageObjects/signup-page'; @@ -6,106 +8,108 @@ import { NetworkController } from '../../shared/pageObjects/network-controller-p import { SwapPage } from '../pageObjects/swap-page'; import { WalletPage } from '../../shared/pageObjects/wallet-page'; import { ActivityListPage } from '../../shared/pageObjects/activity-list-page'; +import { Tenderly, addFundsToAccount } from '../tenderly-network'; let swapPage: SwapPage; let networkController: NetworkController; let walletPage: WalletPage; let activityListPage: ActivityListPage; -const Tenderly = { - Mainnet: { - name: 'Tenderly', - url: 'https://rpc.tenderly.co/fork/cdbcd795-097d-4624-aa16-680374d89a43', - chainID: '1', - symbol: 'ETH', +const testSet = [ + { + quantity: '.5', + source: 'ETH', + type: 'native', + destination: 'DAI', + network: Tenderly.Mainnet, }, -}; + { + quantity: '50', + source: 'DAI', + type: 'unapproved', + destination: 'ETH', + network: Tenderly.Mainnet, + }, + + { + source: 'ETH', + quantity: '.5', + type: 'native', + destination: 'WETH', + network: Tenderly.Mainnet, + }, + { + quantity: '.3', + source: 'WETH', + type: 'wrapped', + destination: 'ETH', + network: Tenderly.Mainnet, + }, + { + quantity: '50', + source: 'DAI', + type: 'ERC20->ERC20', + destination: 'USDC', + network: Tenderly.Mainnet, + }, +]; -test.beforeEach( +test.beforeAll( 'Initialize extension, import wallet and add custom networks', async () => { const extension = new ChromeExtensionPage(); const page = await extension.initExtension(); + page.setDefaultTimeout(15000); + + const wallet = ethers.Wallet.createRandom(); + await addFundsToAccount(Tenderly.Mainnet.url, wallet.address); const signUp = new SignUpPage(page); - await signUp.importWallet(); + await signUp.createWallet(); networkController = new NetworkController(page); swapPage = new SwapPage(page); activityListPage = new ActivityListPage(page); + walletPage = new WalletPage(page); await networkController.addCustomNetwork(Tenderly.Mainnet); - walletPage = new WalletPage(page); - await page.waitForTimeout(2000); + await walletPage.importAccount(wallet.privateKey); }, ); -test('Swap ETH to DAI - Switch to Arbitrum and fetch quote - Switch ETH - WETH', async () => { - await walletPage.importTokens(); - await walletPage.selectSwapAction(); - await swapPage.fetchQuote({ from: 'ETH', to: 'DAI', qty: '.001' }); - await swapPage.swap(); - await swapPage.waitForTransactionToComplete(); - await walletPage.selectActivityList(); - await activityListPage.checkActivityIsConfirmed({ - activity: 'Swap ETH to DAI', - }); - - await networkController.addPopularNetwork({ networkName: 'Arbitrum One' }); - await walletPage.selectSwapAction(); - await swapPage.fetchQuote({ to: 'MATIC', qty: '.001' }); - await swapPage.waitForInsufficentBalance(); - await swapPage.gotBack(); +testSet.forEach((options) => { + test(`should swap ${options.type} token ${options.source} to ${options.destination} on ${options.network.name}'`, async () => { + await walletPage.selectTokenWallet(); + await networkController.selectNetwork(options.network); + await walletPage.selectSwapAction(); + const quoteEntered = await swapPage.enterQuote({ + from: options.source, + to: options.destination, + qty: options.quantity, + }); - await networkController.selectNetwork({ networkName: 'Tenderly' }); - await activityListPage.checkActivityIsConfirmed({ - activity: 'Swap ETH to DAI', - }); - await walletPage.selectTokenWallet(); - await walletPage.importTokens(); - await walletPage.selectSwapAction(); - await swapPage.fetchQuote({ from: 'ETH', to: 'WETH', qty: '.001' }); - await swapPage.swap(); - await swapPage.waitForTransactionToComplete(); - await walletPage.selectActivityList(); - await activityListPage.checkActivityIsConfirmed({ - activity: 'Swap ETH to WETH', - }); -}); - -test('Swap WETH to ETH - Switch to Avalanche and fetch quote - Switch DAI - USDC', async () => { - await walletPage.importTokens(); - await walletPage.selectSwapAction(); - await swapPage.fetchQuote({ from: 'ETH', to: 'WETH', qty: '.001' }); - await swapPage.swap(); - await swapPage.waitForTransactionToComplete(); - await walletPage.selectActivityList(); - await activityListPage.checkActivityIsConfirmed({ - activity: 'Swap ETH to WETH', - }); - - await networkController.addPopularNetwork({ - networkName: 'Avalanche Network C-Chain', - }); - await walletPage.selectSwapAction(); - await swapPage.fetchQuote({ to: 'USDC', qty: '.001' }); - await swapPage.waitForInsufficentBalance(); - - await swapPage.gotBack(); - - await networkController.selectNetwork({ networkName: 'Tenderly' }); - await activityListPage.checkActivityIsConfirmed({ - activity: 'Swap ETH to WETH', - }); - await walletPage.selectTokenWallet(); - await walletPage.importTokens(); - await walletPage.selectSwapAction(); - await swapPage.fetchQuote({ from: 'DAI', to: 'USDC', qty: '.5' }); - await swapPage.switchTokens(); - await swapPage.swap(); - await swapPage.waitForTransactionToComplete(); - await walletPage.selectActivityList(); - await activityListPage.checkActivityIsConfirmed({ - activity: 'Swap USDC to DAI', + if (quoteEntered) { + const quoteFound = await swapPage.waitForQuote(); + if (quoteFound) { + await swapPage.swap(); + const transactionCompleted = + await swapPage.waitForTransactionToComplete({ seconds: 60 }); + if (transactionCompleted) { + await walletPage.selectActivityList(); + await activityListPage.checkActivityIsConfirmed({ + activity: `Swap ${options.source} to ${options.destination}`, + }); + } else { + log.error(`\tERROR: Transaction did not complete. Skipping test`); + test.skip(); + } + } else { + log.error(`\tERROR: No quotes found on. Skipping test`); + test.skip(); + } + } else { + log.error(`\tERROR: Error while entering the quote. Skipping test`); + test.skip(); + } }); }); diff --git a/test/e2e/playwright/swap/tenderly-network.ts b/test/e2e/playwright/swap/tenderly-network.ts new file mode 100644 index 000000000000..996dee47a81a --- /dev/null +++ b/test/e2e/playwright/swap/tenderly-network.ts @@ -0,0 +1,50 @@ +import axios from 'axios'; +import log from 'loglevel'; + +export const Tenderly = { + Mainnet: { + name: 'Ethereum Mainnet', + rpcName: 'Tenderly - Mainnet', + url: 'https://virtual.mainnet.rpc.tenderly.co/03bb8912-7505-4856-839f-52819a26d0cd', + chainID: '1', + symbol: 'ETH', + }, + Optimism: { + name: 'OP Mainnet', + rpcName: '', + url: 'https://virtual.optimism.rpc.tenderly.co/3170a58e-fa67-4ccc-9697-b13aff0f5c1a', + chainID: '10', + symbol: 'ETH', + }, + Polygon: { + name: 'Polygon', + rpcName: '', + url: 'https://virtual.polygon.rpc.tenderly.co/e834a81e-69ba-49e9-a6a5-be5b6eea3cdc', + chainID: '137', + symbol: 'ETH', + }, +}; + +export async function addFundsToAccount( + rpcURL: string, + account: string, + amount: string = '0xDE0B6B3A7640000', // 1 ETH by default +) { + const data = { + jsonrpc: '2.0', + method: 'tenderly_setBalance', + params: [[account], amount], + id: '1234', + }; + const response = await axios.post(rpcURL, data, { + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.data.error) { + log.error( + `\tERROR: RROR: Failed to add funds to Tenderly VirtualTestNet\n${response.data.error}`, + ); + } +}