diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..3cfa7c5b2 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,65 @@ +name: E2E tests + +on: + # Можно выбрать любую ветку из выпадающего списка + workflow_dispatch: {} + +jobs: + e2e: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Install Playwright browsers + run: pnpm --filter demo-wallet e2e:deps + + - name: Build project + run: pnpm build + + - name: Run web e2e tests + env: + CI: "1" + ENABLE_HEADLESS: "true" + E2E_DEBUG_LOGS: "true" + DEBUG: "pw:webserver" + run: pnpm demo-wallet e2e + + - name: Build extension (chrome) + run: pnpm --filter demo-wallet build:extension:chrome + + - name: Run extension e2e tests + env: + CI: "1" + ENABLE_HEADLESS: "false" + E2E_DEBUG_LOGS: "true" + E2E_WALLET_SOURCE_EXTENSION: "../../dist-extension-chrome" + E2E_JS_BRIDGE: "true" + run: pnpm demo-wallet e2e + + - name: Upload Playwright artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-artifacts + path: | + apps/demo-wallet/test-results/ + apps/demo-wallet/playwright-report/ + retention-days: 3 + + diff --git a/.github/workflows/e2e_extension.yml b/.github/workflows/e2e_extension.yml index 4393c8403..133b8c4c1 100644 --- a/.github/workflows/e2e_extension.yml +++ b/.github/workflows/e2e_extension.yml @@ -58,9 +58,11 @@ jobs: run: | pnpm build cd apps/demo-wallet - pnpm e2e + xvfb-run pnpm e2e env: WALLET_MNEMONIC: ${{ secrets.WALLET_MNEMONIC }} + ENABLE_HEADLESS: "false" + E2E_DEBUG_LOGS: "true" E2E_WALLET_SOURCE_EXTENSION: "${{ github.workspace }}/apps/demo-wallet/dist-extension-chrome" E2E_JS_BRIDGE: true ALLURE_BASE_URL: ${{ secrets.ALLURE_BASE_URL }} @@ -84,5 +86,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: playwright-report-extension - path: apps/demo-wallet/test-results/ + path: | + apps/demo-wallet/test-results/ + apps/demo-wallet/playwright-report/ retention-days: 1 diff --git a/.github/workflows/e2e_web.yml b/.github/workflows/e2e_web.yml index c6a426448..43057c78f 100644 --- a/.github/workflows/e2e_web.yml +++ b/.github/workflows/e2e_web.yml @@ -95,6 +95,8 @@ jobs: xvfb-run pnpm e2e env: WALLET_MNEMONIC: ${{ secrets.WALLET_MNEMONIC }} + E2E_DEBUG_LOGS: "true" + DEBUG: "pw:webserver" # VITE_BRIDGE_URL: 'http://localhost:8081/bridge' VITE_BRIDGE_URL: 'https://connect.ton.org/bridge' ALLURE_BASE_URL: ${{ secrets.ALLURE_BASE_URL }} @@ -116,5 +118,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: playwright-report-web - path: apps/demo-wallet/test-results/ + path: | + apps/demo-wallet/test-results/ + apps/demo-wallet/playwright-report/ retention-days: 1 diff --git a/apps/demo-wallet/e2e/demo-wallet/demoWalletFixture.ts b/apps/demo-wallet/e2e/demo-wallet/demoWalletFixture.ts index 1c9c97f3c..52292effb 100644 --- a/apps/demo-wallet/e2e/demo-wallet/demoWalletFixture.ts +++ b/apps/demo-wallet/e2e/demo-wallet/demoWalletFixture.ts @@ -9,6 +9,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; +import type { Page, TestInfo } from '@playwright/test'; import { test } from '@playwright/test'; import type { ConfigFixture } from '../qa'; @@ -17,6 +18,104 @@ import type { TestFixture } from '../qa'; import { DemoWallet } from './DemoWallet'; import { isExtensionWalletSource } from '../qa/WalletApp'; +const enableDebugLogs = process.env.E2E_DEBUG_LOGS === 'true' || !!process.env.CI; +const enableVerboseDebugLogs = process.env.E2E_DEBUG_LOGS_VERBOSE === 'true'; + +type PageDebugState = { + lastUrl?: string; + lastTitle?: string; +}; + +const pageDebugState = new WeakMap(); +let signalHandlersInstalled = false; + +function prefix(testInfo: TestInfo): string { + const project = testInfo.project?.name ?? 'unknown-project'; + return `[E2E][${project}][${testInfo.title}]`; +} + +function safeText(value: unknown, maxLen = 700): string { + const str = typeof value === 'string' ? value : String(value); + return str.length > maxLen ? `${str.slice(0, maxLen)}…(truncated)` : str; +} + +function installCancelHandlers(testInfo: TestInfo, getPages: () => Page[]): void { + if (signalHandlersInstalled) return; + signalHandlersInstalled = true; + + const dump = (signal: string) => { + // eslint-disable-next-line no-console + console.error(`${prefix(testInfo)} received ${signal}. Dumping open pages state:`); + try { + for (const p of getPages()) { + const state = pageDebugState.get(p); + // eslint-disable-next-line no-console + console.error( + `${prefix(testInfo)} page url=${safeText(state?.lastUrl ?? p.url())} title=${safeText( + state?.lastTitle ?? '', + )}`, + ); + } + } catch (e) { + // eslint-disable-next-line no-console + console.error(`${prefix(testInfo)} failed to dump pages: ${String(e)}`); + } + }; + + process.once('SIGTERM', () => dump('SIGTERM')); + process.once('SIGINT', () => dump('SIGINT')); +} + +function attachDebugLogging(page: Page, testInfo: TestInfo): void { + if (!enableDebugLogs) return; + if (pageDebugState.has(page)) return; + + pageDebugState.set(page, {}); + + const updateState = async () => { + const state = pageDebugState.get(page); + if (!state) return; + try { + state.lastUrl = page.url(); + state.lastTitle = await page.title().catch(() => state.lastTitle); + } catch { + // ignore + } + }; + + page.on('framenavigated', (frame) => { + if (frame === page.mainFrame()) void updateState(); + }); + page.on('load', () => void updateState()); + void updateState(); + + page.on('pageerror', (err) => { + // eslint-disable-next-line no-console + console.error(`${prefix(testInfo)} [pageerror] ${safeText(err?.stack ?? err)}`); + }); + + page.on('console', (msg) => { + const type = msg.type(); + if (!enableVerboseDebugLogs && type !== 'error' && type !== 'warning') return; + // eslint-disable-next-line no-console + console.log(`${prefix(testInfo)} [console:${type}] ${safeText(msg.text())}`); + }); + + page.on('requestfailed', (req) => { + // eslint-disable-next-line no-console + console.error( + `${prefix(testInfo)} [requestfailed] ${req.method()} ${req.url()} ${safeText(req.failure()?.errorText ?? '')}`, + ); + }); + + page.on('response', (res) => { + const status = res.status(); + if (status < 400) return; + // eslint-disable-next-line no-console + console.error(`${prefix(testInfo)} [http ${status}] ${res.request().method()} ${res.url()}`); + }); +} + export function detectWalletSource() { const source = process.env.E2E_WALLET_SOURCE ?? 'http://localhost:5173/'; const extensionPath = process.env.E2E_WALLET_SOURCE_EXTENSION; @@ -35,22 +134,39 @@ export function demoWalletFixture(config: ConfigFixture, slowMo = 0) { const mnemonic = config.mnemonic ?? process.env.WALLET_MNEMONIC; return test.extend({ - context: async ({ context: _ }, use) => { + context: async ({ context: _ }, use, testInfo) => { const extensionPath = isExtension ? walletSource : ''; const context = await launchPersistentContext(extensionPath, slowMo); + if (enableDebugLogs) { + // eslint-disable-next-line no-console + console.log( + `${prefix(testInfo)} starting fixtures: appUrl=${safeText(config.appUrl)} walletSource=${safeText( + walletSource, + )} extension=${String(isExtension)} headless=${safeText(process.env.ENABLE_HEADLESS ?? '(default)')}`, + ); + installCancelHandlers(testInfo, () => context.pages()); + context.on('page', (p) => attachDebugLogging(p, testInfo)); + for (const p of context.pages()) attachDebugLogging(p, testInfo); + } await use(context); await context.close(); }, - app: async ({ context }, use) => { + app: async ({ context }, use, testInfo) => { // @ts-expect-error - custom property on context let app = context._app; if (!app) { app = await context.newPage(); // @ts-expect-error - custom property on context context._app = app; + attachDebugLogging(app, testInfo); + const startedAt = Date.now(); + // eslint-disable-next-line no-console + if (enableDebugLogs) console.log(`${prefix(testInfo)} opening dapp: ${safeText(config.appUrl)}`); app.onReady = await app.goto(config.appUrl, { waitUntil: 'load', }); + // eslint-disable-next-line no-console + if (enableDebugLogs) console.log(`${prefix(testInfo)} dapp loaded in ${Date.now() - startedAt}ms`); } // const pages = context.pages(); // context.get @@ -66,10 +182,18 @@ export function demoWalletFixture(config: ConfigFixture, slowMo = 0) { const widget = new TonConnectWidget(app); await use(widget); }, - wallet: async ({ context }, use) => { + wallet: async ({ context }, use, testInfo) => { const source = isExtension ? await getExtensionId(context) : walletSource; const app = new DemoWallet(context, source); + if (enableDebugLogs) { + // eslint-disable-next-line no-console + console.log( + `${prefix(testInfo)} importing wallet. mnemonic=${mnemonic ? '(set)' : '(empty)'} source=${safeText( + source, + )}`, + ); + } const importPromise = app.importWallet(mnemonic ?? ''); // await _app.onReady; await importPromise; diff --git a/apps/demo-wallet/e2e/qa/test.ts b/apps/demo-wallet/e2e/qa/test.ts index aba79e280..79ee1ea5f 100644 --- a/apps/demo-wallet/e2e/qa/test.ts +++ b/apps/demo-wallet/e2e/qa/test.ts @@ -27,9 +27,17 @@ export async function launchPersistentContext(extensionPath: string, slowMo = 0) args.push(`--load-extension=${extensionPath}`); } - const isHeadlessEnv = process.env.ENABLE_HEADLESS === 'true'; const isCi = !!process.env.CI; - const headless = isHeadlessEnv || isCi; + const headless = + process.env.ENABLE_HEADLESS === 'true' ? true : process.env.ENABLE_HEADLESS === 'false' ? false : isCi; + + if (extensionPath !== '' && headless) { + // eslint-disable-next-line no-console + console.warn( + `[E2E] Extension mode detected but headless=true. Chrome extensions usually won't load in headless mode. ` + + `Set ENABLE_HEADLESS=false in CI for extension e2e (run under xvfb).`, + ); + } if (headless) { args.push('--headless=new'); @@ -38,7 +46,7 @@ export async function launchPersistentContext(extensionPath: string, slowMo = 0) slowMo = isCi ? 0 : (parseInt(process.env.E2E_SLOW_MO || '0') ?? slowMo); const browserContext = await chromium.launchPersistentContext('', { args, - headless: false, + headless, slowMo, }); diff --git a/apps/demo-wallet/e2e/runTest.ts b/apps/demo-wallet/e2e/runTest.ts index 55ed0af67..80854721d 100644 --- a/apps/demo-wallet/e2e/runTest.ts +++ b/apps/demo-wallet/e2e/runTest.ts @@ -16,6 +16,14 @@ import type { TestFixture } from './qa'; const isExtension = process.env.E2E_JS_BRIDGE === 'true'; +function logStep(testInfo: TestInfo, message: string): void { + const timestamp = new Date().toISOString(); + const projectName = testInfo.project?.name ?? 'unknown-project'; + + // eslint-disable-next-line no-console + console.log(`[E2E][${timestamp}][${projectName}][${testInfo.title}] ${message}`); +} + interface SendTransactionProperties { waitBeforeApprove?: number; } @@ -26,6 +34,8 @@ export async function runSendTransactionTest( allureClient: AllureApiClient, properties?: SendTransactionProperties, ): Promise { + logStep(testInfo, 'start send transaction test'); + const testAllureId = extractAllureId(testInfo.title); const waitBeforeApprove = properties?.waitBeforeApprove || 0; @@ -43,6 +53,7 @@ export async function runSendTransactionTest( if (testAllureId && allureClient) { try { + logStep(testInfo, `fetching test case data for allureId=${testAllureId}`); const testCaseData = await getTestCaseData(allureClient, testAllureId); precondition = testCaseData.precondition; expectedResult = testCaseData.expectedResult; @@ -53,21 +64,25 @@ export async function runSendTransactionTest( console.error('Error getting test case data:', error); } } else { - // eslint-disable-next-line no-console - console.warn('AllureId not found in test title or client not available'); + logStep(testInfo, 'AllureId not found in test title or client not available'); } + logStep(testInfo, 'waiting for Connect Wallet button'); await expect(widget.connectButtonText).toHaveText('Connect Wallet'); if (isExtension) { + logStep(testInfo, 'connecting wallet via extension'); await widget.connectWallet('Tonkeeper'); await wallet.connect(true); } else { + logStep(testInfo, 'connecting wallet via web url'); await wallet.connectBy(await widget.connectUrl()); await expect(widget.connectButtonText).not.toHaveText('Connect Wallet'); } + logStep(testInfo, 'filling precondition and expected result'); await app.getByTestId('sendTxPrecondition').fill(precondition); await app.getByTestId('sendTxExpectedResult').fill(expectedResult); + logStep(testInfo, 'click send transaction button'); await app.getByTestId('send-transaction-button').click(); // Check for decline in test title, Allure test case name, precondition, or expectedResult @@ -86,9 +101,15 @@ export async function runSendTransactionTest( expectedResultLower.includes('reject'); const shouldConfirm = !shouldDecline; + logStep( + testInfo, + `calling wallet.sendTransaction (isPositiveCase=${isPositiveCase}, shouldConfirm=${shouldConfirm}, waitBeforeApprove=${waitBeforeApprove})`, + ); await wallet.sendTransaction(isPositiveCase, shouldConfirm, waitBeforeApprove); + logStep(testInfo, 'waiting for sendTransactionValidation = "Validation Passed"'); await expect(app.getByTestId('sendTransactionValidation')).toHaveText('Validation Passed'); + logStep(testInfo, 'send transaction test finished successfully'); } export async function runSignDataTest( @@ -96,6 +117,8 @@ export async function runSignDataTest( testInfo: TestInfo, allureClient: AllureApiClient, ): Promise { + logStep(testInfo, 'start sign data test'); + const testAllureId = extractAllureId(testInfo.title); if (testAllureId) { @@ -111,6 +134,7 @@ export async function runSignDataTest( if (testAllureId && allureClient) { try { + logStep(testInfo, `fetching test case data for allureId=${testAllureId}`); const testCaseData = await getTestCaseData(allureClient, testAllureId); precondition = testCaseData.precondition; expectedResult = testCaseData.expectedResult; @@ -120,22 +144,26 @@ export async function runSignDataTest( console.error('Error getting test case data:', error); } } else { - // eslint-disable-next-line no-console - console.warn('AllureId not found in test title or client not available'); + logStep(testInfo, 'AllureId not found in test title or client not available'); } + logStep(testInfo, 'waiting for Connect Wallet button'); await expect(widget.connectButtonText).toHaveText('Connect Wallet'); if (isExtension) { + logStep(testInfo, 'connecting wallet via extension'); await widget.connectWallet('Tonkeeper'); await wallet.connect(true); } else { + logStep(testInfo, 'connecting wallet via web url'); await wallet.connectBy(await widget.connectUrl()); await expect(widget.connectButtonText).not.toHaveText('Connect Wallet'); } + logStep(testInfo, 'filling precondition and expected result for sign data'); await app.getByTestId('signDataPrecondition').fill(precondition || ''); await app.getByTestId('signDataExpectedResult').fill(expectedResult || ''); + logStep(testInfo, 'click sign data button'); await app.getByTestId('sign-data-button').click(); // Check for decline in test title, Allure test case name, precondition, or expectedResult @@ -152,9 +180,12 @@ export async function runSignDataTest( preconditionLower.includes('reject') || expectedResultLower.includes('declined') || expectedResultLower.includes('reject'); - + logStep(testInfo, `calling wallet.signData(shouldApprove=${!shouldDecline})`); await wallet.signData(!shouldDecline); + + logStep(testInfo, 'waiting for signDataValidation = "Validation Passed"'); await expect(app.getByTestId('signDataValidation')).toHaveText('Validation Passed'); + logStep(testInfo, 'sign data test finished successfully'); } export async function runConnectTest( @@ -162,6 +193,8 @@ export async function runConnectTest( testInfo: TestInfo, allureClient: AllureApiClient, ): Promise { + logStep(testInfo, 'start connect test'); + const testAllureId = extractAllureId(testInfo.title); if (testAllureId) { @@ -176,6 +209,7 @@ export async function runConnectTest( let testCaseName: string = ''; if (testAllureId && allureClient) { + logStep(testInfo, `fetching test case data for allureId=${testAllureId}`); const testCaseData = await getTestCaseData(allureClient, testAllureId); precondition = testCaseData.precondition; expectedResult = testCaseData.expectedResult; @@ -199,22 +233,29 @@ export async function runConnectTest( expectedResultLower.includes('declined') || expectedResultLower.includes('reject'); + logStep(testInfo, 'filling precondition and expected result for connect'); await app.getByTestId('connectPrecondition').fill(precondition || ''); await app.getByTestId('connectExpectedResult').fill(expectedResult || ''); + logStep(testInfo, 'waiting for connect-button = "Connect Wallet"'); await expect(app.getByTestId('connect-button')).toHaveText('Connect Wallet'); if (isExtension) { - app.getByTestId('connect-button').click(); - widget.connectWallet('Tonkeeper', true); - wallet.connect(!shouldDecline, shouldSkipConnect); + logStep(testInfo, 'connecting via extension'); + await app.getByTestId('connect-button').click(); + await widget.connectWallet('Tonkeeper', true); + await wallet.connect(!shouldDecline, shouldSkipConnect); } else { + logStep(testInfo, 'connecting via web'); const connectUrl = await widget.connectUrl(await app.getByTestId('connect-button')); await wallet.connectBy(connectUrl, shouldSkipConnect, !shouldDecline); if (!shouldSkipConnect && !shouldDecline) { - expect(widget.connectButtonText).not.toHaveText('Connect Wallet'); + await expect(widget.connectButtonText).not.toHaveText('Connect Wallet'); } } + logStep(testInfo, 'waiting for connectValidation visible'); await app.getByTestId('connectValidation').waitFor({ state: 'visible' }); + logStep(testInfo, 'waiting for connectValidation = "Validation Passed"'); await expect(app.getByTestId('connectValidation')).toHaveText('Validation Passed', { timeout: 1 }); + logStep(testInfo, 'connect test finished successfully'); } diff --git a/apps/demo-wallet/vite.config.ts b/apps/demo-wallet/vite.config.ts index aff69ac7a..aa0fe2f7d 100644 --- a/apps/demo-wallet/vite.config.ts +++ b/apps/demo-wallet/vite.config.ts @@ -21,6 +21,14 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, './src'), + react: path.resolve(__dirname, '../../node_modules/react'), + // Pin react-dom to this app to avoid picking a different hoisted version in CI + 'react-dom': path.resolve(__dirname, './node_modules/react-dom'), + 'react-dom/client': path.resolve(__dirname, './node_modules/react-dom/client'), }, }, + optimizeDeps: { + include: ['react', 'react-dom'], + force: true, + }, }); diff --git a/apps/demo-wallet/vite.extension.config.ts b/apps/demo-wallet/vite.extension.config.ts index 132b7b9f1..636c0613e 100644 --- a/apps/demo-wallet/vite.extension.config.ts +++ b/apps/demo-wallet/vite.extension.config.ts @@ -70,6 +70,10 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, './src'), + react: path.resolve(__dirname, '../../node_modules/react'), + // Pin react-dom to this app to avoid picking a different hoisted version in CI + 'react-dom': path.resolve(__dirname, './node_modules/react-dom'), + 'react-dom/client': path.resolve(__dirname, './node_modules/react-dom/client'), }, }, });