From a82410651e9a01f49be87aec48f2b3ae142e53aa Mon Sep 17 00:00:00 2001 From: Ilyar Date: Thu, 18 Sep 2025 15:24:24 +0200 Subject: [PATCH 1/8] test: setup e2e test --- .github/workflows/e2e.yml | 54 ++++++++++++++ .gitignore | 2 + README.md | 32 ++++++++ apps/demo-wallet/e2e.config.ts | 30 ++++++++ .../demo-wallet/e2e/demo-wallet/DemoWallet.ts | 72 ++++++++++++++++++ .../e2e/demo-wallet/demoWalletFixture.ts | 34 +++++++++ apps/demo-wallet/e2e/demo-wallet/index.ts | 2 + apps/demo-wallet/e2e/qa/TonConnectWidget.ts | 73 +++++++++++++++++++ apps/demo-wallet/e2e/qa/WalletExtension.ts | 35 +++++++++ apps/demo-wallet/e2e/qa/index.ts | 23 ++++++ apps/demo-wallet/e2e/qa/test.ts | 20 +++++ apps/demo-wallet/e2e/qa/util.ts | 20 +++++ apps/demo-wallet/e2e/signData.spec.ts | 23 ++++++ apps/demo-wallet/package.json | 4 + .../src/components/ConnectRequestModal.tsx | 5 +- .../src/components/ImportWallet.tsx | 14 +++- apps/demo-wallet/src/components/Layout.tsx | 8 +- .../src/components/SignDataRequestModal.tsx | 13 +++- .../components/TransactionRequestModal.tsx | 4 +- .../demo-wallet/src/pages/SendTransaction.tsx | 4 +- apps/demo-wallet/src/pages/SetupPassword.tsx | 7 +- apps/demo-wallet/src/pages/SetupWallet.tsx | 22 +++++- apps/demo-wallet/src/pages/UnlockWallet.tsx | 4 +- .../demo-wallet/src/pages/WalletDashboard.tsx | 7 +- .../src/stores/slices/walletSlice.ts | 8 +- apps/demo-wallet/src/vite-env.d.ts | 9 +++ eslint.config.js | 1 + pnpm-lock.yaml | 41 +++++++++++ 28 files changed, 553 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 apps/demo-wallet/e2e.config.ts create mode 100644 apps/demo-wallet/e2e/demo-wallet/DemoWallet.ts create mode 100644 apps/demo-wallet/e2e/demo-wallet/demoWalletFixture.ts create mode 100644 apps/demo-wallet/e2e/demo-wallet/index.ts create mode 100644 apps/demo-wallet/e2e/qa/TonConnectWidget.ts create mode 100644 apps/demo-wallet/e2e/qa/WalletExtension.ts create mode 100644 apps/demo-wallet/e2e/qa/index.ts create mode 100644 apps/demo-wallet/e2e/qa/test.ts create mode 100644 apps/demo-wallet/e2e/qa/util.ts create mode 100644 apps/demo-wallet/e2e/signData.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..e04b03891 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,54 @@ +name: e2e + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + base: + name: e2e + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v4 + - name: Read .nvmrc + run: echo "NVMRC=$(cat .nvmrc)" >> $GITHUB_ENV + + - uses: actions/setup-node@v4 + with: + node-version: '${{ env.NVMRC }}' + cache: 'pnpm' + + - name: Setup dependencies + run: | + sudo apt-get install --no-install-recommends -y xvfb + pnpm install --frozen-lockfile + pnpm --filter demo-wallet e2e:deps + + - name: Build + run: | + pnpm build + pnpm --filter demo-wallet build:extension + env: + VITE_BRIDGE_URL: 'http://localhost:8081/bridge' + + - name: Start TON Connect Bridge + uses: ton-connect/bridge/actions/local@master + + - name: Run e2e specs + run: xvfb-run pnpm --filter demo-wallet e2e + env: + WALLET_MNEMONIC: ${{ secrets.WALLET_MNEMONIC }} + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-artifacts + path: | + apps/demo-wallet/playwright-report/ + apps/demo-wallet/test-results/ + retention-days: 10 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 76d380e5c..231afb1d0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ apps/demo-wallet/dist-extension.crx coverage .stryker* reports +*-results +*-report apps/TONWalletApp/Packages/TONWalletKit/.swiftpm/xcode/xcuserdata/ apps/TONWalletApp/Packages/TONWalletKit/Sources/TONWalletKit/Resources/JS/walletkit-ios-bridge.mjs diff --git a/README.md b/README.md index 2d1273194..0dfbce28b 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,35 @@ The testing environment uses `vitest` for faster test execution and includes mut pnpm kit check # lint and test pnpm kit quality # lint, test with coverage & mutation ``` + + +## E2E + +### (optional) Run TON Connect Bridge local +```bash +git clone https://github.com/ton-connect/bridge.git +cd bridge && docker compose -f docker-compose.memory.yml up --build -d +curl -I -f -s -o /dev/null -w "%{http_code}\n" http://localhost:9103/metrics +``` + +### Install and build deps +```bash +pnpm install --frozen-lockfile +pnpm --filter demo-wallet e2e:deps +pnpm build + +VITE_BRIDGE_URL=https://bridge.tonapi.io/bridge pnpm --filter demo-wallet build:extension +# or +VITE_BRIDGE_URL=http://localhost:8081/bridge pnpm --filter demo-wallet build:extension + +if [ ! -f apps/demo-wallet/.env ]; then echo "setup WALLET_MNEMONIC=".." in file apps/demo-wallet/.env"; fi +``` + +### Run test specs +```bash +pnpm --filter demo-wallet e2e +# or +WALLET_MNEMONIC=".." pnpm --filter demo-wallet e2e +# or +xvfb-run pnpm --filter demo-wallet e2e +``` diff --git a/apps/demo-wallet/e2e.config.ts b/apps/demo-wallet/e2e.config.ts new file mode 100644 index 000000000..73307ae41 --- /dev/null +++ b/apps/demo-wallet/e2e.config.ts @@ -0,0 +1,30 @@ +import 'dotenv/config'; +import { defineConfig, devices } from '@playwright/test'; + +if (!process.env.WALLET_MNEMONIC) { + // eslint-disable-next-line no-console + console.error('WALLET_MNEMONIC not set'); + process.exit(1); +} + +export default defineConfig({ + testDir: './e2e', + timeout: 60_000, + expect: { + timeout: 10_000, + }, + reporter: [['html'], ['list']], + use: { + screenshot: 'on', + trace: 'on', + permissions: ['clipboard-read', 'clipboard-write'], + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], +}); diff --git a/apps/demo-wallet/e2e/demo-wallet/DemoWallet.ts b/apps/demo-wallet/e2e/demo-wallet/DemoWallet.ts new file mode 100644 index 000000000..c00549fa4 --- /dev/null +++ b/apps/demo-wallet/e2e/demo-wallet/DemoWallet.ts @@ -0,0 +1,72 @@ +import { Page } from '@playwright/test'; + +import { testSelector, WalletExtension } from '../qa'; + +export class DemoWallet extends WalletExtension { + get onboardingPage() { + return 'chrome-extension://' + this.extensionId + '/index.extension.html'; + } + + async open(): Promise { + const app = await this.context.newPage(); + await app.goto(this.onboardingPage, { + waitUntil: 'load', + }); + return app; + } + + async importWallet(mnemonic: string): Promise { + const app = await this.open(); + app.locator(testSelector('title'), { hasText: 'Setup Password' }); + app.locator(testSelector('subtitle'), { hasText: 'Create Password' }); + await app.locator(testSelector('password')).fill(this.password); + await app.locator(testSelector('password-confirm')).fill(this.password); + await app.locator(testSelector('password-submit')).click(); + app.locator(testSelector('subtitle'), { hasText: 'Setup Your Wallet' }); + await app.locator(testSelector('import-wallet')).click(); + app.locator(testSelector('subtitle'), { hasText: 'Import Wallet' }); + await app.locator(testSelector('paste-all')).click(); + await app.locator(testSelector('mnemonic')).fill(mnemonic); + await app.locator(testSelector('mnemonic-process')).click(); + await app.locator(testSelector('import-wallet-process')).click(); + app.locator(testSelector('title'), { hasText: 'TON Wallet' }); + await app.locator(testSelector('password-remember')).click(); + await app.close(); + } + + async connectBy(url: string): Promise { + const app = await this.open(); + await app.locator(testSelector('tonconnect-url')).fill(url); + await app.locator(testSelector('tonconnect-process')).click(); + await app.locator(testSelector('request'), { hasText: 'Connect Request' }).waitFor({ state: 'visible' }); + await app.locator(testSelector('connect')).waitFor({ state: 'attached', timeout: 10_000 }); + await app.locator(testSelector('connect')).click(); + await app.locator(testSelector('request')).waitFor({ state: 'detached', timeout: 10_000 }); + await app.close(); + } + + async signData(confirm: boolean = true): Promise { + const app = await this.open(); + await app + .locator(testSelector('request'), { hasText: 'Sign Data Request' }) + .waitFor({ state: 'visible', timeout: 10_000 }); + if (confirm) { + await app.locator(testSelector('sign-data-approve')).waitFor({ state: 'attached', timeout: 10_000 }); + await app.locator(testSelector('sign-data-approve')).click(); + } else { + await app.locator(testSelector('sign-data-reject')).click(); + } + await app.locator(testSelector('request')).waitFor({ state: 'detached', timeout: 10_000 }); + await app.close(); + } + + async connect(_confirm?: boolean): Promise { + // TODO implement DemoWallet connect + throw new Error('DemoWallet connect not implemented'); + } + + async accept(_confirm: boolean = true): Promise { + // TODO implement DemoWallet accept + throw new Error('DemoWallet accept not implemented'); + } +} diff --git a/apps/demo-wallet/e2e/demo-wallet/demoWalletFixture.ts b/apps/demo-wallet/e2e/demo-wallet/demoWalletFixture.ts new file mode 100644 index 000000000..20e7f5eb2 --- /dev/null +++ b/apps/demo-wallet/e2e/demo-wallet/demoWalletFixture.ts @@ -0,0 +1,34 @@ +import { test } from '@playwright/test'; + +import { type TestFixture, launchPersistentContext, getExtensionId, TonConnectWidget, ConfigFixture } from '../qa'; +import { DemoWallet } from './DemoWallet'; + +export const demoWalletFixture = (config: ConfigFixture, slowMo = 0) => { + return test.extend({ + context: async ({ context: _ }, use) => { + const context = await launchPersistentContext(config.extensionPath, slowMo); + await use(context); + await context.close(); + }, + app: async ({ context }, use) => { + const pages = context.pages(); + let app = pages[pages.length - 1]; // return last tab + if (!app) { + app = await context.newPage(); + } + await app.goto(config.appUrl, { + waitUntil: 'load', + }); + await use(app); + }, + widget: async ({ app }, use) => { + const widget = new TonConnectWidget(app); + await use(widget); + }, + wallet: async ({ context }, use) => { + const extension = new DemoWallet(context, await getExtensionId(context)); + await extension.importWallet(config.mnemonic); + await use(extension); + }, + }); +}; diff --git a/apps/demo-wallet/e2e/demo-wallet/index.ts b/apps/demo-wallet/e2e/demo-wallet/index.ts new file mode 100644 index 000000000..060d537c3 --- /dev/null +++ b/apps/demo-wallet/e2e/demo-wallet/index.ts @@ -0,0 +1,2 @@ +export { DemoWallet } from './DemoWallet'; +export { demoWalletFixture } from './demoWalletFixture'; diff --git a/apps/demo-wallet/e2e/qa/TonConnectWidget.ts b/apps/demo-wallet/e2e/qa/TonConnectWidget.ts new file mode 100644 index 000000000..8ba4851dc --- /dev/null +++ b/apps/demo-wallet/e2e/qa/TonConnectWidget.ts @@ -0,0 +1,73 @@ +import { type Page } from '@playwright/test'; + +interface TonConnectSelector { + title: string; + secondTitle: string; + connectButton: string; + connectButtonText: string; + connectUrlButton: string; +} + +function createTonConnectSelector(): TonConnectSelector { + return { + title: '#tc-widget-root h1', + secondTitle: '#tc-widget-root h2', + connectButton: '[data-tc-button]', + connectButtonText: '[data-tc-button] [data-tc-text]', + connectUrlButton: '[data-tc-wallets-modal-universal-desktop] > button', + }; +} + +export class TonConnectWidget { + private page: Page; + private selector: TonConnectSelector; + + constructor(page: Page) { + this.page = page; + this.selector = createTonConnectSelector(); + } + + get title() { + return this.page.locator(this.selector.title); + } + + get secondTitle() { + return this.page.locator(this.selector.secondTitle); + } + + get connectButton() { + return this.page.locator(this.selector.connectButton); + } + + get connectButtonText() { + return this.page.locator(this.selector.connectButtonText); + } + + get connectUrlButton() { + return this.page.locator(this.selector.connectUrlButton); + } + + private clickButton(name: string) { + return this.page.getByRole('button', { name }).click(); + } + + async connectWallet(name: string) { + await this.connect(); + await this.clickButton(name); + await this.clickButton('Browser Extension'); + } + + async connect() { + await this.connectButton.waitFor({ state: 'visible' }); + await this.connectButton.click(); + await this.title.waitFor({ state: 'visible' }); + } + + async connectUrl() { + await this.connect(); + await this.connectUrlButton.waitFor({ state: 'visible' }); + await this.connectUrlButton.click(); + const handle = await this.page.evaluateHandle(() => navigator.clipboard.readText()); + return await handle.jsonValue(); + } +} diff --git a/apps/demo-wallet/e2e/qa/WalletExtension.ts b/apps/demo-wallet/e2e/qa/WalletExtension.ts new file mode 100644 index 000000000..f81f82942 --- /dev/null +++ b/apps/demo-wallet/e2e/qa/WalletExtension.ts @@ -0,0 +1,35 @@ +import type { BrowserContext } from '@playwright/test'; + +export abstract class WalletExtension { + /** + * Creates an instance of Wallet + * + * @param context - The Playwright BrowserContext in which the extension is running + * @param extensionId - The ID of the extension + * @param password - The password for the Wallet + */ + constructor( + readonly context: BrowserContext, + readonly extensionId: string, + readonly password: string = 'tester@1234', + ) { + this.context = context; + this.extensionId = extensionId; + this.password = password; + } + + /** + * Imports a wallet using the given seed phrase + * + * @param mnemonic - The seed phrase to import + */ + abstract importWallet(mnemonic: string): Promise; + + abstract connect(confirm?: boolean): Promise; + + abstract connectBy(url: string): Promise; + + abstract accept(confirm?: boolean): Promise; + + abstract signData(confirm?: boolean): Promise; +} diff --git a/apps/demo-wallet/e2e/qa/index.ts b/apps/demo-wallet/e2e/qa/index.ts new file mode 100644 index 000000000..9ed72e423 --- /dev/null +++ b/apps/demo-wallet/e2e/qa/index.ts @@ -0,0 +1,23 @@ +import type { BrowserContext } from '@playwright/test'; +import { Page } from '@playwright/test'; + +export { TonConnectWidget } from './TonConnectWidget'; +export { WalletExtension } from './WalletExtension'; +export { launchPersistentContext, testWith } from './test'; +export { getExtensionId, testSelector } from './util'; + +export interface ConfigFixture { + extensionPath: string; + mnemonic: string; + appUrl: string; +} + +import { WalletExtension } from './WalletExtension'; +import { TonConnectWidget } from './TonConnectWidget'; + +export type TestFixture = { + context: BrowserContext; + wallet: WalletExtension; + widget: TonConnectWidget; + app: Page; +}; diff --git a/apps/demo-wallet/e2e/qa/test.ts b/apps/demo-wallet/e2e/qa/test.ts new file mode 100644 index 000000000..acb1f811f --- /dev/null +++ b/apps/demo-wallet/e2e/qa/test.ts @@ -0,0 +1,20 @@ +import { chromium, type Fixtures, type TestType } from '@playwright/test'; +import { mergeTests, test as base } from '@playwright/test'; + +export function launchPersistentContext(extensionPath: string, slowMo = 0) { + const browserArgs = [`--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`]; + if (process.env.CI) { + browserArgs.push('--headless=new'); + } + return chromium.launchPersistentContext('', { + headless: false, + args: browserArgs, + slowMo: process.env.CI ? 0 : slowMo, + }); +} + +export function testWith( + customFixtures: TestType, +): TestType { + return mergeTests(base, customFixtures); +} diff --git a/apps/demo-wallet/e2e/qa/util.ts b/apps/demo-wallet/e2e/qa/util.ts new file mode 100644 index 000000000..b699510e3 --- /dev/null +++ b/apps/demo-wallet/e2e/qa/util.ts @@ -0,0 +1,20 @@ +import type { BrowserContext } from '@playwright/test'; + +export async function getExtensionId(context: BrowserContext) { + let [background] = context.serviceWorkers(); + if (!background) { + background = await context.waitForEvent('serviceworker'); + } + const extensionId = background.url().split('/')[2]; + if (!extensionId) { + throw new Error('[getExtensionId] can not getting extensionId'); + } + return extensionId; +} + +export const testSelector = (testId: string) => { + if (testId.includes(' ')) { + throw new Error('[testSelector] test-id cannot contain spaces'); + } + return `[data-test-id="${testId}"]`; +}; diff --git a/apps/demo-wallet/e2e/signData.spec.ts b/apps/demo-wallet/e2e/signData.spec.ts new file mode 100644 index 000000000..0a97f8053 --- /dev/null +++ b/apps/demo-wallet/e2e/signData.spec.ts @@ -0,0 +1,23 @@ +import { testWith } from './qa'; +import { demoWalletFixture } from './demo-wallet'; + +const test = testWith( + demoWalletFixture({ + extensionPath: 'dist-extension', + mnemonic: process.env.WALLET_MNEMONIC!, + appUrl: 'https://tonconnect-demo-dapp-with-react-ui.vercel.app/', + }), +); +const { expect } = test; + +test('Sign Data', async ({ wallet, app, widget }) => { + await expect(widget.connectButtonText).toHaveText('Connect Wallet'); + await wallet.connectBy(await widget.connectUrl()); + await expect(widget.connectButtonText).not.toHaveText('Connect Wallet'); + await app.getByRole('button', { name: 'Sign Text' }).click(); + await wallet.signData(); + const signDataResultSelector = app.locator( + '.sign-data-tester > div:nth-child(6) > div.find-transaction-demo__json-label', + ); + await expect(signDataResultSelector).toHaveText('✅ Verification Result'); +}); diff --git a/apps/demo-wallet/package.json b/apps/demo-wallet/package.json index 57b8054b2..19e24a78f 100644 --- a/apps/demo-wallet/package.json +++ b/apps/demo-wallet/package.json @@ -4,6 +4,8 @@ "version": "0.0.0", "type": "module", "scripts": { + "e2e:deps": "playwright install --with-deps chromium", + "e2e": "playwright test --config e2e.config.ts", "dev": "vite", "build": "tsc -b && vite build", "dev:extension": "cross-env VITE_APP_ENV=extension vite build --watch --config vite.extension.config.ts", @@ -39,11 +41,13 @@ }, "devDependencies": { "@eslint/js": "^9.33.0", + "@playwright/test": "^1.55.0", "@types/chrome": "^0.1.4", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0", "cross-env": "^10.0.0", + "dotenv": "^17.2.2", "eslint": "^9.33.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", diff --git a/apps/demo-wallet/src/components/ConnectRequestModal.tsx b/apps/demo-wallet/src/components/ConnectRequestModal.tsx index 0b03b467c..72ef1120f 100644 --- a/apps/demo-wallet/src/components/ConnectRequestModal.tsx +++ b/apps/demo-wallet/src/components/ConnectRequestModal.tsx @@ -57,7 +57,9 @@ export const ConnectRequestModal: React.FC = ({
{/* Header */}
-

Connect Request

+

+ Connect Request +

A dApp wants to connect to your wallet

@@ -212,6 +214,7 @@ export const ConnectRequestModal: React.FC = ({ Reject