From c0806ca59c90464e424f881a92efd8033fd9bd2b Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 24 Jul 2026 10:56:01 +0300 Subject: [PATCH] tests: wasm UI tests (#4090) --- .github/workflows/ci.yml | 5 + .github/workflows/e2e.yml | 41 ++ Makefile | 4 + package-lock.json | 64 +++ package.json | 4 +- poetry.lock | 44 +- pyproject.toml | 1 + tests/e2e/bigpayment.spec.ts | 77 ++++ tests/e2e/extension-helpers.ts | 658 ++++++++++++++++++++++++++++++ tests/e2e/extensions.ts | 39 ++ tests/e2e/fixtures.ts | 626 ++++++++++++++++++++++++++++ tests/e2e/lnurl-helpers.ts | 203 +++++++++ tests/e2e/paysplit.spec.ts | 88 ++++ tests/e2e/pingpong.spec.ts | 382 +++++++++++++++++ tests/e2e/playwright.config.ts | 69 ++++ tests/e2e/start-lnbits-server.cjs | 85 ++++ tests/e2e/tips.spec.ts | 126 ++++++ uv.lock | 33 ++ 18 files changed, 2546 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 tests/e2e/bigpayment.spec.ts create mode 100644 tests/e2e/extension-helpers.ts create mode 100644 tests/e2e/extensions.ts create mode 100644 tests/e2e/fixtures.ts create mode 100644 tests/e2e/lnurl-helpers.ts create mode 100644 tests/e2e/paysplit.spec.ts create mode 100644 tests/e2e/pingpong.spec.ts create mode 100644 tests/e2e/playwright.config.ts create mode 100644 tests/e2e/start-lnbits-server.cjs create mode 100644 tests/e2e/tips.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea9af997c..8aa8bcc94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,11 @@ jobs: with: make: openapi + test-e2e: + if: ${{ false }} + needs: [ lint ] + uses: ./.github/workflows/e2e.yml + regtest: needs: [ lint ] uses: ./.github/workflows/regtest.yml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..2cf395d1c --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,41 @@ +name: e2e + +on: + workflow_call: + workflow_dispatch: + +jobs: + test-e2e: + name: test-e2e (${{ matrix.name }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - name: bigpayment + spec: tests/e2e/bigpayment.spec.ts + - name: paysplit + spec: tests/e2e/paysplit.spec.ts + - name: pingpong + spec: tests/e2e/pingpong.spec.ts + - name: tips + spec: tests/e2e/tips.spec.ts + + steps: + - uses: actions/checkout@v4 + - uses: lnbits/lnbits/.github/actions/prepare@dev + with: + python-version: "3.12" + node-version: "24.x" + npm: true + - name: Install Playwright browser + run: npm exec playwright install chromium + - name: Run ${{ matrix.name }} e2e tests + run: npm run test:e2e -- "${{ matrix.spec }}" + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-${{ matrix.name }} + path: test-reports + if-no-files-found: ignore diff --git a/Makefile b/Makefile index 283caf9db..beaed5f64 100644 --- a/Makefile +++ b/Makefile @@ -62,6 +62,10 @@ test-api: DEBUG=true \ uv run pytest tests/api +test-e2e: + npm exec playwright install chromium + npm run test:e2e + test-regtest: LNBITS_DATA_FOLDER="./tests/data" \ PYTHONUNBUFFERED=1 \ diff --git a/package-lock.json b/package-lock.json index 2f8cc9292..50a49c817 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "vuex": "4.1.0" }, "devDependencies": { + "@playwright/test": "^1.61.0", "clean-css-cli": "^5.6.3", "concat": "^1.0.3", "prettier": "^3.8.3", @@ -576,6 +577,22 @@ "node": ">=0.10" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@scure/base": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", @@ -1803,6 +1820,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", diff --git a/package.json b/package.json index 52f038001..4c9fb82fa 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,11 @@ "vendor_minify_css": "./node_modules/.bin/cleancss -o ./lnbits/static/bundle.min.css ./lnbits/static/bundle.css", "vendor_minify_js": "./node_modules/.bin/terser ./lnbits/static/bundle.js -o ./lnbits/static/bundle.min.js --compress --mangle", "vendor_minify_components": "./node_modules/.bin/terser ./lnbits/static/bundle-components.js -o ./lnbits/static/bundle-components.min.js --compress --mangle", - "bundle": "npm run sass && npm run vendor_copy && npm run vendor_json && npm run vendor_bundle_css && npm run vendor_bundle_js && npm run vendor_bundle_components && npm run vendor_minify_css && npm run vendor_minify_js && npm run vendor_minify_components" + "bundle": "npm run sass && npm run vendor_copy && npm run vendor_json && npm run vendor_bundle_css && npm run vendor_bundle_js && npm run vendor_bundle_components && npm run vendor_minify_css && npm run vendor_minify_js && npm run vendor_minify_components", + "test:e2e": "playwright test --config tests/e2e/playwright.config.ts" }, "devDependencies": { + "@playwright/test": "^1.61.0", "clean-css-cli": "^5.6.3", "concat": "^1.0.3", "prettier": "^3.8.3", diff --git a/poetry.lock b/poetry.lock index 66865d5e1..6fefc2c76 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1633,7 +1633,7 @@ version = "3.3.2" description = "Lightweight in-process concurrent programming" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d"}, {file = "greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13"}, @@ -2972,6 +2972,28 @@ files = [ {file = "platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934"}, ] +[[package]] +name = "playwright" +version = "1.61.0" +description = "A high-level API to automate web browsers" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "playwright-1.61.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:ff138c3a604f69911e9d42fd036e55c2a171e5616edf04c1e7f60a2a285540b0"}, + {file = "playwright-1.61.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009588c2a7e499bc5a8b425b61fa65490968bbda9cd69e0cf2cff10f8304659a"}, + {file = "playwright-1.61.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:9f7de4536088d12037c13a52b7ea34b59270b78926bb56935070597ffac6b1af"}, + {file = "playwright-1.61.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:54f3b39f6eab832e33458c1dd7da0b5682aedab3b09ae731b5c59fa12fd2024e"}, + {file = "playwright-1.61.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93454322ade8c11d5d6c211bfd91bdfb9ffb4810e3e026371bcbc4bec1b7ee4c"}, + {file = "playwright-1.61.0-py3-none-win32.whl", hash = "sha256:372d55a6f1248fa1dd47599686980cb8fb5bbe6fcda59eab793eb657c11d8a9b"}, + {file = "playwright-1.61.0-py3-none-win_amd64.whl", hash = "sha256:35c6cc4589a5d00964a59d7b3e59641e0aac0c02f15479a7af77d20f6bc79597"}, + {file = "playwright-1.61.0-py3-none-win_arm64.whl", hash = "sha256:e9fcbffcf557a8620fdedd92491eb59a32d18e23d6f3b4f6214b952be324fe51"}, +] + +[package.dependencies] +greenlet = ">=3.1.1,<4.0.0" +pyee = ">=13,<14" + [[package]] name = "pluggy" version = "1.6.0" @@ -3386,6 +3408,24 @@ typing-extensions = ">=4.2.0" dotenv = ["python-dotenv (>=0.10.4)"] email = ["email-validator (>=1.0.3)"] +[[package]] +name = "pyee" +version = "13.0.1" +description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"}, + {file = "pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8"}, +] + +[package.dependencies] +typing-extensions = "*" + +[package.extras] +dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"] + [[package]] name = "pygments" version = "2.19.2" @@ -5148,4 +5188,4 @@ migration = ["psycopg2-binary"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.13" -content-hash = "3097673d0cd279b0bf2b8fa59c1e523273f63d430f2c68ccc268a3cf232068af" +content-hash = "c07a16531b7f021c3b23ad6d35a18684e091655aaa14be7c7404875d2558a986" diff --git a/pyproject.toml b/pyproject.toml index 20328fc5f..eaa296ada 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ dev = [ "anyio~=4.12.1", "pytest~=9.0.2", "pytest-cov~=7.0.0", + "playwright~=1.61.0", "pytest-md~=0.2.0", "pytest-httpserver~=1.1.3", "pytest-mock~=3.15.1", diff --git a/tests/e2e/bigpayment.spec.ts b/tests/e2e/bigpayment.spec.ts new file mode 100644 index 000000000..a85b563b7 --- /dev/null +++ b/tests/e2e/bigpayment.spec.ts @@ -0,0 +1,77 @@ +import {test, expect, randomHex} from './fixtures' +import { + createInvoice, + createWallet, + extensionApi, + extensionFrame, + fundWalletWithFakeBalance, + installAndEnableExtension, + invoicePaymentRequest, + login, + waitForWalletBalance +} from './extension-helpers' +import {BIGPAYMENT, LIVE_EXTENSIONS} from './extensions' + +test('install BigPayment and pay large invoice with fake wallet', async ({ + page, + lnbitsServer +}) => { + await login(page, lnbitsServer) + await installAndEnableExtension(page, BIGPAYMENT, { + preloadExtensions: LIVE_EXTENSIONS + }) + + const collector = await createWallet( + page, + `BigPayment collector ${randomHex()}` + ) + const source = await createWallet(page, `BigPayment source ${randomHex()}`) + const recipient = await createWallet( + page, + `BigPayment recipient ${randomHex()}` + ) + await fundWalletWithFakeBalance(page, source.id, {amountSats: 750}) + + await page.goto('/ext/bigpayment') + const frame = await extensionFrame(page, 'BigPayment') + await expect(frame.getByText('Pay large Lightning invoices')).toBeVisible({ + timeout: 60_000 + }) + + const invoice = await createInvoice(page, recipient, { + amountSats: 500, + memo: 'BigPayment Playwright recipient' + }) + const selection = await extensionApi( + page, + BIGPAYMENT.extId, + 'POST', + '/selection', + { + walletIds: [collector.id, source.id], + collectorWalletId: collector.id + } + ) + expect(selection.collectorWalletId).toBe(collector.id) + + const payment = await extensionApi( + page, + BIGPAYMENT.extId, + 'POST', + '/payments', + { + paymentRequest: invoicePaymentRequest(invoice), + walletIds: [collector.id, source.id], + collectorWalletId: collector.id, + memo: 'BigPayment Playwright payment' + } + ) + expect(payment.paid).toBe(true) + expect(payment.direct).toBe(false) + expect(payment.amountSat).toBe(500) + expect(Array.isArray(payment.transfers)).toBe(true) + expect((payment.transfers as Record[])[0].fromWalletId).toBe( + source.id + ) + await waitForWalletBalance(page, recipient, {expectedSats: 500}) +}) diff --git a/tests/e2e/extension-helpers.ts b/tests/e2e/extension-helpers.ts new file mode 100644 index 000000000..6c1974d6a --- /dev/null +++ b/tests/e2e/extension-helpers.ts @@ -0,0 +1,658 @@ +import {expect, type Frame, type Locator, type Page} from '@playwright/test' + +import { + browserJson, + delay, + isRecord, + type LNbitsE2EServer, + waitForResult +} from './fixtures' + +const INSTALLABLE_EXTENSION_REFRESH_TASK = + 'refresh_installable_extensions_cache' + +export type ExtensionUnderTest = { + extId: string + manifestUrl: string + name: string + permissionTexts?: string[] + repository: string +} + +export type E2EWallet = { + adminkey: string + id: string + inkey: string + name: string +} + +export async function login( + page: Page, + server: LNbitsE2EServer +): Promise { + await page.goto('/') + await page.locator('input[name="username"]').fill(server.username) + await page.locator('input[name="password"]').fill(server.password) + await page.getByRole('button', {name: /^login$/i}).click() + await expect(page).toHaveURL(/\/wallet\/[^/]+$/) + await dismissDisclaimer(page) +} + +export async function dismissDisclaimer(page: Page): Promise { + try { + await page + .getByRole('button', {name: 'I understand'}) + .click({timeout: 5_000}) + } catch (_error) {} +} + +export async function superuserWallet(page: Page): Promise { + const wallet = await page.evaluate(() => { + const global = window as typeof window & { + g: { + user: { + wallets: Array<{ + adminkey: string + id: string + inkey: string + name: string + }> + } + } + } + return { + adminkey: global.g.user.wallets[0].adminkey, + id: global.g.user.wallets[0].id, + inkey: global.g.user.wallets[0].inkey, + name: global.g.user.wallets[0].name + } + }) + return walletFromResponse(wallet) +} + +export async function createWallet( + page: Page, + name: string +): Promise { + const wallet = await browserJson(page, 'POST', '/api/v1/wallet', {name}) + return walletFromResponse(wallet) +} + +export async function fundWalletWithFakeBalance( + page: Page, + walletId: string, + {amountSats}: {amountSats: number} +): Promise { + const response = await browserJson(page, 'PUT', '/users/api/v1/balance', { + id: walletId, + amount: amountSats + }) + if (!isRecord(response) || response.success !== true) { + throw new Error(`Fake balance update failed: ${JSON.stringify(response)}`) + } +} + +export async function walletBalanceSat( + page: Page, + wallet: E2EWallet +): Promise { + const response = await browserJson( + page, + 'GET', + '/api/v1/wallet', + undefined, + wallet.inkey + ) + if (!isRecord(response) || typeof response.balance !== 'number') { + throw new Error( + `Wallet response missing balance: ${JSON.stringify(response)}` + ) + } + return Math.trunc(response.balance / 1000) +} + +export async function waitForWalletBalance( + page: Page, + wallet: E2EWallet, + {expectedSats, timeout = 30_000}: {expectedSats: number; timeout?: number} +): Promise { + return waitForResult( + `wallet ${wallet.id} balance to reach ${expectedSats} sats`, + async () => { + const balance = await walletBalanceSat(page, wallet) + return balance >= expectedSats ? balance : null + }, + {timeout} + ) +} + +export async function createInvoice( + page: Page, + wallet: E2EWallet, + { + amountSats, + memo, + extra = {} + }: {amountSats: number; extra?: Record; memo: string} +): Promise> { + const invoice = await browserJson( + page, + 'POST', + '/api/v1/payments', + { + out: false, + amount: amountSats, + unit: 'sat', + memo, + extra + }, + wallet.inkey + ) + if (!isRecord(invoice)) { + throw new Error( + `Invoice response is not an object: ${JSON.stringify(invoice)}` + ) + } + expect(invoicePaymentRequest(invoice).toLowerCase()).toMatch(/^lnbc/) + return invoice +} + +export async function payInvoiceWithWallet( + page: Page, + wallet: E2EWallet, + paymentRequest: string +): Promise> { + const payment = await browserJson( + page, + 'POST', + '/api/v1/payments', + {out: true, bolt11: paymentRequest}, + wallet.adminkey + ) + if (!isRecord(payment)) { + throw new Error( + `Payment response is not an object: ${JSON.stringify(payment)}` + ) + } + return payment +} + +export function invoicePaymentRequest( + invoice: Record +): string { + const paymentRequest = invoice.payment_request ?? invoice.bolt11 + if (typeof paymentRequest !== 'string') { + throw new Error( + `Invoice response missing payment request: ${JSON.stringify(invoice)}` + ) + } + return paymentRequest +} + +export async function installAndEnableExtension( + page: Page, + extension: ExtensionUnderTest, + {preloadExtensions = []}: {preloadExtensions?: ExtensionUnderTest[]} = {} +): Promise { + await addExtensionManifests(page, [...preloadExtensions, extension]) + await installExtension(page, extension) + await enableExtension(page, extension) +} + +export async function addExtensionManifests( + page: Page, + extensions: ExtensionUnderTest[] +): Promise { + const uniqueExtensions = uniqueExtensionsById(extensions) + if (!uniqueExtensions.length) return + + await page.goto('/admin#extensions') + await dismissDisclaimer(page) + const savedManifests = await savedManifestsFor(page) + const missingExtensions = uniqueExtensions.filter( + extension => !savedManifests.includes(extension.manifestUrl) + ) + + if (missingExtensions.length) { + const manifestInput = page + .locator('.q-field') + .filter({hasText: 'Source URL'}) + .locator('input') + .first() + for (const extension of missingExtensions) { + await manifestInput.fill(extension.manifestUrl) + await manifestInput.press('Enter') + await expect(page.getByText(extension.manifestUrl)).toBeVisible() + } + const saveButton = page.getByRole('button', {name: /^save$/i}) + await expect(saveButton).toBeEnabled() + await saveButton.click() + await expect(page.getByText(/Settings changed/i)).toBeVisible() + } + + const savedAfterUpdate = await savedManifestsFor(page) + const missingUrls = uniqueExtensions + .filter(extension => !savedAfterUpdate.includes(extension.manifestUrl)) + .map(extension => extension.manifestUrl) + expect(missingUrls).toEqual([]) +} + +export async function installExtension( + page: Page, + extension: ExtensionUnderTest +): Promise { + const state = await extensionState(page, extension.extId) + if (state?.isInstalled) { + if (!state.isActive) { + await activateInstalledExtension(page, extension) + await waitForInstalledExtension(page, extension.extId) + } + return + } + + await waitForInstallableExtension(page, extension) + + await page.goto('/extensions') + await dismissDisclaimer(page) + await selectExtensionsTab(page, 'All') + await filterExtensions(page, extension.name) + const extensionCard = extensionCardFor(page, extension) + await expect(extensionCard).toBeVisible({timeout: 120_000}) + await extensionCard.getByRole('button', {name: /^manage$/i}).click() + + const manageDialog = manageExtensionDialog(page) + await expect(manageDialog).toBeVisible({timeout: 60_000}) + await expect( + manageDialog.getByRole('tab', {name: /^releases$/i}) + ).toBeVisible({ + timeout: 60_000 + }) + + const release = await latestGithubRelease(extension) + const version = String(release.tag_name) + const repositoryLabel = manageDialog.getByText(extension.repository).first() + await expect(repositoryLabel).toBeVisible({timeout: 120_000}) + await repositoryLabel.click() + + const releaseLabel = manageDialog.getByText(version).first() + await expect(releaseLabel).toBeVisible({timeout: 120_000}) + await releaseLabel.click() + + const installButton = manageDialog + .getByRole('button', {name: /^install$/i}) + .first() + await expect(installButton).toBeVisible({timeout: 120_000}) + await installButton.click() + + const grantButton = page.getByRole('button', {name: /^grant and install$/i}) + try { + await expect(page.getByText('Grant extension permissions')).toBeVisible({ + timeout: 10_000 + }) + for (const permissionText of extension.permissionTexts ?? []) { + await expect(page.getByText(permissionText).first()).toBeVisible({ + timeout: 60_000 + }) + } + await expect(grantButton).toBeEnabled({timeout: 60_000}) + await grantButton.click() + } catch (_error) {} + + await waitForInstalledExtension(page, extension.extId) + const installed = await extensionState(page, extension.extId) + const grantedPermissionIds = new Set( + Array.isArray(installed?.permissions) + ? installed.permissions.filter(isRecord).map(permission => permission.id) + : [] + ) + const latestConfig = await latestReleaseConfig(extension) + const permissions = Array.isArray(latestConfig.permissions) + ? latestConfig.permissions.filter(isRecord) + : [] + for (const permission of permissions) { + expect(grantedPermissionIds.has(permission.id)).toBe(true) + } +} + +export async function enableExtension( + page: Page, + extension: ExtensionUnderTest +): Promise { + await page.goto('/extensions') + await selectExtensionsTab(page, 'Installed') + await filterExtensions(page, extension.name) + const extensionCard = extensionCardFor(page, extension) + await expect(extensionCard).toBeVisible({timeout: 120_000}) + const enableButton = extensionCard.getByRole('button', {name: /^enable$/i}) + if (await enableButton.isVisible()) { + await enableButton.click() + await expect(page.getByText('Extension enabled!')).toBeVisible({ + timeout: 60_000 + }) + } + await expect(extensionCard.getByRole('link', {name: /^open$/i})).toBeVisible({ + timeout: 60_000 + }) +} + +export async function activateInstalledExtension( + page: Page, + extension: ExtensionUnderTest +): Promise { + await page.goto('/extensions') + await selectExtensionsTab(page, 'Installed') + await filterExtensions(page, extension.name) + const extensionCard = extensionCardFor(page, extension) + await expect(extensionCard).toBeVisible({timeout: 120_000}) + const inactiveToggle = extensionCard.getByText(/^deactivated$/i) + try { + await inactiveToggle.click({timeout: 5_000}) + } catch (_error) { + return + } + await expect( + page.getByText( + new RegExp(`Extension '${escapeRegExp(extension.extId)}' activated!`) + ) + ).toBeVisible({timeout: 60_000}) +} + +export async function waitForInstallableExtension( + page: Page, + extension: ExtensionUnderTest, + {timeout = 360_000}: {timeout?: number} = {} +): Promise { + let lastResponse: unknown + let lastError: unknown + const deadline = Date.now() + timeout + + while (Date.now() < deadline) { + try { + lastError = undefined + await waitForInstallableExtensionRefresh(page, deadline) + const settings = await browserJson(page, 'GET', '/admin/api/v1/settings') + const extensions = await browserJson(page, 'GET', '/api/v1/extension/all') + lastResponse = { + manifests: isRecord(settings) + ? settings.lnbits_extensions_manifests + : undefined, + extensions + } + const manifests = + isRecord(settings) && + Array.isArray(settings.lnbits_extensions_manifests) + ? settings.lnbits_extensions_manifests + : [] + const extensionList = Array.isArray(extensions) ? extensions : [] + if ( + manifests.includes(extension.manifestUrl) && + extensionList.filter(isRecord).some(item => item.id === extension.extId) + ) { + return + } + await waitForInstallableExtensionRefresh(page, deadline) + } catch (error) { + lastError = error + } + await delay(2_000) + } + + throw new Error( + `${extension.name} extension did not become installable: ${JSON.stringify(lastResponse)}. Last error: ${String(lastError)}` + ) +} + +export async function waitForInstalledExtension( + page: Page, + extensionId: string, + {timeout = 120_000}: {timeout?: number} = {} +): Promise { + await waitForResult( + `${extensionId} extension to be installed and active`, + async () => { + const state = await extensionState(page, extensionId) + return state?.isInstalled && state.isActive ? true : null + }, + {timeout, interval: 2_000} + ) +} + +export async function extensionState( + page: Page, + extensionId: string +): Promise | null> { + const extensions = await browserJson(page, 'GET', '/api/v1/extension/all') + if (!Array.isArray(extensions)) { + throw new Error( + `Extensions response is not a list: ${JSON.stringify(extensions)}` + ) + } + return ( + extensions + .filter(isRecord) + .find(extension => extension.id === extensionId) ?? null + ) +} + +export async function grantBackgroundPaymentPermission( + page: Page, + extensionId: string, + walletId: string, + { + maxAmountSats, + destinationPolicy = 'external_allowed' + }: {destinationPolicy?: string; maxAmountSats: number} +): Promise> { + const response = await browserJson( + page, + 'POST', + `/api/v1/extension/${extensionId}/permissions/background-payment`, + { + wallet_id: walletId, + max_amount: maxAmountSats, + destination_policy: destinationPolicy + } + ) + if (!isRecord(response)) { + throw new Error( + `Permission response is not an object: ${JSON.stringify(response)}` + ) + } + return response +} + +export async function grantWalletPaymentsWatchPermission( + page: Page, + extensionId: string, + walletId: string +): Promise> { + const response = await browserJson( + page, + 'POST', + `/api/v1/extension/${extensionId}/permissions/wallet-payments-watch`, + {wallet_id: walletId} + ) + if (!isRecord(response)) { + throw new Error( + `Permission response is not an object: ${JSON.stringify(response)}` + ) + } + return response +} + +export async function extensionApi( + page: Page, + extensionId: string, + method: string, + path: string, + data?: Record +): Promise> { + const body = await browserJson( + page, + method, + `/api/v1/ext/${extensionId}${path}`, + data + ) + if (isRecord(body) && body.ok === false) { + throw new Error(`${method} ${path} failed: ${JSON.stringify(body)}`) + } + const responseData = isRecord(body) && isRecord(body.data) ? body.data : body + if (!isRecord(responseData)) { + throw new Error( + `${method} ${path} returned non-object data: ${JSON.stringify(body)}` + ) + } + return responseData +} + +export async function extensionFrame( + page: Page, + title: string +): Promise { + const iframe = page.locator(`iframe[title="${title}"]`) + await expect(iframe).toBeVisible({timeout: 60_000}) + const handle = await iframe.elementHandle() + const frame = await handle?.contentFrame() + if (!frame) throw new Error(`Extension iframe not found: ${title}`) + return frame +} + +function extensionCardFor(page: Page, extension: ExtensionUnderTest): Locator { + return page.locator('.q-card').filter({hasText: extension.name}).first() +} + +async function filterExtensions(page: Page, searchTerm: string): Promise { + await page + .locator('.q-field') + .filter({hasText: 'Search extensions'}) + .locator('input') + .fill(searchTerm) +} + +async function selectExtensionsTab(page: Page, tabName: string): Promise { + const tab = page.getByRole('tab', { + name: new RegExp(`^${escapeRegExp(tabName)}$`, 'i') + }) + await expect(tab).toBeVisible({timeout: 60_000}) + for (let attempt = 0; attempt < 3; attempt += 1) { + await tab.click() + try { + await expect(tab).toHaveAttribute('aria-selected', 'true', { + timeout: 5_000 + }) + return + } catch (_error) { + await page.waitForTimeout(500) + } + } + await expect(tab).toHaveAttribute('aria-selected', 'true', {timeout: 60_000}) +} + +function manageExtensionDialog(page: Page): Locator { + return page.locator('.q-dialog').filter({hasText: 'Releases'}).last() +} + +async function waitForInstallableExtensionRefresh( + page: Page, + deadline: number +): Promise { + while (Date.now() < deadline) { + const tasks = await browserJson(page, 'GET', '/admin/api/v1/monitor') + if (!Array.isArray(tasks)) return + if ( + !tasks + .filter(isRecord) + .some(task => task.name === INSTALLABLE_EXTENSION_REFRESH_TASK) + ) { + return + } + await delay(2_000) + } +} + +async function latestGithubRelease( + extension: ExtensionUnderTest +): Promise> { + const release = await fetchJson( + `https://api.github.com/repos/${extension.repository}/releases/latest` + ) + if (!isRecord(release) || !release.tag_name) { + throw new Error( + `Invalid GitHub release response: ${JSON.stringify(release)}` + ) + } + return release +} + +async function latestReleaseConfig( + extension: ExtensionUnderTest, + tagName?: string +): Promise> { + const tag = tagName ?? String((await latestGithubRelease(extension)).tag_name) + const config = await fetchJson( + `https://raw.githubusercontent.com/${extension.repository}/${tag}/config.json` + ) + if (!isRecord(config)) { + throw new Error( + `Invalid extension config response: ${JSON.stringify(config)}` + ) + } + return config +} + +async function savedManifestsFor(page: Page): Promise { + const settings = await browserJson(page, 'GET', '/admin/api/v1/settings') + const manifests = isRecord(settings) + ? settings.lnbits_extensions_manifests + : undefined + return Array.isArray(manifests) + ? manifests.filter( + (manifest): manifest is string => typeof manifest === 'string' + ) + : [] +} + +async function fetchJson(url: string): Promise { + const response = await fetch(url, { + headers: { + Accept: 'application/json', + 'User-Agent': 'LNbits Playwright e2e' + } + }) + const text = await response.text() + if (!response.ok) { + throw new Error(`${url} failed with ${response.status}: ${text}`) + } + return text ? JSON.parse(text) : {} +} + +function uniqueExtensionsById( + extensions: ExtensionUnderTest[] +): ExtensionUnderTest[] { + const unique: ExtensionUnderTest[] = [] + const seen = new Set() + for (const extension of extensions) { + if (seen.has(extension.extId)) continue + unique.push(extension) + seen.add(extension.extId) + } + return unique +} + +function walletFromResponse(wallet: unknown): E2EWallet { + if (!isRecord(wallet)) { + throw new Error( + `Wallet response is not an object: ${JSON.stringify(wallet)}` + ) + } + return { + adminkey: String(wallet.adminkey), + id: String(wallet.id), + inkey: String(wallet.inkey), + name: String(wallet.name ?? wallet.id) + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/tests/e2e/extensions.ts b/tests/e2e/extensions.ts new file mode 100644 index 000000000..bbc698dc3 --- /dev/null +++ b/tests/e2e/extensions.ts @@ -0,0 +1,39 @@ +import type {ExtensionUnderTest} from './extension-helpers' + +export const TIPS: ExtensionUnderTest = { + extId: 'tips', + name: 'Tips', + manifestUrl: + 'https://raw.githubusercontent.com/lnbits/tips/refs/heads/main/manifest.json', + repository: 'lnbits/tips', + permissionTexts: ['Make background payments'] +} + +export const BIGPAYMENT: ExtensionUnderTest = { + extId: 'bigpayment', + name: 'BigPayment', + manifestUrl: + 'https://raw.githubusercontent.com/lnbits/bigpayment/refs/heads/main/manifest.json', + repository: 'lnbits/bigpayment', + permissionTexts: ['Pay invoices'] +} + +export const PINGPONG: ExtensionUnderTest = { + extId: 'pingpong', + name: 'Ping Pong', + manifestUrl: + 'https://raw.githubusercontent.com/lnbits/pingpong/refs/heads/main/manifest.json', + repository: 'lnbits/pingpong', + permissionTexts: ['Make background payments'] +} + +export const PAYSPLIT: ExtensionUnderTest = { + extId: 'paysplit', + name: 'PaySplit', + manifestUrl: + 'https://raw.githubusercontent.com/lnbits/paysplit/refs/heads/main/manifest.json', + repository: 'lnbits/paysplit', + permissionTexts: ['Watch wallet payments'] +} + +export const LIVE_EXTENSIONS = [TIPS, BIGPAYMENT, PINGPONG, PAYSPLIT] diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 000000000..13c9b92af --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -0,0 +1,626 @@ +import {randomUUID} from 'node:crypto' +import {unlink} from 'node:fs/promises' + +import {test as base, expect, type Page, type TestInfo} from '@playwright/test' + +export type LNbitsE2EServer = { + baseUrl: string + password: string + username: string +} + +const server: LNbitsE2EServer = { + baseUrl: process.env.LNBITS_E2E_BASE_URL ?? 'http://127.0.0.1:5009', + username: 'superadmin', + password: 'secret1234' +} + +const detailedScreenshotStates = new WeakMap< + TestInfo, + DetailedScreenshotState +>() + +const DETAILED_SCREENSHOT_SCRIPT = ` +(() => { + if (window.__lnbitsDetailedScreenshotsInstalled) return + window.__lnbitsDetailedScreenshotsInstalled = true + + let nextSnapshot = 0 + let scanScheduled = false + const visibleElements = new WeakMap() + + const notify = payload => { + setTimeout(() => { + try { + window.__lnbitsDetailedScreenshot({ + ...payload, + snapshotId: payload.snapshotId || (payload.kind + '-' + Date.now() + '-' + ++nextSnapshot), + url: payload.url || window.location.href + }) + } catch (_error) {} + }, 0) + } + + const notifyUrl = () => { + notify({ + kind: 'url', + label: window.location.href, + url: window.location.href + }) + } + + for (const name of ['pushState', 'replaceState']) { + const original = window.history[name] + window.history[name] = function (...args) { + const result = original.apply(this, args) + setTimeout(notifyUrl, 0) + return result + } + } + + window.addEventListener('hashchange', () => setTimeout(notifyUrl, 0)) + window.addEventListener('popstate', () => setTimeout(notifyUrl, 0)) + window.addEventListener('DOMContentLoaded', () => setTimeout(notifyUrl, 0)) + setTimeout(notifyUrl, 0) + + const groups = [ + { kind: 'dialog', selector: '.q-dialog, [role="dialog"]' }, + { kind: 'toast', selector: '.q-notification' } + ] + + const isVisible = element => { + if (!(element instanceof HTMLElement)) return false + if (element.getAttribute('aria-hidden') === 'true') return false + if (element.classList.contains('q-dialog--hidden')) return false + + const style = window.getComputedStyle(element) + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.opacity === '0' + ) { + return false + } + + const rect = element.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 + } + + const textFor = element => ( + element.getAttribute('aria-label') || + element.innerText || + element.textContent || + '' + ).replace(/\\s+/g, ' ').trim().slice(0, 120) + + const labelFor = element => { + const headings = element.querySelectorAll([ + '[role="heading"]', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + '.text-h1', + '.text-h2', + '.text-h3', + '.text-h4', + '.text-h5', + '.text-h6' + ].join(', ')) + + for (const heading of headings) { + if (isVisible(heading)) { + const headingText = textFor(heading) + if (headingText) return headingText + } + } + + return textFor(element) + } + + const scan = () => { + for (const group of groups) { + for (const element of document.querySelectorAll(group.selector)) { + if (isVisible(element)) { + const label = labelFor(element) + if (visibleElements.get(element) !== label) { + visibleElements.set(element, label) + notify({ + kind: group.kind, + label, + url: window.location.href + }) + } + } else { + visibleElements.delete(element) + } + } + } + } + + const scheduleScan = () => { + if (scanScheduled) return + scanScheduled = true + requestAnimationFrame(() => { + scanScheduled = false + scan() + }) + } + + const installObserver = () => { + if (!document.documentElement) return + new MutationObserver(scheduleScan).observe(document.documentElement, { + attributes: true, + attributeFilter: ['aria-hidden', 'class', 'style'], + childList: true, + subtree: true + }) + scheduleScan() + } + + window.addEventListener('DOMContentLoaded', installObserver) + setTimeout(installObserver, 0) +})() +` + +export const test = base.extend< + {}, + { + lnbitsServer: LNbitsE2EServer + } +>({ + lnbitsServer: [ + async ({}, use) => { + await completeFirstInstall(server) + await use(server) + }, + {scope: 'worker'} + ], + page: async ({page, lnbitsServer}, use, testInfo) => { + void lnbitsServer + await page.addInitScript( + "window.localStorage.setItem('lnbits.disclaimerShown', 'true')" + ) + const recorder = await installDetailedScreenshots(page, testInfo) + page.setDefaultTimeout(60_000) + let failed = false + try { + await use(page) + } catch (error) { + failed = true + throw error + } finally { + await recorder.finish({ + retain: failed || testInfo.status !== testInfo.expectedStatus + }) + } + } +}) + +export {expect} + +async function completeFirstInstall(e2eServer: LNbitsE2EServer): Promise { + const deadline = Date.now() + 90_000 + let lastError = '' + + while (Date.now() < deadline) { + try { + const response = await requestJson( + `${e2eServer.baseUrl}/api/v1/auth/first_install`, + { + method: 'PUT', + data: { + username: e2eServer.username, + password: e2eServer.password, + password_repeat: e2eServer.password, + first_install_token: '' + }, + timeoutMs: 2_000 + } + ) + if (response.status === 200) return + if ( + response.status === 403 && + isRecord(response.body) && + response.body.detail === 'This is not your first install' + ) { + return + } + lastError = `${response.status}: ${JSON.stringify(response.body)}` + } catch (error) { + lastError = String(error) + } + await delay(500) + } + + throw new Error( + `LNbits e2e server did not complete first install. Last error: ${lastError}` + ) +} + +type RequestJsonOptions = { + apiKey?: string + data?: Record + method: string + timeoutMs?: number +} + +export async function apiJson( + baseUrl: string, + method: string, + path: string, + data?: Record, + apiKey?: string, + timeoutMs = 30_000 +): Promise> { + const response = await requestJson(`${baseUrl}${path}`, { + method, + data, + apiKey, + timeoutMs + }) + if (response.status < 200 || response.status >= 300) { + throw new Error( + `${method} ${path} failed with ${response.status}: ${JSON.stringify(response.body)}` + ) + } + if (!isRecord(response.body)) { + throw new Error( + `${method} ${path} returned non-object JSON: ${JSON.stringify(response.body)}` + ) + } + return response.body +} + +async function requestJson( + url: string, + {method, data, apiKey, timeoutMs = 30_000}: RequestJsonOptions +): Promise<{body: unknown; status: number}> { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + + try { + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + ...(apiKey ? {'X-Api-Key': apiKey} : {}) + }, + body: data === undefined ? undefined : JSON.stringify(data), + signal: controller.signal + }) + const text = await response.text() + let body: unknown = {} + try { + body = text ? JSON.parse(text) : {} + } catch (_error) { + body = {detail: text} + } + return {status: response.status, body} + } finally { + clearTimeout(timeout) + } +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export async function browserJson( + page: Page, + method: string, + path: string, + data?: Record, + apiKey?: string +): Promise { + const response = await page.evaluate( + async ({method, path, data, apiKey}) => { + const headers: Record = { + 'Content-Type': 'application/json' + } + if (apiKey) headers['X-Api-Key'] = apiKey + const response = await fetch(path, { + method, + headers, + credentials: 'same-origin', + body: data === undefined ? undefined : JSON.stringify(data) + }) + const text = await response.text() + let body: unknown = {} + try { + body = text ? JSON.parse(text) : {} + } catch (_error) { + body = {detail: text} + } + return {status: response.status, body} + }, + {method, path, data, apiKey} + ) + if (response.status < 200 || response.status >= 300) { + throw new Error( + `${method} ${path} failed with ${response.status}: ${JSON.stringify(response.body)}` + ) + } + return response.body +} + +export async function waitForResult( + description: string, + callback: () => Promise, + {timeout = 30_000, interval = 500}: {interval?: number; timeout?: number} = {} +): Promise { + const deadline = Date.now() + timeout + let lastResult: T | null | undefined + let lastError: unknown + + while (Date.now() < deadline) { + try { + lastError = undefined + lastResult = await callback() + if (lastResult !== null && lastResult !== undefined) return lastResult + } catch (error) { + lastError = error + } + await delay(interval) + } + + throw new Error( + `Timed out waiting for ${description}. Last result: ${JSON.stringify(lastResult)}. Last error: ${String(lastError)}` + ) +} + +export function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +export function randomHex(): string { + return randomUUID().replace(/-/g, '').slice(0, 8) +} + +export type DetailedScreenshotHandle = { + finish(options?: {retain?: boolean}): Promise +} + +export async function installDetailedScreenshots( + page: Page, + testInfo: TestInfo +): Promise { + const recorder = new DetailedScreenshotRecorder(page, testInfo) + await recorder.install() + return recorder +} + +type DetailedScreenshotPayload = { + kind?: unknown + label?: unknown + snapshotId?: unknown + url?: unknown +} + +type DetailedScreenshotRequest = { + kind: 'dialog' | 'toast' | 'url' + label: string + snapshotId: string + url: string +} + +type DetailedScreenshotCapture = { + name: string + path: string +} + +type DetailedScreenshotState = { + captures: DetailedScreenshotCapture[] + finalized: boolean + screenshotIndex: number +} + +class DetailedScreenshotRecorder { + private readonly capturedUrlKeys = new Set() + private readonly page: Page + private readonly pending: DetailedScreenshotRequest[] = [] + private readonly testInfo: TestInfo + private flushPromise?: Promise + private flushTimer?: ReturnType + private lastQueuedKey = '' + private readonly state: DetailedScreenshotState + private stopped = false + + constructor(page: Page, testInfo: TestInfo) { + this.page = page + this.testInfo = testInfo + this.state = detailedScreenshotStateFor(testInfo) + } + + async install(): Promise { + await this.page.exposeBinding( + '__lnbitsDetailedScreenshot', + (_source, payload: DetailedScreenshotPayload) => { + this.queue(this.requestFromPayload(payload)) + } + ) + await this.page.addInitScript(DETAILED_SCREENSHOT_SCRIPT) + this.page.on('framenavigated', frame => { + const url = frame.url() + this.queue({ + kind: 'url', + label: url, + snapshotId: `frame-${Date.now()}`, + url + }) + }) + } + + async finish(options: {retain?: boolean} = {}): Promise { + this.queue({ + kind: 'url', + label: this.page.url(), + snapshotId: `final-${Date.now()}`, + url: this.page.url() + }) + this.stopped = true + if (this.flushTimer) { + clearTimeout(this.flushTimer) + this.flushTimer = undefined + } + await this.flush() + if (typeof options.retain === 'boolean') { + await finalizeDetailedScreenshots(this.testInfo, options.retain) + } + } + + private requestFromPayload( + payload: DetailedScreenshotPayload + ): DetailedScreenshotRequest | null { + const kind = typeof payload.kind === 'string' ? payload.kind : '' + if (!['dialog', 'toast', 'url'].includes(kind)) return null + + const url = + typeof payload.url === 'string' && payload.url + ? payload.url + : this.page.url() + const label = + typeof payload.label === 'string' && payload.label ? payload.label : kind + const snapshotId = + typeof payload.snapshotId === 'string' && payload.snapshotId + ? payload.snapshotId + : `${kind}-${Date.now()}` + + return { + kind: kind as DetailedScreenshotRequest['kind'], + label, + snapshotId, + url + } + } + + private queue(request: DetailedScreenshotRequest | null): void { + if (this.stopped || !request) return + if (!request.url || request.url === 'about:blank') return + + const key = + request.kind === 'url' + ? `${request.kind}:${request.url}` + : `${request.kind}:${request.snapshotId}:${request.url}:${request.label}` + if (request.kind === 'url' && this.capturedUrlKeys.has(key)) return + if (key === this.lastQueuedKey) return + + this.lastQueuedKey = key + this.pending.push(request) + this.scheduleFlush() + } + + private scheduleFlush(): void { + if (this.flushTimer) return + this.flushTimer = setTimeout(() => { + this.flushTimer = undefined + void this.flush() + }, 75) + } + + private async flush(): Promise { + if (this.flushPromise) { + await this.flushPromise + return + } + + this.flushPromise = this.flushQueue() + try { + await this.flushPromise + } finally { + this.flushPromise = undefined + } + } + + private async flushQueue(): Promise { + while (this.pending.length) { + const request = this.pending.shift() + if (!request) continue + await this.capture(request) + } + } + + private async capture(request: DetailedScreenshotRequest): Promise { + if (this.page.isClosed()) return + + if (request.kind === 'url') { + const key = `${request.kind}:${request.url}` + if (this.capturedUrlKeys.has(key)) return + this.capturedUrlKeys.add(key) + } + + const name = this.screenshotName(request) + const path = this.testInfo.outputPath(`${name}.png`) + try { + await this.page.waitForLoadState('domcontentloaded', {timeout: 2_000}) + } catch (_error) {} + try { + await this.page.waitForTimeout(250) + await this.page.screenshot({path, fullPage: true, timeout: 5_000}) + this.state.captures.push({name, path}) + } catch (_error) {} + } + + private screenshotName(request: DetailedScreenshotRequest): string { + this.state.screenshotIndex += 1 + return [ + 'screenshot', + this.state.screenshotIndex.toString().padStart(3, '0'), + request.kind, + slugFor(request.kind === 'url' ? request.url : request.label) + ].join('-') + } +} + +async function finalizeDetailedScreenshots( + testInfo: TestInfo, + retain: boolean +): Promise { + const state = detailedScreenshotStateFor(testInfo) + if (state.finalized) return + state.finalized = true + + if (retain) { + for (const capture of state.captures) { + await testInfo.attach(capture.name, { + path: capture.path, + contentType: 'image/png' + }) + } + return + } + + await Promise.all( + state.captures.map(async capture => { + try { + await unlink(capture.path) + } catch (_error) {} + }) + ) +} + +function detailedScreenshotStateFor( + testInfo: TestInfo +): DetailedScreenshotState { + const existing = detailedScreenshotStates.get(testInfo) + if (existing) return existing + const state = { + captures: [], + finalized: false, + screenshotIndex: 0 + } + detailedScreenshotStates.set(testInfo, state) + return state +} + +function slugFor(value: string): string { + const slug = value + .replace(/^[a-z]+:\/\//i, '') + .split(/[?#]/, 1)[0] + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/^[-._]+|[-._]+$/g, '') + .slice(0, 80) + return slug || 'capture' +} diff --git a/tests/e2e/lnurl-helpers.ts b/tests/e2e/lnurl-helpers.ts new file mode 100644 index 000000000..0ed719f7c --- /dev/null +++ b/tests/e2e/lnurl-helpers.ts @@ -0,0 +1,203 @@ +import http from 'node:http' + +import {apiJson} from './fixtures' +import {type E2EWallet, invoicePaymentRequest} from './extension-helpers' + +export class LNURLPayServer { + private readonly baseUrl: string + private readonly targetWallet: E2EWallet + private server?: http.Server + private serverUrl?: string + + private constructor(baseUrl: string, targetWallet: E2EWallet) { + this.baseUrl = baseUrl + this.targetWallet = targetWallet + } + + static async start( + baseUrl: string, + targetWallet: E2EWallet + ): Promise { + const lnurlServer = new LNURLPayServer(baseUrl, targetWallet) + await lnurlServer.listen() + return lnurlServer + } + + get url(): string { + if (!this.serverUrl) throw new Error('LNURL server is not listening') + return this.serverUrl + } + + get lnurl(): string { + return bech32Encode( + 'lnurl', + convertBits([...new TextEncoder().encode(this.url)], 8, 5, true) + ) + } + + async close(): Promise { + const server = this.server + if (!server) return + await new Promise((resolve, reject) => { + server.close(error => { + if (error) reject(error) + else resolve() + }) + }) + } + + private async listen(): Promise { + this.server = http.createServer((request, response) => { + void this.handleRequest(request, response) + }) + await new Promise((resolve, reject) => { + this.server?.once('error', reject) + this.server?.listen(0, '127.0.0.1', () => resolve()) + }) + const address = this.server.address() + if (!address || typeof address === 'string') { + throw new Error('LNURL server did not bind a TCP address') + } + this.serverUrl = `http://127.0.0.1:${address.port}/pay` + } + + private async handleRequest( + request: http.IncomingMessage, + response: http.ServerResponse + ): Promise { + try { + const requestUrl = new URL(request.url ?? '/', this.url) + if (requestUrl.pathname === '/pay') { + sendJson(response, 200, { + tag: 'payRequest', + callback: this.callbackUrl(), + minSendable: 1000, + maxSendable: 1_000_000, + metadata: JSON.stringify([['text/plain', 'LNbits e2e LNURL-pay']]) + }) + return + } + + if (requestUrl.pathname === '/callback') { + const amountMsat = Number(requestUrl.searchParams.get('amount') ?? '0') + const invoice = await apiJson( + this.baseUrl, + 'POST', + '/api/v1/payments', + { + out: false, + amount: Math.trunc(amountMsat / 1000), + unit: 'sat', + memo: 'LNbits e2e LNURL-pay target' + }, + this.targetWallet.inkey + ) + sendJson(response, 200, { + pr: invoicePaymentRequest(invoice), + routes: [] + }) + return + } + + sendJson(response, 404, { + status: 'ERROR', + reason: 'LNURL route not found.' + }) + } catch (error) { + sendJson(response, 500, { + status: 'ERROR', + reason: String(error) + }) + } + } + + private callbackUrl(): string { + const url = new URL(this.url) + url.pathname = '/callback' + return url.toString() + } +} + +function sendJson( + response: http.ServerResponse, + status: number, + body: Record +): void { + const rawBody = JSON.stringify(body) + response.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(rawBody) + }) + response.end(rawBody) +} + +const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' + +function bech32Encode(hrp: string, data: number[]): string { + const combined = [...data, ...bech32CreateChecksum(hrp, data)] + return `${hrp}1${combined.map(value => BECH32_CHARSET[value]).join('')}` +} + +function bech32CreateChecksum(hrp: string, data: number[]): number[] { + const values = [...bech32HrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0] + const polymod = bech32Polymod(values) ^ 1 + const result: number[] = [] + for (let index = 0; index < 6; index += 1) { + result.push((polymod >> (5 * (5 - index))) & 31) + } + return result +} + +function bech32HrpExpand(hrp: string): number[] { + const highBits = [...hrp].map(char => char.charCodeAt(0) >> 5) + const lowBits = [...hrp].map(char => char.charCodeAt(0) & 31) + return [...highBits, 0, ...lowBits] +} + +function bech32Polymod(values: number[]): number { + const generators = [ + 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3 + ] + let checksum = 1 + for (const value of values) { + const top = checksum >> 25 + checksum = ((checksum & 0x1ffffff) << 5) ^ value + for (let index = 0; index < 5; index += 1) { + if ((top >> index) & 1) checksum ^= generators[index] + } + } + return checksum +} + +function convertBits( + data: number[], + fromBits: number, + toBits: number, + pad: boolean +): number[] { + let acc = 0 + let bits = 0 + const result: number[] = [] + const maxValue = (1 << toBits) - 1 + const maxAcc = (1 << (fromBits + toBits - 1)) - 1 + + for (const value of data) { + if (value < 0 || value >> fromBits) { + throw new Error('Invalid bech32 data value') + } + acc = ((acc << fromBits) | value) & maxAcc + bits += fromBits + while (bits >= toBits) { + bits -= toBits + result.push((acc >> bits) & maxValue) + } + } + + if (pad) { + if (bits > 0) result.push((acc << (toBits - bits)) & maxValue) + } else if (bits >= fromBits || (acc << (toBits - bits)) & maxValue) { + throw new Error('Invalid bech32 padding') + } + + return result +} diff --git a/tests/e2e/paysplit.spec.ts b/tests/e2e/paysplit.spec.ts new file mode 100644 index 000000000..1e8a637ab --- /dev/null +++ b/tests/e2e/paysplit.spec.ts @@ -0,0 +1,88 @@ +import {test, expect, randomHex} from './fixtures' +import { + createInvoice, + createWallet, + extensionApi, + extensionFrame, + fundWalletWithFakeBalance, + grantBackgroundPaymentPermission, + grantWalletPaymentsWatchPermission, + installAndEnableExtension, + invoicePaymentRequest, + login, + payInvoiceWithWallet, + waitForWalletBalance +} from './extension-helpers' +import {LNURLPayServer} from './lnurl-helpers' +import {LIVE_EXTENSIONS, PAYSPLIT} from './extensions' + +test('install PaySplit and split incoming payment with fake wallet', async ({ + page, + lnbitsServer +}) => { + await login(page, lnbitsServer) + const source = await createWallet(page, `PaySplit source ${randomHex()}`) + const target = await createWallet(page, `PaySplit target ${randomHex()}`) + const payer = await createWallet(page, `PaySplit payer ${randomHex()}`) + await fundWalletWithFakeBalance(page, payer.id, {amountSats: 250}) + + await installAndEnableExtension(page, PAYSPLIT, { + preloadExtensions: LIVE_EXTENSIONS + }) + await grantWalletPaymentsWatchPermission(page, PAYSPLIT.extId, source.id) + await grantBackgroundPaymentPermission(page, PAYSPLIT.extId, source.id, { + maxAmountSats: 100 + }) + + const lnurlServer = await LNURLPayServer.start(lnbitsServer.baseUrl, target) + try { + const saved = await extensionApi(page, PAYSPLIT.extId, 'POST', '/sources', { + enabled: true, + maxAmount: 100, + targets: [ + { + alias: 'Playwright target', + lnurl: lnurlServer.lnurl, + percent: 25 + } + ], + walletId: source.id, + walletName: source.name + }) + const sourceConfig = saved.source as Record + const targets = saved.targets as Record[] + expect(sourceConfig.wallet_id).toBe(source.id) + expect(targets[0].percent).toBe(25) + + await page.goto('/ext/paysplit') + const frame = await extensionFrame(page, 'PaySplit') + await frame.locator('#walletSelect').selectOption(source.id) + await expect(frame.locator('.target-lnurl').first()).toHaveValue( + lnurlServer.lnurl, + {timeout: 60_000} + ) + + const invoice = await createInvoice(page, source, { + amountSats: 100, + memo: 'PaySplit Playwright source' + }) + await payInvoiceWithWallet(page, payer, invoicePaymentRequest(invoice)) + await waitForWalletBalance(page, target, { + expectedSats: 25, + timeout: 45_000 + }) + } finally { + await lnurlServer.close() + } + + const sourceConfig = await extensionApi( + page, + PAYSPLIT.extId, + 'GET', + `/sources/${source.id}` + ) + const sourceData = sourceConfig.source as Record + const targets = sourceConfig.targets as Record[] + expect(sourceData.enabled).toBe(true) + expect(targets[0].alias).toBe('Playwright target') +}) diff --git a/tests/e2e/pingpong.spec.ts b/tests/e2e/pingpong.spec.ts new file mode 100644 index 000000000..7e8f1a7e0 --- /dev/null +++ b/tests/e2e/pingpong.spec.ts @@ -0,0 +1,382 @@ +import type {Page, Response} from '@playwright/test' + +import { + test, + expect, + installDetailedScreenshots, + randomHex, + waitForResult, + isRecord +} from './fixtures' +import { + createWallet, + extensionApi, + extensionFrame, + fundWalletWithFakeBalance, + grantBackgroundPaymentPermission, + installAndEnableExtension, + login, + payInvoiceWithWallet, + superuserWallet, + waitForWalletBalance +} from './extension-helpers' +import {LNURLPayServer} from './lnurl-helpers' +import {LIVE_EXTENSIONS, PINGPONG} from './extensions' + +type GameInvoice = { + gameId: string + invoice: { + checkingId: string + paymentHash: string + paymentRequest: string + } + playerSlot: 'player1' | 'player2' + playerToken: string +} + +test('install Ping Pong, play public game, and pay winner with fake wallet', async ({ + page, + browser, + lnbitsServer +}, testInfo) => { + await login(page, lnbitsServer) + const escrow = await superuserWallet(page) + const player1EntryWallet = await createWallet( + page, + `PingPong player 1 entry ${randomHex()}` + ) + const player2EntryWallet = await createWallet( + page, + `PingPong player 2 entry ${randomHex()}` + ) + const player1PayoutTarget = await createWallet( + page, + `PingPong player 1 payout ${randomHex()}` + ) + const player2PayoutTarget = await createWallet( + page, + `PingPong player 2 payout ${randomHex()}` + ) + await fundWalletWithFakeBalance(page, player1EntryWallet.id, { + amountSats: 100 + }) + await fundWalletWithFakeBalance(page, player2EntryWallet.id, { + amountSats: 100 + }) + + await installAndEnableExtension(page, PINGPONG, { + preloadExtensions: LIVE_EXTENSIONS + }) + await grantBackgroundPaymentPermission(page, PINGPONG.extId, escrow.id, { + maxAmountSats: 100 + }) + + await page.goto('/ext/pingpong') + const adminFrame = await extensionFrame(page, 'Ping Pong') + await expect(adminFrame.getByText('Lightning Pong tables')).toBeVisible({ + timeout: 60_000 + }) + + const tableName = `Playwright Pong ${randomHex()}` + const table = await extensionApi(page, PINGPONG.extId, 'POST', '/tables', { + name: tableName, + description: 'Playwright fake wallet table', + walletId: escrow.id, + entrySats: 3, + gamesToWin: 1, + hostPercent: 0 + }) + expect(table.name).toBe(tableName) + + const player1LnurlServer = await LNURLPayServer.start( + lnbitsServer.baseUrl, + player1PayoutTarget + ) + const player2LnurlServer = await LNURLPayServer.start( + lnbitsServer.baseUrl, + player2PayoutTarget + ) + + const player2Context = await browser.newContext({ + baseURL: lnbitsServer.baseUrl, + viewport: {width: 1280, height: 900} + }) + const player2Page = await player2Context.newPage() + player2Page.setDefaultTimeout(60_000) + const player2Recorder = await installDetailedScreenshots( + player2Page, + testInfo + ) + + try { + const player1Game = await createPublicGame( + page, + String(table.id), + tableName, + player1LnurlServer.lnurl + ) + await payInvoiceWithWallet( + page, + player1EntryWallet, + player1Game.invoice.paymentRequest + ) + await waitForResult( + 'Ping Pong player 1 payment to be recorded', + async () => + pingpongGameIfPlayer1Paid( + page, + player1Game.gameId, + player1Game.playerToken + ), + {timeout: 30_000} + ) + await page.goto(publicGamePath(player1Game.gameId)) + const player1WaitingFrame = await extensionFrame(page, 'Ping Pong') + const waitingGame = await publicGame( + page, + player1Game.gameId, + player1Game.playerToken + ) + expect(waitingGame.status).toBe('waiting_opponent') + await expect(player1WaitingFrame.locator('#game-note')).toContainText( + 'Share the game link with player 2.' + ) + + const player2Game = await joinPublicGame( + player2Page, + player1Game.gameId, + tableName, + player2LnurlServer.lnurl + ) + await payInvoiceWithWallet( + player2Page, + player2EntryWallet, + player2Game.invoice.paymentRequest + ) + await waitForResult( + 'Ping Pong game to start playing after both players paid', + async () => { + const game = await publicGame( + page, + player1Game.gameId, + player1Game.playerToken + ) + return game.status === 'playing' && + game.player1Paid === true && + game.player2Paid === true + ? game + : null + }, + {timeout: 60_000} + ) + + await page.goto(publicGamePath(player1Game.gameId)) + await player2Page.goto(publicGamePath(player1Game.gameId)) + const player1PlayingFrame = await extensionFrame(page, 'Ping Pong') + const player2PlayingFrame = await extensionFrame(player2Page, 'Ping Pong') + await expect(player1PlayingFrame.locator('#game-status')).toHaveText( + 'playing', + {timeout: 60_000} + ) + await expect(player2PlayingFrame.locator('#game-status')).toHaveText( + 'playing', + {timeout: 60_000} + ) + await expect(player1PlayingFrame.locator('#game-note')).toContainText( + 'You control the left paddle.' + ) + await expect(player2PlayingFrame.locator('#game-note')).toContainText( + 'You control the right paddle.' + ) + + const finished = await extensionApi( + page, + PINGPONG.extId, + 'POST', + `/games/${player1Game.gameId}/finish`, + { + playerToken: player1Game.playerToken, + winnerSlot: 'player1', + player1Wins: 1, + player2Wins: 0, + currentPlayer1Score: 11, + currentPlayer2Score: 0 + } + ) + expect(finished.winnerSlot).toBe('player1') + + const paidGame = await waitForResult( + 'Ping Pong winner payout to be paid', + async () => { + const game = await publicGame( + page, + player1Game.gameId, + player1Game.playerToken + ) + return game.status === 'paid' && game.payoutStatus === 'paid' + ? game + : null + }, + {timeout: 60_000, interval: 2_000} + ) + expect(paidGame.winnerSlot).toBe('player1') + await waitForWalletBalance(page, player1PayoutTarget, { + expectedSats: Number( + (paidGame.table as Record).winnerPayoutSats + ), + timeout: 60_000 + }) + + await page.goto(publicGamePath(player1Game.gameId)) + await player2Page.goto(publicGamePath(player1Game.gameId)) + const player1PaidFrame = await extensionFrame(page, 'Ping Pong') + const player2PaidFrame = await extensionFrame(player2Page, 'Ping Pong') + await expect(player1PaidFrame.locator('#game-status')).toHaveText( + 'Player 1 won', + {timeout: 60_000} + ) + await expect(player2PaidFrame.locator('#game-status')).toHaveText( + 'Player 1 won', + {timeout: 60_000} + ) + await expect(player1PaidFrame.locator('#game-note')).toContainText( + 'Player 1 won. Payout status: paid.' + ) + } finally { + await player2Recorder.finish() + await player2Context.close() + await player1LnurlServer.close() + await player2LnurlServer.close() + } + + await page.goto('/ext/pingpong') + const updatedFrame = await extensionFrame(page, 'Ping Pong') + await expect(updatedFrame.getByText(tableName)).toBeVisible({timeout: 60_000}) +}) + +async function pingpongGameIfPlayer1Paid( + page: Page, + gameId: string, + playerToken: string +): Promise | null> { + const game = await publicGame(page, gameId, playerToken) + return game.player1Paid === true ? game : null +} + +async function createPublicGame( + page: Page, + tableId: string, + tableName: string, + lnurl: string +): Promise { + await page.goto(publicTablePath(tableId)) + const frame = await extensionFrame(page, 'Ping Pong') + await expect(frame.locator('#table-view')).toBeVisible({timeout: 60_000}) + await expect(frame.locator('#table-name')).toHaveText(tableName) + await frame.locator('#create-lnurl').fill(lnurl) + + const responsePromise = page.waitForResponse( + response => + response.request().method() === 'POST' && + response + .url() + .includes(`/api/v1/ext/${PINGPONG.extId}/tables/${tableId}/games`) + ) + await frame.locator('#create-game').click() + const game = await gameInvoiceFromResponse(await responsePromise) + await expect(frame.locator('#payment-view')).toBeVisible({timeout: 60_000}) + await expect(frame.locator('#invoice-text')).toHaveText( + game.invoice.paymentRequest, + {timeout: 60_000} + ) + return game +} + +async function joinPublicGame( + page: Page, + gameId: string, + tableName: string, + lnurl: string +): Promise { + await page.goto(publicGamePath(gameId)) + const frame = await extensionFrame(page, 'Ping Pong') + await expect(frame.locator('#game-view')).toBeVisible({timeout: 60_000}) + await expect(frame.locator('#game-meta')).toContainText(tableName) + await expect(frame.locator('#join-panel')).toBeVisible({timeout: 60_000}) + await frame.locator('#join-lnurl').fill(lnurl) + + const responsePromise = page.waitForResponse( + response => + response.request().method() === 'POST' && + response + .url() + .includes(`/api/v1/ext/${PINGPONG.extId}/games/${gameId}/join`) + ) + await frame.locator('#join-game').click() + const game = await gameInvoiceFromResponse(await responsePromise) + await expect(frame.locator('#payment-view')).toBeVisible({timeout: 60_000}) + await expect(frame.locator('#invoice-text')).toHaveText( + game.invoice.paymentRequest, + {timeout: 60_000} + ) + return game +} + +async function publicGame( + page: Page, + gameId: string, + playerToken: string +): Promise> { + return extensionApi( + page, + PINGPONG.extId, + 'GET', + `/games/${gameId}/public?playerToken=${playerToken}` + ) +} + +async function gameInvoiceFromResponse( + response: Response +): Promise { + const body = await response.json() + const data = isRecord(body) && isRecord(body.data) ? body.data : body + if (!isRecord(data) || !isRecord(data.invoice)) { + throw new Error( + `Ping Pong game invoice response is invalid: ${JSON.stringify(body)}` + ) + } + const paymentRequest = data.invoice.paymentRequest + const paymentHash = data.invoice.paymentHash + const checkingId = data.invoice.checkingId + if ( + typeof data.gameId !== 'string' || + typeof data.playerSlot !== 'string' || + typeof data.playerToken !== 'string' || + typeof paymentRequest !== 'string' || + typeof paymentHash !== 'string' || + typeof checkingId !== 'string' + ) { + throw new Error( + `Ping Pong game invoice response is incomplete: ${JSON.stringify(body)}` + ) + } + expect(paymentRequest.toLowerCase()).toMatch(/^lnbc/) + return { + gameId: data.gameId, + playerSlot: data.playerSlot as GameInvoice['playerSlot'], + playerToken: data.playerToken, + invoice: { + checkingId, + paymentHash, + paymentRequest + } + } +} + +function publicTablePath(tableId: string): string { + return `/ext/${PINGPONG.extId}/t/${encodeURIComponent(tableId)}` +} + +function publicGamePath(gameId: string): string { + return `/ext/${PINGPONG.extId}/g/${encodeURIComponent(gameId)}` +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 000000000..b61b1d023 --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,69 @@ +import {defineConfig} from '@playwright/test' +import {resolve} from 'node:path' + +const appBaseUrl = process.env.LNBITS_E2E_BASE_URL ?? 'http://127.0.0.1:5009' +const e2eDir = __dirname +const isCi = Boolean(process.env.CI) +const projectRoot = resolve(e2eDir, '../..') +const reportRoot = resolve(projectRoot, 'test-reports') +const configuredWorkers = Number.parseInt( + process.env.PLAYWRIGHT_WORKERS ?? '', + 10 +) + +export default defineConfig({ + testDir: e2eDir, + testMatch: '**/*.spec.ts', + outputDir: resolve(reportRoot, 'test-results'), + timeout: 600_000, + fullyParallel: false, + forbidOnly: isCi, + retries: isCi ? 1 : 0, + workers: + Number.isInteger(configuredWorkers) && configuredWorkers > 0 + ? configuredWorkers + : 1, + expect: { + timeout: 15_000 + }, + reporter: isCi + ? [ + ['github'], + [ + 'html', + { + open: 'never', + outputFolder: resolve(reportRoot, 'playwright-report') + } + ] + ] + : [ + ['list'], + [ + 'html', + { + open: 'never', + outputFolder: resolve(reportRoot, 'playwright-report') + } + ] + ], + use: { + baseURL: appBaseUrl, + browserName: 'chromium', + headless: true, + viewport: { + width: 1280, + height: 900 + }, + trace: 'off', + screenshot: 'only-on-failure', + video: 'off' + }, + webServer: { + command: 'node ./tests/e2e/start-lnbits-server.cjs', + cwd: projectRoot, + url: appBaseUrl, + reuseExistingServer: false, + timeout: 180_000 + } +}) diff --git a/tests/e2e/start-lnbits-server.cjs b/tests/e2e/start-lnbits-server.cjs new file mode 100644 index 000000000..a67f871fd --- /dev/null +++ b/tests/e2e/start-lnbits-server.cjs @@ -0,0 +1,85 @@ +const childProcess = require('node:child_process') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +const rootDir = path.resolve(__dirname, '../..') +const baseUrl = new URL( + process.env.LNBITS_E2E_BASE_URL ?? 'http://127.0.0.1:5009' +) +const host = baseUrl.hostname +const port = baseUrl.port || (baseUrl.protocol === 'https:' ? '443' : '80') +const dataDir = + process.env.LNBITS_E2E_DATA_FOLDER ?? + fs.mkdtempSync(path.join(os.tmpdir(), 'lnbits-e2e-')) +const logDir = path.join(rootDir, 'test-reports', 'test-results') +const logFile = path.join(logDir, 'lnbits-e2e-server.log') + +fs.mkdirSync(dataDir, {recursive: true}) +fs.mkdirSync(logDir, {recursive: true}) + +const log = fs.openSync(logFile, 'a') +const server = childProcess.spawn( + 'uv', + [ + 'run', + 'uvicorn', + 'lnbits.__main__:app', + '--host', + host, + '--port', + port, + '--log-level', + 'warning' + ], + { + cwd: rootDir, + env: { + ...process.env, + AUTH_HTTPS_ONLY: 'false', + DEBUG: 'true', + HOST: host, + LNBITS_ADMIN_UI: 'true', + LNBITS_BACKEND_WALLET_CLASS: 'FakeWallet', + LNBITS_DATA_FOLDER: dataDir, + LNBITS_ENABLE_LOG_TO_FILE: 'false', + LNBITS_EXTENSIONS_MANIFESTS: '[]', + LNBITS_EXTENSIONS_PATH: dataDir, + PORT: port, + PYTHONUNBUFFERED: '1' + }, + stdio: ['ignore', log, log] + } +) + +let shuttingDown = false + +const shutdown = signal => { + if (shuttingDown) return + shuttingDown = true + + if (server.pid && server.exitCode === null) { + try { + server.kill(signal) + } catch (_error) {} + } + + setTimeout(() => { + if (server.pid && server.exitCode === null) { + try { + server.kill('SIGKILL') + } catch (_error) {} + } + process.exit(0) + }, 15_000).unref() +} + +process.on('SIGTERM', () => shutdown('SIGTERM')) +process.on('SIGINT', () => shutdown('SIGINT')) + +server.on('exit', (code, signal) => { + fs.closeSync(log) + if (!shuttingDown) { + process.exit(code ?? (signal ? 1 : 0)) + } +}) diff --git a/tests/e2e/tips.spec.ts b/tests/e2e/tips.spec.ts new file mode 100644 index 000000000..c62aa473d --- /dev/null +++ b/tests/e2e/tips.spec.ts @@ -0,0 +1,126 @@ +import type {Page} from '@playwright/test' + +import {test, expect, apiJson, randomHex} from './fixtures' +import { + extensionFrame, + fundWalletWithFakeBalance, + installAndEnableExtension, + login, + superuserWallet +} from './extension-helpers' +import {LIVE_EXTENSIONS, TIPS} from './extensions' + +test('install Tips extension and pay tip with fake wallet', async ({ + page, + lnbitsServer +}) => { + await login(page, lnbitsServer) + const wallet = await superuserWallet(page) + + await installAndEnableExtension(page, TIPS, { + preloadExtensions: LIVE_EXTENSIONS + }) + await fundWalletWithFakeBalance(page, wallet.id, {amountSats: 10_000}) + + const jarTitle = `Playwright Tips ${randomHex()}` + const tipMessage = `fake wallet tip ${randomHex()}` + const publicUrl = await createTipJar(page, jarTitle) + const paymentRequest = await createPublicTipInvoice( + page, + publicUrl, + tipMessage + ) + + await apiJson( + lnbitsServer.baseUrl, + 'POST', + '/api/v1/payments', + {out: true, bolt11: paymentRequest}, + wallet.adminkey + ) + + const publicFrame = await tipsFrame(page) + await expect(publicFrame.locator('#invoice-status')).toHaveText( + 'Payment received', + {timeout: 30_000} + ) + + await page.goto('/ext/tips') + const adminFrame = await tipsFrame(page) + await expect( + adminFrame.getByRole('cell', {name: jarTitle}).first() + ).toBeVisible({ + timeout: 60_000 + }) + await adminFrame.getByRole('button', {name: 'Refresh'}).click() + await expect( + adminFrame.getByRole('cell', {name: tipMessage}).first() + ).toBeVisible({ + timeout: 60_000 + }) + await expect( + adminFrame.getByRole('cell', {name: 'Paid'}).first() + ).toBeVisible() +}) + +async function createTipJar(page: Page, jarTitle: string): Promise { + await page.goto('/ext/tips') + const frame = await tipsFrame(page) + await expect(frame.getByText('Create Jar')).toBeVisible({timeout: 60_000}) + await frame.getByLabel('Title').fill(jarTitle) + await frame.getByRole('button', {name: /^create$/i}).click() + await expect(frame.getByRole('cell', {name: jarTitle})).toBeVisible({ + timeout: 60_000 + }) + await frame.waitForFunction(() => + [...document.querySelectorAll('input')].some(input => + input.value.includes('/ext/tips/jars/') + ) + ) + const publicUrl = await frame.evaluate(() => + [...document.querySelectorAll('input')] + .map(input => input.value) + .find(value => value.includes('/ext/tips/jars/')) + ) + if (typeof publicUrl !== 'string' || !publicUrl.includes('/ext/tips/jars/')) { + throw new Error(`Tip jar public URL was not found: ${String(publicUrl)}`) + } + return publicUrl +} + +async function createPublicTipInvoice( + page: Page, + publicUrl: string, + tipMessage: string +): Promise { + await page.goto(publicUrl) + const frame = await tipsFrame(page) + await expect(frame.getByRole('heading', {name: 'Leave a Tip'})).toBeVisible({ + timeout: 60_000 + }) + await frame.getByLabel('Name').fill('Playwright') + await frame.getByLabel('Message').fill(tipMessage) + await frame.getByRole('button', {name: 'Create Invoice'}).click() + await expect(frame.getByText('Waiting for payment')).toBeVisible({ + timeout: 60_000 + }) + await frame.waitForFunction(() => + Boolean(document.querySelector('#copy-invoice-button')?.dataset.invoice) + ) + const paymentRequest = await frame + .locator('#copy-invoice-button') + .evaluate(button => button.dataset.invoice) + if ( + typeof paymentRequest !== 'string' || + !paymentRequest.toLowerCase().startsWith('lnbc') + ) { + throw new Error( + `Tip invoice payment request was not found: ${String(paymentRequest)}` + ) + } + return paymentRequest +} + +async function tipsFrame(page: Page) { + return extensionFrame(page, 'Tips') +} diff --git a/uv.lock b/uv.lock index 47b571ff3..114836c06 100644 --- a/uv.lock +++ b/uv.lock @@ -1362,6 +1362,7 @@ dev = [ { name = "mypy" }, { name = "openai" }, { name = "openapi-spec-validator" }, + { name = "playwright" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1440,6 +1441,7 @@ dev = [ { name = "mypy", specifier = "~=1.17.1" }, { name = "openai", specifier = "~=2.14.0" }, { name = "openapi-spec-validator", specifier = "~=0.7.2" }, + { name = "playwright", specifier = "~=1.61.0" }, { name = "pre-commit", specifier = "~=4.5.1" }, { name = "pytest", specifier = "~=9.0.2" }, { name = "pytest-cov", specifier = "~=7.0.0" }, @@ -1832,6 +1834,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, ] +[[package]] +name = "playwright" +version = "1.61.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/ee/31e4e0db36588b817a10b299a0285082545fde7d36543c2abe498bb3d61a/playwright-1.61.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:ff138c3a604f69911e9d42fd036e55c2a171e5616edf04c1e7f60a2a285540b0", size = 43421877, upload-time = "2026-06-29T10:32:48.428Z" }, + { url = "https://files.pythonhosted.org/packages/42/35/71395dd3ecc798965be4a3ef8c443217d4abca168e7cb34536304f9489e6/playwright-1.61.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009588c2a7e499bc5a8b425b61fa65490968bbda9cd69e0cf2cff10f8304659a", size = 42205016, upload-time = "2026-06-29T10:32:52.104Z" }, + { url = "https://files.pythonhosted.org/packages/f4/44/323164cf5cd1647bdefce76ffce27651aadb959d089b48f53ea40918276e/playwright-1.61.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:9f7de4536088d12037c13a52b7ea34b59270b78926bb56935070597ffac6b1af", size = 43421884, upload-time = "2026-06-29T10:32:55.773Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/a35bf179e4ba2522c1893635094a64e407572547bd61528820fc0abc87fe/playwright-1.61.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:54f3b39f6eab832e33458c1dd7da0b5682aedab3b09ae731b5c59fa12fd2024e", size = 47421381, upload-time = "2026-06-29T10:32:59.903Z" }, + { url = "https://files.pythonhosted.org/packages/b7/eb/e3f922348ec17c315f98c463f72faa1181a1c3de0bfe31a8d2edf6561723/playwright-1.61.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93454322ade8c11d5d6c211bfd91bdfb9ffb4810e3e026371bcbc4bec1b7ee4c", size = 47120545, upload-time = "2026-06-29T10:33:03.574Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a6/5be4e52b40a9c0c8a073e7c5b0785c05cf5a9ea8f8a7b5b260e32d970342/playwright-1.61.0-py3-none-win32.whl", hash = "sha256:372d55a6f1248fa1dd47599686980cb8fb5bbe6fcda59eab793eb657c11d8a9b", size = 37844841, upload-time = "2026-06-29T10:33:07.361Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fd/2b78036e5fbe9d5f5645bbe08a1eac7160c51243c0093963edbcf67c35d9/playwright-1.61.0-py3-none-win_amd64.whl", hash = "sha256:35c6cc4589a5d00964a59d7b3e59641e0aac0c02f15479a7af77d20f6bc79597", size = 37844846, upload-time = "2026-06-29T10:33:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/1b0f3c4ee4eb0514bc805b5c2f9a223e5b6de4f11a926f5235d51d0fc81b/playwright-1.61.0-py3-none-win_arm64.whl", hash = "sha256:e9fcbffcf557a8620fdedd92491eb59a32d18e23d6f3b4f6214b952be324fe51", size = 33955127, upload-time = "2026-06-29T10:33:14.008Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -2059,6 +2080,18 @@ email = [ { name = "email-validator" }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + [[package]] name = "pygments" version = "2.20.0"