diff --git a/.github/workflows/run-merge-e2e-tests.yml b/.github/workflows/run-merge-e2e-tests.yml
new file mode 100644
index 0000000..b32ebc9
--- /dev/null
+++ b/.github/workflows/run-merge-e2e-tests.yml
@@ -0,0 +1,63 @@
+name: Playwright merge E2E Tests
+on:
+ pull_request:
+ branches: [main, development]
+ 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
+ - name: Install Playwright Browsers
+ run: npx playwright install --with-deps chromium
+ - name: Build Next.js app
+ run: yarn build:devnet
+ - 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..7c56eb6
--- /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 Array.from(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..afd5399
--- /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) {
+ attempt++;
+
+ console.warn(
+ `[MetaMaskSnap] Attempt ${attempt}/${maxRetries} failed: ${
+ error instanceof Error ? error.message : 'Unknown 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) {
+ console.error(
+ `[MetaMaskSnap] Failed to click "${action.name}": ${
+ error instanceof Error ? error.message : 'Unknown 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..7e2e8ef
--- /dev/null
+++ b/tests/support/metaMask/setupMetaMaskWallet.ts
@@ -0,0 +1,116 @@
+import { ChromiumBrowserContext, 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: ChromiumBrowserContext,
+ 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/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..adaf903
--- /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.privateKeyInput).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..03f303a
--- /dev/null
+++ b/tests/support/template/navigateToConnectWallet.ts
@@ -0,0 +1,20 @@
+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 instanceof Error ? error.message : 'Unknown error'
+ }`
+ );
+ 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..dfb5f4f
--- /dev/null
+++ b/tests/support/template/types.ts
@@ -0,0 +1,176 @@
+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;
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"