diff --git a/jest.config.ts b/jest.config.ts index befa10a5..dd5dcd9c 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -33,7 +33,7 @@ export default { ], moduleFileExtensions: ['ts', 'tsx', 'js'], modulePathIgnorePatterns: ['/sdk-debug/'], - testPathIgnorePatterns: ['/playwright/', '/mobile-e2e/'], + testPathIgnorePatterns: ['/playwright/', '/mobile-e2e/', '/stress-test/'], setupFiles: ['dotenv/config', '@serh11p/jest-webextension-mock', 'fake-indexeddb/auto'], setupFilesAfterEnv: ['./jest.setup.js'] }; diff --git a/package.json b/package.json index 9400fae7..a8c05ff8 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "mobile:ios:export": "xcodebuild -exportArchive -archivePath ios/App/build/MidenWallet.xcarchive -exportPath ios/App/build/export -exportOptionsPlist ios/App/ExportOptions.plist", "zip": "ts-node --project ./tsconfig-utility.json ./utility/buildZip.ts", "test": "jest", + "stress-test": "playwright test --project=stress", "test-debug": "node --inspect-brk node_modules/.bin/jest --runInBand", "test:coverage": "jest --coverage", "test:smoke": "jest src/lib/miden/smoke.integration.test.ts", diff --git a/playwright.config.ts b/playwright.config.ts index d03bfff1..71852cca 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,20 +1,27 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ - testDir: './playwright/tests', - timeout: 30_000, - expect: { - timeout: 10_000, - }, - // Browser extension tests are flaky when run in parallel due to Chrome/extension resource conflicts fullyParallel: false, forbidOnly: !!process.env.CI, - retries: 2, workers: 1, reporter: [['list']], use: { - headless: true, - trace: 'on-first-retry', + trace: 'on-first-retry' }, + projects: [ + { + name: 'default', + testDir: './playwright/tests', + timeout: 30_000, + use: { headless: true }, + retries: 2 + }, + { + name: 'stress', + testDir: './stress-test', + timeout: 7_200_000, // 2 hours + use: { headless: false }, // extensions require headed mode + retries: 0 + } + ] }); - diff --git a/stress-test/fixtures.ts b/stress-test/fixtures.ts new file mode 100644 index 00000000..c90b88d6 --- /dev/null +++ b/stress-test/fixtures.ts @@ -0,0 +1,76 @@ +import { chromium, test as base } from '@playwright/test'; +import { execSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +type Fixtures = { + extensionPath: string; + extensionId: string; + extensionContext: import('@playwright/test').BrowserContext; +}; + +const ROOT_DIR = path.resolve(__dirname, '..'); +const DEFAULT_EXTENSION_PATH = path.join(ROOT_DIR, 'dist', 'chrome_unpacked'); + +function ensureExtensionBuilt(extensionPath: string) { + const manifestPath = path.join(extensionPath, 'manifest.json'); + if (fs.existsSync(manifestPath) || process.env.SKIP_EXTENSION_BUILD === 'true') { + return; + } + + const env = { ...process.env }; + env.DISABLE_TS_CHECKER = 'true'; + // Use REAL testnet client — override any default + env.MIDEN_USE_MOCK_CLIENT = 'false'; + + execSync('yarn build:chrome', { + cwd: ROOT_DIR, + stdio: 'inherit', + env + }); +} + +export const test = base.extend({ + extensionPath: async ({}, use) => { + const extensionPath = process.env.EXTENSION_DIST ?? DEFAULT_EXTENSION_PATH; + ensureExtensionBuilt(extensionPath); + await use(extensionPath); + }, + + extensionContext: [ + async ({ extensionPath }, use) => { + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'miden-stress-')); + + const context = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: [`--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`], + ignoreDefaultArgs: ['--disable-extensions'] + }); + + await use(context); + + await context.close(); + fs.rmSync(userDataDir, { recursive: true }); + }, + { timeout: 120_000 } + ], + + extensionId: [ + async ({ extensionContext }, use) => { + const serviceWorker = + extensionContext.serviceWorkers()[0] ?? + (await extensionContext.waitForEvent('serviceworker', { timeout: 60_000 })); + + const extensionId = new URL(serviceWorker.url()).host; + + // Wait for the extension to fully initialize (IndexedDB, WASM, state) + await new Promise(resolve => setTimeout(resolve, 2_000)); + + await use(extensionId); + }, + { timeout: 120_000 } + ] +}); + +export const expect = test.expect; diff --git a/stress-test/helpers.ts b/stress-test/helpers.ts new file mode 100644 index 00000000..2e12fbd1 --- /dev/null +++ b/stress-test/helpers.ts @@ -0,0 +1,133 @@ +import type { Page } from '@playwright/test'; + +import { WalletMessageType, WalletStatus } from 'lib/shared/types'; +import { WalletType } from 'screens/onboarding/types'; + +const FAUCET_API_BASE = 'https://api.midenbrowserwallet.com/mint'; + +/** + * Send a message to the extension's background service worker via a persistent + * INTERCOM port (matching the real IntercomClient protocol). + */ +export async function sendWalletMessage(page: Page, message: any): Promise { + return await page.evaluate( + (msg: any) => + new Promise((resolve, reject) => { + const port = chrome.runtime.connect({ name: 'INTERCOM' }); + const reqId = Date.now() + Math.random(); + + const listener = (response: any) => { + if (response?.reqId !== reqId) return; + port.onMessage.removeListener(listener); + port.disconnect(); + if (response.type === 'INTERCOM_RESPONSE') { + resolve(response.data); + } else if (response.type === 'INTERCOM_ERROR') { + reject(new Error(JSON.stringify(response.data))); + } + }; + + port.onMessage.addListener(listener); + port.postMessage({ + type: 'INTERCOM_REQUEST', + data: msg, + reqId + }); + }), + message + ); +} + +/** + * Get the current wallet state from the background service worker. + */ +export async function getState(page: Page): Promise { + const res = await sendWalletMessage(page, { type: WalletMessageType.GetStateRequest }); + return res?.state; +} + +/** + * Ensure the wallet is ready (create or unlock as needed). + */ +export async function ensureWalletReady(page: Page, password: string, mnemonic?: string): Promise { + for (let i = 0; i < 10; i++) { + const state = await getState(page); + const status = state?.status; + // WalletStatus is a numeric enum: Idle=0, Locked=1, Ready=2 + if (status === WalletStatus.Ready) { + return state; + } + if (status === WalletStatus.Locked) { + await sendWalletMessage(page, { type: WalletMessageType.UnlockRequest, password }); + } else { + await sendWalletMessage(page, { + type: WalletMessageType.NewWalletRequest, + password, + mnemonic, + ownMnemonic: !!mnemonic + }); + } + await page.waitForTimeout(1_000); + } + throw new Error('Wallet not ready after retries'); +} + +/** + * Create a new HD account in the wallet. + */ +export async function createAccount(page: Page, walletType: WalletType, name: string): Promise { + await sendWalletMessage(page, { + type: WalletMessageType.CreateAccountRequest, + walletType, + name + }); + // Wait for state to propagate + await page.waitForTimeout(500); +} + +/** + * Switch the active account in the wallet. + */ +export async function switchAccount(page: Page, publicKey: string): Promise { + await sendWalletMessage(page, { + type: WalletMessageType.UpdateCurrentAccountRequest, + accountPublicKey: publicKey + }); + await page.waitForTimeout(500); +} + +/** + * Fund an account via the faucet API. + * Requests 100 MDN (10000000000 base units). + */ +export async function fundAccount(bech32Address: string): Promise { + const url = `${FAUCET_API_BASE}/${bech32Address}/10000000000`; + console.log(`Funding account: ${url}`); + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Faucet request failed: ${res.status} ${res.statusText}`); + } + return res; +} + +/** + * Wait for the MIDEN token balance to appear on the Explore page. + */ +export async function waitForBalance(page: Page, timeoutMs: number = 120_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + await page.waitForTimeout(2_000); + const midenVisible = await page + .locator('text=MDN') + .first() + .isVisible() + .catch(() => false); + console.log(`[waitForBalance] Checking for balance visibility: ${midenVisible}`); // Debug log + if (midenVisible) { + return; + } + await page.waitForTimeout(3_000); + await page.reload({ waitUntil: 'domcontentloaded' }); + } + throw new Error(`Balance did not appear within ${timeoutMs}ms`); +} diff --git a/stress-test/stress-test.spec.ts b/stress-test/stress-test.spec.ts new file mode 100644 index 00000000..a1558ec5 --- /dev/null +++ b/stress-test/stress-test.spec.ts @@ -0,0 +1,248 @@ +import { WalletType } from 'screens/onboarding/types'; + +import { expect, test } from './fixtures'; +import { createAccount, fundAccount, getState, switchAccount, waitForBalance } from './helpers'; + +const TEST_PASSWORD = 'StressTest123!'; +const NUM_RECEIVERS = 10; +const NOTES_PER_RECEIVER = 10; +const TOTAL_NOTES = NUM_RECEIVERS * NOTES_PER_RECEIVER; +const AMOUNT_PER_NOTE = '0.1'; // 0.01 MDN per note + +test.describe.configure({ mode: 'serial' }); + +test.describe('Stress Test: Private Notes', () => { + test.skip(({ browserName }) => browserName !== 'chromium', 'Extension only runs in Chromium'); + + test('send 100 private notes to 10 accounts and verify receipt', async ({ extensionContext, extensionId }) => { + const fullpageUrl = `chrome-extension://${extensionId}/fullpage.html`; + const page = await extensionContext.newPage(); + + // ==================== PHASE 1: SETUP ==================== + console.log('=== PHASE 1: SETUP ==='); + + await page.goto(fullpageUrl, { waitUntil: 'domcontentloaded' }); + + // Onboard wallet via UI + const welcome = page.getByTestId('onboarding-welcome'); + await welcome.waitFor({ timeout: 30_000 }); + await welcome.getByRole('button', { name: /create a new wallet/i }).click(); + + // Back up seed phrase + await page.getByText(/back up your wallet/i).waitFor({ timeout: 15_000 }); + await page.getByRole('button', { name: /show/i }).click(); + + // Extract first and last seed words for verification + const seedWords = await page.$$eval('article > label > label > p:last-child', paragraphs => + paragraphs.map(p => p.textContent?.trim() || '') + ); + const firstWord = seedWords[0]; + const lastWord = seedWords[11]; + if (!firstWord || !lastWord) { + throw new Error('Failed to read seed words from backup screen'); + } + + await page.getByRole('button', { name: /continue/i }).click(); + + // Verify seed phrase + await page.getByTestId('verify-seed-phrase').waitFor({ timeout: 15_000 }); + const verifyContainer = page.getByTestId('verify-seed-phrase'); + await verifyContainer.locator(`button:has-text("${firstWord}")`).first().click(); + await verifyContainer.locator(`button:has-text("${lastWord}")`).first().click(); + await verifyContainer.getByRole('button', { name: /continue/i }).click(); + + // Set password + await expect(page).toHaveURL(/create-password/); + await page.locator('input[placeholder="Enter password"]').first().fill(TEST_PASSWORD); + await page.locator('input[placeholder="Enter password again"]').first().fill(TEST_PASSWORD); + await page.getByRole('button', { name: /continue/i }).click(); + + // Complete onboarding + await expect(page.getByText(/your wallet is ready/i)).toBeVisible({ timeout: 60_000 }); + await page.getByRole('button', { name: /get started/i }).click(); + await expect(page.getByText('Send')).toBeVisible({ timeout: 30_000 }); + + console.log('Wallet onboarded successfully'); + + // Get sender account info + const initialState = await getState(page); + const senderAccount = initialState.accounts[0]; + const senderPubKey = senderAccount.publicKey; + console.log(`Sender address: ${senderPubKey}`); + + // Fund sender via faucet API + await fundAccount(senderPubKey); + console.log('Faucet request sent, waiting for faucet note...'); + + // Go to Receive page and wait for the faucet note to appear + await page.goto(`${fullpageUrl}#/receive`, { waitUntil: 'domcontentloaded' }); + await page.getByTestId('receive-page').waitFor({ timeout: 30_000 }); + + // Poll until a claimable note shows up + for (let attempt = 0; attempt < 120; attempt++) { + // Wait for React to fully render after each reload + await page.getByTestId('receive-page').waitFor({ timeout: 30_000 }); + await page.waitForTimeout(2_000); + + const claimSpan = page.locator('span:text("Claim")').first(); + if (await claimSpan.isVisible().catch(() => false)) { + break; + } + await page.waitForTimeout(5_000); + await page.reload({ waitUntil: 'domcontentloaded' }); + } + + // Click the parent