From a0ac410c73879cdfb3e0de5bce21fb5d528cc8b7 Mon Sep 17 00:00:00 2001 From: Roland Moldovan Date: Thu, 27 Nov 2025 14:48:08 +0200 Subject: [PATCH 01/10] Add E2E tests for provider connection and transaction signing; remove Cypress assets/tests --- .github/workflows/run-merge-e2e-tests.yml | 61 +++ .gitignore | 11 +- cypress/assets/globalData.ts | 4 - cypress/assets/index.ts | 1 - cypress/assets/testKeystore.json | 19 - cypress/constants/enums.ts | 38 -- cypress/constants/globalLinks.ts | 1 - .../BatchTransactions/BatchTransactions.cy.ts | 25 -- .../cancelBatchTransactions.cy.ts | 23 -- cypress/e2e/BatchTransactions/helpers.ts | 32 -- cypress/e2e/SCTransactions/SCActions.ts | 48 --- .../e2e/SCTransactions/SCTransaction.cy.ts | 45 --- .../e2e/SCTransactions/SCTransactionData.ts | 20 - cypress/e2e/Send/Send.cy.ts | 34 -- cypress/e2e/SignMessage/SignMessage.cy.ts | 39 -- cypress/e2e/Unlock/Unlock.cy.ts | 17 - cypress/support/commands.ts | 70 ---- cypress/support/e2e.ts | 1 - cypress/support/index.ts | 15 - package.json | 9 +- playwright.config.ts | 75 ++++ tests/config.ts | 14 + tests/connectWallet/memoryProvider.spec.ts | 30 ++ tests/connectWallet/metaMask.spec.ts | 94 +++++ tests/connectWallet/webWallet.spec.ts | 61 +++ tests/support/fs/downloadFileFromUrl.ts | 63 ++++ tests/support/fs/ensureDirectoryExists.ts | 9 + tests/support/fs/readValueFromFile.ts | 13 + tests/support/fs/writeValueToFile.ts | 37 ++ tests/support/metaMask/constants.ts | 7 + tests/support/metaMask/createPassword.ts | 43 +++ tests/support/metaMask/downloadMetaMask.ts | 55 +++ .../metaMask/fillSecretRecoveryPhrase.ts | 50 +++ tests/support/metaMask/handleMetaMaskSnap.ts | 150 ++++++++ tests/support/metaMask/handleMetrics.ts | 36 ++ tests/support/metaMask/importWallet.ts | 160 ++++++++ tests/support/metaMask/loadExtension.ts | 123 ++++++ tests/support/metaMask/prepareExtension.ts | 29 ++ tests/support/metaMask/setupMetaMaskWallet.ts | 113 ++++++ tests/support/metaMask/unzipArchive.ts | 81 ++++ tests/support/metaMask/waitForMetaMaskLoad.ts | 92 +++++ tests/support/template/checkBalanceUpdate.ts | 53 +++ tests/support/template/checkButtonStatus.ts | 27 ++ .../template/checkClipboardContains.ts | 41 ++ .../template/checkConnectionToWallet.ts | 12 + .../template/confirmPrivateKeyTransaction.ts | 10 + .../template/confirmWalletTransaction.ts | 38 ++ .../template/connectInMemoryProvider.ts | 32 ++ tests/support/template/connectWebWallet.ts | 120 ++++++ tests/support/template/constants.ts | 25 ++ .../template/extractBalanceFromContainer.ts | 39 ++ .../support/template/getPageAndWaitForLoad.ts | 78 ++++ tests/support/template/globalSetup.ts | 95 +++++ tests/support/template/handlePingPong.ts | 36 ++ tests/support/template/index.ts | 51 +++ tests/support/template/loadLocalEnv.ts | 17 + .../template/navigateToConnectWallet.ts | 18 + .../template/parseTransactionsTable.ts | 340 +++++++++++++++++ .../template/pasteStringFromClipboard.ts | 10 + .../support/template/signBatchTransactions.ts | 14 + tests/support/template/testIdSelector.ts | 7 + tests/support/template/testdata.ts | 119 ++++++ tests/support/template/types.ts | 183 +++++++++ .../template/waitForToastToBeClosed.ts | 7 + .../template/waitForToastToBeDisplayed.ts | 7 + .../template/waitForTransactionToastToShow.ts | 46 +++ tests/support/template/waitUntilStable.ts | 21 ++ tests/templateActions/nativeAuth.spec.ts | 56 +++ tests/templateActions/pingAndPongAbi.spec.ts | 104 +++++ .../templateActions/signAndSendBatch.spec.ts | 93 +++++ tests/templateActions/signMessage.spec.ts | 70 ++++ tests/templateActions/swapAndLock.ts | 95 +++++ yarn.lock | 354 +++++++++++++++++- 73 files changed, 3627 insertions(+), 439 deletions(-) create mode 100644 .github/workflows/run-merge-e2e-tests.yml delete mode 100644 cypress/assets/globalData.ts delete mode 100644 cypress/assets/index.ts delete mode 100644 cypress/assets/testKeystore.json delete mode 100644 cypress/constants/enums.ts delete mode 100644 cypress/constants/globalLinks.ts delete mode 100644 cypress/e2e/BatchTransactions/BatchTransactions.cy.ts delete mode 100644 cypress/e2e/BatchTransactions/cancelBatchTransactions.cy.ts delete mode 100644 cypress/e2e/BatchTransactions/helpers.ts delete mode 100644 cypress/e2e/SCTransactions/SCActions.ts delete mode 100644 cypress/e2e/SCTransactions/SCTransaction.cy.ts delete mode 100644 cypress/e2e/SCTransactions/SCTransactionData.ts delete mode 100644 cypress/e2e/Send/Send.cy.ts delete mode 100644 cypress/e2e/SignMessage/SignMessage.cy.ts delete mode 100644 cypress/e2e/Unlock/Unlock.cy.ts delete mode 100644 cypress/support/commands.ts delete mode 100644 cypress/support/e2e.ts delete mode 100644 cypress/support/index.ts create mode 100644 playwright.config.ts create mode 100644 tests/config.ts create mode 100644 tests/connectWallet/memoryProvider.spec.ts create mode 100644 tests/connectWallet/metaMask.spec.ts create mode 100644 tests/connectWallet/webWallet.spec.ts create mode 100644 tests/support/fs/downloadFileFromUrl.ts create mode 100644 tests/support/fs/ensureDirectoryExists.ts create mode 100644 tests/support/fs/readValueFromFile.ts create mode 100644 tests/support/fs/writeValueToFile.ts create mode 100644 tests/support/metaMask/constants.ts create mode 100644 tests/support/metaMask/createPassword.ts create mode 100644 tests/support/metaMask/downloadMetaMask.ts create mode 100644 tests/support/metaMask/fillSecretRecoveryPhrase.ts create mode 100644 tests/support/metaMask/handleMetaMaskSnap.ts create mode 100644 tests/support/metaMask/handleMetrics.ts create mode 100644 tests/support/metaMask/importWallet.ts create mode 100644 tests/support/metaMask/loadExtension.ts create mode 100644 tests/support/metaMask/prepareExtension.ts create mode 100644 tests/support/metaMask/setupMetaMaskWallet.ts create mode 100644 tests/support/metaMask/unzipArchive.ts create mode 100644 tests/support/metaMask/waitForMetaMaskLoad.ts create mode 100644 tests/support/template/checkBalanceUpdate.ts create mode 100644 tests/support/template/checkButtonStatus.ts create mode 100644 tests/support/template/checkClipboardContains.ts create mode 100644 tests/support/template/checkConnectionToWallet.ts create mode 100644 tests/support/template/confirmPrivateKeyTransaction.ts create mode 100644 tests/support/template/confirmWalletTransaction.ts create mode 100644 tests/support/template/connectInMemoryProvider.ts create mode 100644 tests/support/template/connectWebWallet.ts create mode 100644 tests/support/template/constants.ts create mode 100644 tests/support/template/extractBalanceFromContainer.ts create mode 100644 tests/support/template/getPageAndWaitForLoad.ts create mode 100644 tests/support/template/globalSetup.ts create mode 100644 tests/support/template/handlePingPong.ts create mode 100644 tests/support/template/index.ts create mode 100644 tests/support/template/loadLocalEnv.ts create mode 100644 tests/support/template/navigateToConnectWallet.ts create mode 100644 tests/support/template/parseTransactionsTable.ts create mode 100644 tests/support/template/pasteStringFromClipboard.ts create mode 100644 tests/support/template/signBatchTransactions.ts create mode 100644 tests/support/template/testIdSelector.ts create mode 100644 tests/support/template/testdata.ts create mode 100644 tests/support/template/types.ts create mode 100644 tests/support/template/waitForToastToBeClosed.ts create mode 100644 tests/support/template/waitForToastToBeDisplayed.ts create mode 100644 tests/support/template/waitForTransactionToastToShow.ts create mode 100644 tests/support/template/waitUntilStable.ts create mode 100644 tests/templateActions/nativeAuth.spec.ts create mode 100644 tests/templateActions/pingAndPongAbi.spec.ts create mode 100644 tests/templateActions/signAndSendBatch.spec.ts create mode 100644 tests/templateActions/signMessage.spec.ts create mode 100644 tests/templateActions/swapAndLock.ts diff --git a/.github/workflows/run-merge-e2e-tests.yml b/.github/workflows/run-merge-e2e-tests.yml new file mode 100644 index 0000000..2ddd1ec --- /dev/null +++ b/.github/workflows/run-merge-e2e-tests.yml @@ -0,0 +1,61 @@ +name: Playwright merge E2E Tests +on: + pull_request: + branches: [main] + workflow_dispatch: +jobs: + E2E-RUN: + runs-on: runner-dapp + env: + KEYSTORE1_JSON_B64: ${{ secrets.KEYSTORE1_JSON_B64 }} + KEYSTORE1_PASSWORD: ${{ secrets.KEYSTORE1_PASSWORD }} + KEYSTORE1_ADDRESS: ${{ secrets.KEYSTORE1_ADDRESS }} + + KEYSTORE2_JSON_B64: ${{ secrets.KEYSTORE2_JSON_B64 }} + KEYSTORE2_PASSWORD: ${{ secrets.KEYSTORE2_PASSWORD }} + KEYSTORE2_ADDRESS: ${{ secrets.KEYSTORE2_ADDRESS }} + + KEYSTORE3_JSON_B64: ${{ secrets.KEYSTORE3_JSON_B64 }} + KEYSTORE3_PASSWORD: ${{ secrets.KEYSTORE3_PASSWORD }} + KEYSTORE3_ADDRESS: ${{ secrets.KEYSTORE3_ADDRESS }} + + KEYSTORE4_JSON_B64: ${{ secrets.KEYSTORE4_JSON_B64 }} + KEYSTORE4_PASSWORD: ${{ secrets.KEYSTORE4_PASSWORD }} + KEYSTORE4_ADDRESS: ${{ secrets.KEYSTORE4_ADDRESS }} + + KEYSTORE5_PEM_B64: ${{ secrets.KEYSTORE5_PEM_B64 }} + KEYSTORE5_ADDRESS: ${{ secrets.KEYSTORE5_ADDRESS }} + + KEYSTORE6_PRIVATE_KEY_UTF8: ${{ secrets.KEYSTORE6_PRIVATE_KEY_UTF8 }} + KEYSTORE6_ADDRESS: ${{ secrets.KEYSTORE6_ADDRESS }} + + METAMASK_MNEMONIC: ${{ secrets.METAMASK_MNEMONIC }} + METAMASK_ADDRESS: ${{ secrets.METAMASK_ADDRESS }} + METAMASK_PASSWORD: ${{ secrets.METAMASK_PASSWORD }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Setup Yarn + run: npm install -g yarn + - name: Install Dependencies + uses: nick-fields/retry@v3 + with: + timeout_minutes: 120 + retry_on: error + max_attempts: 2 + command: yarn install --frozen-lockfile + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright E2E Tests + run: xvfb-run yarn run run-playwright-test + - name: Upload Playwright test report + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 1 diff --git a/.gitignore b/.gitignore index a85e034..d5d6897 100644 --- a/.gitignore +++ b/.gitignore @@ -43,8 +43,17 @@ yalc.lock .idea .yalc - #cypress cypress/reports/ cypress/screenshots/ cypress/videos/ + +# playwright +playwright-report/ +test-results/ + +# secrets (local only) +tests/support/wallets/ + +# metamask cache +.cache/ \ No newline at end of file diff --git a/cypress/assets/globalData.ts b/cypress/assets/globalData.ts deleted file mode 100644 index f3060f0..0000000 --- a/cypress/assets/globalData.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const userData = { - passsword: 'Develop13#', - adress: 'erd1z9ewlfndgp6dwu8s6cprl2e0xw70fyeazsmwa28xpwaga75qfquqmzjm29' -}; diff --git a/cypress/assets/index.ts b/cypress/assets/index.ts deleted file mode 100644 index 12d65e6..0000000 --- a/cypress/assets/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './globalData'; diff --git a/cypress/assets/testKeystore.json b/cypress/assets/testKeystore.json deleted file mode 100644 index 77b9ae7..0000000 --- a/cypress/assets/testKeystore.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": 4, - "id": "2c3ba9b9-0661-4e8b-85f7-7a56913e0ade", - "kind": "mnemonic", - "crypto": { - "ciphertext": "822daae637f8a77af71225ed3e54ad36cbdcb3526c5f3745462ca65b136f398678db5d4c847708b7f70a8e4edf014b03e43219454327ab4958e71ed5e9bd8d0c099f4bc89eea896f39f999de486a1811c0a6ec9bc166af130827826d77f89a8f2d4016ed1a6ebecda3cd472456204affbb8c417748a438ad5ec2f0d3b822d57d3b2c08ebb9d5542753a3c4571c348db0c6", - "cipherparams": { "iv": "7d1ff8e7e9b362c931a9e1d6113c7e39" }, - "cipher": "aes-128-ctr", - "kdf": "scrypt", - "kdfparams": { - "dklen": 32, - "salt": "1e598d67730fb6f93c62b9848f4cdb9c8dbe134f1080100d708c6124f7fb5781", - "n": 4096, - "r": 8, - "p": 1 - }, - "mac": "d1605f8a4de5d4e44b78f7bc5707f796e9a529316119cb60ca75fb807f3b81bf" - } -} diff --git a/cypress/constants/enums.ts b/cypress/constants/enums.ts deleted file mode 100644 index c9a2ec7..0000000 --- a/cypress/constants/enums.ts +++ /dev/null @@ -1,38 +0,0 @@ -export enum AssertionEnum { - contain = 'contain', - include = 'include', - beChecked = 'be.checked' -} - -export enum ErrorMessagesEnum { - invalidAddress = 'Invalid address', - required = 'Required' -} - -export enum RoutesEnum { - unlock = '/unlock', - send = '/send', - dashboard = '/dashboard' -} - -export enum WalletIDEnum { - unguardedWallet1 = 'check_0', - unguardedWallet2 = 'check_1', - unguardedWallet3 = 'check_2', - unguardedWallet4 = 'check_3', - guardedWallet = 'check_9' -} - -export const enum GlobalSelectorsEnum { - connect = 'Connect', - signAutoSend = 'sign-auto-send', - sendTransactions = 'send-transactions', - swapLock = 'swap-lock', - transactionToastTitle = 'transactionToastTitle', - ledgerLoginButton = 'ledgerLoginButton' -} - -export const enum GlobalDataEnum { - pendingToast = 'Processing', - confirmedToast = 'transactions successful' -} diff --git a/cypress/constants/globalLinks.ts b/cypress/constants/globalLinks.ts deleted file mode 100644 index 6809dd5..0000000 --- a/cypress/constants/globalLinks.ts +++ /dev/null @@ -1 +0,0 @@ -export const DEVNET_API = 'https://devnet-api.multiversx.com'; diff --git a/cypress/e2e/BatchTransactions/BatchTransactions.cy.ts b/cypress/e2e/BatchTransactions/BatchTransactions.cy.ts deleted file mode 100644 index 7fc46a6..0000000 --- a/cypress/e2e/BatchTransactions/BatchTransactions.cy.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { signTransactions } from './helpers'; -import { WalletIDEnum, GlobalSelectorsEnum } from '../../constants/enums'; - -describe('Batch Transaction', () => { - beforeEach(() => { - cy.login(WalletIDEnum.unguardedWallet1, 'Connect'); - cy.wait(5000); - }); - afterEach(() => { - cy.checkToast(); - cy.contains('Button', 'Close').click(); - }); - it('should successfully sign 5 transactions for auto-send batch', () => { - signTransactions(GlobalSelectorsEnum.signAutoSend); - }); - - it('should successfully sign 5 transactions for send-transactions', () => { - signTransactions(GlobalSelectorsEnum.sendTransactions); - }); - - it('should successfully sign 4 transactions for swap-lock', () => { - cy.wait(3000); - signTransactions(GlobalSelectorsEnum.swapLock); - }); -}); diff --git a/cypress/e2e/BatchTransactions/cancelBatchTransactions.cy.ts b/cypress/e2e/BatchTransactions/cancelBatchTransactions.cy.ts deleted file mode 100644 index 20cc188..0000000 --- a/cypress/e2e/BatchTransactions/cancelBatchTransactions.cy.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { cancelTransactions } from './helpers'; -import { WalletIDEnum } from '../../constants/enums'; - -describe('cancelTransactions', () => { - beforeEach(() => { - cy.login(WalletIDEnum.unguardedWallet1, 'Connect'); - cy.wait(5000); - }); - afterEach(() => { - cy.contains('Button', 'Close').click(); - }); - it('should return transaction canceled for auto-send batch ', () => { - cancelTransactions('sign-auto-send'); - }); - - it('should return transaction canceled for swap-lock', () => { - cancelTransactions('swap-lock'); - }); - - it('should return transaction canceled for send-transactions ', () => { - cancelTransactions('send-transactions'); - }); -}); diff --git a/cypress/e2e/BatchTransactions/helpers.ts b/cypress/e2e/BatchTransactions/helpers.ts deleted file mode 100644 index 74e0954..0000000 --- a/cypress/e2e/BatchTransactions/helpers.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { userData } from '../../assets/globalData'; -import { scSelectors } from '../SCTransactions/SCTransactionData'; - -export const signTransactions = (selector: string) => { - let i = selector === 'swap-lock' ? 1 : 0; - - const widgetInfo = [ - 'Hash', - 'Receiver', - 'Amount', - 'Gas price', - 'Gas limit', - 'Data' - ]; - - cy.getSelector(selector).click(); - cy.getSelector(scSelectors.accesPass).type(userData.passsword); - cy.getSelector(scSelectors.submitButton).click(); - for (i; i < 5; i++) { - cy.getSelector('signBtn').click(); - } - cy.contains('Processing transactions'); - cy.checkWidgetMsg(widgetInfo); -}; - -export const cancelTransactions = (selector: string) => { - cy.getSelector(selector).click(); - cy.getSelector(scSelectors.accesPass).type(userData.passsword); - cy.getSelector(scSelectors.submitButton).click(); - cy.getSelector('closeButton').click(); - cy.contains('Transaction canceled'); -}; diff --git a/cypress/e2e/SCTransactions/SCActions.ts b/cypress/e2e/SCTransactions/SCActions.ts deleted file mode 100644 index f8d36b6..0000000 --- a/cypress/e2e/SCTransactions/SCActions.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { scSelectors } from './SCTransactionData'; -import { userData } from '../../assets/globalData'; -import { AssertionEnum } from '../../constants/enums'; -// Custom function to make Ping or Pong transactions -export const scTransaction = (type: string) => { - cy.getSelector(type).then((btn) => { - cy.wait(1500); - if (btn.prop('disabled')) { - return; - } else { - cy.wait(1500); - cy.getSelector(type).click(); - cy.getSelector(scSelectors.accesPass).type(userData.passsword); - cy.getSelector(scSelectors.submitButton).click(); - cy.getSelector(scSelectors.signButton).click(); - checkPingDetails(); - } - }); -}; - -export const checkPingDetails = () => { - cy.get('p') - .should(AssertionEnum.contain, 'Contract') - .should(AssertionEnum.contain, 'Hash') - .should(AssertionEnum.contain, 'Receiver') - .should(AssertionEnum.contain, 'Amount') - .should(AssertionEnum.contain, 'Gas price') - .should(AssertionEnum.contain, 'Gas limit') - .should(AssertionEnum.contain, 'Data'); -}; - -export const pingPongHandler = (type: string) => { - cy.getSelector(`btnPing${type}`).then((btn) => { - cy.wait(1500); - if (btn.prop('disabled')) { - console.log('btn prop', btn.prop('disabled')); - scTransaction(`btnPong${type}`); - } else { - scTransaction(`btnPing${type}`); - } - }); -}; - -export const initTransaction = () => { - cy.getSelector('btnPongAbi').click(); - cy.getSelector(scSelectors.accesPass).type(userData.passsword); - cy.getSelector(scSelectors.submitButton).click(); -}; diff --git a/cypress/e2e/SCTransactions/SCTransaction.cy.ts b/cypress/e2e/SCTransactions/SCTransaction.cy.ts deleted file mode 100644 index 907984f..0000000 --- a/cypress/e2e/SCTransactions/SCTransaction.cy.ts +++ /dev/null @@ -1,45 +0,0 @@ -/// -import { initTransaction, pingPongHandler } from './SCActions'; -import { WalletIDEnum } from '../../constants/enums'; - -describe('Smart Contract Transactions', () => { - afterEach(() => { - cy.contains('Button', 'Close').click(); - }); - - it('should successfully execute the Ping & Pong ABI', () => { - cy.login(WalletIDEnum.unguardedWallet1, 'Connect'); - cy.wait(5000); - pingPongHandler('Abi'); - cy.checkToast(); - }); - - it('should successfully execute the Ping & Pong RAW ', () => { - cy.login(WalletIDEnum.unguardedWallet2, 'Connect'); - cy.wait(5000); - pingPongHandler('Raw'); - cy.checkToast(); - }); - - it('should successfully execute the Ping & Pong Service', () => { - cy.login(WalletIDEnum.unguardedWallet3, 'Connect'); - cy.wait(5000); - pingPongHandler('Service'); - cy.checkToast(); - }); - - it('should not execute ping&pong aciton', () => { - cy.login(WalletIDEnum.unguardedWallet4, 'Connect'); - cy.wait(5000); - initTransaction(); - cy.getSelector('closeButton').click(); - cy.contains('Transaction canceled'); - }); - - it('should display the guardian modal', () => { - cy.login(WalletIDEnum.guardedWallet, 'Connect'); - cy.wait(2000); - initTransaction(); - cy.contains('Verify Guardian'); - }); -}); diff --git a/cypress/e2e/SCTransactions/SCTransactionData.ts b/cypress/e2e/SCTransactions/SCTransactionData.ts deleted file mode 100644 index b299fae..0000000 --- a/cypress/e2e/SCTransactions/SCTransactionData.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const SCTransactionData = { - confirmationMessage: 'transaction successful', - succesMessage: 'Ping transaction successful', - modalConfirmation: 'transactionToastTitle', - ping: 'Ping', - pong: 'Pong', - transactions: '/transactions' -}; -export const scSelectors = { - accesPass: 'accessPass', - submitButton: 'submitButton', - signButton: 'signBtn', - loginBtn: 'loginBtn', - webWalletLogin: 'webWalletLoginBtn', - btnPingAbi: 'btnPingAbi', - btnPongAbi: 'btnPongAbi', - btnPingRaw: 'btnPingRaw', - btnPongRaw: 'btnPongRaw', - transactionBtn: '[data-cy="transactionBtn"]' -}; diff --git a/cypress/e2e/Send/Send.cy.ts b/cypress/e2e/Send/Send.cy.ts deleted file mode 100644 index 3fb89cb..0000000 --- a/cypress/e2e/Send/Send.cy.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { userData } from '../../assets/globalData'; -import { AssertionEnum, RoutesEnum, WalletIDEnum } from '../../constants/enums'; -describe('Send', () => { - beforeEach(() => { - cy.login(WalletIDEnum.unguardedWallet1, 'Send transaction'); - }); - afterEach(() => { - cy.contains('Button', 'Close').click(); - }); - it('should successfully send a demo transaction', () => { - cy.checkUrl(RoutesEnum.send); - cy.getSelector('confirmData').should(AssertionEnum.contain, 'Hello_world'); - cy.getSelector('sendTrxBtn').click(); - cy.get('p') - .should(AssertionEnum.contain, 'Sender') - .should( - AssertionEnum.contain, - 'erd16xlzk48ftvhxp8dyq6d0kkfpgpfechlzycfm9xmdmwna66pvkymqvz4vzq' - ) - .should(AssertionEnum.contain, 'Transaction status') - .should(AssertionEnum.contain, 'succes'); - }); - - it('should display the cancelled status', () => { - cy.contains('Send transaction').click(); - cy.getSelector('accessPass').type(userData.passsword); - cy.getSelector('submitButton').click(); - - cy.getSelector('cancelTrxBtn').click(); - cy.get('p') - .should(AssertionEnum.contain, 'Transaction status') - .should(AssertionEnum.contain, 'cancelled'); - }); -}); diff --git a/cypress/e2e/SignMessage/SignMessage.cy.ts b/cypress/e2e/SignMessage/SignMessage.cy.ts deleted file mode 100644 index fa5e5be..0000000 --- a/cypress/e2e/SignMessage/SignMessage.cy.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { userData } from '../../assets/globalData'; -import { AssertionEnum, RoutesEnum, WalletIDEnum } from '../../constants/enums'; -import { scSelectors } from '../SCTransactions/SCTransactionData'; - -describe('Sign Message', () => { - beforeEach(() => { - cy.login(WalletIDEnum.unguardedWallet1, 'Connect'); - cy.wait(5000); - }); - afterEach(() => { - cy.contains('Button', 'Close').click(); - }); - it('should sign', () => { - const widgetInfo = ['Signature', 'Encoded message', 'Decoded message']; - cy.getSelector('signMsgBtn').should('be.disabled'); - cy.get('textarea').first().type('Test'); - cy.getSelector('signMsgBtn').click(); - cy.getSelector(scSelectors.accesPass).type(userData.passsword); - cy.getSelector(scSelectors.submitButton).click(); - cy.getSelector('message').should('have.value', 'Test'); - cy.getSelector('signButton').click(); - widgetInfo.forEach((element) => { - cy.get('label').should(AssertionEnum.contain, element); - }); - cy.getSelector('closeTransactionSuccessBtn').click(); - cy.getSelector('signMsgBtn').should('be.disabled'); - }); - - it('should not sign', () => { - cy.get('textarea').first().type('Test'); - cy.getSelector('signMsgBtn').click(); - cy.wait(2000); - cy.getSelector(scSelectors.accesPass).type(userData.passsword); - cy.getSelector(scSelectors.submitButton).click(); - cy.getSelector('closeBtn').click(); - cy.checkUrl(RoutesEnum.dashboard); - cy.contains('Transaction canceled'); - }); -}); diff --git a/cypress/e2e/Unlock/Unlock.cy.ts b/cypress/e2e/Unlock/Unlock.cy.ts deleted file mode 100644 index aafd800..0000000 --- a/cypress/e2e/Unlock/Unlock.cy.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { GlobalSelectorsEnum } from '../../constants/enums'; - -describe('Unlock', () => { - beforeEach(() => { - cy.visit('/'); - }); - - it('should open the xPortal modal', () => { - cy.contains('xPortal App').click(); - cy.contains('Scan the QR code using the xPortal App'); - }); - - it('should open the ledger modal', () => { - cy.getSelector(GlobalSelectorsEnum.ledgerLoginButton); - cy.contains('Connect Ledger'); - }); -}); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts deleted file mode 100644 index c8a211b..0000000 --- a/cypress/support/commands.ts +++ /dev/null @@ -1,70 +0,0 @@ -/// -import { userData } from '../assets/globalData'; -import { - AssertionEnum, - GlobalSelectorsEnum, - GlobalDataEnum -} from '../constants/enums'; -import { DEVNET_API } from '../constants/globalLinks'; - -// Check the url global function -Cypress.Commands.add('checkUrl', (url) => { - cy.url().should(AssertionEnum.include, url); -}); - -//Login with keystore global function -Cypress.Commands.add('login', (walletID, selector) => { - // cy.session(walletID, () => { - cy.visit('/'); - cy.contains(selector).click(); - if (selector === GlobalSelectorsEnum.connect) { - cy.getSelector('webWalletLoginBtn').click(); - } - cy.getSelector('keystoreBtn').click(); - cy.checkUrl('/unlock/keystore'); - cy.getSelector('submitButton').click(); - cy.checkUrl('/unlock/keystore'); - - cy.get('input[type=file]').selectFile('./cypress/assets/testKeystore.json', { - force: true - }); - cy.getSelector('accessPass').type(userData.passsword); - cy.getSelector('submitButton').click(); - - cy.getSelector(walletID).click(); - cy.getSelector('confirmBtn').click(); -}); -// }); - -Cypress.Commands.add('getSelector', (selector, ...cypressAction) => { - return cy.get(`[data-testid=${selector}]`, ...cypressAction); -}); - -// Add the custom command for api intercepts -Cypress.Commands.add('apiIntercept', (method, param) => { - cy.intercept(method, `${DEVNET_API}${param}`).as(param); -}); - -Cypress.on('uncaught:exception', () => { - // Do nothing or handle the exception as per your requirements - // You can remove the comment and add your custom handling logic here - // console.log('An uncaught exception occurred but will be ignored'); - return false; // Prevent Cypress from failing the test -}); -Cypress.Commands.add('checkWidgetMsg', (msgArr) => { - msgArr.forEach((element) => { - cy.get('p').should(AssertionEnum.contain, element); - }); -}); - -Cypress.Commands.add('checkToast', () => { - cy.getSelector(GlobalSelectorsEnum.transactionToastTitle).should( - AssertionEnum.contain, - GlobalDataEnum.pendingToast - ); - cy.wait(5000); - cy.getSelector(GlobalSelectorsEnum.transactionToastTitle).should( - AssertionEnum.contain, - GlobalDataEnum.confirmedToast - ); -}); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts deleted file mode 100644 index 1221b17..0000000 --- a/cypress/support/e2e.ts +++ /dev/null @@ -1 +0,0 @@ -import './commands'; diff --git a/cypress/support/index.ts b/cypress/support/index.ts deleted file mode 100644 index 24f5588..0000000 --- a/cypress/support/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-namespace, @typescript-eslint/no-unused-vars -declare namespace Cypress { - interface Chainable { - checkUrl: (url: string) => void; - login: (walletID: string, selector: string) => void; - apiIntercept: (method: string, param: string) => void; - checkWidgetMsg: (msgArr: string[]) => void; - checkToast: () => void; - - getSelector: ( - selector: string, - ...cypressAction: [] - ) => Chainable>; - } -} diff --git a/package.json b/package.json index 3f9e4ea..91190c1 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "build:devnet": "yarn run copy-devnet-config & next build", "preview": "next start", "test": "jest", - "cy:run": "node scripts/cypress.ts" + "run-playwright-test": "playwright test", + "run-playwright-test-ui": "playwright test --ui" }, "dependencies": { "@fortawesome/fontawesome-svg-core": "6.5.1", @@ -28,18 +29,20 @@ "@multiversx/sdk-dapp": "^5.x", "@multiversx/sdk-dapp-ui": "^0.x", "@multiversx/sdk-dapp-utils": "3.0.2", + "@playwright/test": "^1.57.0", "axios": "^1.10.0", + "bignumber.js": "^9.x", "classnames": "2.3.2", "eslint": "8.50.0", "eslint-config-next": "13.5.3", - "bignumber.js": "^9.x", "moment": "2.29.4", "next": "13.5.4", "next-transpile-modules": "10.0.1", "protobufjs": "7.5.4", "react": "18.2.0", "react-dom": "18.2.0", - "typescript": "5.2.2" + "typescript": "5.2.2", + "vite": "^7.2.4" }, "devDependencies": { "@swc/core": "1.3.91", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..c4a60e3 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,75 @@ +import { defineConfig, devices } from '@playwright/test'; +import { TEST_CONFIG } from './tests/config'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + globalSetup: './tests/support/template/globalSetup.ts', + /* timeout for each test */ + timeout: TEST_CONFIG.timeout, + /* timeout for locators */ + expect: { timeout: TEST_CONFIG.expectTimeout }, + /* directory where the tests are located */ + testDir: './tests', + /* pattern to match the tests */ + testMatch: '**/*.spec.ts', + + /* Run tests in files in parallel */ + fullyParallel: TEST_CONFIG.fullyParallel, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: TEST_CONFIG.retries, + /* Parallel tests on CI only. */ + workers: TEST_CONFIG.workers, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:3000', + ignoreHTTPSErrors: true, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + // Default to headless mode; will be overridden by the environment variable + headless: true, + + /* Capture screenshot on test failure */ + screenshot: 'only-on-failure', + + /* Record video on any failure */ + video: 'retain-on-failure' + }, + + /* Configure projects for major browsers */ + projects: [ + { + use: { + ...devices['Desktop Chrome'], + launchOptions: { + args: [ + '--start-maximized', + '--disable-web-security', + '--disable-features=VizDisplayCompositor' + ] + } + } + } + ], + webServer: { + command: 'yarn next start', + url: 'http://localhost:3000', + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + ignoreHTTPSErrors: true + } +}); diff --git a/tests/config.ts b/tests/config.ts new file mode 100644 index 0000000..74d7584 --- /dev/null +++ b/tests/config.ts @@ -0,0 +1,14 @@ +export const TEST_CONFIG = { + /* Retry on CI only */ + retries: process.env.CI ? 1 : 0, + /* Parallel tests on CI only. */ + workers: 5, + /* Timeout for each test */ + timeout: 120_000, + /* Timeout for locators */ + expectTimeout: 60_000, + /* Run tests in files in parallel */ + fullyParallel: false +} as const; + +export const WALLET_URL_TIMEOUT_MS = 10_000; diff --git a/tests/connectWallet/memoryProvider.spec.ts b/tests/connectWallet/memoryProvider.spec.ts new file mode 100644 index 0000000..2ddc7c4 --- /dev/null +++ b/tests/connectWallet/memoryProvider.spec.ts @@ -0,0 +1,30 @@ +import { test } from '@playwright/test'; +import * as TestActions from '../support/template'; +import { readValueFromFile } from '../support/template'; +import { TestDataEnums } from '../support/template/testdata'; + +const privateKeyConfig = { + address: TestDataEnums.keystoreWalletAddress6, + privateKey: readValueFromFile(TestDataEnums.keystoreFilePath6, 'utf8').trim() +}; + +test.describe('Connect a wallet', () => { + // Connect wallet tests verify wallet connection functionality + + test.beforeEach(async ({ page }) => { + await TestActions.navigateToConnectWallet(page); + }); + + test.describe('In Memory Provider Connection', () => { + test('should successfully connect with Address and Private Key', async ({ + page + }) => { + await TestActions.connectInMemoryProvider({ + page, + loginMethod: privateKeyConfig + }); + + await TestActions.checkConnectionToWallet(page, privateKeyConfig.address); + }); + }); +}); diff --git a/tests/connectWallet/metaMask.spec.ts b/tests/connectWallet/metaMask.spec.ts new file mode 100644 index 0000000..734bedf --- /dev/null +++ b/tests/connectWallet/metaMask.spec.ts @@ -0,0 +1,94 @@ +import { BrowserContext, expect, Page, test } from '@playwright/test'; +import { setupMetaMaskWallet } from '../support/metaMask/setupMetaMaskWallet'; +import * as TestActions from '../support/template'; +import { getPageAndWaitForLoad } from '../support/template/getPageAndWaitForLoad'; +import { OriginPageEnum, UrlRegex } from '../support/template/testdata'; +import { TEST_CONSTANTS } from '../support/template/constants'; + +// TODO: Load variables from .env.test.local when running locally +// Get password and address from environment variables +const METAMASK_MNEMONIC = process.env.METAMASK_MNEMONIC || ''; +const METAMASK_ADDRESS = process.env.METAMASK_ADDRESS || ''; +const METAMASK_PASSWORD = process.env.METAMASK_PASSWORD || ''; + +// Validate that required environment variables are present +if (!METAMASK_PASSWORD || !METAMASK_ADDRESS || !METAMASK_MNEMONIC) { + throw new Error( + 'METAMASK_PASSWORD, METAMASK_MNEMONIC, and METAMASK_ADDRESS environment variables are missing. Please set them in .env.test.local for local development or as a GitHub Secret for CI.' + ); +} + +test.describe('Connect a wallet', () => { + let metamaskContext: BrowserContext; + let extensionId: string; + let dAppPage: Page; + + test.beforeEach(async () => { + // Setup MetaMask for each test + const result = await setupMetaMaskWallet( + METAMASK_MNEMONIC, + METAMASK_PASSWORD + ); + metamaskContext = result.context; + extensionId = result.extensionId; + + // Create a page in the MetaMask context for the dApp + dAppPage = await metamaskContext.newPage(); + await TestActions.navigateToConnectWallet(dAppPage); + }); + + test.afterEach(async () => { + // Close the browser context to free up resources + if (metamaskContext) { + try { + await metamaskContext.close(); + // Wait a moment to ensure the context is fully closed + // Persistent contexts need a bit more time to release the userDataDir lock + await new Promise((resolve) => setTimeout(resolve, 2000)); + } catch (error) { + // Context might already be closed, ignore the error + } + } + }); + + test.describe('MetaMask Connection', () => { + test('should successfully connect with MetaMask', async () => { + // Use the MetaMask context and dApp page from beforeEach + const context = metamaskContext; + const page = dAppPage; + + // Click the connect MetaMask button + await page.getByTestId('metamask').click(); + + // Get the notification page and wait for it to load in the MetaMask context + const notificationPage = await getPageAndWaitForLoad( + context, + `chrome-extension://${extensionId}/notification.html`, + { + viewport: { width: 360, height: 592 } + } + ); + + // Handle MetaMask Snap privacy warning + await TestActions.handleMetaMaskSnap( + context, + extensionId, + notificationPage + ); + + // Switch to template page + const templatePage = await getPageAndWaitForLoad( + context, + OriginPageEnum.templateDashboard + ); + + // Verify template page opened + await expect(templatePage).toHaveURL(UrlRegex.templateDashboard, { + timeout: TEST_CONSTANTS.URL_NAVIGATION_TIMEOUT + }); + + // Verify connection using TestActions helper + await TestActions.checkConnectionToWallet(page, METAMASK_ADDRESS); + }); + }); +}); diff --git a/tests/connectWallet/webWallet.spec.ts b/tests/connectWallet/webWallet.spec.ts new file mode 100644 index 0000000..b449d69 --- /dev/null +++ b/tests/connectWallet/webWallet.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test'; +import * as TestActions from '../support/template'; +import { TestDataEnums } from '../support/template/testdata'; + +const keystoreConfig = { + keystore: TestDataEnums.keystoreFilePath1, + password: TestDataEnums.keystorePassword1, + address: TestDataEnums.keystoreWalletAddress1 +}; + +const pemConfig = { + pem: TestDataEnums.keystoreFilePath5 +}; + +test.describe('Connect a wallet', () => { + // Connect wallet tests verify wallet connection functionality + + test.beforeEach(async ({ page }) => { + await TestActions.navigateToConnectWallet(page); + }); + + test.describe('Keystore Connection', () => { + test('should successfully connect with keystore file', async ({ page }) => { + await TestActions.connectWebWallet({ page, loginMethod: keystoreConfig }); + + await TestActions.checkConnectionToWallet( + page, + TestDataEnums.keystoreWalletAddress1 + ); + }); + }); + + test.describe('PEM Connection', () => { + test('should successfully connect with PEM file', async ({ page }) => { + await TestActions.connectWebWallet({ page, loginMethod: pemConfig }); + + await TestActions.checkConnectionToWallet( + page, + TestDataEnums.keystoreWalletAddress5 + ); + }); + }); + + test.describe('Connection Validation', () => { + test('should handle missing password for keystore', async ({ page }) => { + // Test with keystore but no password + const invalidKeystoreConfig = { + keystore: TestDataEnums.keystoreFilePath1, + password: undefined + }; + + // This should throw an error for missing password + await expect( + TestActions.connectWebWallet({ + page, + loginMethod: invalidKeystoreConfig + }) + ).rejects.toThrow('Password is required when using a keystore'); + }); + }); +}); diff --git a/tests/support/fs/downloadFileFromUrl.ts b/tests/support/fs/downloadFileFromUrl.ts new file mode 100644 index 0000000..53610b0 --- /dev/null +++ b/tests/support/fs/downloadFileFromUrl.ts @@ -0,0 +1,63 @@ +import { createWriteStream } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pipeline } from 'stream/promises'; +import axios from 'axios'; + +type DownloaderOptions = { + url: string; + outputDir: string; + fileName: string; + overrideFile?: boolean; +}; + +type DownloadFileResult = { + filePath: string; + downloadSkipped: boolean; +}; + +export async function downloadFileFromUrl( + options: DownloaderOptions +): Promise { + try { + const { url, outputDir, fileName, overrideFile } = options; + const filePath = path.join(outputDir, fileName); + + await fs.mkdir(outputDir, { recursive: true }); + + const fileExists = await fileExistsSafe(filePath); + if (fileExists && !overrideFile) { + return { + filePath, + downloadSkipped: true + }; + } + + const response = await axios.get(url, { + responseType: 'stream' + }); + + const writer = createWriteStream(filePath); + await pipeline(response.data, writer); + + return { + filePath, + downloadSkipped: false + }; + } catch (error) { + throw new Error( + `Failed to download file: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ); + } +} + +async function fileExistsSafe(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} diff --git a/tests/support/fs/ensureDirectoryExists.ts b/tests/support/fs/ensureDirectoryExists.ts new file mode 100644 index 0000000..8dfd7c8 --- /dev/null +++ b/tests/support/fs/ensureDirectoryExists.ts @@ -0,0 +1,9 @@ +import fs from 'fs'; +import path from 'path'; + +export const ensureDirectoryExists = (filePath: string) => { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +}; diff --git a/tests/support/fs/readValueFromFile.ts b/tests/support/fs/readValueFromFile.ts new file mode 100644 index 0000000..b72424e --- /dev/null +++ b/tests/support/fs/readValueFromFile.ts @@ -0,0 +1,13 @@ +import fs from 'fs'; + +export const readValueFromFile = ( + inPath: string, + encoding: 'utf8' | 'buffer' = 'utf8' +): string => { + if (encoding === 'buffer') { + const data = fs.readFileSync(inPath); + return data.toString('utf8'); + } + + return fs.readFileSync(inPath, 'utf8'); +}; diff --git a/tests/support/fs/writeValueToFile.ts b/tests/support/fs/writeValueToFile.ts new file mode 100644 index 0000000..a39dcbb --- /dev/null +++ b/tests/support/fs/writeValueToFile.ts @@ -0,0 +1,37 @@ +import fs from 'fs'; +import { ensureDirectoryExists } from './ensureDirectoryExists'; + +export type FileEncoding = 'base64' | 'utf8' | 'none'; + +export const writeValueToFile = ( + value: string, + outPath: string, + encoding: FileEncoding, + skipIfExists: boolean = false +) => { + // Check if file exists and skip if requested + if (skipIfExists && fs.existsSync(outPath)) { + return; + } + + // ensure directory exists + ensureDirectoryExists(outPath); + + switch (encoding) { + case 'base64': + fs.writeFileSync( + outPath, + Buffer.from(value, 'base64').toString('utf8'), + 'utf8' + ); + break; + case 'utf8': + fs.writeFileSync(outPath, value, 'utf8'); + break; + case 'none': + fs.writeFileSync(outPath, value); + break; + default: + throw new Error(`Unsupported encoding: ${encoding}`); + } +}; diff --git a/tests/support/metaMask/constants.ts b/tests/support/metaMask/constants.ts new file mode 100644 index 0000000..4daa969 --- /dev/null +++ b/tests/support/metaMask/constants.ts @@ -0,0 +1,7 @@ +export const DEFAULT_METAMASK_VERSION = '13.6.0'; + +export const resolveMetamaskVersion = () => + process.env.METAMASK_VERSION?.trim() || DEFAULT_METAMASK_VERSION; + +export const buildMetamaskZipUrl = (version: string) => + `https://github.com/MetaMask/metamask-extension/releases/download/v${version}/metamask-chrome-${version}.zip`; diff --git a/tests/support/metaMask/createPassword.ts b/tests/support/metaMask/createPassword.ts new file mode 100644 index 0000000..12118df --- /dev/null +++ b/tests/support/metaMask/createPassword.ts @@ -0,0 +1,43 @@ +import { Page } from '@playwright/test'; + +const DEFAULT_TIMEOUT = 10000; + +const createPasswordSelectors = { + newPasswordInput: '[data-testid="create-password-new-input"]', + confirmPasswordInput: '[data-testid="create-password-confirm-input"]', + termsCheckbox: '[data-testid="create-password-terms"]', + submitButton: '[data-testid="create-password-submit"]' +}; + +export async function createPassword(page: Page, password: string) { + await enterPassword(page, password); + await confirmPassword(page, password); + await acceptTerms(page); + await submitPasswordForm(page); +} + +async function enterPassword(page: Page, password: string) { + const passwordField = page.locator(createPasswordSelectors.newPasswordInput); + await passwordField.waitFor({ state: 'visible', timeout: DEFAULT_TIMEOUT }); + await passwordField.fill(password); +} + +async function confirmPassword(page: Page, password: string) { + const confirmField = page.locator( + createPasswordSelectors.confirmPasswordInput + ); + await confirmField.waitFor({ state: 'visible', timeout: DEFAULT_TIMEOUT }); + await confirmField.fill(password); +} + +async function acceptTerms(page: Page) { + const checkbox = page.locator(createPasswordSelectors.termsCheckbox); + await checkbox.waitFor({ state: 'visible', timeout: DEFAULT_TIMEOUT }); + await checkbox.click(); +} + +async function submitPasswordForm(page: Page) { + const submitButton = page.locator(createPasswordSelectors.submitButton); + await submitButton.waitFor({ state: 'visible', timeout: DEFAULT_TIMEOUT }); + await submitButton.click(); +} diff --git a/tests/support/metaMask/downloadMetaMask.ts b/tests/support/metaMask/downloadMetaMask.ts new file mode 100644 index 0000000..fea2626 --- /dev/null +++ b/tests/support/metaMask/downloadMetaMask.ts @@ -0,0 +1,55 @@ +// downloadFileFromUrl.ts +import { createWriteStream } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pipeline } from 'stream/promises'; +import axios from 'axios'; + +type Options = { + url: string; + outputDir: string; + fileName: string; + overwrite?: boolean; +}; + +export async function downloadFileFromUrl({ + url, + outputDir, + fileName, + overwrite = false +}: Options): Promise<{ filePath: string; downloadSkipped: boolean }> { + const filePath = path.join(outputDir, fileName); + + await fs.mkdir(outputDir, { recursive: true }); + + if (!overwrite && (await exists(filePath))) { + return { filePath, downloadSkipped: true }; + } + + const response = await axios.get(url, { + responseType: 'stream', + validateStatus: () => true + }); + if (response.status !== 200) { + throw new Error(`Download failed (${response.status}) for ${url}`); + } + + const tmpPath = `${filePath}.part`; + await pipeline(response.data, createWriteStream(tmpPath)); + // remove target file if exists, then rename + try { + await fs.rm(filePath, { force: true }); + } catch {} + await fs.rename(tmpPath, filePath); + + return { filePath, downloadSkipped: false }; +} + +async function exists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} diff --git a/tests/support/metaMask/fillSecretRecoveryPhrase.ts b/tests/support/metaMask/fillSecretRecoveryPhrase.ts new file mode 100644 index 0000000..bf83a84 --- /dev/null +++ b/tests/support/metaMask/fillSecretRecoveryPhrase.ts @@ -0,0 +1,50 @@ +import { Page } from '@playwright/test'; + +const secretRecoverySelectors = { + wordInput: (index: number) => `[data-testid="import-srp__srp-word-${index}"]`, + confirmButton: '[data-testid="import-srp-confirm"]', + errorMessage: '[data-testid="import-srp__error"]' +}; + +export async function fillSecretRecoveryPhrase(page: Page, seedPhrase: string) { + const words = seedPhrase.split(' '); + await fillSeedWords(page, words); + await confirmSeedPhrase(page); +} + +async function fillSeedWords(page: Page, words: string[]) { + // Skip first word (already filled in the textarea) + const remainingWords = words.slice(1); + + for (const [index, word] of remainingWords.entries()) { + const inputSelector = secretRecoverySelectors.wordInput(index + 1); + await fillSingleWord(page, inputSelector, word); + } +} + +async function fillSingleWord(page: Page, selector: string, word: string) { + const input = page.locator(selector); + await input.fill(word); + await input.press('Enter'); +} + +async function confirmSeedPhrase(page: Page) { + const confirmButton = page.locator(secretRecoverySelectors.confirmButton); + + if (await confirmButton.isDisabled()) { + const errorText = await getErrorText(page); + throw new Error( + `[ConfirmSecretRecoveryPhrase] Invalid seed phrase. MetaMask error: ${errorText}` + ); + } + + await confirmButton.click(); +} + +async function getErrorText(page: Page): Promise { + return ( + (await page + .locator(secretRecoverySelectors.errorMessage) + .textContent({ timeout: 1000 })) || 'Unknown error' + ); +} diff --git a/tests/support/metaMask/handleMetaMaskSnap.ts b/tests/support/metaMask/handleMetaMaskSnap.ts new file mode 100644 index 0000000..bd2de34 --- /dev/null +++ b/tests/support/metaMask/handleMetaMaskSnap.ts @@ -0,0 +1,150 @@ +import { BrowserContext, Page } from '@playwright/test'; +import { getPageAndWaitForLoad } from '../template/getPageAndWaitForLoad'; +import { waitUntilStable } from '../template/waitUntilStable'; + +const RETRY_DELAY_BASE_MS = 500; +const CLICK_TIMEOUT_MS = 6000; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const snapSelectors = { + confirmationSubmitButton: '[data-testid="confirmation-submit-button"]', + actions: [ + { type: 'testId', name: 'snap-privacy-warning-scroll' }, + { type: 'button', name: 'Accept' }, + { type: 'button', name: 'Connect' }, + { type: 'button', name: 'Confirm' }, + { type: 'checkbox', name: 'MultiversX' }, + { type: 'testId', name: 'snap-install-warning-modal-confirm' }, + { type: 'button', name: 'Ok' }, + { type: 'button', name: 'Approve' } + ] as const +}; + +export async function handleMetaMaskSnap( + context: BrowserContext, + extensionId: string, + initialPage: Page, + maxRetries = 5 +): Promise { + let page = initialPage; + let attempt = 0; + let currentActionIndex = 0; + + while (attempt <= maxRetries) { + try { + await waitUntilStable(page); + + if (await clickIfConfirmationVisible(page)) return; + + for (let i = currentActionIndex; i < snapSelectors.actions.length; i++) { + const action = snapSelectors.actions[i]; + currentActionIndex = i; + await attemptClick(page, action); + } + + return; // All steps completed successfully + } catch (error: any) { + attempt++; + + console.warn( + `[MetaMaskSnap] Attempt ${attempt}/${maxRetries} failed: ${ + error?.message || error + }` + ); + console.warn( + '[MetaMaskSnap] Open pages at failure:', + context.pages().map((p) => p.url()) + ); + + if (attempt > maxRetries) { + throw new Error('[MetaMaskSnap] Max retries reached.'); + } + + page = await reacquireNotificationPage(context, extensionId); + await exponentialBackoff(attempt); + } + } + + throw new Error('[MetaMaskSnap] Unexpected end of flow.'); +} + +async function attemptClick( + page: Page, + action: { type: 'testId' | 'checkbox' | 'button'; name: string } +) { + const element = getLocator(page, action); + + try { + await element.waitFor({ state: 'visible', timeout: CLICK_TIMEOUT_MS }); + await element.click(); + } catch (error: any) { + console.error( + `[MetaMaskSnap] Failed to click "${action.name}": ${ + error?.message || error + }` + ); + console.error( + `[MetaMaskSnap] Page state -> closed: ${page.isClosed()}, url: ${page.url()}` + ); + throw error; + } +} + +function getLocator( + page: Page, + action: { type: 'testId' | 'checkbox' | 'button'; name: string } +) { + switch (action.type) { + case 'testId': + return page.getByTestId(action.name); + case 'checkbox': + return page.getByRole('checkbox', { name: action.name }); + case 'button': + return page.getByRole('button', { name: action.name }); + default: + throw new Error(`Unknown action type: ${action.type}`); + } +} + +async function clickIfConfirmationVisible(page: Page): Promise { + const confirmationButton = page.locator( + snapSelectors.confirmationSubmitButton + ); + const isVisible = await confirmationButton.isVisible().catch(() => false); + + if (isVisible) { + await confirmationButton.click(); + return true; + } + + return false; +} + +async function reacquireNotificationPage( + context: BrowserContext, + extensionId: string +): Promise { + try { + const page = await getPageAndWaitForLoad( + context, + `chrome-extension://${extensionId}/notification.html`, + { viewport: { width: 360, height: 592 } } + ); + await waitUntilStable(page); + console.warn('[MetaMaskSnap] Reacquired notification page.'); + return page; + } catch (error) { + throw new Error( + `[MetaMaskSnap] Failed to reacquire notification page: ${ + (error as Error).message + }` + ); + } +} + +async function exponentialBackoff(attempt: number) { + const delay = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1); + console.warn(`[MetaMaskSnap] Retrying in ${delay}ms...`); + await sleep(delay); +} diff --git a/tests/support/metaMask/handleMetrics.ts b/tests/support/metaMask/handleMetrics.ts new file mode 100644 index 0000000..291af27 --- /dev/null +++ b/tests/support/metaMask/handleMetrics.ts @@ -0,0 +1,36 @@ +import { Page } from '@playwright/test'; + +const DEFAULT_TIMEOUT = 10000; + +const metaMetricsSelectors = { + checkbox: '#metametrics-opt-in', + continueButton: '[data-testid="metametrics-i-agree"]', + doneButton: '[data-testid="onboarding-complete-done"]' +}; + +export async function handleMetaMetrics(page: Page) { + await ensureMetaMetricsOptOut(page); + await clickContinue(page); + await clickDone(page); +} + +async function ensureMetaMetricsOptOut(page: Page) { + const checkbox = page.locator(metaMetricsSelectors.checkbox); + await checkbox.waitFor({ state: 'visible', timeout: DEFAULT_TIMEOUT }); + + if (await checkbox.isChecked()) { + await checkbox.click(); + } +} + +async function clickContinue(page: Page) { + const button = page.locator(metaMetricsSelectors.continueButton); + await button.waitFor({ state: 'visible', timeout: DEFAULT_TIMEOUT }); + await button.click(); +} + +async function clickDone(page: Page) { + const button = page.locator(metaMetricsSelectors.doneButton); + await button.waitFor({ state: 'visible', timeout: DEFAULT_TIMEOUT }); + await button.click(); +} diff --git a/tests/support/metaMask/importWallet.ts b/tests/support/metaMask/importWallet.ts new file mode 100644 index 0000000..899f12b --- /dev/null +++ b/tests/support/metaMask/importWallet.ts @@ -0,0 +1,160 @@ +import path from 'node:path'; +import { chromium, ChromiumBrowserContext, Page } from '@playwright/test'; +import { METAMASK_CACHE_DIR_NAME } from '../template/constants'; +import { waitUntilStable } from '../template/waitUntilStable'; +import { prepareExtension } from './prepareExtension'; + +const DEFAULT_VIEWPORT = { width: 360, height: 592 }; +const CONTEXT_CLOSE_GRACE_MS = 1_000; +const EXT_ID_RETRIES = 5; +const EXT_ID_RETRY_DELAY_MS = 300; +const METAMASK_USER_DIR = 'metamask-user-data'; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +function assertTruthy( + value: T, + msg: string +): asserts value is NonNullable { + if (value === null || value === undefined) { + throw new Error(msg); + } +} + +function toAbsolutePath(p: string) { + return path.resolve(p); +} + +function getUserDataDir(): string { + return path.join(process.cwd(), METAMASK_CACHE_DIR_NAME, METAMASK_USER_DIR); +} + +async function firstPageOrThrow( + context: ChromiumBrowserContext +): Promise { + const [page] = context.pages(); + assertTruthy(page, 'No pages were opened by the extension context.'); + return page; +} + +async function tryGetExtensionIdOnce( + context: ChromiumBrowserContext +): Promise { + // Prefer service workers (MV3); background pages are rare in MV3. + const sw = context.serviceWorkers(); + for (const worker of sw) { + try { + const url = worker.url(); + if (url.startsWith('chrome-extension://')) { + const id = url.split('/')[2]; + if (id) return id; + } + } catch { + // ignore and continue + } + } + + // Fallback: parse from any extension page if present + for (const page of context.pages()) { + try { + const url = page.url(); + if (url.startsWith('chrome-extension://')) { + const id = url.split('/')[2]; + if (id) return id; + } + } catch { + // ignore and continue + } + } + + return null; +} + +async function getExtensionIdWithRetry( + context: ChromiumBrowserContext +): Promise { + for (let attempt = 1; attempt <= EXT_ID_RETRIES; attempt++) { + const id = await tryGetExtensionIdOnce(context); + if (id) return id; + await sleep(EXT_ID_RETRY_DELAY_MS); + } + throw new Error( + 'Could not determine extension ID (service worker/background not ready).' + ); +} + +export async function createBrowserContextWithExtension( + extensionPath: string +): Promise { + assertTruthy(extensionPath, 'Extension path is required'); + + const absoluteExtensionPath = toAbsolutePath(extensionPath); + const userDataDir = getUserDataDir(); + + // Give any previous persistent context time to release file locks. + await sleep(CONTEXT_CLOSE_GRACE_MS); + + try { + const context = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: [ + `--disable-extensions-except=${absoluteExtensionPath}`, + `--load-extension=${absoluteExtensionPath}` + ], + viewport: DEFAULT_VIEWPORT + }); + + assertTruthy(context, 'Failed to create Chromium persistent context'); + return context; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if ( + msg.includes('Target page, context or browser has been closed') || + msg.includes('userDataDir') || + msg.includes('already in use') + ) { + throw new Error( + `Failed to create browser context: a previous persistent context may still be closing or userDataDir is locked. Original error: ${msg}` + ); + } + throw new Error(`Failed to create browser context: ${msg}`); + } +} + +export async function getExtensionId( + context: ChromiumBrowserContext, + _extensionName: string // kept for signature parity; reserved for future filtering +): Promise { + assertTruthy(context, 'Browser context is required'); + try { + return await getExtensionIdWithRetry(context); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to get extension ID: ${msg}`); + } +} + +// Downloads (if needed), loads MetaMask as an extension, and returns the context + extensionId. +export async function setupMetaMaskExtension(): Promise<{ + context: ChromiumBrowserContext; + extensionId: string; +}> { + try { + const extensionPath = await prepareExtension(); + assertTruthy(extensionPath, 'Failed to prepare MetaMask extension'); + + const context = await createBrowserContextWithExtension(extensionPath); + + // Ensure the initial extension page is stable before querying workers/pages + const first = await firstPageOrThrow(context); + await waitUntilStable(first); + + const extensionId = await getExtensionId(context, 'MetaMask'); + assertTruthy(extensionId, 'Failed to resolve MetaMask extension ID'); + + return { context, extensionId }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to setup MetaMask extension: ${msg}`); + } +} diff --git a/tests/support/metaMask/loadExtension.ts b/tests/support/metaMask/loadExtension.ts new file mode 100644 index 0000000..6e1bd24 --- /dev/null +++ b/tests/support/metaMask/loadExtension.ts @@ -0,0 +1,123 @@ +import path from 'node:path'; +import { chromium, type ChromiumBrowserContext } from '@playwright/test'; +import { METAMASK_CACHE_DIR_NAME } from '../template/constants'; +import { waitUntilStable } from '../template/waitUntilStable'; +import { prepareExtension } from './prepareExtension'; + +const METAMASK_USER_DIR = 'metamask-user-data'; +const CONTEXT_RELEASE_GRACE_MS = 1000; +const SERVICE_WORKER_RETRIES = 3; +const SERVICE_WORKER_RETRY_DELAY_MS = 300; + +// Utility: delay +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// Utility: fail early with types preserved +function ensure(value: T | null | undefined, message: string): T { + if (value == null) throw new Error(message); + return value; +} + +// Extract extension id from a service worker url +function extractExtensionIdFromUrl(url: string): string | null { + if (!url.startsWith('chrome-extension://')) return null; + const id = url.split('/')[2]; + return id && id.length > 0 ? id : null; +} + +// Try to read the extension id from existing service workers (with a small retry window for CI flakiness). +export async function getExtensionId( + context: ChromiumBrowserContext, + _extensionName: string // kept for signature parity / future filtering if needed +): Promise { + if (!context) throw new Error('Browser context is required'); + + for (let attempt = 0; attempt <= SERVICE_WORKER_RETRIES; attempt++) { + const workers = context.serviceWorkers(); + for (const sw of workers) { + const id = extractExtensionIdFromUrl(sw.url()); + if (id) return id; + } + if (attempt < SERVICE_WORKER_RETRIES) { + await sleep(SERVICE_WORKER_RETRY_DELAY_MS); + } + } + + throw new Error( + 'Could not find extension ID from service workers. The extension may not be loaded yet.' + ); +} + +// Launch a persistent Chromium context with a single loaded extension. +export async function createBrowserContextWithExtension( + extensionPath: string +): Promise { + const absoluteExtensionPath = path.resolve( + ensure(extensionPath, 'Extension path is required') + ); + + const userDataDir = path.join( + process.cwd(), + METAMASK_CACHE_DIR_NAME, + METAMASK_USER_DIR + ); + + // Give the previous persistent context time to fully release its lock on userDataDir. + await sleep(CONTEXT_RELEASE_GRACE_MS); + + try { + const context = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: [ + `--disable-extensions-except=${absoluteExtensionPath}`, + `--load-extension=${absoluteExtensionPath}` + ] + }); + + return ensure(context, 'Failed to create browser context'); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + const isBusy = + msg.includes('Target page, context or browser has been closed') || + msg.includes('userDataDir') || + msg.includes('already in use'); + + if (isBusy) { + throw new Error( + 'Failed to create browser context: previous persistent context may still be closing. ' + + `Ensure contexts are closed before launching a new one. Original error: ${msg}` + ); + } + throw new Error(`Failed to create browser context with extension: ${msg}`); + } +} + +// Prepare the MetaMask extension, start a browser context, stabilize, and return the context + extensionId. +export async function setupMetaMaskExtension(): Promise<{ + context: ChromiumBrowserContext; + extensionId: string; +}> { + // 1) Ensure extension bits exist on disk (download/unzip if needed). + const extensionPath = ensure( + await prepareExtension(), + 'Failed to prepare MetaMask extension' + ); + + // 2) Launch a persistent context with the extension. + const context = await createBrowserContextWithExtension(extensionPath); + + // 3) Wait for the first page to be interactive (extension often opens a tab). + const firstPage = ensure( + context.pages()[0], + 'Extension did not open an initial page' + ); + await waitUntilStable(firstPage); + + // 4) Resolve the extension id via service worker. + const extensionId = ensure( + await getExtensionId(context, 'MetaMask'), + 'Failed to get extension ID' + ); + + return { context, extensionId }; +} diff --git a/tests/support/metaMask/prepareExtension.ts b/tests/support/metaMask/prepareExtension.ts new file mode 100644 index 0000000..4d2e447 --- /dev/null +++ b/tests/support/metaMask/prepareExtension.ts @@ -0,0 +1,29 @@ +// prepareExtension.ts +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { downloadFileFromUrl } from '../fs/downloadFileFromUrl'; +import { buildMetamaskZipUrl, resolveMetamaskVersion } from './constants'; +import { unzipArchive } from './unzipArchive'; + +export async function prepareExtension(forceCache = true): Promise { + const version = resolveMetamaskVersion(); + + const outputDir = forceCache + ? path.join(process.cwd(), '.cache') + : path.resolve('./', 'downloads'); + + await fs.mkdir(outputDir, { recursive: true }); + + const fileName = `metamask-chrome-${version}.zip`; + const url = buildMetamaskZipUrl(version); + + const { filePath } = await downloadFileFromUrl({ + url, + outputDir, + fileName, + overrideFile: false + }); + + const { outputPath } = await unzipArchive({ archivePath: filePath }); + return outputPath; +} diff --git a/tests/support/metaMask/setupMetaMaskWallet.ts b/tests/support/metaMask/setupMetaMaskWallet.ts new file mode 100644 index 0000000..9d81cdf --- /dev/null +++ b/tests/support/metaMask/setupMetaMaskWallet.ts @@ -0,0 +1,113 @@ +import { Page } from '@playwright/test'; +import { handleMetaMetrics } from '../metaMask/handleMetrics'; +import { getPageAndWaitForLoad } from '../template/getPageAndWaitForLoad'; +import { waitUntilStable } from '../template/waitUntilStable'; +import { createPassword } from './createPassword'; +import { fillSecretRecoveryPhrase } from './fillSecretRecoveryPhrase'; +import { setupMetaMaskExtension } from './loadExtension'; + +const DEFAULT_PAGE_TIMEOUT = 10000; + +const metaMaskSelectors = { + unlockPasswordInput: '[data-testid="unlock-password"]', + importWalletButton: '[data-testid="onboarding-import-wallet"]', + importWithSRPButton: '[data-testid="onboarding-import-with-srp-button"]', + srpNoteInput: '[data-testid="srp-input-import__srp-note"]' +}; + +export async function setupMetaMaskWallet(mnemonic: string, password: string) { + try { + const { context, extensionId } = await initializeMetaMaskExtension(); + const metamaskPage = await openAndPrepareExtensionPage( + context, + extensionId + ); + + const onboardingState = await detectOnboardingState(metamaskPage); + + if (onboardingState === 'unlock') { + await unlockExistingWallet(metamaskPage, password); + return { context, extensionId, metamaskPage }; + } + + await importWalletFlow(metamaskPage, mnemonic, password); + await handleMetaMetrics(metamaskPage); + + return { context, extensionId, metamaskPage }; + } catch (error) { + throw new Error( + `Failed to setup MetaMask wallet: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ); + } +} + +async function initializeMetaMaskExtension() { + const { context, extensionId } = await setupMetaMaskExtension(); + if (!extensionId) throw new Error('Failed to get MetaMask extension ID'); + return { context, extensionId }; +} + +async function openAndPrepareExtensionPage(context: any, extensionId: string) { + const metamaskPage = await getPageAndWaitForLoad( + context, + `chrome-extension://${extensionId}/` + ); + await waitUntilStable(metamaskPage); + return metamaskPage; +} + +async function detectOnboardingState(page: Page) { + const unlockPassword = page.locator(metaMaskSelectors.unlockPasswordInput); + const importWallet = page.locator(metaMaskSelectors.importWalletButton); + + return Promise.race([ + unlockPassword + .waitFor({ state: 'visible', timeout: DEFAULT_PAGE_TIMEOUT }) + .then(() => 'unlock' as const), + importWallet + .waitFor({ state: 'visible', timeout: DEFAULT_PAGE_TIMEOUT }) + .then(() => 'import' as const) + ]).catch(() => { + throw new Error( + 'Neither "unlock-password" nor "onboarding-import-wallet" appeared within timeout' + ); + }); +} + +async function unlockExistingWallet(page: Page, password: string) { + const passwordInput = page.locator(metaMaskSelectors.unlockPasswordInput); + await passwordInput.fill(password); + await passwordInput.press('Enter'); +} + +async function importWalletFlow( + page: Page, + mnemonic: string, + password: string +) { + await startImportProcess(page); + await fillFirstSecretRecoveryPhraseWord(page, mnemonic); + await fillSecretRecoveryPhrase(page, mnemonic); + await createPassword(page, password); +} + +async function startImportProcess(page: Page) { + const importButton = page.locator(metaMaskSelectors.importWalletButton); + await importButton.click(); + + const srpButton = page.locator(metaMaskSelectors.importWithSRPButton); + await srpButton.waitFor({ state: 'visible', timeout: DEFAULT_PAGE_TIMEOUT }); + await srpButton.click(); +} + +async function fillFirstSecretRecoveryPhraseWord(page: Page, mnemonic: string) { + const srpInput = page.locator(metaMaskSelectors.srpNoteInput); + await srpInput.waitFor({ state: 'visible', timeout: DEFAULT_PAGE_TIMEOUT }); + await srpInput.click(); + + const [firstWord] = mnemonic.split(' '); + await srpInput.type(firstWord); + await srpInput.press('Enter'); +} diff --git a/tests/support/metaMask/unzipArchive.ts b/tests/support/metaMask/unzipArchive.ts new file mode 100644 index 0000000..ca1b9a6 --- /dev/null +++ b/tests/support/metaMask/unzipArchive.ts @@ -0,0 +1,81 @@ +import { execFile } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +type UnzipArchiveOptions = { + archivePath: string; + overwrite?: boolean; +}; + +type UnzipArchiveResult = { + outputPath: string; + unzipSkipped: boolean; +}; + +// Unzips an archive using the system `unzip` command. +//Returns the extraction path and whether extraction was skipped due to cache. +export async function unzipArchive( + options: UnzipArchiveOptions +): Promise { + const { archivePath, overwrite = false } = options; + const outputPath = deriveOutputPath(archivePath); + + if (await shouldSkipUnzip(outputPath, overwrite)) { + return { outputPath, unzipSkipped: true }; + } + + await ensureCleanOutputDir(outputPath, overwrite); + await extractZipArchive(archivePath, outputPath); + + return { outputPath, unzipSkipped: false }; +} + +function deriveOutputPath(archivePath: string): string { + const { dir, name } = path.parse(archivePath); + return path.join(dir, name); +} + +async function shouldSkipUnzip( + outputPath: string, + overwrite: boolean +): Promise { + try { + await fs.access(outputPath); + return !overwrite; + } catch { + return false; + } +} + +async function ensureCleanOutputDir( + outputPath: string, + overwrite: boolean +): Promise { + try { + if (overwrite) { + await fs.rm(outputPath, { recursive: true, force: true }); + } + } catch { + // ignore + } + await fs.mkdir(outputPath, { recursive: true }); +} + +// Extracts a ZIP archive using the system `unzip` utility. +// Requires `unzip` to be available on the system (default on macOS/Linux, available via WSL on Windows). +async function extractZipArchive( + archivePath: string, + outputPath: string +): Promise { + try { + await execFileAsync('unzip', ['-o', archivePath, '-d', outputPath]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `[unzipArchive] Failed to extract ${archivePath}: ${message}` + ); + } +} diff --git a/tests/support/metaMask/waitForMetaMaskLoad.ts b/tests/support/metaMask/waitForMetaMaskLoad.ts new file mode 100644 index 0000000..136f41b --- /dev/null +++ b/tests/support/metaMask/waitForMetaMaskLoad.ts @@ -0,0 +1,92 @@ +import { errors, Page } from '@playwright/test'; +import { WaitForMetaMaskLoadOptions } from '../template/types'; +import { waitUntilStable } from '../template/waitUntilStable'; + +const DEFAULT_TIMEOUT = 10_000; +const DEFAULT_CONCURRENCY = 3; + +const BASE_LOADING_SELECTORS: readonly string[] = [ + '.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', + '.spinner' +]; + +/** + * Waits for MetaMask UI to be usable: + * 1) (optional) wait for page stability + * 2) wait for known loading indicators to hide (best-effort) + * 3) brief post delay (conditional) + */ +export async function waitForMetaMaskLoad( + page: Page, + options: WaitForMetaMaskLoadOptions +): Promise { + const { + selectorTimeoutMs = DEFAULT_TIMEOUT, + extraLoadingSelectors = [], + skipInitialStabilityWait = false, + concurrency = DEFAULT_CONCURRENCY // how many selectors to wait on in parallel + } = options; + + try { + if (!skipInitialStabilityWait) { + await waitUntilStable(page); + } + + const selectors = Array.from( + new Set([...BASE_LOADING_SELECTORS, ...extraLoadingSelectors]) + ); + + await waitSelectorsHiddenWithConcurrency( + page, + selectors, + selectorTimeoutMs, + concurrency + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[waitForMetaMaskLoad] Non-fatal warning: ${msg}`); + } +} + +async function waitSelectorsHiddenWithConcurrency( + page: Page, + selectors: string[], + perSelectorTimeoutMs: number, + concurrency: number +) { + // One stability wait per batch, not per selector + await waitUntilStable(page); + + // Process selectors with a small concurrency to reduce polling in CI/CD + let i = 0; + while (i < selectors.length) { + const batch = selectors.slice(i, i + Math.max(1, concurrency)); + await Promise.all( + batch.map(async (selector) => { + try { + await page.waitForSelector(selector, { + state: 'hidden', + timeout: perSelectorTimeoutMs + }); + } catch (err) { + if (err instanceof errors.TimeoutError) { + // OK if a selector never appears on this screen + return; + } + throw err; + } + }) + ); + i += batch.length; + } +} diff --git a/tests/support/template/checkBalanceUpdate.ts b/tests/support/template/checkBalanceUpdate.ts new file mode 100644 index 0000000..68bdc1a --- /dev/null +++ b/tests/support/template/checkBalanceUpdate.ts @@ -0,0 +1,53 @@ +import { expect, Page } from '@playwright/test'; +import { TEST_CONSTANTS } from './constants'; +import { extractBalanceFromContainer } from './extractBalanceFromContainer'; +import { CheckBalanceUpdateType } from './types'; + +export const checkBalanceUpdate = async ({ + page, + containerSelector, + initialBalance, + expectedChange +}: CheckBalanceUpdateType) => { + const expectedBalance = initialBalance + expectedChange; + + const pollFunction = async () => { + const balance = await extractBalanceFromContainer({ + page, + containerSelector, + selectorType: 'testId' + }); + + return balance; + }; + + await expect + .poll(pollFunction, { + message: `Balance should reach ${expectedBalance} (change: ${expectedChange})`, + timeout: TEST_CONSTANTS.BALANCE_POLLING_TIMEOUT + }) + .toBeCloseTo(expectedBalance, TEST_CONSTANTS.BALANCE_PRECISION); +}; + +export const checkPingPongBalanceUpdate = async ({ + page, + containerSelector, + initialBalance, + isPing +}: { + page: Page; + containerSelector: string; + initialBalance: number; + isPing: boolean; +}) => { + const expectedChange = isPing + ? TEST_CONSTANTS.PING_BALANCE_CHANGE + : TEST_CONSTANTS.PONG_BALANCE_CHANGE; + + await checkBalanceUpdate({ + page, + containerSelector, + initialBalance, + expectedChange + }); +}; diff --git a/tests/support/template/checkButtonStatus.ts b/tests/support/template/checkButtonStatus.ts new file mode 100644 index 0000000..64c79ee --- /dev/null +++ b/tests/support/template/checkButtonStatus.ts @@ -0,0 +1,27 @@ +import { expect } from '@playwright/test'; +import { PingPongEnum } from './testdata'; +import { CheckButtonStatusType } from './types'; + +export const checkButtonStatus = async ({ + page, + type, + lastClickedButton +}: CheckButtonStatusType) => { + const container = page.locator(`#ping-pong-${type}`); + await container.scrollIntoViewIfNeeded(); + + const suffix = type === PingPongEnum.abi ? 'Abi' : 'Raw'; + const pingButton = container.getByTestId(`btnPing${suffix}`); + const pongButton = container.getByTestId(`btnPong${suffix}`); + + // Check that ping button became disabled by looking for disabled attribute + if (lastClickedButton === 'ping') { + await expect(pingButton).toHaveAttribute('disabled'); + return; + } + + if (lastClickedButton === 'pong') { + await expect(pongButton).toHaveAttribute('disabled'); + return; + } +}; diff --git a/tests/support/template/checkClipboardContains.ts b/tests/support/template/checkClipboardContains.ts new file mode 100644 index 0000000..1c9b9c8 --- /dev/null +++ b/tests/support/template/checkClipboardContains.ts @@ -0,0 +1,41 @@ +import { expect } from '@playwright/test'; +import { pasteStringFromClipboard } from './pasteStringFromClipboard'; +import { CheckClipboardContainsType } from './types'; + +export const checkClipboardContains = async ({ + page, + context, + text, + timeout = 5000 // 5 seconds +}: CheckClipboardContainsType) => { + const startTime = Date.now(); + const maxAttempts = 10; + let attempts = 0; + + while (attempts < maxAttempts) { + try { + const clipboardText = await pasteStringFromClipboard(page, context); + if (clipboardText.includes(text)) { + return; // Success - text found + } + } catch (error) { + // Retry + } + + attempts++; + + // Check if we've exceeded the timeout + if (Date.now() - startTime > timeout) { + throw new Error( + `Clipboard check timed out after ${timeout}ms. Expected text "${text}" was not found.` + ); + } + + // Wait a bit before the next attempt + await page.waitForTimeout(500); + } + + // Game over - we've exhausted all attempts + const finalClipboardText = await pasteStringFromClipboard(page, context); + expect(finalClipboardText).toContain(text); +}; diff --git a/tests/support/template/checkConnectionToWallet.ts b/tests/support/template/checkConnectionToWallet.ts new file mode 100644 index 0000000..f4cd674 --- /dev/null +++ b/tests/support/template/checkConnectionToWallet.ts @@ -0,0 +1,12 @@ +import { expect, Page } from '@playwright/test'; +import { SelectorsEnum } from './testdata'; + +// Check if the template is connected to the web wallet by veryfing that +// the template page contains the wallet address which confirms the connection +export const checkConnectionToWallet = async ( + page: Page, + walletAddress: string +) => { + const addressElement = await page.getByTestId(SelectorsEnum.accountAddress); + await expect(addressElement).toContainText(walletAddress); +}; diff --git a/tests/support/template/confirmPrivateKeyTransaction.ts b/tests/support/template/confirmPrivateKeyTransaction.ts new file mode 100644 index 0000000..b521a0c --- /dev/null +++ b/tests/support/template/confirmPrivateKeyTransaction.ts @@ -0,0 +1,10 @@ +import { Page } from '@playwright/test'; +import { SelectorsEnum } from './testdata'; + +export const confirmPrivateKeyTransaction = async ( + page: Page, + privateKey: string +) => { + await page.getByTestId(SelectorsEnum.privateKey).fill(privateKey); + await page.getByTestId(SelectorsEnum.submitButton).click(); +}; diff --git a/tests/support/template/confirmWalletTransaction.ts b/tests/support/template/confirmWalletTransaction.ts new file mode 100644 index 0000000..e5d284c --- /dev/null +++ b/tests/support/template/confirmWalletTransaction.ts @@ -0,0 +1,38 @@ +import { Page } from '@playwright/test'; +import { SelectorsEnum } from './testdata'; +import { getTestIdSelector } from './testIdSelector'; +import { WebWalletLoginMethodType } from './types'; + +const confirmWithKeystore = async (walletPage: Page, password: string) => { + await walletPage.getByTestId(SelectorsEnum.passwordInput).fill(password); + await walletPage.getByTestId(SelectorsEnum.submitButton).click(); +}; + +const confirmWithPem = async (walletPage: Page, pemPath: string) => { + await walletPage.setInputFiles( + getTestIdSelector(SelectorsEnum.walletFile), + pemPath + ); + await walletPage.getByTestId(SelectorsEnum.submitButton).click(); +}; + +export const confirmWalletTransaction = async ( + page: Page, + loginMethod: WebWalletLoginMethodType +) => { + switch (true) { + // Authenticate with keystore + case Boolean(loginMethod.keystore): + await confirmWithKeystore(page, loginMethod.password ?? ''); + return; + + // Authenticate with PEM + case Boolean(loginMethod.pem): + await confirmWithPem(page, loginMethod.pem!); + return; + + // No authentication method provided + default: + return; + } +}; diff --git a/tests/support/template/connectInMemoryProvider.ts b/tests/support/template/connectInMemoryProvider.ts new file mode 100644 index 0000000..5836976 --- /dev/null +++ b/tests/support/template/connectInMemoryProvider.ts @@ -0,0 +1,32 @@ +import { SelectorsEnum } from './testdata'; +import { InMemoryProviderType } from './types'; + +const authenticateWithInMemoryProvider = async ({ + page, + loginMethod +}: InMemoryProviderType) => { + await page.locator(SelectorsEnum.addressInput).fill(loginMethod.address); + await page + .locator(SelectorsEnum.privateKeyInput) + .fill(loginMethod.privateKey); + + await page.getByRole('button', { name: 'Submit' }).click(); +}; + +export const connectInMemoryProvider = async ({ + page, + loginMethod +}: InMemoryProviderType) => { + await page.getByTestId(SelectorsEnum.inMemoryButton).click(); + + if (!loginMethod.address || !loginMethod.privateKey) { + throw new Error( + 'Both address and private key are required when using an in memory provider.' + ); + } + + await authenticateWithInMemoryProvider({ + page, + loginMethod + }); +}; diff --git a/tests/support/template/connectWebWallet.ts b/tests/support/template/connectWebWallet.ts new file mode 100644 index 0000000..dd2dfaf --- /dev/null +++ b/tests/support/template/connectWebWallet.ts @@ -0,0 +1,120 @@ +import { TEST_CONSTANTS } from './constants'; +import { getPageAndWaitForLoad } from './getPageAndWaitForLoad'; +import { OriginPageEnum, SelectorsEnum } from './testdata'; +import { getTestIdSelector } from './testIdSelector'; +import { + AuthenticateWithKeystoreType, + AuthenticateWithPemType, + ConnectWebWalletType +} from './types'; + +const authenticateWithKeystore = async ({ + walletPage, + keystorePath, + keystorePassword +}: AuthenticateWithKeystoreType) => { + // Click the keystore button + await walletPage.getByTestId(SelectorsEnum.keystoreButton).click(); + + // Upload the keystore file + await walletPage.setInputFiles( + getTestIdSelector(SelectorsEnum.walletFile), + keystorePath + ); + + // Wait for file processing to complete + await walletPage.waitForLoadState(); + + // Fill the password input + const passwordInput = walletPage.getByTestId(SelectorsEnum.passwordInput); + await passwordInput.fill(keystorePassword); + + // Wait for file processing to complete + await walletPage.waitForLoadState(); + + // Click the submit button + await walletPage.getByTestId(SelectorsEnum.submitButton).click(); + + // Click the confirm button + await walletPage.getByTestId(SelectorsEnum.confirmButton).click(); +}; + +const authenticateWithPem = async ({ + walletPage, + pemPath +}: AuthenticateWithPemType) => { + // Click the PEM button + await walletPage.getByTestId(SelectorsEnum.pemButton).click(); + + // Upload the PEM file + await walletPage.setInputFiles( + getTestIdSelector(SelectorsEnum.walletFile), + pemPath + ); + + // Wait for file processing to complete + await walletPage.waitForLoadState(); + + // Click the submit button + await walletPage.getByTestId(SelectorsEnum.submitButton).click(); +}; + +export const connectWebWallet = async ({ + page, + loginMethod +}: ConnectWebWalletType) => { + // Click the cross-window button to open wallet + await page.getByTestId(SelectorsEnum.crossWindow).click(); + + // Wait for the web wallet page to be loaded which is the new tab + const walletPage = await getPageAndWaitForLoad( + page.context(), + OriginPageEnum.multiversxWallet + ); + + // If the web wallet page is not loaded, throw an error + if (!walletPage) { + throw new Error( + `Web wallet page containing ${OriginPageEnum.multiversxWallet} is not loaded.` + ); + } + + // Authenticate with keystore + if (loginMethod.keystore) { + if (!loginMethod.password) { + throw new Error('Password is required when using a keystore.'); + } + + await authenticateWithKeystore({ + walletPage, + keystorePath: loginMethod.keystore, + keystorePassword: loginMethod.password + }); + + try { + await walletPage.waitForEvent('close', { + timeout: TEST_CONSTANTS.PAGE_CLOSE_TIMEOUT + }); + } catch (error) { + // do nothing + } + return; + } + + // Authenticate with PEM + if (loginMethod.pem) { + await authenticateWithPem({ + walletPage, + pemPath: loginMethod.pem + }); + + try { + await walletPage.waitForEvent('close', { + timeout: TEST_CONSTANTS.PAGE_CLOSE_TIMEOUT + }); + } catch (error) { + // do nothing + } + return; + } +}; diff --git a/tests/support/template/constants.ts b/tests/support/template/constants.ts new file mode 100644 index 0000000..af40153 --- /dev/null +++ b/tests/support/template/constants.ts @@ -0,0 +1,25 @@ +export const PATHS = { + WALLETS_DIR: 'tests/support/wallets' +} as const; + +export const METAMASK_CACHE_DIR_NAME = '.cache'; + +export const TEST_CONSTANTS = { + MIN_BALANCE_FOR_PING_PONG: 1, // 1 EGLD + MIN_BALANCE_FOR_BATCH_TX: 5, // 5 EGLD + MIN_BALANCE_FOR_WRAP_AND_MULTI_TRANSFER: 4, // 4 EGLD + MIN_BALANCE_FOR_SWAP_AND_LOCK: 4, // 4 EGLD + + URL_NAVIGATION_TIMEOUT: 10000, // 10 seconds + BALANCE_POLLING_TIMEOUT: 90000, // 90 seconds + PAGE_WAIT_TIMEOUT: 30000, // 30 seconds + TOAST_WAIT_TIMEOUT: 60000, // 60 seconds + + PING_PONG_COOLDOWN: 3 * 60 * 1000, // 3 minutes in milliseconds + PING_BALANCE_CHANGE: -1.0, // Balance decreases by 1 EGLD + gas fees + PONG_BALANCE_CHANGE: 1.0, // Balance increases by 1 EGLD - gas fees + + BALANCE_PRECISION: 2, + + PAGE_CLOSE_TIMEOUT: 10000 // 10 seconds +} as const; diff --git a/tests/support/template/extractBalanceFromContainer.ts b/tests/support/template/extractBalanceFromContainer.ts new file mode 100644 index 0000000..df6a9ad --- /dev/null +++ b/tests/support/template/extractBalanceFromContainer.ts @@ -0,0 +1,39 @@ +import { expect } from '@playwright/test'; +import { SelectorsEnum } from './testdata'; +import { getTestIdSelector } from './testIdSelector'; +import { ExtractBalanceFromContainerType } from './types'; + +export const extractBalanceFromContainer = async ({ + page, + containerSelector, + selectorType = 'testId' +}: ExtractBalanceFromContainerType): Promise => { + const containerElement = + selectorType === 'testId' + ? page.getByTestId(containerSelector) + : page.locator(containerSelector); + + await containerElement.scrollIntoViewIfNeeded(); + + const balanceElement = containerElement.getByTestId(SelectorsEnum.balance); + await expect(balanceElement).toBeVisible(); + + // Integer part + const intText = ( + await balanceElement + .locator(getTestIdSelector(SelectorsEnum.formatAmountInt)) + .innerText() + )?.trim(); + + // Decimals part + const decimalsText = ( + await balanceElement + .locator(getTestIdSelector(SelectorsEnum.formatAmountDecimals)) + .innerText() + )?.trim(); + + // Combine with decimal point + const fullNumber = decimalsText ? `${intText}.${decimalsText}` : intText; + + return parseFloat(fullNumber || '0'); +}; diff --git a/tests/support/template/getPageAndWaitForLoad.ts b/tests/support/template/getPageAndWaitForLoad.ts new file mode 100644 index 0000000..86965a2 --- /dev/null +++ b/tests/support/template/getPageAndWaitForLoad.ts @@ -0,0 +1,78 @@ +import { BrowserContext, Page } from '@playwright/test'; +import { waitUntilStable } from './waitUntilStable'; + +const DEFAULT_WAIT_TIMEOUT = 15_000; + +export async function getPageAndWaitForLoad( + context: BrowserContext, + pageUrlFragment: string, + options?: { viewport?: { width: number; height: number }; timeout?: number } +): Promise { + let retries = 0; + const maxRetries = 3; + + while (retries < maxRetries) { + try { + const timeoutMs = options?.timeout || DEFAULT_WAIT_TIMEOUT; + + // Try to find an already open page or wait (by polling) for a page to + // navigate to the expected URL. Relying on the 'page' event can + // miss cases where the page already exists (about:blank) and then + // navigates later, or when navigation happens in the same tab. + const page = + context.pages().find((p) => p.url().includes(pageUrlFragment)) || + (await waitForPageByUrlFragment(context, pageUrlFragment, timeoutMs)); + + if (!page) throw new Error('Notification page not found'); + + if (options?.viewport) { + await page.setViewportSize(options.viewport); + } + + // Wait for the page to be stable + await waitUntilStable(page); + + return page; + } catch (error) { + retries++; + const message = error instanceof Error ? error.message : 'Unknown error'; + const openedPageUrls = context + .pages() + .map((p) => p.url()) + .filter(Boolean); + console.warn( + `[getPageAndWaitForLoad] Retry ${retries}/3 after error: ${message}. Open pages (${ + openedPageUrls.length + }): ${openedPageUrls.join(', ')}` + ); + + if (retries >= maxRetries) throw error; + await new Promise((r) => setTimeout(r, 1000 * retries)); + } + } + + throw new Error('getPageAndWaitForLoad: Unexpected end of retries'); +} + +async function waitForPageByUrlFragment( + context: BrowserContext, + fragment: string, + timeoutMs: number +): Promise { + const pollIntervalMs = 250; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const match = context.pages().find((p) => p.url().includes(fragment)); + if (match) return match; + + // Briefly wait for any new page to appear; ignore timeouts and keep polling. + try { + await context.waitForEvent('page', { timeout: pollIntervalMs }); + } catch { + // no-op; continue polling + } + } + + return null; +} diff --git a/tests/support/template/globalSetup.ts b/tests/support/template/globalSetup.ts new file mode 100644 index 0000000..5196214 --- /dev/null +++ b/tests/support/template/globalSetup.ts @@ -0,0 +1,95 @@ +// This global setup materializes secret keystore files from environment variables +// so Playwright tests can reference stable file paths without committing secrets. +// +// Secrets source: +// - On CI (GitHub Actions): injected via repository/environment Secrets +// (e.g. KEYSTORE1_JSON_B64, KEYSTORE{N}_PASSWORD, KEYSTORE{N}_ADDRESS). +// - Locally: provided via .env.test.local/.env and loaded here. +// +// Why base64? +// - JSON keystores are stored as KEYSTORE{N}_JSON_B64 to pass safely through +// CI env vars and avoid accidental logging. We decode and write them to +// WALLETS_DIR on startup. Passwords/addresses are read directly from env +// by the tests and are never written to disk. + +import path from 'path'; +import { loadLocalEnv } from './loadLocalEnv'; +import { writeValueToFile } from '../fs/writeValueToFile'; +import { type FileEncoding } from './types'; + +// Load environment variables for local runs; CI already has env set +loadLocalEnv('test'); + +// Write keystore files from environment variables +async function writeKeystoreFilesFromEnv( + defaultEncoding: FileEncoding = 'base64', + skipIfExists: boolean = false +) { + // Resolve wallets directory without relying on process.cwd + const walletsDir = process.env.WALLETS_DIR + ? path.resolve(process.env.WALLETS_DIR) + : path.resolve(__dirname, '..', 'wallets'); + + const mappings: Array<{ + envKey: string; + outPath: string; + encoding?: FileEncoding; + }> = [ + { + envKey: 'KEYSTORE1_JSON_B64', + outPath: `${walletsDir}/keystoreFile1.json`, + encoding: 'base64' + }, + { + envKey: 'KEYSTORE2_JSON_B64', + outPath: `${walletsDir}/keystoreFile2.json`, + encoding: 'base64' + }, + { + envKey: 'KEYSTORE3_JSON_B64', + outPath: `${walletsDir}/keystoreFile3.json`, + encoding: 'base64' + }, + { + envKey: 'KEYSTORE4_JSON_B64', + outPath: `${walletsDir}/keystoreFile4.json`, + encoding: 'base64' + }, + { + envKey: 'KEYSTORE5_PEM_B64', + outPath: `${walletsDir}/keystoreFile5.pem`, + encoding: 'base64' + }, + { + envKey: 'KEYSTORE6_PRIVATE_KEY_UTF8', + outPath: `${walletsDir}/keystoreFile6.key`, + encoding: 'none' + } + ]; + + // Write keystore files from environment variables + for (const { envKey, outPath, encoding } of mappings) { + const value = process.env[envKey]; + if (!value?.trim()) { + console.warn(`[globalSetup] Missing or empty env var: ${envKey}`); + continue; + } + + try { + writeValueToFile( + value, + outPath, + encoding ?? defaultEncoding, + skipIfExists + ); + console.log(`[globalSetup] Wrote ${outPath}`); + } catch (error) { + console.error(`[globalSetup] Failed to write ${outPath}:`, error); + } + } +} + +// Global setup function +export default async function globalSetup() { + await writeKeystoreFilesFromEnv('base64', true); +} diff --git a/tests/support/template/handlePingPong.ts b/tests/support/template/handlePingPong.ts new file mode 100644 index 0000000..fe5f9cb --- /dev/null +++ b/tests/support/template/handlePingPong.ts @@ -0,0 +1,36 @@ +import { TEST_CONSTANTS } from './constants'; +import { PingPongEnum } from './testdata'; +import { HandlePingPongType } from './types'; + +export const handlePingPong = async ({ page, type }: HandlePingPongType) => { + const container = page.locator(`#ping-pong-${type}`); + await container.scrollIntoViewIfNeeded(); + + const suffix = type === PingPongEnum.abi ? 'Abi' : 'Raw'; + const pingButton = container.getByTestId(`btnPing${suffix}`); + const pongButton = container.getByTestId(`btnPong${suffix}`); + + // Use Playwright's isDisabled() for clarity + const isPingEnabled = !(await pingButton.isDisabled()); + const isPongEnabled = !(await pongButton.isDisabled()); + + if (!isPingEnabled && !isPongEnabled) { + throw new Error('Neither Ping nor Pong button are enabled!'); + } + + if (isPingEnabled) { + await pingButton.click(); + return 'ping'; + } + + if (isPongEnabled) { + await pongButton.click(); + return 'pong'; + } + + throw new Error( + `Neither Ping nor Pong button are enabled! Did you send a Ping or Pong within ${ + TEST_CONSTANTS.PING_PONG_COOLDOWN / 1000 / 60 + } minutes?` + ); +}; diff --git a/tests/support/template/index.ts b/tests/support/template/index.ts new file mode 100644 index 0000000..f577492 --- /dev/null +++ b/tests/support/template/index.ts @@ -0,0 +1,51 @@ +export { checkBalanceUpdate } from './checkBalanceUpdate'; + +export { checkButtonStatus } from './checkButtonStatus'; + +export { checkClipboardContains } from './checkClipboardContains'; + +export { checkConnectionToWallet } from './checkConnectionToWallet'; + +export { checkPingPongBalanceUpdate } from './checkBalanceUpdate'; + +export { confirmWalletTransaction } from './confirmWalletTransaction'; + +export { connectInMemoryProvider } from './connectInMemoryProvider'; + +export { connectWebWallet } from './connectWebWallet'; + +export { countTransactions } from './parseTransactionsTable'; + +export { ensureDirectoryExists } from '../fs/ensureDirectoryExists'; + +export { extractBalanceFromContainer } from './extractBalanceFromContainer'; + +export { filterTransactions } from './parseTransactionsTable'; + +export { getTestIdSelector } from './testIdSelector'; + +export { handleMetaMaskSnap } from '../metaMask/handleMetaMaskSnap'; + +export { handlePingPong } from './handlePingPong'; + +export { navigateToConnectWallet } from './navigateToConnectWallet'; + +export { parseTransactionsTable } from './parseTransactionsTable'; + +export { pasteStringFromClipboard } from './pasteStringFromClipboard'; + +export { readValueFromFile } from '../fs/readValueFromFile'; + +export { signBatchTransactions } from './signBatchTransactions'; + +export { getPageAndWaitForLoad } from './getPageAndWaitForLoad'; + +export { waitForToastToBeClosed } from './waitForToastToBeClosed'; + +export { waitForToastToBeDisplayed } from './waitForToastToBeDisplayed'; + +export { waitForTransactionToastToContain } from './waitForTransactionToastToShow'; + +export { waitUntilStable } from './waitUntilStable'; + +export { writeValueToFile } from '../fs/writeValueToFile'; diff --git a/tests/support/template/loadLocalEnv.ts b/tests/support/template/loadLocalEnv.ts new file mode 100644 index 0000000..cafb2cf --- /dev/null +++ b/tests/support/template/loadLocalEnv.ts @@ -0,0 +1,17 @@ +import { loadEnv } from 'vite'; + +export function loadLocalEnv(modeDefault: string = 'test'): void { + try { + const mode = process.env.MODE || modeDefault; + const localEnv = loadEnv(mode, process.cwd(), ''); + for (const [key, value] of Object.entries(localEnv)) { + if (process.env[key] === undefined) { + process.env[key] = value; + } + } + } catch (_) { + // If vite's loadEnv isn't available, silently skip. + } +} + + diff --git a/tests/support/template/navigateToConnectWallet.ts b/tests/support/template/navigateToConnectWallet.ts new file mode 100644 index 0000000..d19919f --- /dev/null +++ b/tests/support/template/navigateToConnectWallet.ts @@ -0,0 +1,18 @@ +import { Page } from '@playwright/test'; + +export const navigateToConnectWallet = async (page: Page) => { + const maxRetries = 5; + for (let i = 0; i < maxRetries; i++) { + try { + await page.goto('/', { waitUntil: 'load', timeout: 60 * 1000 }); // Increase timeout + break; + } catch (error) { + console.warn( + `[navigateToConnectWallet] Attempt ${i + 1} failed: ${error.message}` + ); + if (i === maxRetries - 1) throw error; + await page.waitForTimeout(5000); // Increase wait time + } + } + await page.getByRole('button', { name: 'Connect' }).first().click(); +}; diff --git a/tests/support/template/parseTransactionsTable.ts b/tests/support/template/parseTransactionsTable.ts new file mode 100644 index 0000000..9b65d67 --- /dev/null +++ b/tests/support/template/parseTransactionsTable.ts @@ -0,0 +1,340 @@ +import { SelectorsEnum } from './testdata'; +import { getTestIdSelector } from './testIdSelector'; +import { + ParseTransactionsTableType, + TransactionFilter, + TransactionRow +} from './types'; + +// Function to count transactions with given filters +export const countTransactions = ( + transactions: TransactionRow[], + filter: TransactionFilter +): number => { + const filteredTransactions = filterTransactions(transactions, filter); + const count = filteredTransactions.length; + + if (filter.enableLogging) { + console.log( + `countTransactions: Found ${count} matching transactions out of ${transactions.length} total` + ); + } + + return count; +}; + +// Helper function to parse age text into minutes +const parseAge = (ageText: string): number => { + const match = ageText.match(/(\d+)\s*(sec|min|hr|day)/); + if (!match) { + return 0; + } + + const value = parseInt(match[1]); + const unit = match[2].toLowerCase(); + + // TODO: add more units if needed besides sec, min, hr, day + switch (unit) { + case 'sec': + return value / 60; // seconds to minutes + case 'min': + return value; + case 'hr': + return value * 60; // hours to minutes + case 'day': + return value * 24 * 60; // days to minutes + default: + return 1000; // default in case of unexpected unit + } +}; + +// Helper function to filter transactions +export const filterTransactions = ( + transactions: TransactionRow[], + filter: TransactionFilter +): TransactionRow[] => { + const enableLogging = filter.enableLogging || false; + + if (enableLogging) { + console.log( + `Filtering ${transactions.length} transactions with criteria:`, + { + exactValue: filter.exactValue, + methods: filter.methods, + maxAgeInMinutes: filter.maxAgeInMinutes, + minAgeInMinutes: filter.minAgeInMinutes, + onlySuccessful: filter.onlySuccessful, + onlyFailed: filter.onlyFailed, + fromAddress: filter.fromAddress, + toAddress: filter.toAddress + } + ); + } + + const filteredTransactions = transactions.filter((transaction, index) => { + let matches = true; + let reason = ''; + + // Age filter + if ( + filter.maxAgeInMinutes !== undefined && + transaction.ageInMinutes > filter.maxAgeInMinutes + ) { + matches = false; + reason = `age ${transaction.ageInMinutes} > max ${filter.maxAgeInMinutes}`; + } + if ( + matches && + filter.minAgeInMinutes !== undefined && + transaction.ageInMinutes < filter.minAgeInMinutes + ) { + matches = false; + reason = `age ${transaction.ageInMinutes} < min ${filter.minAgeInMinutes}`; + } + + // Method filter + if ( + matches && + filter.methods && + !filter.methods.includes(transaction.method) + ) { + matches = false; + reason = `method "${transaction.method}" not in [${filter.methods.join( + ', ' + )}]`; + } + + // Value filter + if ( + matches && + filter.exactValue !== undefined && + transaction.value !== filter.exactValue + ) { + matches = false; + reason = `value ${transaction.value} !== ${filter.exactValue}`; + } + + // Status filter + if (matches && filter.onlySuccessful && transaction.isFailed) { + matches = false; + reason = 'transaction failed but onlySuccessful=true'; + } + if (matches && filter.onlyFailed && !transaction.isFailed) { + matches = false; + reason = 'transaction successful but onlyFailed=true'; + } + + // Address filters + if ( + matches && + filter.fromAddress && + !transaction.fromAddress.includes(filter.fromAddress) + ) { + matches = false; + reason = `fromAddress "${transaction.fromAddress}" does not contain "${filter.fromAddress}"`; + } + if ( + matches && + filter.toAddress && + !transaction.toAddress.includes(filter.toAddress) + ) { + matches = false; + reason = `toAddress "${transaction.toAddress}" does not contain "${filter.toAddress}"`; + } + + if (enableLogging) { + console.log( + `Transaction ${index + 1}: ${ + matches ? 'MATCH' : 'FILTERED' + } - method="${transaction.method}", value=${transaction.value}, age=${ + transaction.ageInMinutes + }min${reason ? `, reason: ${reason}` : ''}` + ); + } + + return matches; + }); + + if (enableLogging) { + console.log( + `Filtered ${transactions.length} transactions down to ${filteredTransactions.length} matches` + ); + } + + return filteredTransactions; +}; + +export const parseTransactionsTable = async ({ + page, + tableSelector, + tableType, + maxRows, + enableLogging = false +}: ParseTransactionsTableType): Promise => { + // Set table selector based on table type + const defaultTableSelector = + tableType === 'ping-pong' + ? SelectorsEnum.transactionsPingPongTable + : SelectorsEnum.transactionsAllTable; + + const finalTableSelector = tableSelector || defaultTableSelector; + const table = page.locator(finalTableSelector); + await table.waitFor({ state: 'visible' }); + await table.scrollIntoViewIfNeeded(); + + // Get all table rows (excluding header) + const rows = table.locator(SelectorsEnum.transactionsTableBodyRow); + const totalRowCount = await rows.count(); + + // Limit the number of rows to process if maxRows is specified + const rowCount = maxRows ? Math.min(maxRows, totalRowCount) : totalRowCount; + + if (enableLogging) { + console.log( + `Found ${totalRowCount} transaction rows, processing ${rowCount} rows` + ); + } + + const transactions: TransactionRow[] = []; + + for (let i = 0; i < rowCount; i++) { + const row = rows.nth(i); + + try { + if (enableLogging) { + console.log(`Processing row ${i + 1}/${rowCount}`); + } + + // Extract transaction hash + const hashElement = row.locator( + getTestIdSelector(SelectorsEnum.transactionLink) + ); + const hash = await hashElement.getAttribute('href'); + const transactionHash = hash ? hash.split('/').pop() || '' : ''; + + // Extract age + const ageElement = row.locator( + getTestIdSelector(SelectorsEnum.transactionAge) + ); + const ageElementCount = await ageElement.count(); + + if (enableLogging) { + console.log(`Row ${i + 1} age element count: ${ageElementCount}`); + } + + let ageText = ''; + let age = 0; + + if (ageElementCount > 0) { + ageText = (await ageElement.textContent()) || ''; + age = parseAge(ageText); + if (enableLogging) { + console.log(`Row ${i + 1} age text: "${ageText}" -> ${age} minutes`); + } + } else if (enableLogging) { + console.log(`Row ${i + 1} age element not found`); + } + + // Extract shard info + const senderShardElement = row.locator( + getTestIdSelector(SelectorsEnum.senderShard) + ); + const receiverShardElement = row.locator( + getTestIdSelector(SelectorsEnum.receiverShard) + ); + const senderShard = (await senderShardElement.textContent()) || ''; + const receiverShard = (await receiverShardElement.textContent()) || ''; + + // Extract from/to addresses - use more flexible selectors + const senderElement = row.locator( + `${getTestIdSelector( + SelectorsEnum.transactionSender + )} ${getTestIdSelector(SelectorsEnum.trimFullAddress)}` + ); + const receiverElement = row.locator( + `${getTestIdSelector( + SelectorsEnum.transactionReceiver + )} ${getTestIdSelector(SelectorsEnum.receiverLink)}` + ); + + // Check if elements exist before trying to get text content + const fromAddress = + (await senderElement.count()) > 0 + ? (await senderElement.textContent()) || '' + : ''; + const toAddress = + (await receiverElement.count()) > 0 + ? (await receiverElement.textContent()) || '' + : ''; + + // Extract method + const methodElement = row.locator( + getTestIdSelector(SelectorsEnum.method) + ); + const method = (await methodElement.textContent()) || ''; + + // Extract value + const valueIntElement = row.locator( + getTestIdSelector(SelectorsEnum.formatAmountInt) + ); + const valueDecimalsElement = row.locator( + getTestIdSelector(SelectorsEnum.formatAmountDecimals) + ); + const valueSymbolElement = row.locator( + getTestIdSelector(SelectorsEnum.formatAmountSymbol) + ); + + // Check if elements exist before trying to get text content + const valueIntElementCount = await valueIntElement.count(); + const valueDecimalsElementCount = await valueDecimalsElement.count(); + const valueSymbolElementCount = await valueSymbolElement.count(); + + // Extract value components with fallbacks for missing elements + // Some transaction rows may not have all value elements (e.g., failed transactions) + const valueInt = + valueIntElementCount > 0 + ? (await valueIntElement.textContent()) || '0' + : '0'; + const valueDecimals = + valueDecimalsElementCount > 0 + ? (await valueDecimalsElement.textContent()) || '.00' + : '.00'; + const valueSymbol = + valueSymbolElementCount > 0 + ? (await valueSymbolElement.textContent()) || '' + : ''; + + const value = parseFloat(`${valueInt}${valueDecimals}`); + + // Check if transaction failed (has error icon) + const hasErrorIcon = + (await row.locator(SelectorsEnum.errorIcon).count()) > 0; + + transactions.push({ + hash: transactionHash, + age: ageText, + ageInMinutes: age, + senderShard, + receiverShard, + fromAddress, + toAddress, + method: method.toLowerCase(), + value, + symbol: valueSymbol, + isFailed: hasErrorIcon + }); + + if (enableLogging) { + console.log(`Successfully processed row ${i + 1}`); + } + } catch (error) { + if (enableLogging) { + console.log(`Error processing row ${i + 1}:`, error); + } + // Continue with next row instead of failing completely + continue; + } + } + + return transactions; +}; diff --git a/tests/support/template/pasteStringFromClipboard.ts b/tests/support/template/pasteStringFromClipboard.ts new file mode 100644 index 0000000..49cf581 --- /dev/null +++ b/tests/support/template/pasteStringFromClipboard.ts @@ -0,0 +1,10 @@ +import { BrowserContext, Page } from '@playwright/test'; + +export const pasteStringFromClipboard = async ( + page: Page, + context: BrowserContext +) => { + await context.grantPermissions(['clipboard-write', 'clipboard-read']); + const text = await page.evaluate(() => navigator.clipboard.readText()); + return text; +}; diff --git a/tests/support/template/signBatchTransactions.ts b/tests/support/template/signBatchTransactions.ts new file mode 100644 index 0000000..7d0650a --- /dev/null +++ b/tests/support/template/signBatchTransactions.ts @@ -0,0 +1,14 @@ +import { SelectorsEnum } from './testdata'; +import { SignBatchTransactionsType } from './types'; + +export const signBatchTransactions = async ({ + walletPage, + buttonSelector, + numberOfTransactions +}: SignBatchTransactionsType) => { + let i = buttonSelector === 'swap-lock' ? 1 : 0; + for (i; i < numberOfTransactions; i++) { + await walletPage.waitForLoadState(); + await walletPage.getByTestId(SelectorsEnum.signButton).click(); + } +}; diff --git a/tests/support/template/testIdSelector.ts b/tests/support/template/testIdSelector.ts new file mode 100644 index 0000000..1019495 --- /dev/null +++ b/tests/support/template/testIdSelector.ts @@ -0,0 +1,7 @@ +/** + * Helper function to create a data-testid selector string + * @param testId - The test ID value + * @returns CSS selector string for data-testid attribute + */ +export const getTestIdSelector = (testId: string): string => + `[data-testid="${testId}"]`; diff --git a/tests/support/template/testdata.ts b/tests/support/template/testdata.ts new file mode 100644 index 0000000..70b6098 --- /dev/null +++ b/tests/support/template/testdata.ts @@ -0,0 +1,119 @@ +import path from 'path'; +import { loadLocalEnv } from './loadLocalEnv'; + +// Ensure local .env files (e.g., .env.test.local) are loaded when running locally. +loadLocalEnv('test'); + +const walletsDir = process.env.WALLETS_DIR + ? path.resolve(process.env.WALLETS_DIR) + : path.resolve(__dirname, '..', 'wallets'); + +export const NA = 'N/A'; + +export enum OriginPageEnum { + multiversxWallet = 'devnet-wallet.multiversx.com', + templateHome = 'localhost:3000/home', + templateDashboard = 'localhost:3000/dashboard', + templateUnlock = 'localhost:3000/unlock' +} + +export const UrlRegex = { + templateDashboard: /localhost:3000\/dashboard/, + multiversxWallet: /devnet-wallet\.multiversx\.com/ +} as const; + +export enum PingPongEnum { + raw = 'raw', + abi = 'abi', + service = 'service' +} + +export enum SelectorsEnum { + // TODO: arange selectors by template dashboard, web wallet, transaction table, table containers + // Template dashboard selectors + walletFile = 'walletFile', + passwordInput = 'accessPass', + submitButton = 'submitButton', + confirmButton = 'confirmBtn', + keystoreCloseModalButton = 'keystoreCloseModalBtn', + signButton = 'signBtn', + topInfoContainer = 'topInfo', + crossWindow = 'crossWindow', + keystoreButton = 'keystoreBtn', + inMemoryButton = 'inMemoryProvider', + pemButton = 'pemBtn', + pingButton = 'btnPingRaw', + pongButton = 'btnPongRaw', + toastTitle = 'transactionToastTitle', + toastContent = 'transactionToastContent', + toastStatus = 'transactionDetailsStatus', + balance = 'balance', + formatAmountInt = 'formatAmountInt', + formatAmountDecimals = 'formatAmountDecimals', + signMsgButton = 'signMsgBtn', + signMsgWalletButton = 'signButton', + signAndBatchButton = 'sign-auto-send', + wrapAndMultiTransferButton = 'wrap-multi-transfer', + swapAndLockButton = 'swap-lock', + nativeAuthContainer = '#native-auth', + trimFullAddress = 'trimFullAddress', + pingPongAbiContainer = '#ping-pong-abi', + batchTransactionsContainer = '#batch-transactions', + signMessageContainer = '#sign-message', + accountAddress = 'accountAddress', + heroTag = 'heroTag', + shard = 'addressShard', + decodedMessage = 'decodedMessage', + addressInput = 'input[name="address"]', + privateKeyInput = 'input[name="privateKey"]', + cancelButton = 'cancelButton', + sidePanel = '#side-panel', + sidePanelCloseIcon = 'svg', + + // Web wallet selectors + signCancelButton = 'signCancelBtn', + + // Transaction table selectors + transactionLink = 'transactionLink', + transactionAge = 'transactionAge', + senderShard = 'senderShard', + receiverShard = 'receiverShard', + transactionSender = 'transactionSender', + transactionReceiver = 'transactionReceiver', + receiverLink = 'receiverLink', + method = 'method', + formatAmountSymbol = 'formatAmountSymbol', + errorIcon = '.mvx\\:text-error', + + // Table containers + transactionsAllTable = '#transactions-all table', + transactionsPingPongTable = '#transactions-ping-pong table', + transactionsTableBodyRow = 'tbody tr.transactions-table-body-row' +} + +// Test data is parameterized via environment variables so secrets (passwords, +// addresses, keystores) come from GitHub Secrets in CI or from local .env. +export const TestDataEnums = { + keystorePassword1: process.env.KEYSTORE1_PASSWORD || NA, + keystorePassword2: process.env.KEYSTORE2_PASSWORD || NA, + keystorePassword3: process.env.KEYSTORE3_PASSWORD || NA, + keystorePassword4: process.env.KEYSTORE4_PASSWORD || NA, + + keystoreWalletAddress1: process.env.KEYSTORE1_ADDRESS || NA, + keystoreWalletAddress2: process.env.KEYSTORE2_ADDRESS || NA, + keystoreWalletAddress3: process.env.KEYSTORE3_ADDRESS || NA, + keystoreWalletAddress4: process.env.KEYSTORE4_ADDRESS || NA, + keystoreWalletAddress5: process.env.KEYSTORE5_ADDRESS || NA, + keystoreWalletAddress6: process.env.KEYSTORE6_ADDRESS || NA, + + metamaskMnemonic: process.env.METAMASK_MNEMONIC, + metamaskAddress: process.env.METAMASK_ADDRESS, + metamaskPassword: process.env.METAMASK_PASSWORD, + + keystoreFilePath1: `${walletsDir}/keystoreFile1.json`, + keystoreFilePath2: `${walletsDir}/keystoreFile2.json`, + keystoreFilePath3: `${walletsDir}/keystoreFile3.json`, + keystoreFilePath4: `${walletsDir}/keystoreFile4.json`, + keystoreFilePath5: `${walletsDir}/keystoreFile5.pem`, + keystoreFilePath6: `${walletsDir}/keystoreFile6.key` +} as const; diff --git a/tests/support/template/types.ts b/tests/support/template/types.ts new file mode 100644 index 0000000..c986dc1 --- /dev/null +++ b/tests/support/template/types.ts @@ -0,0 +1,183 @@ +import { BrowserContext, Page } from '@playwright/test'; +import { PingPongEnum } from './testdata'; + +export type FileEncoding = 'base64' | 'utf8' | 'none'; + +export interface AuthenticateWithKeystoreType { + walletPage: Page; + keystorePath: string; + keystorePassword: string; +} + +export interface ConnectPasskeyWalletType { + page: Page; +} + +export interface InMemoryProviderType { + page: Page; + loginMethod: InMemoryProviderLoginMethodType; +} + +export interface AuthenticateWithPemType { + walletPage: Page; + pemPath: string; +} + +export interface CheckBalanceUpdateType { + page: Page; + containerSelector: string; + initialBalance: number; + expectedChange: number; +} + +export interface CheckButtonStatusType { + page: Page; + type: PingPongEnum; + lastClickedButton: 'ping' | 'pong'; +} + +export interface CheckClipboardContainsType { + page: Page; + context: BrowserContext; + text: string; + timeout?: number; +} + +export interface CheckConnectionToWalletType { + page: Page; + walletAddress: string; +} + +export interface CheckToastShowsTransactionsSignedType { + page: Page; + numberOfTransactions: number; +} + +export interface ConfirmWalletTransactionType { + page: Page; + loginMethod: WebWalletLoginMethodType; +} + +export interface ConnectWebWalletType { + page: Page; + loginMethod: WebWalletLoginMethodType; +} + +export interface CreateNotFoundErrorType { + urlSubstring: string; + timeout: number; + currentPageUrl: string; + availablePagesUrls: string; +} + +export interface ExtractBalanceFromContainerType { + page: Page; + containerSelector: string; + selectorType: 'testId' | 'locator'; +} + +export interface HandlePingPongType { + page: Page; + type: PingPongEnum; +} + +export interface WebWalletLoginMethodType { + keystore?: string; + password?: string; + pem?: string; +} + +export interface InMemoryProviderLoginMethodType { + address: string; + privateKey: string; +} +export interface NavigateToConnectWalletType { + page: Page; +} + +export interface ParseTransactionsTableType { + page: Page; + tableSelector?: string; + tableType: 'all' | 'ping-pong'; + maxRows?: number; + enableLogging?: boolean; +} + +export interface PasteStringFromClipboardType { + page: Page; + context: BrowserContext; +} + +export interface SignBatchTransactionsType { + walletPage: Page; + buttonSelector: string; + numberOfTransactions: number; +} + +export interface TransactionFilter { + maxAgeInMinutes?: number; + minAgeInMinutes?: number; + methods?: string[]; + exactValue?: number; + onlySuccessful?: boolean; + onlyFailed?: boolean; + fromAddress?: string; + toAddress?: string; + enableLogging?: boolean; +} + +export interface TransactionRow { + hash: string; + age: string; + ageInMinutes: number; + senderShard: string; + receiverShard: string; + fromAddress: string; + toAddress: string; + method: string; + value: number; + symbol: string; + isFailed: boolean; +} + +export interface WaitForToastToBeClosedType { + page: Page; +} + +export interface WaitForToastToBeDisplayedType { + page: Page; +} + +export interface WaitForTransactionToastToShowType { + page: Page; + toastTitle: string; + toastContent: string; +} + +export interface WaitForTransactionToastToContainType { + page: Page; + toastTitle?: string; + toastContent?: string; + toastStatus?: string; + toastIndex?: number; +} + +export interface GetPageAndWaitForLoadOptions { + maxRetries?: number; + timeout?: number; + viewport?: { width: number; height: number }; + waitForReady?: (page: Page) => Promise; +} + +export type GetPageAndWaitForLoad = ( + context: BrowserContext, + urlSubstring: string | RegExp, + options?: GetPageAndWaitForLoadOptions +) => Promise; + +export type WaitForMetaMaskLoadOptions = { + selectorTimeoutMs?: number; + extraLoadingSelectors?: string[]; + skipInitialStabilityWait?: boolean; + concurrency?: number; +}; diff --git a/tests/support/template/waitForToastToBeClosed.ts b/tests/support/template/waitForToastToBeClosed.ts new file mode 100644 index 0000000..8ad2ece --- /dev/null +++ b/tests/support/template/waitForToastToBeClosed.ts @@ -0,0 +1,7 @@ +import { Page } from '@playwright/test'; +import { SelectorsEnum } from './testdata'; + +export const waitForToastToBeClosed = async (page: Page) => { + const toast = page.getByTestId(SelectorsEnum.toastContent).first(); + await toast.waitFor({ state: 'hidden' }); +}; diff --git a/tests/support/template/waitForToastToBeDisplayed.ts b/tests/support/template/waitForToastToBeDisplayed.ts new file mode 100644 index 0000000..7a4b176 --- /dev/null +++ b/tests/support/template/waitForToastToBeDisplayed.ts @@ -0,0 +1,7 @@ +import { Page } from '@playwright/test'; +import { SelectorsEnum } from './testdata'; + +export const waitForToastToBeDisplayed = async (page: Page) => { + const toast = page.getByTestId(SelectorsEnum.toastContent).first(); + await toast.waitFor({ state: 'visible' }); +}; diff --git a/tests/support/template/waitForTransactionToastToShow.ts b/tests/support/template/waitForTransactionToastToShow.ts new file mode 100644 index 0000000..58b8c62 --- /dev/null +++ b/tests/support/template/waitForTransactionToastToShow.ts @@ -0,0 +1,46 @@ +import { expect } from '@playwright/test'; +import { TEST_CONSTANTS } from './constants'; +import { SelectorsEnum } from './testdata'; +import { WaitForTransactionToastToContainType } from './types'; + +export const waitForTransactionToastToContain = async ({ + page, + toastTitle, + toastContent, + toastStatus, + toastIndex = 0 +}: WaitForTransactionToastToContainType) => { + if (!toastTitle && !toastContent && !toastStatus) { + throw new Error('Either toast title, content or status must be provided'); + } + + const titleLocator = page.getByTestId(SelectorsEnum.toastTitle); + const contentLocator = page.getByTestId(SelectorsEnum.toastContent); + const statusLocator = page.getByTestId(SelectorsEnum.toastStatus); + + // Wait for toast visibility + await expect(contentLocator.nth(toastIndex)).toBeVisible({ + timeout: TEST_CONSTANTS.TOAST_WAIT_TIMEOUT + }); + + if (toastTitle) { + await expect(titleLocator.nth(toastIndex)).toContainText(toastTitle, { + timeout: TEST_CONSTANTS.TOAST_WAIT_TIMEOUT, + useInnerText: true + }); + } + + if (toastContent) { + await expect(contentLocator.nth(toastIndex)).toContainText(toastContent, { + timeout: TEST_CONSTANTS.TOAST_WAIT_TIMEOUT, + useInnerText: true + }); + } + + if (toastStatus) { + await expect(statusLocator.nth(toastIndex)).toContainText(toastStatus, { + timeout: TEST_CONSTANTS.TOAST_WAIT_TIMEOUT, + useInnerText: true + }); + } +}; diff --git a/tests/support/template/waitUntilStable.ts b/tests/support/template/waitUntilStable.ts new file mode 100644 index 0000000..d57887a --- /dev/null +++ b/tests/support/template/waitUntilStable.ts @@ -0,0 +1,21 @@ +import { Page } from '@playwright/test'; + +const DEFAULT_TIMEOUT = 10000; +const RESOURCES_LOAD_DELAY = 1000; // Extra delay for font resources to load + +export const waitUntilStable = async (page: Page) => { + try { + await page.waitForLoadState('domcontentloaded', { + timeout: DEFAULT_TIMEOUT + }); + await page.waitForLoadState('load', { timeout: DEFAULT_TIMEOUT }); + + await new Promise((resolve) => setTimeout(resolve, RESOURCES_LOAD_DELAY)); + } catch (error) { + throw new Error( + `Failed to wait for page stability: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ); + } +}; diff --git a/tests/templateActions/nativeAuth.spec.ts b/tests/templateActions/nativeAuth.spec.ts new file mode 100644 index 0000000..5854344 --- /dev/null +++ b/tests/templateActions/nativeAuth.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test'; +import * as TestActions from '../support/template'; +import { extractBalanceFromContainer } from '../support/template'; +import { SelectorsEnum, TestDataEnums } from '../support/template/testdata'; + +const keystoreConfig = { + keystore: TestDataEnums.keystoreFilePath1, + password: TestDataEnums.keystorePassword1, + address: TestDataEnums.keystoreWalletAddress1 +}; + +// TODO: Unable to load profile error appears -> check if this is a known issue +// and then adjust the test accordingly (if necessary) +test.describe.skip('Native auth', () => { + test.beforeEach(async ({ page }) => { + await TestActions.navigateToConnectWallet(page); + await TestActions.connectWebWallet({ page, loginMethod: keystoreConfig }); + await TestActions.checkConnectionToWallet(page, keystoreConfig.address); + }); + + test('should display correct wallet address and balance in native auth', async ({ + page + }) => { + // Get account balance before any actions + const accountBalance = await TestActions.extractBalanceFromContainer({ + page, + containerSelector: SelectorsEnum.topInfoContainer, + selectorType: 'testId' + }); + + // Scroll to the native auth container into viewport + await page + .locator(SelectorsEnum.nativeAuthContainer) + .scrollIntoViewIfNeeded(); + + // Verify container is in viewport + const container = page.locator(SelectorsEnum.nativeAuthContainer); + await expect(container).toBeInViewport(); + + // Check that the address is displayed and matches the account address + const nativeAuthAddress = page + .locator(SelectorsEnum.nativeAuthContainer) + .getByTestId(SelectorsEnum.trimFullAddress); + + await expect(nativeAuthAddress).toHaveText(keystoreConfig.address); + + // Check that the balance is displayed and matches the account balance + const nativeAuthBalance = await extractBalanceFromContainer({ + page, + containerSelector: SelectorsEnum.nativeAuthContainer, + selectorType: 'locator' + }); + + await expect(nativeAuthBalance).toBe(accountBalance); + }); +}); diff --git a/tests/templateActions/pingAndPongAbi.spec.ts b/tests/templateActions/pingAndPongAbi.spec.ts new file mode 100644 index 0000000..5e5eb24 --- /dev/null +++ b/tests/templateActions/pingAndPongAbi.spec.ts @@ -0,0 +1,104 @@ +import { expect, test } from '@playwright/test'; +import * as TestActions from '../support/template'; +import { TEST_CONSTANTS } from '../support/template/constants'; +import { + OriginPageEnum, + PingPongEnum, + SelectorsEnum, + TestDataEnums, + UrlRegex +} from '../support/template/testdata'; +import { WALLET_URL_TIMEOUT_MS } from '../config'; + +const pemConfig = { + pem: TestDataEnums.keystoreFilePath5 +}; + +test.describe('Ping & Pong (ABI)', () => { + // Note: Ping/Pong buttons have a 3-minute cooldown period after being clicked + + test.beforeEach(async ({ page }) => { + await TestActions.navigateToConnectWallet(page); + await TestActions.connectWebWallet({ page, loginMethod: pemConfig }); + await TestActions.checkConnectionToWallet( + page, + TestDataEnums.keystoreWalletAddress5 + ); + }); + + test('should have sufficient balance for ping & pong operations', async ({ + page + }) => { + // Get account balance before any actions + const accountBalance = await TestActions.extractBalanceFromContainer({ + page, + containerSelector: SelectorsEnum.topInfoContainer, + selectorType: 'testId' + }); + + // Check that account balance is greater than 1 required for ping & pong + expect(accountBalance).toBeGreaterThan( + TEST_CONSTANTS.MIN_BALANCE_FOR_PING_PONG + ); + }); + + test('should complete full ping/pong transaction flow', async ({ page }) => { + // Get account balance before any actions + const accountBalance = await TestActions.extractBalanceFromContainer({ + page, + containerSelector: SelectorsEnum.topInfoContainer, + selectorType: 'testId' + }); + + // Scroll to the ping & pong container into viewport + await page + .locator(SelectorsEnum.pingPongAbiContainer) + .scrollIntoViewIfNeeded(); + + const clickedButton = await TestActions.handlePingPong({ + page, + type: PingPongEnum.abi + }); + + // Switch to web wallet page + const walletPage = await TestActions.getPageAndWaitForLoad( + page.context(), + OriginPageEnum.multiversxWallet + ); + + // Verify wallet page opened + await expect(walletPage).toHaveURL(UrlRegex.multiversxWallet, { + timeout: WALLET_URL_TIMEOUT_MS + }); + + // Sign transaction by confirming with pem + await TestActions.confirmWalletTransaction(walletPage, pemConfig); + + // Click on Sign button to confirm the transaction in the web wallet + await walletPage.getByTestId(SelectorsEnum.signButton).click(); + + // Switch to template dashboard page + const templatePage = await TestActions.getPageAndWaitForLoad( + page.context(), + OriginPageEnum.templateDashboard + ); + + // Wait for transaction toast to be displayed + await TestActions.waitForToastToBeDisplayed(templatePage); + + // Check balance change based on the clicked button + await TestActions.checkPingPongBalanceUpdate({ + page: templatePage, + containerSelector: SelectorsEnum.topInfoContainer, + initialBalance: accountBalance, + isPing: clickedButton === 'ping' + }); + + // Check that the button status changed after the action + await TestActions.checkButtonStatus({ + page, + type: PingPongEnum.abi, + lastClickedButton: clickedButton + }); + }); +}); diff --git a/tests/templateActions/signAndSendBatch.spec.ts b/tests/templateActions/signAndSendBatch.spec.ts new file mode 100644 index 0000000..2134a21 --- /dev/null +++ b/tests/templateActions/signAndSendBatch.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from '@playwright/test'; +import * as TestActions from '../support/template'; +import { TEST_CONSTANTS } from '../support/template/constants'; +import { + OriginPageEnum, + SelectorsEnum, + TestDataEnums, + UrlRegex +} from '../support/template/testdata'; +import { WALLET_URL_TIMEOUT_MS } from '../config'; + +const keystoreConfig = { + keystore: TestDataEnums.keystoreFilePath2, + password: TestDataEnums.keystorePassword2 +}; + +test.describe('Sign & send batch', () => { + test.beforeEach(async ({ page }) => { + await TestActions.navigateToConnectWallet(page); + await TestActions.connectWebWallet({ page, loginMethod: keystoreConfig }); + await TestActions.checkConnectionToWallet( + page, + TestDataEnums.keystoreWalletAddress2 + ); + }); + + test('should have sufficient balance for batch transactions', async ({ + page + }) => { + // Get account balance before any actions + const accountBalance = await TestActions.extractBalanceFromContainer({ + page, + containerSelector: SelectorsEnum.topInfoContainer, + selectorType: 'testId' + }); + + // Check that account balance is greater than 5 required for batch transactions + expect(accountBalance).toBeGreaterThan( + TEST_CONSTANTS.MIN_BALANCE_FOR_BATCH_TX + ); + }); + + test('should complete full batch transaction flow', async ({ page }) => { + const numberOfTransactions = 5; + + // Scroll to the batch transactions container into viewport + await page + .locator(SelectorsEnum.batchTransactionsContainer) + .scrollIntoViewIfNeeded(); + + // Click on the sign and batch button + await page.getByTestId(SelectorsEnum.signAndBatchButton).click(); + + // Switch to web wallet page + const walletPage = await TestActions.getPageAndWaitForLoad( + page.context(), + OriginPageEnum.multiversxWallet + ); + + // Verify wallet page opened + await expect(walletPage).toHaveURL(UrlRegex.multiversxWallet, { + timeout: WALLET_URL_TIMEOUT_MS + }); + + // Sign transaction by confirming with keystore in the web wallet + await TestActions.confirmWalletTransaction(walletPage, keystoreConfig); + + // Sign batch transactions in the web wallet + await TestActions.signBatchTransactions({ + walletPage, + buttonSelector: SelectorsEnum.signAndBatchButton, + numberOfTransactions + }); + + // Switch to template page + const templatePage = await TestActions.getPageAndWaitForLoad( + page.context(), + OriginPageEnum.templateDashboard + ); + + // Wait for transaction toast to be displayed + await TestActions.waitForToastToBeDisplayed(templatePage); + + // Check that the transaction toast shows that all transactions are signed + await TestActions.waitForTransactionToastToContain({ + page: templatePage, + toastStatus: '5 / 5 transactions processed' + }); + + // Wait for the transaction toast to be closed + await TestActions.waitForToastToBeClosed(templatePage); + }); +}); diff --git a/tests/templateActions/signMessage.spec.ts b/tests/templateActions/signMessage.spec.ts new file mode 100644 index 0000000..821222e --- /dev/null +++ b/tests/templateActions/signMessage.spec.ts @@ -0,0 +1,70 @@ +import { expect, test } from '@playwright/test'; +import * as TestActions from '../support/template'; +import { + OriginPageEnum, + SelectorsEnum, + TestDataEnums, + UrlRegex +} from '../support/template/testdata'; +import { WALLET_URL_TIMEOUT_MS } from '../config'; + +const keystoreConfig = { + keystore: TestDataEnums.keystoreFilePath1, + password: TestDataEnums.keystorePassword1 +}; + +test.describe('Sign Message', () => { + test.beforeEach(async ({ page }) => { + await TestActions.navigateToConnectWallet(page); + await TestActions.connectWebWallet({ page, loginMethod: keystoreConfig }); + await TestActions.checkConnectionToWallet( + page, + TestDataEnums.keystoreWalletAddress1 + ); + }); + + test('should complete full message signing flow', async ({ page }) => { + const message = 'mvx'; + + // Scroll to the sign message container into viewport + await page + .locator(SelectorsEnum.signMessageContainer) + .scrollIntoViewIfNeeded(); + + // Enter message + await page + .getByRole('textbox', { name: 'Write message here' }) + .fill(message); + + // Click on Sign button + await page.getByTestId(SelectorsEnum.signMsgButton).click(); + + // Switch to web wallet page + const walletPage = await TestActions.getPageAndWaitForLoad( + page.context(), + OriginPageEnum.multiversxWallet + ); + + // Verify wallet page opened + await expect(walletPage).toHaveURL(UrlRegex.multiversxWallet, { + timeout: WALLET_URL_TIMEOUT_MS + }); + + // Sign transaction by confirming with keystore in the web wallet + await TestActions.confirmWalletTransaction(walletPage, keystoreConfig); + + // Click on Sign button to confirm the sign message in the web wallet + await walletPage.getByTestId(SelectorsEnum.signMsgWalletButton).click(); + + // Switch to template dashboard page + const templatePage = await TestActions.getPageAndWaitForLoad( + page.context(), + OriginPageEnum.templateDashboard + ); + + // Verify the decoded message contains the expected message + const decodedMessage = + 'cb0f0cbcf70e54e0e79fb2c1a48e01883e15fb3bb686773a7bb8b26ebb69447de75dd8c8645e9af1'; + await expect(templatePage.getByText(decodedMessage)).toBeVisible(); + }); +}); diff --git a/tests/templateActions/swapAndLock.ts b/tests/templateActions/swapAndLock.ts new file mode 100644 index 0000000..ab83689 --- /dev/null +++ b/tests/templateActions/swapAndLock.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test'; +import * as TestActions from '../support/template'; +import { TEST_CONSTANTS } from '../support/template/constants'; +import { + OriginPageEnum, + SelectorsEnum, + TestDataEnums, + UrlRegex +} from '../support/template/testdata'; +import { WALLET_URL_TIMEOUT_MS } from '../config'; + +const keystoreConfig = { + keystore: TestDataEnums.keystoreFilePath3, + password: TestDataEnums.keystorePassword3 +}; + +test.describe('Swap & Lock', () => { + test.beforeEach(async ({ page }) => { + await TestActions.navigateToConnectWallet(page); + await TestActions.connectWebWallet({ page, loginMethod: keystoreConfig }); + await TestActions.checkConnectionToWallet( + page, + TestDataEnums.keystoreWalletAddress3 + ); + }); + + test('should have sufficient balance for batch transactions', async ({ + page + }) => { + // Get account balance before any actions + const accountBalance = await TestActions.extractBalanceFromContainer({ + page, + containerSelector: SelectorsEnum.topInfoContainer, + selectorType: 'testId' + }); + + // Check that account balance is greater than 4 required for swap and lock + expect(accountBalance).toBeGreaterThan( + TEST_CONSTANTS.MIN_BALANCE_FOR_SWAP_AND_LOCK + ); + }); + + test('should complete full swap and lock transaction flow', async ({ + page + }) => { + const numberOfTransactions = 4; // 2 transactions for wrap, 1 for swap, 1 for multi-transfer + + // Scroll to the batch transactions container into viewport + await page + .locator(SelectorsEnum.batchTransactionsContainer) + .scrollIntoViewIfNeeded(); + + // Click on the swap and lock button + await page.getByTestId(SelectorsEnum.swapAndLockButton).click(); + + // Switch to web wallet page + const walletPage = await TestActions.getPageAndWaitForLoad( + page.context(), + OriginPageEnum.multiversxWallet + ); + + // Verify wallet page opened + await expect(walletPage).toHaveURL(UrlRegex.multiversxWallet, { + timeout: WALLET_URL_TIMEOUT_MS + }); + + // Sign transaction by confirming with keystore in the web wallet + await TestActions.confirmWalletTransaction(walletPage, keystoreConfig); + + // Sign swap and lock transactions in the web wallet + await TestActions.signBatchTransactions({ + walletPage, + buttonSelector: SelectorsEnum.signAndBatchButton, + numberOfTransactions + }); + + // Switch to template page + const templatePage = await TestActions.getPageAndWaitForLoad( + page.context(), + OriginPageEnum.templateDashboard + ); + + // Wait for transaction toast to be displayed + await TestActions.waitForToastToBeDisplayed(templatePage); + + // Check that the transaction toast shows that all transactions were signed + await TestActions.waitForTransactionToastToContain({ + page: templatePage, + toastStatus: '4 / 4 transactions processed' + }); + + // Wait for the transaction toast to be closed + await TestActions.waitForToastToBeClosed(templatePage); + }); +}); diff --git a/yarn.lock b/yarn.lock index 0e682c6..1501320 100644 --- a/yarn.lock +++ b/yarn.lock @@ -349,6 +349,136 @@ resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.10.0.tgz#2af2f7c7e5150f497bdabd848ce7b218a27cf745" integrity sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg== +"@esbuild/aix-ppc64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c" + integrity sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA== + +"@esbuild/android-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz#8aa4965f8d0a7982dc21734bf6601323a66da752" + integrity sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg== + +"@esbuild/android-arm@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz#300712101f7f50f1d2627a162e6e09b109b6767a" + integrity sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg== + +"@esbuild/android-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz#87dfb27161202bdc958ef48bb61b09c758faee16" + integrity sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg== + +"@esbuild/darwin-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz#79197898ec1ff745d21c071e1c7cc3c802f0c1fd" + integrity sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg== + +"@esbuild/darwin-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz#146400a8562133f45c4d2eadcf37ddd09718079e" + integrity sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA== + +"@esbuild/freebsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz#1c5f9ba7206e158fd2b24c59fa2d2c8bb47ca0fe" + integrity sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg== + +"@esbuild/freebsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz#ea631f4a36beaac4b9279fa0fcc6ca29eaeeb2b3" + integrity sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ== + +"@esbuild/linux-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz#e1066bce58394f1b1141deec8557a5f0a22f5977" + integrity sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ== + +"@esbuild/linux-arm@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz#452cd66b20932d08bdc53a8b61c0e30baf4348b9" + integrity sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw== + +"@esbuild/linux-ia32@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz#b24f8acc45bcf54192c7f2f3be1b53e6551eafe0" + integrity sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA== + +"@esbuild/linux-loong64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz#f9cfffa7fc8322571fbc4c8b3268caf15bd81ad0" + integrity sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng== + +"@esbuild/linux-mips64el@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz#575a14bd74644ffab891adc7d7e60d275296f2cd" + integrity sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw== + +"@esbuild/linux-ppc64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz#75b99c70a95fbd5f7739d7692befe60601591869" + integrity sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA== + +"@esbuild/linux-riscv64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz#2e3259440321a44e79ddf7535c325057da875cd6" + integrity sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w== + +"@esbuild/linux-s390x@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz#17676cabbfe5928da5b2a0d6df5d58cd08db2663" + integrity sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg== + +"@esbuild/linux-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz#0583775685ca82066d04c3507f09524d3cd7a306" + integrity sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw== + +"@esbuild/netbsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4" + integrity sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg== + +"@esbuild/netbsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz#77da0d0a0d826d7c921eea3d40292548b258a076" + integrity sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ== + +"@esbuild/openbsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz#6296f5867aedef28a81b22ab2009c786a952dccd" + integrity sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A== + +"@esbuild/openbsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz#f8d23303360e27b16cf065b23bbff43c14142679" + integrity sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw== + +"@esbuild/openharmony-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d" + integrity sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg== + +"@esbuild/sunos-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz#a6ed7d6778d67e528c81fb165b23f4911b9b13d6" + integrity sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w== + +"@esbuild/win32-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz#9ac14c378e1b653af17d08e7d3ce34caef587323" + integrity sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg== + +"@esbuild/win32-ia32@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz#918942dcbbb35cc14fca39afb91b5e6a3d127267" + integrity sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ== + +"@esbuild/win32-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz#9bdad8176be7811ad148d1f8772359041f46c6c5" + integrity sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA== + "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.9.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3" @@ -1096,6 +1226,13 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@playwright/test@^1.57.0": + version "1.57.0" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.57.0.tgz#a14720ffa9ed7ef7edbc1f60784fc6134acbb003" + integrity sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA== + dependencies: + playwright "1.57.0" + "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" @@ -1149,46 +1286,156 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@rollup/rollup-android-arm-eabi@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz#7e478b66180c5330429dd161bf84dad66b59c8eb" + integrity sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w== + +"@rollup/rollup-android-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz#2b025510c53a5e3962d3edade91fba9368c9d71c" + integrity sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w== + "@rollup/rollup-darwin-arm64@4.34.9": version "4.34.9" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.9.tgz#363467bc49fd0b1e17075798ac8e9ad1e1e29535" integrity sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ== +"@rollup/rollup-darwin-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz#3577c38af68ccf34c03e84f476bfd526abca10a0" + integrity sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA== + "@rollup/rollup-darwin-x64@4.34.9": version "4.34.9" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.9.tgz#c2fe3d85fffe47f0ed0f076b3563ada22c8af19c" integrity sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q== +"@rollup/rollup-darwin-x64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz#2bf5f2520a1f3b551723d274b9669ba5b75ed69c" + integrity sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ== + +"@rollup/rollup-freebsd-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz#4bb9cc80252564c158efc0710153c71633f1927c" + integrity sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w== + +"@rollup/rollup-freebsd-x64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz#2301289094d49415a380cf942219ae9d8b127440" + integrity sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q== + +"@rollup/rollup-linux-arm-gnueabihf@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz#1d03d776f2065e09fc141df7d143476e94acca88" + integrity sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw== + +"@rollup/rollup-linux-arm-musleabihf@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz#8623de0e040b2fd52a541c602688228f51f96701" + integrity sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg== + "@rollup/rollup-linux-arm64-gnu@4.34.9": version "4.34.9" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.9.tgz#1015c9d07a99005025d13b8622b7600029d0b52f" integrity sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw== +"@rollup/rollup-linux-arm64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz#ce2d1999bc166277935dde0301cde3dd0417fb6e" + integrity sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w== + "@rollup/rollup-linux-arm64-musl@4.34.9": version "4.34.9" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.9.tgz#8f895eb5577748fc75af21beae32439626e0a14c" integrity sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A== +"@rollup/rollup-linux-arm64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz#88c2523778444da952651a2219026416564a4899" + integrity sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A== + +"@rollup/rollup-linux-loong64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz#578ca2220a200ac4226c536c10c8cc6e4f276714" + integrity sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g== + +"@rollup/rollup-linux-ppc64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz#aa338d3effd4168a20a5023834a74ba2c3081293" + integrity sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw== + +"@rollup/rollup-linux-riscv64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz#16ba582f9f6cff58119aa242782209b1557a1508" + integrity sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g== + +"@rollup/rollup-linux-riscv64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz#e404a77ebd6378483888b8064c703adb011340ab" + integrity sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A== + +"@rollup/rollup-linux-s390x-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz#92ad52d306227c56bec43d96ad2164495437ffe6" + integrity sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg== + "@rollup/rollup-linux-x64-gnu@4.34.9": version "4.34.9" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.9.tgz#7193cbd8d128212b8acda37e01b39d9e96259ef8" integrity sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A== +"@rollup/rollup-linux-x64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz#fd0dea3bb9aa07e7083579f25e1c2285a46cb9fa" + integrity sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w== + "@rollup/rollup-linux-x64-musl@4.34.9": version "4.34.9" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.9.tgz#29a6867278ca0420b891574cfab98ecad70c59d1" integrity sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA== +"@rollup/rollup-linux-x64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz#37a3efb09f18d555f8afc490e1f0444885de8951" + integrity sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q== + +"@rollup/rollup-openharmony-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz#c489bec9f4f8320d42c9b324cca220c90091c1f7" + integrity sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw== + "@rollup/rollup-win32-arm64-msvc@4.34.9": version "4.34.9" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.9.tgz#89427dcac0c8e3a6d32b13a03a296a275d0de9a9" integrity sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q== +"@rollup/rollup-win32-arm64-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz#152832b5f79dc22d1606fac3db946283601b7080" + integrity sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw== + +"@rollup/rollup-win32-ia32-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz#54d91b2bb3bf3e9f30d32b72065a4e52b3a172a5" + integrity sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA== + +"@rollup/rollup-win32-x64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz#df9df03e61a003873efec8decd2034e7f135c71e" + integrity sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg== + "@rollup/rollup-win32-x64-msvc@4.34.9": version "4.34.9" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.9.tgz#1973871850856ae72bc678aeb066ab952330e923" integrity sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw== +"@rollup/rollup-win32-x64-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz#38ae84f4c04226c1d56a3b17296ef1e0460ecdfe" + integrity sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ== + "@rtsao/scc@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" @@ -1448,6 +1695,11 @@ dependencies: "@babel/types" "^7.28.2" +"@types/estree@1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + "@types/graceful-fs@^4.1.3": version "4.1.9" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" @@ -3570,6 +3822,38 @@ es-toolkit@1.39.3: resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.39.3.tgz#934b2cab9578c496dcbc0305cae687258cb14aee" integrity sha512-Qb/TCFCldgOy8lZ5uC7nLGdqJwSabkQiYQShmw4jyiPk1pZzaYWTwaYKYP7EgLccWYgZocMrtItrwh683voaww== +esbuild@^0.25.0: + version "0.25.12" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.12.tgz#97a1d041f4ab00c2fce2f838d2b9969a2d2a97a5" + integrity sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg== + optionalDependencies: + "@esbuild/aix-ppc64" "0.25.12" + "@esbuild/android-arm" "0.25.12" + "@esbuild/android-arm64" "0.25.12" + "@esbuild/android-x64" "0.25.12" + "@esbuild/darwin-arm64" "0.25.12" + "@esbuild/darwin-x64" "0.25.12" + "@esbuild/freebsd-arm64" "0.25.12" + "@esbuild/freebsd-x64" "0.25.12" + "@esbuild/linux-arm" "0.25.12" + "@esbuild/linux-arm64" "0.25.12" + "@esbuild/linux-ia32" "0.25.12" + "@esbuild/linux-loong64" "0.25.12" + "@esbuild/linux-mips64el" "0.25.12" + "@esbuild/linux-ppc64" "0.25.12" + "@esbuild/linux-riscv64" "0.25.12" + "@esbuild/linux-s390x" "0.25.12" + "@esbuild/linux-x64" "0.25.12" + "@esbuild/netbsd-arm64" "0.25.12" + "@esbuild/netbsd-x64" "0.25.12" + "@esbuild/openbsd-arm64" "0.25.12" + "@esbuild/openbsd-x64" "0.25.12" + "@esbuild/openharmony-arm64" "0.25.12" + "@esbuild/sunos-x64" "0.25.12" + "@esbuild/win32-arm64" "0.25.12" + "@esbuild/win32-ia32" "0.25.12" + "@esbuild/win32-x64" "0.25.12" + escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" @@ -4095,7 +4379,12 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@^2.3.2, fsevents@~2.3.2: +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +fsevents@^2.3.2, fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== @@ -6324,6 +6613,20 @@ platform@1.3.6: resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.6.tgz#48b4ce983164b209c2d45a107adb31f473a6e7a7" integrity sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg== +playwright-core@1.57.0: + version "1.57.0" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.57.0.tgz#3dcc9a865af256fa9f0af0d67fc8dd54eecaebf5" + integrity sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ== + +playwright@1.57.0: + version "1.57.0" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.57.0.tgz#74d1dacff5048dc40bf4676940b1901e18ad0f46" + integrity sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw== + dependencies: + playwright-core "1.57.0" + optionalDependencies: + fsevents "2.3.2" + pngjs@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" @@ -6387,7 +6690,7 @@ postcss@8.4.31: picocolors "^1.0.0" source-map-js "^1.0.2" -postcss@^8.4.23: +postcss@^8.4.23, postcss@^8.5.6: version "8.5.6" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== @@ -6785,6 +7088,37 @@ ripemd160@^2.0.0, ripemd160@^2.0.1, ripemd160@^2.0.3: hash-base "^3.1.2" inherits "^2.0.4" +rollup@^4.43.0: + version "4.53.3" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.53.3.tgz#dbc8cd8743b38710019fb8297e8d7a76e3faa406" + integrity sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.53.3" + "@rollup/rollup-android-arm64" "4.53.3" + "@rollup/rollup-darwin-arm64" "4.53.3" + "@rollup/rollup-darwin-x64" "4.53.3" + "@rollup/rollup-freebsd-arm64" "4.53.3" + "@rollup/rollup-freebsd-x64" "4.53.3" + "@rollup/rollup-linux-arm-gnueabihf" "4.53.3" + "@rollup/rollup-linux-arm-musleabihf" "4.53.3" + "@rollup/rollup-linux-arm64-gnu" "4.53.3" + "@rollup/rollup-linux-arm64-musl" "4.53.3" + "@rollup/rollup-linux-loong64-gnu" "4.53.3" + "@rollup/rollup-linux-ppc64-gnu" "4.53.3" + "@rollup/rollup-linux-riscv64-gnu" "4.53.3" + "@rollup/rollup-linux-riscv64-musl" "4.53.3" + "@rollup/rollup-linux-s390x-gnu" "4.53.3" + "@rollup/rollup-linux-x64-gnu" "4.53.3" + "@rollup/rollup-linux-x64-musl" "4.53.3" + "@rollup/rollup-openharmony-arm64" "4.53.3" + "@rollup/rollup-win32-arm64-msvc" "4.53.3" + "@rollup/rollup-win32-ia32-msvc" "4.53.3" + "@rollup/rollup-win32-x64-gnu" "4.53.3" + "@rollup/rollup-win32-x64-msvc" "4.53.3" + fsevents "~2.3.2" + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" @@ -7435,7 +7769,7 @@ through@^2.3.8: resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== -tinyglobby@^0.2.13: +tinyglobby@^0.2.13, tinyglobby@^0.2.15: version "0.2.15" resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== @@ -7812,6 +8146,20 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +vite@^7.2.4: + version "7.2.4" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.2.4.tgz#a3a09c7e25487612ecc1119c7d412c73da35bd4e" + integrity sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w== + dependencies: + esbuild "^0.25.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" + optionalDependencies: + fsevents "~2.3.3" + w3c-xmlserializer@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz#aebdc84920d806222936e3cdce408e32488a3073" From d94a1ebe02f69410fd30063c6293f445d7083c0b Mon Sep 17 00:00:00 2001 From: Roland Moldovan Date: Thu, 27 Nov 2025 14:55:52 +0200 Subject: [PATCH 02/10] Update fillSeedWords function to use Array.from for entries iteration --- tests/support/metaMask/fillSecretRecoveryPhrase.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/support/metaMask/fillSecretRecoveryPhrase.ts b/tests/support/metaMask/fillSecretRecoveryPhrase.ts index bf83a84..7c56eb6 100644 --- a/tests/support/metaMask/fillSecretRecoveryPhrase.ts +++ b/tests/support/metaMask/fillSecretRecoveryPhrase.ts @@ -16,7 +16,7 @@ async function fillSeedWords(page: Page, words: string[]) { // Skip first word (already filled in the textarea) const remainingWords = words.slice(1); - for (const [index, word] of remainingWords.entries()) { + for (const [index, word] of Array.from(remainingWords.entries())) { const inputSelector = secretRecoverySelectors.wordInput(index + 1); await fillSingleWord(page, inputSelector, word); } From 9e068058063a4f7070343a0bbd2d1d0b33cee184 Mon Sep 17 00:00:00 2001 From: Roland Moldovan Date: Thu, 27 Nov 2025 14:59:22 +0200 Subject: [PATCH 03/10] Update confirmPrivateKeyTransaction to use correct test ID for private key input --- tests/support/template/confirmPrivateKeyTransaction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/support/template/confirmPrivateKeyTransaction.ts b/tests/support/template/confirmPrivateKeyTransaction.ts index b521a0c..adaf903 100644 --- a/tests/support/template/confirmPrivateKeyTransaction.ts +++ b/tests/support/template/confirmPrivateKeyTransaction.ts @@ -5,6 +5,6 @@ export const confirmPrivateKeyTransaction = async ( page: Page, privateKey: string ) => { - await page.getByTestId(SelectorsEnum.privateKey).fill(privateKey); + await page.getByTestId(SelectorsEnum.privateKeyInput).fill(privateKey); await page.getByTestId(SelectorsEnum.submitButton).click(); }; From c113e8b40794183a363d7f58a2e64bb8f27c9733 Mon Sep 17 00:00:00 2001 From: Roland Moldovan Date: Thu, 27 Nov 2025 15:07:27 +0200 Subject: [PATCH 04/10] Refactor error handling in MetaMask tests to improve clarity and remove deprecated waitForMetaMaskLoad function --- tests/support/metaMask/handleMetaMaskSnap.ts | 8 +- tests/support/metaMask/setupMetaMaskWallet.ts | 7 +- tests/support/metaMask/waitForMetaMaskLoad.ts | 92 ------------------- .../template/navigateToConnectWallet.ts | 4 +- tests/support/template/types.ts | 7 -- 5 files changed, 12 insertions(+), 106 deletions(-) delete mode 100644 tests/support/metaMask/waitForMetaMaskLoad.ts diff --git a/tests/support/metaMask/handleMetaMaskSnap.ts b/tests/support/metaMask/handleMetaMaskSnap.ts index bd2de34..afd5399 100644 --- a/tests/support/metaMask/handleMetaMaskSnap.ts +++ b/tests/support/metaMask/handleMetaMaskSnap.ts @@ -44,12 +44,12 @@ export async function handleMetaMaskSnap( } return; // All steps completed successfully - } catch (error: any) { + } catch (error) { attempt++; console.warn( `[MetaMaskSnap] Attempt ${attempt}/${maxRetries} failed: ${ - error?.message || error + error instanceof Error ? error.message : 'Unknown error' }` ); console.warn( @@ -78,10 +78,10 @@ async function attemptClick( try { await element.waitFor({ state: 'visible', timeout: CLICK_TIMEOUT_MS }); await element.click(); - } catch (error: any) { + } catch (error) { console.error( `[MetaMaskSnap] Failed to click "${action.name}": ${ - error?.message || error + error instanceof Error ? error.message : 'Unknown error' }` ); console.error( diff --git a/tests/support/metaMask/setupMetaMaskWallet.ts b/tests/support/metaMask/setupMetaMaskWallet.ts index 9d81cdf..7e2e8ef 100644 --- a/tests/support/metaMask/setupMetaMaskWallet.ts +++ b/tests/support/metaMask/setupMetaMaskWallet.ts @@ -1,4 +1,4 @@ -import { Page } from '@playwright/test'; +import { ChromiumBrowserContext, Page } from '@playwright/test'; import { handleMetaMetrics } from '../metaMask/handleMetrics'; import { getPageAndWaitForLoad } from '../template/getPageAndWaitForLoad'; import { waitUntilStable } from '../template/waitUntilStable'; @@ -49,7 +49,10 @@ async function initializeMetaMaskExtension() { return { context, extensionId }; } -async function openAndPrepareExtensionPage(context: any, extensionId: string) { +async function openAndPrepareExtensionPage( + context: ChromiumBrowserContext, + extensionId: string +) { const metamaskPage = await getPageAndWaitForLoad( context, `chrome-extension://${extensionId}/` diff --git a/tests/support/metaMask/waitForMetaMaskLoad.ts b/tests/support/metaMask/waitForMetaMaskLoad.ts deleted file mode 100644 index 136f41b..0000000 --- a/tests/support/metaMask/waitForMetaMaskLoad.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { errors, Page } from '@playwright/test'; -import { WaitForMetaMaskLoadOptions } from '../template/types'; -import { waitUntilStable } from '../template/waitUntilStable'; - -const DEFAULT_TIMEOUT = 10_000; -const DEFAULT_CONCURRENCY = 3; - -const BASE_LOADING_SELECTORS: readonly string[] = [ - '.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', - '.spinner' -]; - -/** - * Waits for MetaMask UI to be usable: - * 1) (optional) wait for page stability - * 2) wait for known loading indicators to hide (best-effort) - * 3) brief post delay (conditional) - */ -export async function waitForMetaMaskLoad( - page: Page, - options: WaitForMetaMaskLoadOptions -): Promise { - const { - selectorTimeoutMs = DEFAULT_TIMEOUT, - extraLoadingSelectors = [], - skipInitialStabilityWait = false, - concurrency = DEFAULT_CONCURRENCY // how many selectors to wait on in parallel - } = options; - - try { - if (!skipInitialStabilityWait) { - await waitUntilStable(page); - } - - const selectors = Array.from( - new Set([...BASE_LOADING_SELECTORS, ...extraLoadingSelectors]) - ); - - await waitSelectorsHiddenWithConcurrency( - page, - selectors, - selectorTimeoutMs, - concurrency - ); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.warn(`[waitForMetaMaskLoad] Non-fatal warning: ${msg}`); - } -} - -async function waitSelectorsHiddenWithConcurrency( - page: Page, - selectors: string[], - perSelectorTimeoutMs: number, - concurrency: number -) { - // One stability wait per batch, not per selector - await waitUntilStable(page); - - // Process selectors with a small concurrency to reduce polling in CI/CD - let i = 0; - while (i < selectors.length) { - const batch = selectors.slice(i, i + Math.max(1, concurrency)); - await Promise.all( - batch.map(async (selector) => { - try { - await page.waitForSelector(selector, { - state: 'hidden', - timeout: perSelectorTimeoutMs - }); - } catch (err) { - if (err instanceof errors.TimeoutError) { - // OK if a selector never appears on this screen - return; - } - throw err; - } - }) - ); - i += batch.length; - } -} diff --git a/tests/support/template/navigateToConnectWallet.ts b/tests/support/template/navigateToConnectWallet.ts index d19919f..03f303a 100644 --- a/tests/support/template/navigateToConnectWallet.ts +++ b/tests/support/template/navigateToConnectWallet.ts @@ -8,7 +8,9 @@ export const navigateToConnectWallet = async (page: Page) => { break; } catch (error) { console.warn( - `[navigateToConnectWallet] Attempt ${i + 1} failed: ${error.message}` + `[navigateToConnectWallet] Attempt ${i + 1} failed: ${ + error instanceof Error ? error.message : 'Unknown error' + }` ); if (i === maxRetries - 1) throw error; await page.waitForTimeout(5000); // Increase wait time diff --git a/tests/support/template/types.ts b/tests/support/template/types.ts index c986dc1..dfb5f4f 100644 --- a/tests/support/template/types.ts +++ b/tests/support/template/types.ts @@ -174,10 +174,3 @@ export type GetPageAndWaitForLoad = ( urlSubstring: string | RegExp, options?: GetPageAndWaitForLoadOptions ) => Promise; - -export type WaitForMetaMaskLoadOptions = { - selectorTimeoutMs?: number; - extraLoadingSelectors?: string[]; - skipInitialStabilityWait?: boolean; - concurrency?: number; -}; From c62978688cdc2add0642123750877df08341e0ee Mon Sep 17 00:00:00 2001 From: Roland Moldovan Date: Thu, 27 Nov 2025 15:14:31 +0200 Subject: [PATCH 05/10] Update E2E test workflow to trigger on both main and development branches --- .github/workflows/run-merge-e2e-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-merge-e2e-tests.yml b/.github/workflows/run-merge-e2e-tests.yml index 2ddd1ec..d0276e4 100644 --- a/.github/workflows/run-merge-e2e-tests.yml +++ b/.github/workflows/run-merge-e2e-tests.yml @@ -1,7 +1,7 @@ name: Playwright merge E2E Tests on: pull_request: - branches: [main] + branches: [main, development] workflow_dispatch: jobs: E2E-RUN: From e70d958fb71887068e8c90609fb0c7faea4f0216 Mon Sep 17 00:00:00 2001 From: Roland Moldovan Date: Thu, 27 Nov 2025 15:47:24 +0200 Subject: [PATCH 06/10] Enhance E2E test workflow by specifying Chromium for Playwright installation and adding Next.js build step --- .github/workflows/run-merge-e2e-tests.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-merge-e2e-tests.yml b/.github/workflows/run-merge-e2e-tests.yml index d0276e4..cc672f7 100644 --- a/.github/workflows/run-merge-e2e-tests.yml +++ b/.github/workflows/run-merge-e2e-tests.yml @@ -49,7 +49,9 @@ jobs: max_attempts: 2 command: yarn install --frozen-lockfile - name: Install Playwright Browsers - run: npx playwright install --with-deps + run: npx playwright install --with-deps chromium + - name: Build Next.js app + run: yarn next build - name: Run Playwright E2E Tests run: xvfb-run yarn run run-playwright-test - name: Upload Playwright test report From 950f43d004cf827d3636a261bf66552968c57211 Mon Sep 17 00:00:00 2001 From: Roland Moldovan Date: Thu, 27 Nov 2025 16:02:05 +0200 Subject: [PATCH 07/10] Update E2E test workflow to remove frozen lockfile option in yarn install command --- .github/workflows/run-merge-e2e-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-merge-e2e-tests.yml b/.github/workflows/run-merge-e2e-tests.yml index cc672f7..16f14ed 100644 --- a/.github/workflows/run-merge-e2e-tests.yml +++ b/.github/workflows/run-merge-e2e-tests.yml @@ -47,7 +47,7 @@ jobs: timeout_minutes: 120 retry_on: error max_attempts: 2 - command: yarn install --frozen-lockfile + command: yarn install - name: Install Playwright Browsers run: npx playwright install --with-deps chromium - name: Build Next.js app From 0c06a7b5a730bcc6b4dad49ef6fc196e60c2dfe0 Mon Sep 17 00:00:00 2001 From: Roland Moldovan Date: Thu, 27 Nov 2025 16:11:08 +0200 Subject: [PATCH 08/10] Add baseUrl configuration to tsconfig.json for improved module resolution --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index f40a4c6..5edab71 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,7 @@ "name": "next" } ], + "baseUrl": ".", "paths": { "@/*": ["./src/*"] } From ce8aa2eeb4e519818ae65d5b3c926a2f1e6f15e9 Mon Sep 17 00:00:00 2001 From: Roland Moldovan Date: Thu, 27 Nov 2025 16:15:24 +0200 Subject: [PATCH 09/10] Add jsconfig.json for module resolution and remove baseUrl from tsconfig.json --- jsconfig.json | 6 ++++++ tsconfig.json | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 jsconfig.json diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..18254f7 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { "@/*": ["./src/*"] } + } +} diff --git a/tsconfig.json b/tsconfig.json index 5edab71..f40a4c6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,6 @@ "name": "next" } ], - "baseUrl": ".", "paths": { "@/*": ["./src/*"] } From 9588e453a605fbfe0ebcd670e9c19677275ce933 Mon Sep 17 00:00:00 2001 From: Roland Moldovan Date: Thu, 27 Nov 2025 16:34:16 +0200 Subject: [PATCH 10/10] Remove jsconfig.json and update E2E test workflow to use 'yarn build:devnet' instead of 'yarn next build' --- .github/workflows/run-merge-e2e-tests.yml | 2 +- jsconfig.json | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 jsconfig.json diff --git a/.github/workflows/run-merge-e2e-tests.yml b/.github/workflows/run-merge-e2e-tests.yml index 16f14ed..b32ebc9 100644 --- a/.github/workflows/run-merge-e2e-tests.yml +++ b/.github/workflows/run-merge-e2e-tests.yml @@ -51,7 +51,7 @@ jobs: - name: Install Playwright Browsers run: npx playwright install --with-deps chromium - name: Build Next.js app - run: yarn next build + run: yarn build:devnet - name: Run Playwright E2E Tests run: xvfb-run yarn run run-playwright-test - name: Upload Playwright test report diff --git a/jsconfig.json b/jsconfig.json deleted file mode 100644 index 18254f7..0000000 --- a/jsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "paths": { "@/*": ["./src/*"] } - } -}