Files
xverse-web-extension/tests/pages/wallet.ts
Victor Kirov 0bab5f807e feat: Support Keystone hardware wallet [ENG-6127] (#849)
* Hide rare sats warning (#827)

* Hide warning

* Remove unused translation and blog link

* feat: add keystone account import

* feat: adaptor keystone transaction transport

* feat: support keystone sign message

* feat: support keystone btc send

* update: package.json

fix: en.json

* uplift: clean unused code

* fix: package name

* add: sendOrdinal, signPsbt, psbt confirm keystone transport

* fix: import tip, sign psbt error msg

* fix: e2e test account connect wallet button

* fix: sign mfp check, remove keystone auto confirm, sign tip

* fix: selectAccount options

* fix: keystone batchPsbtSigning and isKeystone

* Fixes and improvements

* Revert lock file

* Fix lock file

* Update core and fix types

* Move keystone utils to correct location

* Add keystone functionality to create inscription screen

* Add speed up txn keystone functionality

* Fix error rename

* Fix keystone speed up txn

* Fix some hw wallet logci

* Remove link to shop

* Bump core

* Fix lock file merge

* Bump core and fix outdated dependency

* Bump core

* Fix the Keystone flow header and copy

* Update the props type

* Remove the double spinning button state

* Remove unneeded if

* Move Ledger and Keystone assets to hw folder

* Update sats-connect core and remove keystone todos

* Bump core

* Fix core bump issue

* Fix keystone ordinals send

* Remove keystone page that wasn't in figma

* Fix strange behavior when deleting accounts

* Fix merge issue

* Add the intemediate Connect Hardware Wallet screen

* Fix the e2e tests

* Update copy for the Connect hardware wallet button

---------

Co-authored-by: Tim Man <tim@secretkeylabs.com>
Co-authored-by: Den <36603049+dhriaznov@users.noreply.github.com>
Co-authored-by: Jordan K <65149726+jordankzf@users.noreply.github.com>
Co-authored-by: Ken Liao <ken@secretkeylabs.com>
Co-authored-by: keystoneGithub <eng@keyst.one>
Co-authored-by: Denys Hriaznov <hriaznov.dev@gmail.com>
2025-01-23 11:43:52 +01:00

1235 lines
46 KiB
TypeScript

import { expect, type Locator, type Page } from '@playwright/test';
import Onboarding from './onboarding';
const strongPW = Onboarding.generateSecurePasswordCrypto();
export default class Wallet {
readonly balance: Locator;
readonly allUpperButtons: Locator;
readonly labelAccountName: Locator;
readonly buttonGenerateAccount: Locator;
readonly buttonConnectHardwareWallet: Locator;
readonly inputName: Locator;
readonly buttonAccountOptions: Locator;
readonly accountBalance: Locator;
readonly buttonRenameAccount: Locator;
readonly buttonResetAccountName: Locator;
readonly labelInfoRenameAccount: Locator;
readonly errorMessageRenameAccount: Locator;
readonly manageTokenButton: Locator;
readonly buttonMenu: Locator;
readonly buttonLock: Locator;
readonly buttonConfirm: Locator;
readonly buttonDenyDataCollection: Locator;
readonly buttonNetwork: Locator;
readonly buttonSave: Locator;
readonly buttonMainnet: Locator;
readonly buttonTestnet: Locator;
readonly buttonBack: Locator;
readonly inputStacksURL: Locator;
readonly inputBTCURL: Locator;
readonly inputFallbackBTCURL: Locator;
readonly labelCoinTitle: Locator;
readonly checkboxToken: Locator;
readonly checkboxTokenActive: Locator;
readonly checkboxTokenInactive: Locator;
readonly buttonStacks: Locator;
readonly buttonBRC20: Locator;
readonly buttonRunes: Locator;
readonly headingTokens: Locator;
readonly divTokenRow: Locator;
readonly labelTokenSubtitle: Locator;
readonly labelCoinBalanceCurrency: Locator;
readonly navigationDashboard: Locator;
readonly navigationNFT: Locator;
readonly navigationStacking: Locator;
readonly navigationExplore: Locator;
readonly navigationSettings: Locator;
readonly divAppSlide: Locator;
readonly divAppCard: Locator;
readonly divAppTitle: Locator;
readonly carouselApp: Locator;
readonly buttonDownArrow: Locator;
readonly inputSwapAmount: Locator;
readonly buttonSelectCoin: Locator;
readonly buttonContinue: Locator;
readonly buttonDetails: Locator;
readonly nameToken: Locator;
readonly buttonInsufficientBalance: Locator;
readonly imageToken: Locator;
readonly swapTokenBalance: Locator;
readonly textUSD: Locator;
readonly buttonStartStacking: Locator;
readonly headingStacking: Locator;
readonly containerStackingInfo: Locator;
readonly infoTextStacking: Locator;
readonly buttonUpdatePassword: Locator;
readonly errorMessage: Locator;
readonly headerNewPassword: Locator;
readonly infoUpdatePassword: Locator;
readonly buttonCurrency: Locator;
readonly buttonShowSeedphrase: Locator;
readonly textCurrency: Locator;
readonly iconFlag: Locator;
readonly selectCurrency: Locator;
readonly totalItem: Locator;
readonly tabsCollectiblesItems: Locator;
readonly containersCollectibleItem: Locator;
readonly containerRareSats: Locator;
readonly containerInscription: Locator;
readonly nameInscription: Locator;
readonly amountInscription: Locator;
readonly buttonNext: Locator;
readonly inputMemo: Locator;
readonly inputRecipientAddress: Locator;
readonly inputSendAmount: Locator;
readonly errorMessageAddressInvalid: Locator;
readonly errorInsufficientBalance: Locator;
readonly errorMessageAddressRequired: Locator;
readonly containerFeeRate: Locator;
readonly inputBTCAddress: Locator;
readonly coinBalance: Locator;
readonly transactionHistoryAmount: Locator;
readonly containerTransactionHistory: Locator;
readonly errorMessageSendSelf: Locator;
readonly infoMessageSendSelf: Locator;
readonly inputBTCAmount: Locator;
readonly buttonExpand: Locator;
readonly confirmCurrencyAmount: Locator;
readonly confirmTotalAmount: Locator;
readonly confirmAmount: Locator;
readonly sendAddress: Locator;
readonly receiveAddress: Locator;
readonly confirmBalance: Locator;
readonly buttonCancel: Locator;
readonly labelCoinBalanceCrypto: Locator;
readonly labelBalanceAmountSelector: Locator;
readonly buttonClose: Locator;
readonly sendTransactionID: Locator;
readonly errorInsufficientFunds: Locator;
readonly noFundsBTCMessage: Locator;
readonly coinSecondaryButton: Locator;
readonly coinSecondaryContainer: Locator;
readonly coinContractAddress: Locator;
readonly textCoinTitle: Locator;
readonly sendSTXValue: Locator;
readonly buttonList: Locator;
readonly tabAvailable: Locator;
readonly tabListed: Locator;
readonly tabNotListed: Locator;
readonly buttonSetPrice: Locator;
readonly runeSKIBIDI: Locator;
readonly runeItem: Locator;
readonly runeItemCheckbox: Locator;
readonly buttonFloorPrice: Locator;
readonly button5Price: Locator;
readonly button10Price: Locator;
readonly button20Price: Locator;
readonly buttonCustomPrice: Locator;
readonly buttonApply: Locator;
readonly inputListingPrice: Locator;
readonly runeContainer: Locator;
readonly runeTitle: Locator;
readonly runePrice: Locator;
readonly buttonEnable: Locator;
readonly buttonSend: Locator;
readonly labelSatsValue: Locator;
readonly labelOwnedBy: Locator;
readonly labelBundle: Locator;
readonly buttonSupportRarity: Locator;
readonly itemCollection: Locator;
readonly backToGallery: Locator;
readonly buttonShare: Locator;
readonly buttonOpenOrdinalViewer: Locator;
readonly numberInscription: Locator;
readonly numberOrdinal: Locator;
readonly containersCollectibleItemCollection: Locator;
readonly containersCollectibleItemSingle: Locator;
readonly nameInscriptionCollection: Locator;
readonly nameInscriptionSingle: Locator;
readonly sendAmount: Locator;
readonly sendCurrencyAmount: Locator;
readonly listedRune: Locator;
readonly signingAddress: Locator;
readonly buttonSign: Locator;
readonly buttonReload: Locator;
readonly listedRunePrice: Locator;
readonly buttonGetQuotes: Locator;
readonly buttonSwap: Locator;
readonly inputField: Locator;
readonly buttonEditFee: Locator;
readonly feeAmount: Locator;
readonly transactionHistoryInfo: Locator;
readonly buttonReceive: Locator;
readonly sendRuneAmount: Locator;
readonly buttonInsufficientFunds: Locator;
readonly nameSwapPlace: Locator;
readonly quoteAmount: Locator;
readonly infoMessage: Locator;
readonly buttonSwapPlace: Locator;
readonly buttonSlippage: Locator;
readonly buttonSwapToken: Locator;
readonly minReceivedAmount: Locator;
readonly nameRune: Locator;
readonly buttonSelectFee: Locator;
readonly labelTotalFee: Locator;
readonly itemUTXO: Locator;
readonly titleUTXO: Locator;
readonly buttonQRAddress: Locator;
readonly labelAddress: Locator;
readonly containerQRCode: Locator;
readonly labelFeePriority: Locator;
readonly divAddress: Locator;
readonly buttonPreferences: Locator;
readonly buttonSecurity: Locator;
readonly buttonAdvanced: Locator;
readonly errorInsufficientBRC20Balance: Locator;
readonly BRC20FeeAmount: Locator;
readonly buttonTransactionSend: Locator;
constructor(readonly page: Page) {
this.page = page;
this.navigationDashboard = page.getByTestId('nav-dashboard');
this.navigationNFT = page.getByTestId('nav-nft');
this.navigationStacking = page.getByTestId('nav-stacking');
this.navigationExplore = page.getByTestId('nav-explore');
this.navigationSettings = page.getByTestId('nav-settings');
// this.balance = page.getByTestId('total-balance-value');
this.balance = page.getByLabel(/^Total balance/);
this.textCurrency = page.getByTestId('currency-text');
this.allUpperButtons = page.getByTestId('transaction-buttons-row').getByRole('button');
this.buttonTransactionSend = this.allUpperButtons.nth(0);
this.manageTokenButton = page.getByRole('button', { name: 'Manage token list' });
this.buttonMenu = page.getByRole('button', { name: 'Open Header Options' });
this.buttonLock = page.getByRole('button', { name: 'Lock' });
this.buttonConfirm = page.getByRole('button', { name: 'Confirm' });
this.buttonDenyDataCollection = page.getByRole('button', { name: 'Deny' });
this.labelBalanceAmountSelector = page.getByTestId('balance-label');
this.buttonClose = page.getByRole('button', { name: 'Close' });
this.buttonEditFee = page.getByTestId('fee-button');
this.feeAmount = page.getByTestId('fee-amount');
this.BRC20FeeAmount = page.getByTestId('brc20-fee');
this.buttonSelectFee = page.getByTestId('fee-select-button');
this.labelTotalFee = page.getByTestId('total-fee');
this.labelFeePriority = page.getByTestId('fee-priority');
// Account
this.labelAccountName = page.getByLabel('Account Name');
this.buttonAccountOptions = page.getByLabel('Open Account Options');
this.accountBalance = page.getByTestId('account-balance');
this.buttonRenameAccount = page.getByRole('button', { name: 'Rename account' });
this.buttonResetAccountName = page.getByRole('button', { name: 'Reset name' });
this.labelInfoRenameAccount = page
.locator('form div')
.filter({ hasText: 'name can only include alphabetical and numerical' });
this.buttonGenerateAccount = page.getByRole('button', { name: 'Generate account' });
this.buttonConnectHardwareWallet = page.getByRole('button', {
name: 'Add hardware wallet account',
});
this.inputName = page.locator('input[type="text"]');
this.errorMessageRenameAccount = page
.locator('p')
.filter({ hasText: 'contain alphabetic and numeric' });
// Settings network
this.buttonNetwork = page.getByRole('button', { name: 'Network' });
this.buttonSave = page.getByRole('button', { name: 'Save' });
this.buttonMainnet = page.getByRole('button', { name: 'Mainnet' });
this.buttonTestnet = page.getByRole('button', { name: 'Testnet' });
this.buttonPreferences = page.getByRole('button', { name: 'Preferences' });
this.buttonSecurity = page.getByRole('button', { name: 'Security' });
this.buttonAdvanced = page.getByRole('button', { name: 'Advanced' });
this.buttonBack = page.getByTestId('back-button');
this.buttonNext = page.getByRole('button', { name: 'Next' });
this.inputStacksURL = page.getByTestId('Stacks URL');
this.inputBTCURL = page.getByTestId('BTC URL');
this.inputFallbackBTCURL = page.getByTestId('Fallback BTC URL');
this.buttonUpdatePassword = page.getByRole('button', { name: 'Update Password' });
this.errorMessage = page.getByText(/incorrect password/i);
this.headerNewPassword = page.getByRole('heading', { name: 'Enter your new password' });
this.infoUpdatePassword = page.getByText(/password successfully updated/i);
this.buttonCurrency = page.getByRole('button', { name: 'Fiat Currency' });
this.buttonShowSeedphrase = page.getByRole('button', { name: 'Show Seedphrase' });
this.selectCurrency = page.getByTestId('currency-button');
this.iconFlag = page.locator('img[alt="flag"]');
// Token
this.labelCoinTitle = page.getByLabel('Coin Title');
this.checkboxToken = page.locator('label[role="checkbox"]');
this.checkboxTokenActive = page.locator('label[role="checkbox"][aria-checked="true"]');
this.checkboxTokenInactive = page.locator('label[role="checkbox"][aria-checked="false"]');
this.buttonStacks = page.getByRole('button', { name: 'STACKS' });
this.buttonBRC20 = page.getByRole('button', { name: 'BRC-20' });
this.buttonRunes = page.getByRole('button', { name: 'RUNES' });
this.headingTokens = page.getByRole('heading', { name: 'Manage tokens' });
this.divTokenRow = page.getByLabel('Token Row');
this.labelTokenSubtitle = page.getByLabel('Token SubTitle');
this.labelCoinBalanceCurrency = page.getByLabel('Currency Balance Container').locator('span');
this.labelCoinBalanceCrypto = page.getByLabel('CoinBalance Container').locator('p');
// Coin details
this.coinBalance = page.getByTestId('coin-balance');
this.containerTransactionHistory = page.getByTestId('transaction-container');
this.transactionHistoryAmount = page.getByTestId('transaction-amount');
this.transactionHistoryInfo = page.getByTestId('transaction-info');
this.coinSecondaryButton = page.getByTestId('coin-secondary-button');
this.coinSecondaryContainer = page.getByTestId('coin-secondary-container');
this.coinContractAddress = page.getByTestId('coin-contract-address');
this.textCoinTitle = page.getByTestId('coin-title-text');
// Collectibles
this.totalItem = page.getByTestId('total-items');
this.tabsCollectiblesItems = page.getByTestId('tab-list').locator('button');
this.containerRareSats = page.getByTestId('rareSats-container');
this.nameInscription = page.getByTestId('inscription-name');
this.containersCollectibleItem = page.getByTestId('collection-container');
this.amountInscription = page.getByTestId('inscription-amount');
this.containersCollectibleItemCollection = this.containersCollectibleItem.filter({
has: this.amountInscription.filter({
hasText: /\d+\s+item(s)?/i,
}),
});
this.containersCollectibleItemSingle = this.containersCollectibleItem.filter({
has: this.amountInscription.filter({
hasText: /^$/,
}),
});
this.nameInscriptionCollection =
this.containersCollectibleItemCollection.getByTestId('inscription-name');
this.nameInscriptionSingle =
this.containersCollectibleItemSingle.getByTestId('inscription-name');
this.buttonEnable = page.getByRole('button', { name: 'Enable' });
this.containerInscription = page.getByTestId('inscription-container');
this.backToGallery = page.getByTestId('back-to-gallery');
this.itemCollection = page.getByTestId('collection-item');
this.buttonSend = page.locator('button').filter({ hasText: 'Send' });
this.buttonShare = page.locator('button').filter({ hasText: 'Share' });
this.buttonReceive = page.getByRole('button', { name: /^Receive/i });
this.buttonOpenOrdinalViewer = page.getByRole('button', { name: 'Open in Ordinal Viewer' });
this.labelBundle = page.locator('h1').filter({ hasText: 'Bundle' });
this.labelSatsValue = page.locator('h1').filter({ hasText: 'Sats value' });
this.labelOwnedBy = page.locator('h1').filter({ hasText: 'Owned by' });
this.buttonSupportRarity = page.getByRole('button', { name: 'See supported rarity scale' });
this.numberInscription = page.getByTestId('inscription-number');
this.numberOrdinal = page.getByTestId('ordinal-number');
// Explore
this.carouselApp = page.getByTestId('app-carousel');
this.divAppSlide = page.getByTestId('app-slide');
this.divAppCard = page.getByTestId('app-card');
this.divAppTitle = page.getByTestId('app-title');
// Receive
this.buttonQRAddress = page.getByTestId('qr-button');
this.labelAddress = page.getByTestId('address-label');
this.divAddress = page.getByTestId('address-div');
this.containerQRCode = page.getByTestId('qr-container');
// Swap
this.imageToken = page.getByTestId('token-image');
this.buttonSelectCoin = page.getByTestId('select-coin-button');
this.inputSwapAmount = page.getByTestId('swap-amount');
this.nameToken = page.getByTestId('token-name');
this.buttonDownArrow = page.getByTestId('down-arrow-button');
this.buttonContinue = page.getByRole('button', { name: 'Continue' });
this.buttonGetQuotes = page.getByRole('button', { name: 'Get quotes' });
this.buttonSwap = page.getByRole('button', { name: 'Swap', exact: true });
this.nameSwapPlace = page.getByTestId('place-name');
this.quoteAmount = page.getByTestId('quote-label');
this.infoMessage = page.getByTestId('info-message');
this.buttonSwapPlace = page.getByTestId('swap-place-button');
this.buttonSwapToken = page.getByTestId('swap-token-button');
this.buttonSlippage = page.getByTestId('slippage-button');
this.minReceivedAmount = page.getByTestId('min-received-amount');
this.nameRune = page.getByTestId('rune-name');
this.itemUTXO = page.getByTestId('utxo-item');
this.titleUTXO = page.getByTestId('utxo-title');
this.buttonDetails = page.getByRole('button', { name: 'Details' });
this.buttonInsufficientBalance = page.getByRole('button', { name: 'Insufficient balance' });
this.buttonInsufficientFunds = page.getByRole('button', { name: 'Insufficient funds' });
this.swapTokenBalance = page.getByTestId('swap-token-balance');
this.textUSD = page.getByTestId('usd-text');
this.noFundsBTCMessage = page.getByTestId('no-funds-message');
// Send
this.inputSendAmount = page.getByTestId('send-input');
this.inputRecipientAddress = page.getByTestId('recipient-address');
this.inputMemo = page.getByTestId('memo-input');
this.errorMessageAddressInvalid = page
.locator('p')
.filter({ hasText: 'Recipient address invalid' });
this.errorMessageAddressRequired = page
.locator('p')
.filter({ hasText: 'Recipient address is required' });
this.infoMessageSendSelf = page
.locator('p')
.filter({ hasText: 'You are transferring to yourself' });
this.errorMessageSendSelf = page.locator('p').filter({ hasText: 'Cannot send to self' });
this.errorInsufficientBalance = page.locator('p').filter({ hasText: 'Insufficient balance' });
this.errorInsufficientBRC20Balance = page.getByText('Insufficient BRC-20 balance');
this.errorInsufficientFunds = page.locator('p').filter({ hasText: 'Insufficient funds' });
this.containerFeeRate = page.getByTestId('feerate-container');
this.inputBTCAddress = page.locator('input[type="text"]');
this.inputBTCAmount = page.getByTestId('btc-amount');
this.buttonExpand = page.getByRole('button', { name: 'Inputs & Outputs' });
this.confirmTotalAmount = page.getByTestId('confirm-total-amount');
this.confirmCurrencyAmount = page.getByTestId('confirm-currency-amount');
this.confirmAmount = page.getByTestId('confirm-amount');
this.sendAddress = page.getByTestId('address-send');
this.receiveAddress = page.getByTestId('address-receive');
this.confirmBalance = page.getByTestId('confirm-balance');
this.buttonCancel = page.getByRole('button', { name: 'Cancel' });
this.buttonSign = page.getByRole('button', { name: 'Sign' });
this.sendTransactionID = page.getByTestId('transaction-id');
this.sendSTXValue = page.getByTestId('send-value');
this.inputField = page.locator('input[type="text"]');
this.sendRuneAmount = page.getByTestId('send-rune-amount');
// List
this.buttonList = page.getByRole('button', { name: 'List List' });
this.tabAvailable = page.getByTestId('available-tab');
this.tabListed = page.getByRole('button', { name: 'LISTED', exact: true });
this.tabNotListed = page.getByRole('button', { name: 'NOT LISTED' });
this.listedRune = page.getByTestId('listed-rune-container');
this.buttonSetPrice = page.getByRole('button', { name: 'Set price' });
// this is the test rune to be used for listing
this.runeSKIBIDI = page.getByTestId('SKIBIDI•OHIO•RIZZ');
this.runeItem = page.getByTestId('rune-item');
this.runeItemCheckbox = page.locator('#list-rune');
this.buttonFloorPrice = page.getByRole('button', { name: 'Floor', exact: true });
this.button5Price = page.getByRole('button', { name: '+5%', exact: true });
this.button10Price = page.getByRole('button', { name: '+10%', exact: true });
this.button20Price = page.getByRole('button', { name: '+20%', exact: true });
this.buttonCustomPrice = page.getByRole('button', { name: 'Custom', exact: true });
this.buttonApply = page.getByRole('button', { name: 'Apply', exact: true });
this.inputListingPrice = page.locator('input[type="number"]');
this.runeContainer = page.getByTestId('rune-container');
this.runeTitle = page.getByTestId('rune-title');
this.runePrice = page.getByTestId('rune-price').locator('p').filter({ hasText: 'sats' });
this.sendAmount = page.getByTestId('send-amount');
this.sendCurrencyAmount = page.getByTestId('send-currency-amount');
this.signingAddress = page.getByTestId('signing-address');
this.buttonReload = page.getByTestId('reload-button');
this.listedRunePrice = page.getByTestId('listed-price');
// Stacking
this.buttonStartStacking = page.getByRole('button', { name: 'Start stacking' });
this.headingStacking = page.getByRole('heading', { name: 'Stack STX, earn BTC' });
this.containerStackingInfo = page.getByTestId('stacking-info');
this.infoTextStacking = page.locator('h1').filter({ hasText: 'STX with other stackers' });
}
// Helper function to restore the wallet, switch to testnet if parameter is true and navigate to dashboard
async setupTest(extensionId, wallet, testnet) {
const onboardingPage = new Onboarding(this.page);
await onboardingPage.restoreWallet(strongPW, wallet);
await this.page.goto(`chrome-extension://${extensionId}/popup.html`);
await this.checkVisualsStartpage();
if (testnet) {
await this.navigationSettings.click();
await this.switchToTestnetNetwork();
await this.navigationDashboard.click();
await this.checkVisualsStartpage();
}
}
async checkVisualsStartpage() {
// to-do fix the element itself, after the native-segwit update it resolves to 2 elements
// data-testid="total-balance-value"
await expect(this.balance.first()).toBeVisible();
await expect(this.manageTokenButton).toBeVisible();
// Deny data collection --> modal window is not always appearing so when it does we deny the data collection
if (await this.buttonDenyDataCollection.isVisible()) {
await this.buttonDenyDataCollection.click();
}
await expect(this.labelAccountName).toBeVisible();
await expect(this.buttonMenu).toBeVisible();
// expect(await this.labelTokenSubtitle.count()).toBeGreaterThanOrEqual(2);
await expect(this.navigationDashboard).toBeVisible();
await expect(this.navigationNFT).toBeVisible();
await expect(this.navigationStacking).toBeVisible();
await expect(this.navigationExplore).toBeVisible();
await expect(this.navigationSettings).toBeVisible();
expect(await this.divTokenRow.count()).toBeGreaterThan(1);
}
async checkVisualsSendSTXPage3() {
expect(this.page.url()).toContain('confirm-stx-tx');
await expect(this.buttonConfirm).toBeVisible();
await expect(this.buttonCancel).toBeVisible();
await expect(this.receiveAddress).toBeVisible();
await expect(this.confirmAmount).toBeVisible();
await expect(this.buttonEditFee).toBeVisible();
await expect(this.feeAmount).toBeVisible();
await expect(this.buttonBack).toBeVisible();
}
/**
* Checks the visibility and state of UI elements state on first page in Send Flow
*
* @param {string} url - The expected URL to validate the correct page navigation.
* @param {boolean} moreInputFields - (default: false).
*/
async checkVisualsSendPage1(url: string, moreInputFields: boolean = false) {
expect(this.page.url()).toContain(url);
await expect(this.buttonNext).toBeVisible();
await expect(this.buttonNext).toBeDisabled();
if (moreInputFields) {
// Recipient Address for STX or amount for BRC20
await expect(this.inputField.first()).toBeVisible();
// Memo for STX or recipient for BRC20
await expect(this.inputField.last()).toBeVisible();
} else {
await expect(this.receiveAddress).toBeVisible();
}
await expect(this.imageToken).toBeVisible();
// await expect(this.buttonBack).toBeVisible();
}
/**
* Validates UI element visibility and state on second page in Send Flow
*
* @param {string} url - URL to verify page navigation; ensures the test is on the correct page.
* @param {boolean} isSTX - Indicates if the page is STX-specific; adjusts element checks accordingly (default: false).
*/
async checkVisualsSendPage2(url: string, isSTX: boolean = false) {
expect(this.page.url()).toContain(url);
await expect(this.buttonNext).toBeVisible();
await expect(this.buttonNext).toBeDisabled();
// Conditional check based on the type of token
if (isSTX) {
// STX-specific fields
await expect(this.inputField.first()).toBeVisible();
} else {
// General send amount field
await expect(this.inputSendAmount).toBeVisible();
}
await expect(this.labelBalanceAmountSelector).toBeVisible();
await expect(this.buttonEditFee).toBeVisible();
await expect(this.feeAmount).toBeVisible();
await expect(this.imageToken).toBeVisible();
// await expect(this.buttonBack).toBeVisible();
}
/**
* Checks the visuals and elements on the send transaction review page.
*
* @param {string} url - The expected partial URL of the review page.
* @param {boolean} editableFees - Optional. Indicates whether the fees can be edited on the Review page
* @param {string} sendAddress - Optional. The expected last 4 characters of the sender's address
* @param {string} recipientAddress - Optional. The expected last 4 characters of the receiver's address
* @param {boolean} totalAmountShown - Optional. Indicates whether the total amount is shown. Default is true.
* @param {boolean} tokenImageShown - Optional. Indicates whether the token image is shown. Default is true.
* @param {string} ordinalNumber - Optional. The expected ordinal number to be displayed for single Inscriptions
*/
async checkVisualsSendTransactionReview(
url: string,
editableFees?: boolean,
sendAddress?: string,
recipientAddress?: string,
totalAmountShown: boolean = true,
tokenImageShown: boolean = true,
ordinalNumber?: string,
) {
expect(this.page.url()).toContain(url);
await expect(this.buttonExpand).toBeVisible();
await expect(this.buttonCancel).toBeEnabled();
await expect(this.buttonConfirm).toBeEnabled();
await expect(this.feeAmount).toBeVisible();
// Not all TX Screens show a total amount
if (totalAmountShown) {
await expect(this.confirmTotalAmount.first()).toBeVisible();
await expect(this.confirmCurrencyAmount.first()).toBeVisible();
}
if (tokenImageShown) {
await expect(this.imageToken.first()).toBeVisible();
}
if (editableFees) {
await expect(this.buttonEditFee).toBeVisible();
}
await this.buttonExpand.click();
await expect(this.confirmAmount.first()).toBeVisible();
await expect(this.confirmBalance.first()).toBeVisible();
// Execute these checks only if sendAddress is provided
if (sendAddress) {
await expect(this.sendAddress.first()).toBeVisible();
expect(await this.sendAddress.first().innerText()).toContain(sendAddress.slice(-4));
}
// Execute these checks only if recipientAddress is provided
if (recipientAddress) {
await expect(this.receiveAddress.first()).toBeVisible();
expect(await this.receiveAddress.first().innerText()).toContain(recipientAddress.slice(-4));
}
// Collection Inscriptions don't have the ordinal number displayed in the Review
// Check if the right ordinal number is shown
if (ordinalNumber) {
const reviewNumberOrdinal = await this.numberInscription.first().innerText();
expect(ordinalNumber).toMatch(reviewNumberOrdinal);
}
}
// Check Visuals of Rune Dashboard (without List button), return balance amount
async checkVisualsRunesDashboard(runeName: string) {
await expect(this.imageToken.first()).toBeVisible();
await expect(this.textCoinTitle).toBeVisible();
await expect(this.textCoinTitle).toContainText(runeName);
await expect(this.coinBalance).toBeVisible();
await expect(this.buttonReceive).toBeVisible();
await expect(this.buttonSend).toBeVisible();
const originalBalanceText = await this.coinBalance.innerText();
const numericOriginalValue = parseFloat(originalBalanceText.replace(/[^\d.-]/g, ''));
return numericOriginalValue;
}
async checkVisualsListRunesPage() {
await expect(this.tabNotListed).toBeVisible();
await expect(this.tabListed).toBeVisible();
await expect(this.runeItem.first()).toBeVisible();
expect(await this.runeItem.count()).toBeGreaterThanOrEqual(1);
}
async checkVisualsSwapPage() {
expect(this.page.url()).toContain('swap');
await expect(this.buttonDownArrow.first()).toBeVisible();
await expect(this.buttonGetQuotes.first()).toBeVisible();
await expect(this.buttonGetQuotes.first()).toBeDisabled();
await expect(this.inputSwapAmount.first()).toBeVisible();
await expect(this.swapTokenBalance).toContainText('--');
await expect(this.buttonBack).toBeVisible();
await expect(this.nameToken.first()).toContainText('Select asset');
await expect(this.nameToken).toHaveCount(2);
await expect(this.buttonDownArrow).toHaveCount(2);
await expect(this.buttonGetQuotes).toBeVisible();
await expect(this.textUSD).toBeVisible();
await expect(this.buttonSwapToken).toBeVisible();
}
// Helper function to fill in swap amount and returns usd value as number
async fillSwapAmount(amount) {
// .Fill() did not work with the field so we need to use this method
await this.inputSwapAmount.pressSequentially(amount.toString());
await expect(this.buttonGetQuotes).toBeEnabled();
const usdAmount = await this.textUSD.innerText();
const numericUSDValue = parseFloat(usdAmount.replace(/[^0-9.]/g, ''));
expect(numericUSDValue).toBeGreaterThan(0);
return numericUSDValue;
}
// had to disable this rule as my first assertion was always changed to a wrong assertion
/* eslint-disable playwright/prefer-web-first-assertions */
async checkVisualsQuotePage(
tokenName: string,
slippage: boolean,
numericQuoteValue: number,
numericUSDValue: number,
) {
await expect(this.buttonSwap).toBeVisible();
await expect(this.buttonEditFee).toBeVisible();
if (slippage) {
await expect(this.buttonSlippage).toBeVisible();
}
// Only 2 token should be visible
expect(await this.buttonSwapPlace.count()).toBe(2);
// await expect(await this.imageToken.count()).toBe(2);
// Check Rune token name
await expect(this.infoMessage.last()).toContainText(tokenName);
// Check if USD amount from quote page is the same as from th swap start flow page
const usdAmountQuote = await this.textUSD.first().innerText();
const numericUSDQuote = parseFloat(usdAmountQuote.replace(/[^0-9.]/g, ''));
expect(numericUSDQuote).toEqual(numericUSDValue);
}
// check visuals of List on ME page
async checkVisualsListOnMEPage() {
await expect(this.buttonFloorPrice).toBeVisible();
await expect(this.button5Price).toBeVisible();
await expect(this.button10Price).toBeVisible();
await expect(this.button20Price).toBeVisible();
await expect(this.buttonCustomPrice).toBeVisible();
await expect(this.buttonContinue).toBeVisible();
await expect(this.buttonContinue).toBeDisabled();
await expect(this.runeContainer.first()).toBeVisible();
}
async invalidAddressCheck(addressField) {
await addressField.fill(`Test Address 123`);
await this.buttonNext.click();
await expect(this.errorMessageAddressInvalid).toBeVisible();
await expect(this.buttonNext).toBeDisabled();
}
// had to disable this rule as my first assertion was always changed to a wrong assertion
/* eslint-disable playwright/prefer-web-first-assertions */
async switchToHighFees(feePriorityShown: boolean = true) {
// Save the current fee amount for comparison
const originalFee = await this.feeAmount.innerText();
const numericOriginalFee = parseFloat(originalFee.replace(/[^0-9.]/g, ''));
expect(numericOriginalFee).toBeGreaterThan(0);
let feePriority = 'Medium';
if (feePriorityShown) {
feePriority = await this.labelFeePriority.innerText();
}
// Click on edit Fee button
await this.buttonEditFee.click();
await expect(this.buttonSelectFee.first()).toBeVisible();
await expect(this.labelTotalFee.first()).toBeVisible();
// Compare fee to previous saved fee
const fee = await this.buttonSelectFee
.filter({ hasText: feePriority })
.locator(this.labelTotalFee)
.innerText();
const numericFee = parseFloat(fee.replace(/[^0-9.]/g, ''));
expect(numericFee).toBe(numericOriginalFee);
// Save high fee rate for comparison
const highFee = await this.labelTotalFee.first().innerText();
const numericHighFee = parseFloat(highFee.replace(/[^0-9.]/g, ''));
// Switch to high fee
await this.buttonSelectFee.first().click();
const newFee = await this.feeAmount.innerText();
const numericNewFee = parseFloat(newFee.replace(/[^0-9.]/g, ''));
expect(numericNewFee).toBe(numericHighFee);
}
async navigateToCollectibles() {
await this.navigationNFT.click();
expect(this.page.url()).toContain('nft-dashboard');
// If 'enable' rare sats pop up is appearing
if (await this.buttonEnable.isVisible()) {
await this.buttonEnable.click();
}
// Check visuals on opening Collectibles page
await expect(this.tabsCollectiblesItems.first()).toBeVisible();
await expect(this.totalItem).toBeVisible();
}
// had to disable this rule as my first assertion was always changed to a wrong assertion
async checkAmountsSendingSTX(amountSTXSend, STXTest, sendFee) {
expect(await this.receiveAddress.first().innerText()).toContain(STXTest.slice(-4));
// Sending amount without Fee
const sendAmount = await this.confirmAmount.first().innerText();
const numericValueSendAmount = parseFloat(sendAmount.replace(/[^0-9.]/g, ''));
expect(numericValueSendAmount).toEqual(amountSTXSend);
// Fees
const fee = await this.feeAmount.innerText();
const numericValueFee = parseFloat(fee.replace(/[^0-9.]/g, ''));
expect(numericValueFee).toEqual(sendFee);
}
async checkAmountsSendingBTC(selfBTCTest, BTCTest, amountBTCSend) {
// Sending amount without Fee
const amountText = await this.confirmAmount.first().innerText();
const numericValueAmountText = parseFloat(amountText.replace(/[^0-9.]/g, ''));
expect(numericValueAmountText).toEqual(amountBTCSend);
// Address check sending and receiving
expect(await this.sendAddress.innerText()).toContain(selfBTCTest.slice(-4));
expect(await this.receiveAddress.first().innerText()).toContain(BTCTest.slice(-4));
const confirmAmountAfter = await this.confirmAmount.last().innerText();
const originalFee = await this.feeAmount.innerText();
const confirmBalance = await this.confirmBalance.innerText();
// Extract amounts for balance, sending amount and amount afterwards
const num1 = parseFloat(confirmAmountAfter.replace(/[^0-9.]/g, ''));
// We need to convert the sats value to BTC for this calculation
const feeSatsAmount = parseFloat(originalFee.replace(/[^0-9.]/g, ''));
const num2 = feeSatsAmount / 100000000;
const num3 = parseFloat(confirmBalance.replace(/[^0-9.]/g, ''));
// Balance - fees - sending amount
const roundedResult = Number((num3 - num2 - amountBTCSend).toFixed(9));
// Check if Balance value after the transaction is the same as the calculated value
expect(num1).toEqual(roundedResult);
}
async confirmSendTransaction(transactionIDShown: boolean = true) {
await expect(this.buttonConfirm).toBeEnabled();
await this.buttonConfirm.click();
await expect(this.buttonClose).toBeVisible({ timeout: 30000 });
if (transactionIDShown) {
await expect(this.sendTransactionID).toBeVisible();
}
await this.buttonClose.click();
}
async getAddress(whichAddress: string): Promise<string> {
// click on 'Receive' button
await this.allUpperButtons.nth(1).click();
// Locate the QR button to the address
const button = this.divAddress.filter({ hasText: whichAddress }).locator(this.buttonQRAddress);
// Need to click on the QR Code button to get the full Address
await button.click();
await expect(this.containerQRCode).toBeVisible();
const address = await this.labelAddress.innerText();
await this.buttonBack.click();
return address;
}
async getTokenBalance(tokenname: string) {
try {
const locator = this.page
.locator('button')
.filter({ hasText: new RegExp(`${tokenname}.*\\d`) });
console.log(`Looking for balance for ${tokenname}`);
const balanceText = await locator.innerText();
console.log('Found balance text:', balanceText);
// Extract just the numeric portion (matches any number with decimal points)
const matches = balanceText.match(/\d+\.?\d*/);
if (!matches) {
throw new Error(`Could not extract balance from text: ${balanceText}`);
}
const numericValue = parseFloat(matches[0]);
console.log('Parsed numeric value:', numericValue);
return numericValue;
} catch (error) {
console.error(`Error getting balance for ${tokenname}:`, error);
throw error;
}
}
async clickOnSpecificToken(tokenname: string) {
const specificToken = this.page.getByRole('button').getByLabel(`Token Title: ${tokenname}`);
// filter({
// has: this.labelTokenSubtitle.getByText(tokenname, { exact: true }),
await specificToken.last().click();
}
async clickOnSpecificInscription(inscriptionName: string) {
const specificToken = this.containersCollectibleItem
.filter({
has: this.nameInscription.getByText(inscriptionName, { exact: true }),
})
.getByTestId('inscription-container');
await specificToken.last().click();
}
// This function tries to click on a specific rune, if the rune is not enabled it will enable the test rune and then click on it
async checkAndClickOnSpecificRune(tokenname: string) {
// Check if test rune is enabled and if not enabled the test rune
try {
// click on the test rune
await this.clickOnSpecificToken(tokenname);
} catch (error) {
// if the rune was not clickable we need to enable the test rune
// Execute alternative commands if an error occurs
console.log('Test Rune is not active and need to be enabled');
// Insert your fallback logic here
// check if the test rune is enabled
await this.manageTokenButton.click();
await this.buttonRunes.click();
await expect(this.divTokenRow.first()).toBeVisible();
await expect(this.runeSKIBIDI).toBeVisible({ timeout: 30000 });
// if clause for enabled check
const count = await this.checkboxTokenActive.count();
// If no runes are enabled enable the Test rune
if (count !== 1) {
console.log(
'No active token checkbox found or there are multiple, taking alternative action.',
);
// Activate Test rune
await this.runeSKIBIDI.locator('label[role="checkbox"]').click();
} else {
console.log('One active token checkbox is present.');
}
// Switch back to dashboard and click on the rune
await this.buttonBack.click();
await this.clickOnSpecificToken(tokenname);
}
}
async checkNetworkSettingVisuals() {
await expect(this.buttonSave).toBeVisible();
await expect(this.buttonBack).toBeVisible();
await expect(this.buttonMainnet).toBeVisible();
await expect(this.buttonTestnet).toBeVisible();
await expect(this.inputStacksURL).toBeVisible();
await expect(this.inputBTCURL).toBeVisible();
await expect(this.inputFallbackBTCURL).toBeVisible();
}
async checkTestnetUrls(shouldContainTestnet: boolean) {
const inputsURL = [this.inputStacksURL, this.inputBTCURL, this.inputFallbackBTCURL];
const checks = inputsURL.map(async (input) => {
const inputValue = await input.inputValue();
const message = `URL does not contain 'testnet' in ${input}`;
if (shouldContainTestnet) {
return expect(inputValue, message).toContain('testnet');
}
return expect(inputValue, message).not.toContain('testnet');
});
await Promise.all(checks);
}
async switchToTestnetNetwork() {
await expect(this.buttonNetwork).toBeVisible();
await expect(this.buttonNetwork).toHaveText('NetworkMainnet');
await this.buttonNetwork.click();
await this.checkNetworkSettingVisuals();
await expect(this.buttonMainnet.locator('img')).toHaveAttribute('alt', 'tick');
await expect(this.buttonTestnet.locator('img[alt="tick"]')).toHaveCount(0);
await this.buttonTestnet.click();
await expect(this.buttonTestnet.locator('img')).toHaveAttribute('alt', 'tick');
await expect(this.buttonMainnet.locator('img[alt="tick"]')).toHaveCount(0);
await this.inputStacksURL.fill('https://api.nakamoto.testnet.hiro.so');
// To speed up some checks we use our own servers
// await this.inputBTCURL.fill('https://btc-testnet.xverse.app');
await this.checkTestnetUrls(true);
// TODO think of a better way to do this
// Wait for the network to be switched so that API doesn't fail because of the rate limiting
await this.page.waitForTimeout(15000);
await this.buttonSave.click();
await expect(this.buttonNetwork).toBeVisible({ timeout: 30000 });
await expect(this.buttonNetwork).toHaveText('NetworkTestnet');
}
async switchToMainnetNetwork() {
await expect(this.buttonNetwork).toBeVisible();
await expect(this.buttonNetwork).toHaveText('NetworkTestnet');
await this.buttonNetwork.click();
await this.checkNetworkSettingVisuals();
await expect(this.buttonTestnet.locator('img')).toHaveAttribute('alt', 'tick');
await expect(this.buttonMainnet.locator('img[alt="tick"]')).toHaveCount(0);
await this.buttonMainnet.click();
await expect(this.buttonMainnet.locator('img')).toHaveAttribute('alt', 'tick');
await expect(this.buttonTestnet.locator('img[alt="tick"]')).toHaveCount(0);
await this.checkTestnetUrls(false);
// TODO think of a better way to do this
// Wait for the network to be switched so that API doesn't fail because of the rate limiting
await this.page.waitForTimeout(15000);
await this.buttonSave.click();
await expect(this.buttonNetwork).toBeVisible({ timeout: 30000 });
await expect(this.buttonNetwork).toHaveText('NetworkMainnet');
}
async getBalanceOfAllAccounts() {
const count = await this.accountBalance.count();
let totalBalance = 0;
if (count > 1) {
for (let i = 0; i < count; i++) {
const balanceText = await this.accountBalance.nth(i).innerText();
const numericValue = parseFloat(balanceText.replace(/[^\d.-]/g, ''));
totalBalance += numericValue;
}
} else {
const balanceText = await this.accountBalance.innerText();
totalBalance = parseFloat(balanceText.replace(/[^\d.-]/g, ''));
}
return totalBalance;
}
async getBalanceOfAllTokens() {
const count = await this.labelCoinBalanceCurrency.count();
let totalBalance = 0;
if (count > 1) {
for (let i = 0; i < count; i++) {
const balanceText = await this.labelCoinBalanceCurrency.nth(i).innerText();
const numericValue = parseFloat(balanceText.replace(/[^\d.-]/g, ''));
totalBalance += numericValue;
}
} else {
const balanceText = await this.labelCoinBalanceCurrency.innerText();
totalBalance = parseFloat(balanceText.replace(/[^\d.-]/g, ''));
}
return totalBalance;
}
async selectLastToken(tokenType: 'BRC20' | 'STACKS'): Promise<string> {
await this.manageTokenButton.click();
expect(this.page.url()).toContain('manage-tokens');
// Click on the specific token type button if BRC20 is selected
if (tokenType === 'BRC20') {
await this.buttonBRC20.click();
} else if (tokenType === 'STACKS') {
await this.buttonStacks.click();
}
const chosenToken = this.divTokenRow.last();
const tokenName = (await chosenToken.getAttribute('data-testid')) || 'default-value';
await this.buttonBack.click();
return tokenName;
}
// The function toggleRandomToken takes a boolean parameter enable to determine the action:
// true indicates enabling a token (using inactive tokens).
// false indicates disabling a token (using active tokens).
async toggleRandomToken(enable: boolean): Promise<string> {
const tokenStateLocator = enable ? this.checkboxTokenInactive : this.checkboxTokenActive;
await expect(tokenStateLocator.first()).toBeVisible();
const numberOfTokens = await tokenStateLocator.count();
// Generate a random index within the range of available tokens
const chosenIndex = Math.floor(Math.random() * numberOfTokens);
// Access the nth token (adjusting for zero-based indexing)
const chosenToken = this.divTokenRow.filter({ has: tokenStateLocator }).nth(chosenIndex);
const tokenName = (await chosenToken.getAttribute('data-testid')) || 'default-value';
// Click the switch handle to toggle the token's state
await chosenToken.locator('label[role="checkbox"]').click();
return tokenName;
}
// The function toggleAllTokens takes a boolean parameter enable.
// true indicates enabling token (using inactive tokens).
// false indicates disabling token (using active tokens).
async toggleAllTokens(enable: boolean) {
// Determine which tokens to interact with based on the 'enable' parameter
const tokenSelector = enable ? this.checkboxTokenInactive : this.checkboxTokenActive;
const actionTokens = this.divTokenRow.filter({ has: tokenSelector });
const count = await actionTokens.count();
for (let i = 0; i < count; i++) {
// Since clicking the switch will change its state, always interact with the first one
await actionTokens.first().locator('label[role="checkbox"]').click();
}
}
// Helper function to wait for a field to get greater than 0. Some fields are slowly to load for the E2E so we need to ensure that their is a value loaded before continue
async waitForTextAboveZero(selector, timeout = 30000) {
const startTime = Date.now();
while (true) {
if (Date.now() - startTime > timeout) {
throw new Error('Timeout waiting for text to be above 0');
}
const text = await selector.innerText();
const numericValue = parseFloat(text.replace(/[^0-9.]/g, ''));
if (numericValue > 0) {
return; // Exit the function when the condition is met
}
await this.page.waitForTimeout(1000); // Check every second
}
}
}