taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit cc452bb4cc64188ffcd20a80364efb4d55f9915b
parent 73bda535a258d48871418485a63cbbadbdad5822
Author: Florian Dold <dold@taler.net>
Date:   Sat, 22 Aug 2026 16:27:27 +0200

wallet-webui: add browser integration coverage

Diffstat:
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-chrome-extension-withdrawal.ts | 242+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-demo.ts | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-extension-integration.ts | 835+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-extension-sqlite.ts | 417+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-extension-upgrade.ts | 594+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-firefox-extension-withdrawal.ts | 264+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-cash-acceptor.ts | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-deposit-kyc-auth-swiss.ts | 334+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-deposit-kyc-auth.ts | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-donau.ts | 459+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-errors.ts | 356+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-lifecycle.ts | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-peer-deposit.ts | 310+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-peer-tos.ts | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-sqlite.ts | 1241+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-startup.ts | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-storybook.ts | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-withdrawal.ts | 1274+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/integrationtests/wallet-web-ui-test-support.ts | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
20 files changed, 7832 insertions(+), 0 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-chrome-extension-withdrawal.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-chrome-extension-withdrawal.ts @@ -0,0 +1,242 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import path from "node:path"; +import * as nodeUrl from "node:url"; +import type { BrowserContext, Page } from "playwright-core"; +import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; +import { GlobalTestState } from "../harness/harness.js"; +import { findBrowserBinary } from "../stagefright/stage.js"; + +function findWalletWebUiDirectory(): string { + const candidates = [ + process.env.TALER_WALLET_WEBUI_DIR, + path.resolve(process.cwd(), "packages/wallet-webui"), + path.resolve(process.cwd(), "../wallet-webui"), + ].filter((candidate): candidate is string => Boolean(candidate)); + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, "dev.mjs"))) return candidate; + } + throw Error( + "wallet-webui sources not found; run from the wallet-core checkout or set TALER_WALLET_WEBUI_DIR", + ); +} + +async function reserveTcpPort(): Promise<number> { + const server = net.createServer(); + await new Promise<void>((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") + throw Error("could not reserve a TCP port"); + await new Promise<void>((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + return address.port; +} + +async function waitForBuild(url: string, timeoutMs = 90_000): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const status = await new Promise<number | undefined>( + (resolve, reject) => { + const request = http.get(url, (response) => { + response.resume(); + resolve(response.statusCode); + }); + request.once("error", reject); + }, + ); + if (status && status >= 200 && status < 300) return; + } catch { + // The build server is not listening yet. + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw Error("wallet-webui extension build did not become ready"); +} + +async function screenshot( + page: Page, + directory: string, + name: string, +): Promise<void> { + let lastError: unknown; + for (let attempt = 0; attempt < 3; attempt++) { + try { + await page.screenshot({ + path: path.join(directory, `${name}.png`), + fullPage: true, + }); + return; + } catch (error) { + lastError = error; + await page.waitForTimeout(100); + } + } + throw lastError; +} + +async function waitForWalletPage( + context: BrowserContext, + extensionId: string, +): Promise<Page> { + const prefix = `chrome-extension://${extensionId}/wallet.html`; + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const page = context + .pages() + .find((candidate) => candidate.url().startsWith(prefix)); + if (page) return page; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw Error( + `popup did not open the full wallet; pages: ${context + .pages() + .map((page) => page.url()) + .join(", ")}`, + ); +} + +/** Load the unpacked MV3 extension and withdraw from the harness exchange. */ +export async function runWalletWebUiChromeExtensionWithdrawalTest( + t: GlobalTestState, +) { + const { exchange } = await createSimpleTestkudosEnvironmentV3(t); + const walletWebUiDirectory = findWalletWebUiDirectory(); + const buildPort = await reserveTcpPort(); + t.spawnService( + process.execPath, + [path.join(walletWebUiDirectory, "dev.mjs"), `--port=${buildPort}`], + "wallet-webui-build", + ); + await waitForBuild(`http://127.0.0.1:${buildPort}/`); + + const extensionDirectory = path.join( + walletWebUiDirectory, + "dist/extension/chrome", + ); + const screenshotDirectory = path.join( + t.testDir, + "wallet-webui-extension-screenshots", + ); + fs.mkdirSync(screenshotDirectory, { recursive: true }); + const playwright = await import("playwright-core"); + const savedUrl = globalThis.URL; + const savedUrlSearchParams = globalThis.URLSearchParams; + Object.assign(globalThis, { + URL: nodeUrl.URL, + URLSearchParams: nodeUrl.URLSearchParams, + }); + try { + const context = await playwright.chromium.launchPersistentContext( + path.join(t.testDir, "chromium-extension-profile"), + { + headless: true, + executablePath: findBrowserBinary(), + viewport: { width: 390, height: 844 }, + args: [ + `--disable-extensions-except=${extensionDirectory}`, + `--load-extension=${extensionDirectory}`, + ], + }, + ); + try { + const serviceWorker = + context.serviceWorkers()[0] ?? + (await context.waitForEvent("serviceworker", { timeout: 30_000 })); + const extensionId = new nodeUrl.URL(serviceWorker.url()).hostname; + const popup = await context.newPage(); + await popup.goto(`chrome-extension://${extensionId}/popup.html`); + await popup.getByRole("button", { name: "Open full wallet" }).waitFor(); + await screenshot(popup, screenshotDirectory, "01-popup"); + await popup.getByRole("button", { name: "Open full wallet" }).click(); + const wallet = await waitForWalletPage(context, extensionId); + await wallet + .getByText("Welcome to Taler Wallet!", { exact: true }) + .waitFor(); + + await wallet.goto( + `chrome-extension://${extensionId}/wallet.html#/exchanges`, + ); + await wallet.getByRole("heading", { name: "Exchanges" }).waitFor(); + await wallet + .getByLabel("Exchange URL or Taler add-exchange URI") + .fill(exchange.baseUrl); + await wallet.getByRole("button", { name: "Add exchange" }).click(); + await wallet.getByRole("heading", { name: "Exchange details" }).waitFor(); + await wallet.getByText("TESTKUDOS", { exact: true }).waitFor(); + await screenshot(wallet, screenshotDirectory, "02-local-exchange"); + + await wallet.goto( + `chrome-extension://${extensionId}/wallet.html#/withdraw?kind=demo`, + ); + await wallet.getByRole("heading", { name: "Withdraw" }).waitFor(); + await wallet.locator('input[inputmode="decimal"]').fill("10"); + await wallet + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await wallet + .getByRole("heading", { name: "Review withdrawal" }) + .waitFor(); + await wallet + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await wallet + .getByRole("heading", { name: "Pending withdrawal" }) + .waitFor(); + await wallet + .getByText("Wire transfer instructions", { exact: true }) + .waitFor(); + await screenshot(wallet, screenshotDirectory, "03-pending-withdrawal"); + + await wallet.goto( + `chrome-extension://${extensionId}/wallet.html#/transactions`, + ); + await wallet.getByRole("heading", { name: "History" }).waitFor(); + await wallet + .getByRole("button", { name: /localhost/i }) + .first() + .click(); + await wallet + .getByRole("button", { name: "View bank transfer instructions" }) + .click(); + await wallet + .getByRole("heading", { name: "Pending withdrawal" }) + .waitFor(); + await wallet + .getByText("Wire transfer instructions", { exact: true }) + .waitFor(); + await screenshot(wallet, screenshotDirectory, "04-continued-withdrawal"); + } finally { + await context.close(); + } + } finally { + Object.assign(globalThis, { + URL: savedUrl, + URLSearchParams: savedUrlSearchParams, + }); + } +} + +runWalletWebUiChromeExtensionWithdrawalTest.suites = ["wallet-webui"]; +runWalletWebUiChromeExtensionWithdrawalTest.timeoutMs = 180_000; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-demo.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-demo.ts @@ -0,0 +1,217 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import path from "node:path"; +import { GlobalTestState } from "../harness/harness.js"; +import { Stage } from "../stagefright/stage.js"; +import { + findWalletWebUiDirectory, + reserveTcpPort, + waitForPwa, +} from "./wallet-web-ui-test-support.js"; + +/** Exercise the isolated, in-memory wallet-webui demo artifact. */ +export async function runWalletWebUiDemoTest(t: GlobalTestState) { + const walletWebUiDirectory = findWalletWebUiDirectory(); + const port = await reserveTcpPort(); + const demoUrl = `http://127.0.0.1:${port}/`; + t.spawnService( + process.execPath, + [path.join(walletWebUiDirectory, "dev.mjs"), "--demo", `--port=${port}`], + "wallet-webui-demo", + ); + await waitForPwa(demoUrl); + + const stage = await Stage.create("wallet-web-ui-demo", { + browserType: "chromium", + screenshotDir: path.join(t.testDir, "wallet-webui-demo-screenshots"), + timeoutMs: 45_000, + viewport: { width: 390, height: 844 }, + }); + await stage.page.addInitScript(() => { + const browserGlobal = globalThis as any; + browserGlobal.__demoIsolation = { + workers: 0, + serviceWorkers: 0, + indexedDb: 0, + fetches: 0, + externalWindows: 0, + }; + if (browserGlobal.Worker) { + browserGlobal.Worker = new Proxy(browserGlobal.Worker, { + construct(target, argumentsList) { + browserGlobal.__demoIsolation.workers++; + return Reflect.construct(target, argumentsList); + }, + }); + } + if (browserGlobal.navigator.serviceWorker?.register) { + browserGlobal.navigator.serviceWorker.register = new Proxy( + browserGlobal.navigator.serviceWorker.register.bind( + browserGlobal.navigator.serviceWorker, + ), + { + apply(target, thisArg, argumentsList) { + browserGlobal.__demoIsolation.serviceWorkers++; + return Reflect.apply(target, thisArg, argumentsList); + }, + }, + ); + } + if (browserGlobal.indexedDB?.open) { + browserGlobal.indexedDB.open = new Proxy( + browserGlobal.indexedDB.open.bind(browserGlobal.indexedDB), + { + apply(target, thisArg, argumentsList) { + browserGlobal.__demoIsolation.indexedDb++; + return Reflect.apply(target, thisArg, argumentsList); + }, + }, + ); + } + browserGlobal.fetch = new Proxy(browserGlobal.fetch, { + apply(target, thisArg, argumentsList) { + browserGlobal.__demoIsolation.fetches++; + return Reflect.apply(target, thisArg, argumentsList); + }, + }); + browserGlobal.open = new Proxy(browserGlobal.open, { + apply(target, thisArg, argumentsList) { + browserGlobal.__demoIsolation.externalWindows++; + return Reflect.apply(target, thisArg, argumentsList); + }, + }); + }); + + await stage.run(async () => { + await stage.step("demo balances", async (page) => { + await page.goto(demoUrl); + await page.getByRole("heading", { name: "Balances" }).waitFor(); + await page.getByRole("region", { name: "Demo mode" }).waitFor(); + await page + .locator("strong:visible", { hasText: "42.50" }) + .first() + .waitFor(); + await page.getByText("Demo · fictional", { exact: true }).waitFor(); + }); + + await stage.step("seeded transaction edge states", async (page) => { + await page.goto(`${demoUrl}#/transactions`); + await page.getByRole("heading", { name: "History" }).waitFor(); + await page + .getByRole("button", { name: /Deposit to bank account/i }) + .click(); + await page + .getByText("Bank account verification required", { exact: true }) + .waitFor(); + await page + .getByRole("button", { name: "View authentication transfer" }) + .waitFor(); + }); + + await stage.step("simulated merchant payment", async (page) => { + await page.getByRole("button", { name: /Try a demo scenario/ }).click(); + await page.getByRole("menuitem", { name: "Sample payment" }).click(); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("heading", { name: "Review payment" }).waitFor(); + await page.getByText("Museum admission", { exact: true }).waitFor(); + await page.getByRole("button", { name: "Pay CHF:5.10" }).click(); + await page.getByText("Payment complete", { exact: true }).waitFor(); + await page.getByRole("button", { name: "Back to wallet" }).click(); + await page + .locator("strong:visible", { hasText: "37.40" }) + .first() + .waitFor(); + }); + + await stage.step("simulated incoming peer payment", async (page) => { + await page.getByRole("button", { name: /Try a demo scenario/ }).click(); + await page.getByRole("menuitem", { name: "Receive money" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("heading", { name: "Receive money" }).waitFor(); + await page.getByText("Shared lunch", { exact: true }).waitFor(); + await page.getByRole("button", { name: "Receive money" }).click(); + await page.getByRole("heading", { name: "Shared lunch" }).waitFor(); + await page.getByText("Completed", { exact: true }).waitFor(); + }); + + await stage.step("simulated deposit", async (page) => { + await page.goto(`${demoUrl}#/deposit?currency=CHF`); + await page + .getByRole("heading", { name: "Deposit to a bank account" }) + .waitFor(); + await page + .getByLabel("Bank account") + .selectOption({ label: "Everyday account" }); + await page.getByLabel("Amount").fill("2"); + await page.getByRole("button", { name: "Review deposit" }).click(); + await page.getByRole("button", { name: "Confirm deposit" }).click(); + await page + .getByRole("heading", { name: "Transaction details" }) + .waitFor(); + await page.getByText("Completed", { exact: true }).waitFor(); + }); + + await stage.step("simulated integrated withdrawal", async (page) => { + await page.getByRole("button", { name: /Try a demo scenario/ }).click(); + await page.getByRole("menuitem", { name: "Bank withdrawal" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("heading", { name: "Withdraw from your bank" }) + .waitFor(); + await page.getByLabel(/Amount to withdraw/).fill("10"); + await page.getByRole("button", { name: "Withdraw", exact: true }).click(); + await page.getByRole("heading", { name: "Review withdrawal" }).waitFor(); + await page.getByRole("button", { name: "Withdraw", exact: true }).click(); + await page + .getByRole("heading", { name: "Confirm with your bank" }) + .waitFor(); + await page.getByRole("button", { name: "Continue to bank" }).click(); + await page + .getByText("The demo bank confirmed the withdrawal.", { exact: true }) + .waitFor(); + await page.getByRole("button", { name: "View wallet" }).click(); + await page.getByRole("heading", { name: "Balances" }).waitFor(); + }); + + await stage.step("reset and isolation", async (page) => { + await page.getByRole("button", { name: "Reset demo data" }).click(); + await page.getByRole("dialog", { name: "Reset demo data?" }).waitFor(); + await page + .getByRole("dialog") + .getByRole("button", { name: "Reset demo data" }) + .click(); + await page + .locator("strong:visible", { hasText: "42.50" }) + .first() + .waitFor(); + const isolation = await page.evaluate( + () => (globalThis as any).__demoIsolation, + ); + t.assertDeepEqual(isolation, { + workers: 0, + serviceWorkers: 0, + indexedDb: 0, + fetches: 0, + externalWindows: 0, + }); + }); + + await stage.step("desktop dark demo", async (page) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.emulateMedia({ colorScheme: "dark" }); + await page.goto(demoUrl); + await page.getByRole("heading", { name: "Balances" }).waitFor(); + }); + }); +} + +runWalletWebUiDemoTest.suites = ["wallet-webui"]; +runWalletWebUiDemoTest.timeoutMs = 120_000; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-extension-integration.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-extension-integration.ts @@ -0,0 +1,835 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + */ + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import path from "node:path"; +import * as nodeUrl from "node:url"; +import type { BrowserContext, Page } from "playwright-core"; +import { Builder, By, until } from "selenium-webdriver"; +import * as Firefox from "selenium-webdriver/firefox.js"; +import { GlobalTestState } from "../harness/harness.js"; +import { findBrowserBinary } from "../stagefright/stage.js"; + +const SETTINGS_KEY = "gnu-taler-wallet-browser-integration-v1"; +const EXTENSION_ID = "wallet@taler.net"; +const EXTENSION_UUID = "5e0d7cc0-49c7-4d18-8433-7b3c3e39de4d"; +const TALER_URI = "taler://withdraw/bank.example/operation-123"; + +interface IntegrationSettings { + autoOpen: boolean; + injectApi: boolean; + allowCallback: boolean; + hijackLinks: boolean; +} + +function findWalletWebUiDirectory(): string { + const candidates = [ + process.env.TALER_WALLET_WEBUI_DIR, + path.resolve(process.cwd(), "packages/wallet-webui"), + path.resolve(process.cwd(), "../wallet-webui"), + ].filter((candidate): candidate is string => Boolean(candidate)); + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, "dev.mjs"))) return candidate; + } + throw Error("wallet-webui sources not found"); +} + +async function reserveTcpPort(): Promise<number> { + const server = net.createServer(); + await new Promise<void>((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw Error("no test port"); + await new Promise<void>((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + return address.port; +} + +async function waitForBuild(url: string): Promise<void> { + const deadline = Date.now() + 90_000; + while (Date.now() < deadline) { + try { + const status = await new Promise<number | undefined>( + (resolve, reject) => { + const request = http.get(url, (response) => { + response.resume(); + resolve(response.statusCode); + }); + request.once("error", reject); + }, + ); + if (status && status >= 200 && status < 300) return; + } catch { + // Build server has not started yet. + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw Error("wallet-webui extension build did not become ready"); +} + +function fixtureHtml(url: URL): string { + const support = url.searchParams.get("support") ?? ""; + const metadata = url.searchParams.get("metadata"); + const metadataTags = + metadata === "true" + ? `<meta name="taler-uri" content="${TALER_URI}">` + : metadata === "multiple" + ? `<meta name="taler-uri" content="${TALER_URI}"> +<meta name="taler-uri" content="taler://refund/merchant.example/order/session">` + : metadata === "invalid-first" + ? `<meta name="taler-uri" content="https://example.com/not-taler"> +<meta name="taler-uri" content="${TALER_URI}">` + : ""; + return `<!doctype html> +<html><head> +<meta charset="utf-8"> +<script> +window.callbackCount = 0; +window.callbackValue = null; +window.fallbackCount = 0; +window.talerCallback = (value) => { + window.callbackCount += 1; + window.callbackValue = value; +}; +</script> +${support ? `<meta name="taler-support" content="${support}">` : ""} +${metadataTags} +</head><body> +<h1>Integration fixture</h1> +<a id="taler-link" href="${TALER_URI}" onclick="window.fallbackCount += 1; event.preventDefault()">Taler action</a> +</body></html>`; +} + +async function startFixtureServer(): Promise<{ + baseUrl: string; + close(): Promise<void>; +}> { + const port = await reserveTcpPort(); + const server = http.createServer((request, response) => { + const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`); + response.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + }); + response.end(fixtureHtml(url)); + }); + await new Promise<void>((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", resolve); + }); + return { + baseUrl: `http://127.0.0.1:${port}`, + close: () => + new Promise<void>((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + }; +} + +function startBuild( + t: GlobalTestState, + walletDirectory: string, +): Promise<void> { + return reserveTcpPort().then(async (port) => { + t.spawnService( + process.execPath, + [path.join(walletDirectory, "dev.mjs"), `--port=${port}`], + "wallet-webui-integration-build", + ); + await waitForBuild(`http://127.0.0.1:${port}/`); + }); +} + +async function chromeSetSettings(page: Page, settings: IntegrationSettings) { + await page.evaluate( + async ([key, value]) => + (globalThis as any).chrome.storage.local.set({ [key]: value }), + [SETTINGS_KEY, settings] as const, + ); +} + +async function chromePopupAction( + extensionPage: Page, + operation: "inspect" | "open", +): Promise<{ available?: boolean; opened?: boolean }> { + return extensionPage.evaluate( + async (requestedOperation) => + (globalThis as any).chrome.runtime.sendMessage({ + kind: "wallet-popup-action", + operation: requestedOperation, + }), + operation, + ); +} + +async function clearChromeActionInbox(extensionPage: Page): Promise<void> { + await extensionPage.evaluate(async () => { + await (globalThis as any).chrome.storage.session.set({ + walletActionInboxV1: {}, + }); + }); +} + +async function waitForChromeWalletPage( + context: BrowserContext, + extensionId: string, + previous: Set<Page>, +): Promise<Page> { + const prefix = `chrome-extension://${extensionId}/wallet.html`; + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + const page = context + .pages() + .find( + (candidate) => + !previous.has(candidate) && candidate.url().startsWith(prefix), + ); + if (page) return page; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw Error("integration did not open a Chrome wallet action tab"); +} + +async function stopChromeExtensionServiceWorker( + page: Page, + scriptUrl: string, +): Promise<void> { + const cdp = await page.context().newCDPSession(page); + try { + let resolveVersion: ((versionId: string) => void) | undefined; + const version = new Promise<string>((resolve) => { + resolveVersion = resolve; + }); + cdp.on("ServiceWorker.workerVersionUpdated", ({ versions }) => { + for (const candidate of versions) { + if ( + candidate.scriptURL === scriptUrl && + candidate.status === "activated" + ) { + resolveVersion?.(candidate.versionId); + resolveVersion = undefined; + } + } + }); + await cdp.send("ServiceWorker.enable"); + const versionId = await Promise.race([ + version, + new Promise<never>((_, reject) => + setTimeout( + () => + reject( + new Error("Chrome extension service worker was not reported"), + ), + 10_000, + ), + ), + ]); + await cdp.send("ServiceWorker.stopWorker", { versionId }); + } finally { + await cdp.detach(); + } +} + +async function exerciseChromeWalletReconnect( + context: BrowserContext, + extensionId: string, + serviceWorkerUrl: string, +): Promise<void> { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/wallet.html`); + await page + .getByRole("heading", { name: /Balances|Welcome to Taler Wallet!/ }) + .waitFor(); + await page.waitForFunction( + () => + (globalThis as any).document.querySelector('[role="status"]') + ?.textContent === "Wallet host: Connected", + ); + await page.evaluate(() => { + const runtime = (globalThis as any).chrome.runtime; + const originalConnect = runtime.connect.bind(runtime); + (globalThis as any).__walletReconnectCount = 0; + runtime.connect = (...args: unknown[]) => { + (globalThis as any).__walletReconnectCount++; + return originalConnect(...args); + }; + }); + + await stopChromeExtensionServiceWorker(page, serviceWorkerUrl); + await page.waitForFunction( + () => (globalThis as any).__walletReconnectCount > 0, + undefined, + { timeout: 15_000 }, + ); + await page.waitForFunction( + () => + (globalThis as any).document.querySelector('[role="status"]') + ?.textContent === "Wallet host: Connected", + undefined, + { timeout: 15_000 }, + ); + await page.getByRole("link", { name: "History" }).click(); + await page.getByRole("heading", { name: "History" }).waitFor(); + assert.doesNotMatch( + await page.locator("body").innerText(), + /Attempting to use a disconnected port object/, + ); + await page.close(); +} + +async function exerciseChromeIntegration( + context: BrowserContext, + extensionId: string, + baseUrl: string, +): Promise<void> { + const admin = await context.newPage(); + await admin.goto(`chrome-extension://${extensionId}/wallet.html`); + const settings: IntegrationSettings = { + autoOpen: false, + injectApi: true, + allowCallback: true, + hijackLinks: true, + }; + await chromeSetSettings(admin, settings); + + const none = await context.newPage(); + await none.goto(`${baseUrl}/?case=none`); + assert.deepEqual( + await none.evaluate(() => ({ + callbackCount: (globalThis as any).callbackCount, + hasApi: "taler" in globalThis, + })), + { callbackCount: 0, hasApi: false }, + ); + + const callback = await context.newPage(); + await callback.goto(`${baseUrl}/?support=callback`); + await callback.waitForFunction(() => (globalThis as any).callbackCount === 1); + assert.deepEqual( + await callback.evaluate(() => (globalThis as any).callbackValue), + { + present: true, + }, + ); + assert.equal(await callback.evaluate(() => "taler" in globalThis), false); + await chromeSetSettings(admin, { ...settings, allowCallback: false }); + const callbackDisabled = await context.newPage(); + await callbackDisabled.goto(`${baseUrl}/?support=callback&case=disabled`); + await callbackDisabled.waitForTimeout(250); + assert.equal( + await callbackDisabled.evaluate(() => (globalThis as any).callbackCount), + 0, + ); + await chromeSetSettings(admin, settings); + + const api = await context.newPage(); + await api.goto(`${baseUrl}/?support=api`); + await api.waitForFunction(() => "taler" in globalThis); + const apiResult = await api.evaluate((uri) => { + const taler = (globalThis as any).taler; + return { + keys: Object.keys(taler).sort(), + extensionId: taler.info.extensionId, + action: taler.__internal.convertURIToWebExtensionPath(uri), + callbackCount: (globalThis as any).callbackCount, + }; + }, TALER_URI); + assert.deepEqual(apiResult.keys, ["__internal", "info"]); + assert.equal(apiResult.extensionId, extensionId); + assert.equal( + new URL(apiResult.action).searchParams.get("talerUri"), + TALER_URI, + ); + assert.equal(apiResult.callbackCount, 0); + await api.evaluate(() => { + const anchor = (globalThis as any).document.querySelector("#taler-link"); + anchor?.addEventListener( + "click", + (globalThis as any).taler.__internal.anchorOnClick, + ); + }); + await api.locator("#taler-link").click(); + await api.waitForURL(`chrome-extension://${extensionId}/wallet.html**`); + await chromeSetSettings(admin, { ...settings, injectApi: false }); + const apiDisabled = await context.newPage(); + await apiDisabled.goto(`${baseUrl}/?support=api&case=disabled`); + await apiDisabled.waitForTimeout(250); + assert.equal(await apiDisabled.evaluate(() => "taler" in globalThis), false); + await chromeSetSettings(admin, settings); + + const link = await context.newPage(); + await link.goto(`${baseUrl}/?support=uri`); + const beforeLink = new Set(context.pages()); + await link.locator("#taler-link").click(); + const linkWallet = await waitForChromeWalletPage( + context, + extensionId, + beforeLink, + ); + assert.equal(await link.evaluate(() => (globalThis as any).fallbackCount), 0); + await linkWallet.close(); + + await clearChromeActionInbox(admin); + const metadata = await context.newPage(); + const beforeMetadata = new Set(context.pages()); + await metadata.goto(`${baseUrl}/?metadata=true`); + await admin.waitForTimeout(250); + assert.equal( + context + .pages() + .some( + (page) => + !beforeMetadata.has(page) && + page.url().includes("/wallet.html#/action/"), + ), + false, + ); + const pendingCount = await admin.evaluate(async () => { + const value = await (globalThis as any).chrome.storage.session.get( + "walletActionInboxV1", + ); + return Object.keys(value.walletActionInboxV1 ?? {}).length; + }); + assert.equal(pendingCount, 0, "inspection must not create an inbox entry"); + + await metadata.bringToFront(); + assert.deepEqual(await chromePopupAction(admin, "inspect"), { + available: true, + }); + await metadata + .locator('meta[name="taler-uri"]') + .evaluate((element) => element.remove()); + assert.deepEqual(await chromePopupAction(admin, "inspect"), { + available: false, + }); + await metadata.evaluate((uri) => { + const pageDocument = (globalThis as any).document; + const meta = pageDocument.createElement("meta"); + meta.name = "taler-uri"; + meta.content = uri; + pageDocument.head.append(meta); + }, TALER_URI); + assert.deepEqual(await chromePopupAction(admin, "inspect"), { + available: true, + }); + await none.bringToFront(); + assert.deepEqual(await chromePopupAction(admin, "inspect"), { + available: false, + }); + + const multiple = await context.newPage(); + await multiple.goto(`${baseUrl}/?metadata=multiple`); + await multiple.bringToFront(); + assert.deepEqual(await chromePopupAction(admin, "inspect"), { + available: true, + }); + const beforePopupOpen = new Set(context.pages()); + assert.deepEqual(await chromePopupAction(admin, "open"), { opened: true }); + const popupWallet = await waitForChromeWalletPage( + context, + extensionId, + beforePopupOpen, + ); + const openedMetadata = await admin.evaluate(async () => { + const value = await (globalThis as any).chrome.storage.session.get( + "walletActionInboxV1", + ); + const entries = Object.values(value.walletActionInboxV1 ?? {}) as Array<{ + uri: string; + }>; + return entries.at(-1)?.uri; + }); + assert.equal(openedMetadata, TALER_URI, "first metadata action must win"); + await popupWallet.close(); + + const invalidFirst = await context.newPage(); + await invalidFirst.goto(`${baseUrl}/?metadata=invalid-first`); + await invalidFirst.bringToFront(); + assert.deepEqual(await chromePopupAction(admin, "inspect"), { + available: false, + }); + + await metadata.goto(`${baseUrl}/?case=navigated-away`); + await metadata.bringToFront(); + assert.deepEqual(await chromePopupAction(admin, "inspect"), { + available: false, + }); + + await chromeSetSettings(admin, { ...settings, autoOpen: true }); + const auto = await context.newPage(); + const beforeAuto = new Set(context.pages()); + await auto.goto(`${baseUrl}/?metadata=true&case=auto`); + const autoWallet = await waitForChromeWalletPage( + context, + extensionId, + beforeAuto, + ); + await autoWallet.close(); + + await chromeSetSettings(admin, { ...settings, hijackLinks: false }); + const disabledLink = await context.newPage(); + await disabledLink.goto(`${baseUrl}/?support=uri&case=disabled`); + await disabledLink.locator("#taler-link").click(); + assert.equal( + await disabledLink.evaluate(() => (globalThis as any).fallbackCount), + 1, + ); +} + +/** Verify each DD39 mechanism independently in a real Chromium extension. */ +export async function runWalletWebUiChromeExtensionIntegrationTest( + t: GlobalTestState, +) { + const walletDirectory = findWalletWebUiDirectory(); + await startBuild(t, walletDirectory); + const fixture = await startFixtureServer(); + const extensionDirectory = path.join( + walletDirectory, + "dist/extension/chrome", + ); + const playwright = await import("playwright-core"); + const savedUrl = globalThis.URL; + const savedUrlSearchParams = globalThis.URLSearchParams; + Object.assign(globalThis, { + URL: nodeUrl.URL, + URLSearchParams: nodeUrl.URLSearchParams, + }); + try { + const context = await playwright.chromium.launchPersistentContext( + path.join(t.testDir, "chromium-extension-integration-profile"), + { + headless: true, + executablePath: findBrowserBinary(), + args: [ + `--disable-extensions-except=${extensionDirectory}`, + `--load-extension=${extensionDirectory}`, + ], + }, + ); + try { + const worker = + context.serviceWorkers()[0] ?? + (await context.waitForEvent("serviceworker", { timeout: 30_000 })); + await exerciseChromeIntegration( + context, + new nodeUrl.URL(worker.url()).hostname, + fixture.baseUrl, + ); + await exerciseChromeWalletReconnect( + context, + new nodeUrl.URL(worker.url()).hostname, + worker.url(), + ); + } finally { + await context.close(); + } + } finally { + Object.assign(globalThis, { + URL: savedUrl, + URLSearchParams: savedUrlSearchParams, + }); + await fixture.close(); + } +} + +async function firefoxSetSettings( + driver: Firefox.Driver, + settings: IntegrationSettings, +): Promise<void> { + const stored = await driver.executeAsyncScript( + "const key = arguments[0]; const value = arguments[1]; const done = arguments[arguments.length - 1]; chrome.storage.local.set({ [key]: value }).then(async () => done((await chrome.storage.local.get(key))[key]), (e) => done({ error: String(e) }));", + SETTINGS_KEY, + settings, + ); + assert.deepEqual(stored, settings); +} + +async function firefoxPopupActionForPage( + driver: Firefox.Driver, + pageUrl: string, + operation: "inspect" | "open", + pageName = "taler-integration-current-page", +): Promise<{ available?: boolean; opened?: boolean }> { + return (await driver.executeAsyncScript( + "const pageUrl = arguments[0]; const operation = arguments[1]; const pageName = arguments[2]; const done = arguments[arguments.length - 1]; window.open(pageUrl, pageName); setTimeout(() => chrome.runtime.sendMessage({ kind: 'wallet-popup-action', operation }).then(done, (error) => done({ error: String(error) })), 500);", + pageUrl, + operation, + pageName, + )) as { available?: boolean; opened?: boolean }; +} + +async function openFirefoxExtensionPage(driver: Firefox.Driver): Promise<void> { + await driver.setContext(Firefox.Context.CHROME); + await driver.executeScript( + "window.openTrustedLinkIn(arguments[0], 'current')", + `moz-extension://${EXTENSION_UUID}/wallet.html`, + ); + await driver.setContext(Firefox.Context.CONTENT); + await driver.wait( + until.urlContains(`/wallet.html`), + 15_000, + "Firefox extension page did not load", + ); +} + +async function firefoxValue<T>( + driver: Firefox.Driver, + script: string, +): Promise<T> { + return (await driver.executeScript(script)) as T; +} + +/** Verify callback, API, metadata and link isolation in a real Firefox add-on. */ +export async function runWalletWebUiFirefoxExtensionIntegrationTest( + t: GlobalTestState, +) { + const walletDirectory = findWalletWebUiDirectory(); + await startBuild(t, walletDirectory); + const fixture = await startFixtureServer(); + const extensionDirectory = path.join( + walletDirectory, + "dist/extension/firefox", + ); + const options = new Firefox.Options() + .addArguments("-headless") + .setPreference( + "extensions.webextensions.uuids", + JSON.stringify({ [EXTENSION_ID]: EXTENSION_UUID }), + ); + if (process.env.BROWSER_BINARY) options.setBinary(process.env.BROWSER_BINARY); + const service = new Firefox.ServiceBuilder().addArguments( + "--allow-system-access", + ); + const driver = (await new Builder() + .forBrowser("firefox") + .setFirefoxOptions(options) + .setFirefoxService(service) + .build()) as Firefox.Driver; + try { + await driver.manage().setTimeouts({ pageLoad: 30_000, script: 30_000 }); + const addonId = await driver.installAddon(extensionDirectory, true); + assert.equal(addonId, EXTENSION_ID); + await openFirefoxExtensionPage(driver); + const settings: IntegrationSettings = { + autoOpen: false, + injectApi: true, + allowCallback: true, + hijackLinks: true, + }; + await firefoxSetSettings(driver, settings); + + await driver.get(`${fixture.baseUrl}/?case=none`); + assert.deepEqual( + await firefoxValue( + driver, + "return { callbackCount: window.callbackCount, hasApi: 'taler' in window }", + ), + { callbackCount: 0, hasApi: false }, + ); + + await driver.get(`${fixture.baseUrl}/?support=callback`); + await driver.wait( + async () => + (await firefoxValue<number>(driver, "return window.callbackCount")) === + 1, + 15_000, + "Firefox callback integration was not injected", + ); + assert.deepEqual( + await firefoxValue(driver, "return window.callbackValue"), + { + present: true, + }, + ); + assert.equal(await firefoxValue(driver, "return 'taler' in window"), false); + await openFirefoxExtensionPage(driver); + await firefoxSetSettings(driver, { ...settings, allowCallback: false }); + await driver.get(`${fixture.baseUrl}/?support=callback&case=disabled`); + await driver.sleep(250); + assert.equal(await firefoxValue(driver, "return window.callbackCount"), 0); + await openFirefoxExtensionPage(driver); + await firefoxSetSettings(driver, settings); + + await driver.get(`${fixture.baseUrl}/?support=api`); + await driver.wait( + async () => + await firefoxValue<boolean>(driver, "return 'taler' in window"), + 15_000, + "Firefox window.taler API was not injected", + ); + const apiResult = await firefoxValue<any>( + driver, + `return { + keys: Object.keys(window.taler).sort(), + extensionId: window.taler.info.extensionId, + action: window.taler.__internal.convertURIToWebExtensionPath(${JSON.stringify(TALER_URI)}), + callbackCount: window.callbackCount, + }`, + ); + assert.deepEqual(apiResult.keys, ["__internal", "info"]); + assert.equal(apiResult.extensionId, EXTENSION_ID); + assert.equal( + new URL(apiResult.action).searchParams.get("talerUri"), + TALER_URI, + ); + assert.equal(apiResult.callbackCount, 0); + await driver.executeScript( + "document.querySelector('#taler-link').addEventListener('click', window.taler.__internal.anchorOnClick)", + ); + await driver.findElement(By.id("taler-link")).click(); + await driver.wait( + until.urlContains(`moz-extension://${EXTENSION_UUID}/wallet.html`), + 15_000, + "Firefox compatibility API URL did not open the wallet", + ); + await firefoxSetSettings(driver, { ...settings, injectApi: false }); + await driver.get(`${fixture.baseUrl}/?support=api&case=disabled`); + await driver.sleep(250); + assert.equal(await firefoxValue(driver, "return 'taler' in window"), false); + await openFirefoxExtensionPage(driver); + await firefoxSetSettings(driver, settings); + await driver.executeAsyncScript( + "const done = arguments[arguments.length - 1]; chrome.storage.session.set({ walletActionInboxV1: {} }).then(() => done(true), () => done(false));", + ); + + assert.deepEqual( + await firefoxPopupActionForPage( + driver, + `${fixture.baseUrl}/?metadata=true&case=current-page`, + "inspect", + ), + { available: true }, + ); + const pendingCount = (await driver.executeAsyncScript( + "const done = arguments[arguments.length - 1]; chrome.storage.session.get('walletActionInboxV1').then((value) => done(Object.keys(value.walletActionInboxV1 || {}).length), (e) => done(-1));", + )) as number; + assert.equal( + pendingCount, + 0, + "Firefox metadata inspection must not create an inbox entry", + ); + assert.deepEqual( + await firefoxPopupActionForPage( + driver, + `${fixture.baseUrl}/?case=active-page-without-metadata`, + "inspect", + "taler-integration-other-page", + ), + { available: false }, + ); + assert.deepEqual( + await firefoxPopupActionForPage( + driver, + `${fixture.baseUrl}/?metadata=multiple`, + "inspect", + ), + { available: true }, + ); + const beforePopupOpen = await driver.getAllWindowHandles(); + assert.deepEqual( + await firefoxPopupActionForPage( + driver, + `${fixture.baseUrl}/?metadata=multiple`, + "open", + ), + { opened: true }, + ); + const openedMetadata = (await driver.executeAsyncScript( + "const done = arguments[arguments.length - 1]; chrome.storage.session.get('walletActionInboxV1').then((value) => { const entries = Object.values(value.walletActionInboxV1 || {}); done(entries.length ? entries[entries.length - 1].uri : null); }, (error) => done({ error: String(error) }));", + )) as string | null; + assert.equal(openedMetadata, TALER_URI, "Firefox first metadata must win"); + const extensionHandle = await driver.getWindowHandle(); + for (const handle of await driver.getAllWindowHandles()) { + if (beforePopupOpen.includes(handle)) continue; + await driver.switchTo().window(handle); + if ((await driver.getCurrentUrl()).includes("/wallet.html#/action/")) { + await driver.close(); + } + } + await driver.switchTo().window(extensionHandle); + assert.deepEqual( + await firefoxPopupActionForPage( + driver, + `${fixture.baseUrl}/?metadata=invalid-first`, + "inspect", + ), + { available: false }, + ); + assert.deepEqual( + await firefoxPopupActionForPage( + driver, + `${fixture.baseUrl}/?case=navigated-away`, + "inspect", + ), + { available: false }, + ); + + await firefoxSetSettings(driver, { ...settings, autoOpen: true }); + const beforeAuto = await driver.getAllWindowHandles(); + await driver.get(`${fixture.baseUrl}/?metadata=true&case=auto`); + await driver.wait( + async () => + (await driver.getAllWindowHandles()).length > beforeAuto.length, + 15_000, + "Firefox metadata action did not auto-open", + ); + const autoSourceHandle = await driver.getWindowHandle(); + const autoActionHandle = (await driver.getAllWindowHandles()).find( + (handle) => !beforeAuto.includes(handle), + ); + if (autoActionHandle) { + await driver.switchTo().window(autoActionHandle); + await driver.close(); + await driver.switchTo().window(autoSourceHandle); + } + await openFirefoxExtensionPage(driver); + await firefoxSetSettings(driver, settings); + + await driver.get(`${fixture.baseUrl}/?support=uri`); + const beforeLink = await driver.getAllWindowHandles(); + await driver.findElement(By.id("taler-link")).click(); + await driver.wait( + async () => + (await driver.getAllWindowHandles()).length > beforeLink.length, + 15_000, + "Firefox URI link was not hijacked", + ); + assert.equal(await firefoxValue(driver, "return window.fallbackCount"), 0); + const sourceHandle = await driver.getWindowHandle(); + const actionHandle = (await driver.getAllWindowHandles()).find( + (handle) => !beforeLink.includes(handle), + ); + if (actionHandle) { + await driver.switchTo().window(actionHandle); + await driver.close(); + await driver.switchTo().window(sourceHandle); + } + + await openFirefoxExtensionPage(driver); + await firefoxSetSettings(driver, { ...settings, hijackLinks: false }); + await driver.get(`${fixture.baseUrl}/?support=uri&case=disabled`); + await driver.findElement(By.id("taler-link")).click(); + await driver.wait(until.elementLocated(By.id("taler-link")), 5_000); + assert.equal(await firefoxValue(driver, "return window.fallbackCount"), 1); + } finally { + await driver.quit(); + await fixture.close(); + } +} + +runWalletWebUiChromeExtensionIntegrationTest.suites = ["wallet-webui"]; +runWalletWebUiChromeExtensionIntegrationTest.timeoutMs = 240_000; +runWalletWebUiFirefoxExtensionIntegrationTest.suites = ["wallet-webui"]; +runWalletWebUiFirefoxExtensionIntegrationTest.timeoutMs = 240_000; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-extension-sqlite.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-extension-sqlite.ts @@ -0,0 +1,417 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import * as nodeUrl from "node:url"; +import type { BrowserContext, Page } from "playwright-core"; +import { Builder } from "selenium-webdriver"; +import * as Firefox from "selenium-webdriver/firefox.js"; +import { GlobalTestState, runCommand } from "../harness/harness.js"; +import { findBrowserBinary } from "../stagefright/stage.js"; +import { findWalletWebUiDirectory } from "./wallet-web-ui-test-support.js"; + +const FIREFOX_EXTENSION_ID = "wallet@taler.net"; +const FIREFOX_EXTENSION_UUID = "5e0d7cc0-49c7-4d18-8433-7b3c3e39de4d"; + +type SqliteTestMessage = + | { + kind: "wallet-extension-sqlite-test"; + operation: "call"; + generation: string; + command: "open" | "write" | "read" | "destroy" | "roundtrip"; + value?: string; + } + | { + kind: "wallet-extension-sqlite-test"; + operation: "restart-worker"; + }; + +type SqliteTestResponse = + | { ok: true; host: string; result?: any } + | { ok: false; host: string; error: string }; + +async function startSqliteTestBuild( + t: GlobalTestState, + walletWebUiDirectory: string, +): Promise<void> { + await runCommand(t, "wallet-webui-extension-sqlite-build", process.execPath, [ + path.join(walletWebUiDirectory, "build.mjs"), + "--test-extension-sqlite", + ]); +} + +function readManifest(directory: string): any { + return JSON.parse( + fs.readFileSync(path.join(directory, "manifest.json"), "utf8"), + ); +} + +async function runPersistenceTest( + send: (message: SqliteTestMessage) => Promise<SqliteTestResponse>, + expectedHost: string, +): Promise<{ + opened: any; + persistedValue: string; + roundtrip: any; +}> { + const call = async (message: SqliteTestMessage): Promise<any> => { + const response = await send(message); + assert.equal(response.host, expectedHost); + if (!response.ok) throw new Error(response.error); + return response.result; + }; + const generation = randomUUID(); + const persistedValue = `extension-sqlite-${randomUUID()}`; + const opened = await call({ + kind: "wallet-extension-sqlite-test", + operation: "call", + generation, + command: "open", + }); + await call({ + kind: "wallet-extension-sqlite-test", + operation: "call", + generation, + command: "write", + value: persistedValue, + }); + await call({ + kind: "wallet-extension-sqlite-test", + operation: "restart-worker", + }); + + let reopened: any; + let lastError: unknown; + for (let attempt = 0; attempt < 20; attempt++) { + try { + reopened = await call({ + kind: "wallet-extension-sqlite-test", + operation: "call", + generation, + command: "open", + }); + lastError = undefined; + break; + } catch (error) { + lastError = error; + await call({ + kind: "wallet-extension-sqlite-test", + operation: "restart-worker", + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + if (lastError || !reopened) { + throw lastError ?? new Error("SQLite extension worker did not reopen"); + } + assert.equal(reopened.sqliteVersion, opened.sqliteVersion); + const row = await call({ + kind: "wallet-extension-sqlite-test", + operation: "call", + generation, + command: "read", + }); + assert.equal(row?.value, persistedValue); + await call({ + kind: "wallet-extension-sqlite-test", + operation: "call", + generation, + command: "destroy", + }); + + const roundtrip = await call({ + kind: "wallet-extension-sqlite-test", + operation: "call", + generation: randomUUID(), + command: "roundtrip", + }); + assert.equal(roundtrip.sqliteValue?.value, 42); + assert.equal(roundtrip.idbValue?.value, 42); + assert.deepEqual(roundtrip.active, { + backend: "indexeddb", + generation: 2, + }); + assert.ok(roundtrip.forwardRecords > 0); + assert.equal(roundtrip.forwardRecords, roundtrip.rollbackRecords); + return { opened, persistedValue, roundtrip }; +} + +async function sendFromChromePage( + page: Page, + message: SqliteTestMessage, +): Promise<SqliteTestResponse> { + return page.evaluate( + (request) => + new Promise((resolve, reject) => { + const extension = (globalThis as any).chrome; + extension.runtime.sendMessage( + request, + (response: SqliteTestResponse) => { + const error = extension.runtime.lastError; + if (error) reject(new Error(error.message)); + else resolve(response); + }, + ); + }), + message, + ); +} + +async function waitForWalletPage( + context: BrowserContext, + extensionId: string, +): Promise<Page> { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/wallet.html#/settings`); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + return page; +} + +async function stopChromeExtensionServiceWorker( + page: Page, + scriptUrl: string, +): Promise<void> { + const cdp = await page.context().newCDPSession(page); + try { + let resolveVersion: ((versionId: string) => void) | undefined; + const version = new Promise<string>((resolve) => { + resolveVersion = resolve; + }); + cdp.on("ServiceWorker.workerVersionUpdated", ({ versions }) => { + for (const candidate of versions) { + if ( + candidate.scriptURL === scriptUrl && + candidate.status === "activated" + ) { + resolveVersion?.(candidate.versionId); + resolveVersion = undefined; + } + } + }); + await cdp.send("ServiceWorker.enable"); + const versionId = await Promise.race([ + version, + new Promise<never>((_, reject) => + setTimeout( + () => + reject( + new Error("Chrome extension service worker was not reported"), + ), + 10_000, + ), + ), + ]); + await cdp.send("ServiceWorker.stopWorker", { versionId }); + } finally { + await cdp.detach(); + } +} + +export async function runWalletWebUiChromeExtensionSqliteTest( + t: GlobalTestState, +): Promise<void> { + const walletWebUiDirectory = findWalletWebUiDirectory(); + await startSqliteTestBuild(t, walletWebUiDirectory); + const extensionDirectory = path.join( + walletWebUiDirectory, + "dist/extension/chrome", + ); + const manifest = readManifest(extensionDirectory); + assert.ok(manifest.permissions.includes("offscreen")); + assert.match( + manifest.content_security_policy.extension_pages, + /wasm-unsafe-eval/, + ); + + const playwright = await import("playwright-core"); + const savedUrl = globalThis.URL; + const savedUrlSearchParams = globalThis.URLSearchParams; + Object.assign(globalThis, { + URL: nodeUrl.URL, + URLSearchParams: nodeUrl.URLSearchParams, + }); + try { + const context = await playwright.chromium.launchPersistentContext( + path.join(t.testDir, "chromium-extension-sqlite-profile"), + { + headless: true, + executablePath: findBrowserBinary(), + viewport: { width: 390, height: 844 }, + args: [ + `--disable-extensions-except=${extensionDirectory}`, + `--load-extension=${extensionDirectory}`, + ], + }, + ); + try { + const serviceWorker = + context.serviceWorkers()[0] ?? + (await context.waitForEvent("serviceworker", { timeout: 30_000 })); + const extensionId = new nodeUrl.URL(serviceWorker.url()).hostname; + const page = await waitForWalletPage(context, extensionId); + const result = await runPersistenceTest( + (message) => sendFromChromePage(page, message), + "chrome-offscreen-worker", + ); + const offscreenContexts = await page.evaluate(async () => { + const runtime = (globalThis as any).chrome.runtime; + return runtime.getContexts({ contextTypes: ["OFFSCREEN_DOCUMENT"] }); + }); + assert.equal(offscreenContexts.length, 1); + assert.ok( + offscreenContexts[0].documentUrl.endsWith("/sqlite-offscreen.html"), + ); + await stopChromeExtensionServiceWorker(page, serviceWorker.url()); + const coldStart = await sendFromChromePage(page, { + kind: "wallet-extension-sqlite-test", + operation: "restart-worker", + }); + assert.deepEqual(coldStart, { + ok: true, + host: "chrome-offscreen-worker", + }); + fs.writeFileSync( + path.join(t.testDir, "wallet-webui-chrome-extension-sqlite.json"), + `${JSON.stringify( + { + target: "chrome", + host: "chrome-offscreen-worker", + sqliteVersion: result.opened.sqliteVersion, + sahPoolCapacity: result.opened.capacity, + persistedAfterWorkerTermination: true, + conversionRecords: result.roundtrip.forwardRecords, + serviceWorkerColdRestart: true, + offscreenDocument: offscreenContexts[0].documentUrl, + workerBytes: fs.statSync( + path.join(extensionDirectory, "sqlite-opfs-worker.js"), + ).size, + wasmBytes: fs.statSync( + path.join(extensionDirectory, "sqlite3.wasm"), + ).size, + }, + null, + 2, + )}\n`, + ); + } finally { + await context.close(); + } + } finally { + Object.assign(globalThis, { + URL: savedUrl, + URLSearchParams: savedUrlSearchParams, + }); + } +} + +async function sendFromFirefox( + driver: Firefox.Driver, + message: SqliteTestMessage, +): Promise<SqliteTestResponse> { + const envelope = (await driver.executeAsyncScript( + `const request = arguments[0]; + const done = arguments[arguments.length - 1]; + chrome.runtime.sendMessage(request, (response) => { + const error = chrome.runtime.lastError; + done(error ? { error: error.message } : { response }); + });`, + message, + )) as { error?: string; response?: SqliteTestResponse }; + if (envelope.error) throw new Error(envelope.error); + if (!envelope.response) + throw new Error("Firefox SQLite probe returned no response"); + return envelope.response; +} + +export async function runWalletWebUiFirefoxExtensionSqliteTest( + t: GlobalTestState, +): Promise<void> { + const walletWebUiDirectory = findWalletWebUiDirectory(); + await startSqliteTestBuild(t, walletWebUiDirectory); + const extensionDirectory = path.join( + walletWebUiDirectory, + "dist/extension/firefox", + ); + const manifest = readManifest(extensionDirectory); + assert.equal(manifest.permissions.includes("offscreen"), false); + assert.deepEqual(manifest.background, { + scripts: ["background.js"], + }); + assert.match( + manifest.content_security_policy.extension_pages, + /wasm-unsafe-eval/, + ); + + const options = new Firefox.Options() + .addArguments("-headless") + .setPreference( + "extensions.webextensions.uuids", + JSON.stringify({ [FIREFOX_EXTENSION_ID]: FIREFOX_EXTENSION_UUID }), + ); + if (process.env.BROWSER_BINARY) options.setBinary(process.env.BROWSER_BINARY); + const service = new Firefox.ServiceBuilder().addArguments( + "--allow-system-access", + ); + const driver = (await new Builder() + .forBrowser("firefox") + .setFirefoxOptions(options) + .setFirefoxService(service) + .build()) as Firefox.Driver; + try { + await driver + .manage() + .setTimeouts({ implicit: 0, pageLoad: 30_000, script: 90_000 }); + const addonId = await driver.installAddon(extensionDirectory, true); + assert.equal(addonId, FIREFOX_EXTENSION_ID); + await driver.setContext(Firefox.Context.CHROME); + await driver.executeScript( + "window.openTrustedLinkIn(arguments[0], 'current')", + `moz-extension://${FIREFOX_EXTENSION_UUID}/wallet.html#/settings`, + ); + await driver.setContext(Firefox.Context.CONTENT); + await driver.wait( + async () => (await driver.getTitle()) === "GNU Taler Wallet", + 30_000, + ); + const result = await runPersistenceTest( + (message) => sendFromFirefox(driver, message), + "firefox-background-worker", + ); + fs.writeFileSync( + path.join(t.testDir, "wallet-webui-firefox-extension-sqlite.json"), + `${JSON.stringify( + { + target: "firefox", + host: "firefox-background-worker", + sqliteVersion: result.opened.sqliteVersion, + sahPoolCapacity: result.opened.capacity, + persistedAfterWorkerTermination: true, + conversionRecords: result.roundtrip.forwardRecords, + workerBytes: fs.statSync( + path.join(extensionDirectory, "sqlite-opfs-worker.js"), + ).size, + wasmBytes: fs.statSync(path.join(extensionDirectory, "sqlite3.wasm")) + .size, + }, + null, + 2, + )}\n`, + ); + } finally { + await driver.quit(); + } +} + +runWalletWebUiChromeExtensionSqliteTest.suites = ["wallet-webui"]; +runWalletWebUiChromeExtensionSqliteTest.timeoutMs = 180_000; +runWalletWebUiFirefoxExtensionSqliteTest.suites = ["wallet-webui"]; +runWalletWebUiFirefoxExtensionSqliteTest.timeoutMs = 180_000; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-extension-upgrade.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-extension-upgrade.ts @@ -0,0 +1,594 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under + the terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import assert from "node:assert/strict"; +import { generateKeyPairSync } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import * as nodeUrl from "node:url"; +import type { BrowserContext, Page } from "playwright-core"; +import { Builder } from "selenium-webdriver"; +import * as Firefox from "selenium-webdriver/firefox.js"; +import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; +import { GlobalTestState, runCommand } from "../harness/harness.js"; +import { findBrowserBinary } from "../stagefright/stage.js"; +import { findWalletWebUiDirectory } from "./wallet-web-ui-test-support.js"; + +const FIREFOX_EXTENSION_ID = "wallet@taler.net"; +const FIREFOX_EXTENSION_UUID = "acdb6f48-4e4c-4f3f-a3bc-987b23cd1a57"; + +interface UpgradeArtifacts { + legacy: string; + next: string; +} + +function readJson(filename: string): any { + return JSON.parse(fs.readFileSync(filename, "utf8")); +} + +function writeJson(filename: string, value: unknown): void { + fs.writeFileSync(filename, `${JSON.stringify(value, undefined, 2)}\n`); +} + +function stageLegacyExtension( + backgroundBundle: string, + destination: string, + target: "chrome" | "firefox", + version: string, +): void { + fs.rmSync(destination, { recursive: true, force: true }); + fs.mkdirSync(destination, { recursive: true }); + fs.copyFileSync(backgroundBundle, path.join(destination, "background.js")); + fs.writeFileSync( + path.join(destination, "wallet.html"), + "<!doctype html><meta charset=utf-8><title>Legacy wallet fixture</title>", + ); + const common = { + name: "GNU Taler Wallet (legacy fixture)", + version, + version_name: `${version}-legacy-fixture`, + }; + writeJson( + path.join(destination, "manifest.json"), + target === "chrome" + ? { + ...common, + manifest_version: 3, + permissions: ["storage", "unlimitedStorage"], + host_permissions: ["http://*/*", "https://*/*"], + background: { service_worker: "background.js" }, + } + : { + ...common, + manifest_version: 2, + permissions: ["storage", "unlimitedStorage", "<all_urls>"], + background: { scripts: ["background.js"], persistent: false }, + browser_specific_settings: { + gecko: { id: FIREFOX_EXTENSION_ID, strict_min_version: "58.0" }, + }, + }, + ); +} + +function stageNextExtension( + source: string, + target: "chrome" | "firefox", + destination: string, +): string { + fs.rmSync(destination, { recursive: true, force: true }); + fs.cpSync(path.join(source, "dist/extension", target), destination, { + recursive: true, + }); + const manifestFile = path.join(destination, "manifest.json"); + const manifest = readJson(manifestFile); + const legacyVersion = String(manifest.version); + // The checkout builds both implementations at the same repository version. + // An actual store update is necessarily newer, so stage the replacement as + // the next patch to make Firefox exercise its real update path. + const [major, minor, patch] = String(manifest.version) + .split(".") + .map((part) => Number(part)); + manifest.version = `${major}.${minor}.${patch + 1}`; + manifest.version_name = `${major}.${minor}.${patch + 1}-upgrade-test`; + writeJson(manifestFile, manifest); + return legacyVersion; +} + +async function prepareArtifacts( + t: GlobalTestState, + target: "chrome" | "firefox", +): Promise<UpgradeArtifacts> { + const walletWebUi = findWalletWebUiDirectory(); + const repositoryRoot = path.resolve(walletWebUi, "../.."); + const backgroundBundle = path.join( + t.testDir, + `legacy-background-${target}.js`, + ); + await runCommand( + t, + `legacy-extension-build-${target}`, + path.join(repositoryRoot, "node_modules/.bin/esbuild"), + [ + path.join(walletWebUi, "src/testing/legacy-extension-background.ts"), + "--bundle", + "--platform=browser", + "--format=iife", + "--conditions=browser", + `--outfile=${backgroundBundle}`, + ], + ); + await runCommand( + t, + `wallet-webui-extension-build-${target}`, + process.execPath, + [path.join(walletWebUi, "build.mjs")], + ); + const legacy = path.join(t.testDir, `legacy-extension-${target}`); + const next = path.join(t.testDir, `wallet-webui-extension-${target}`); + const legacyVersion = stageNextExtension(walletWebUi, target, next); + stageLegacyExtension(backgroundBundle, legacy, target, legacyVersion); + if (target === "chrome") { + const { publicKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "spki", format: "der" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + }); + const key = publicKey.toString("base64"); + for (const directory of [legacy, next]) { + const filename = path.join(directory, "manifest.json"); + writeJson(filename, { ...readJson(filename), key }); + } + } + return { legacy, next }; +} + +async function legacyChromeCall( + page: Page, + operation: string, + payload: unknown, +): Promise<any> { + const response = await page.evaluate( + async ({ operation, payload }) => + new Promise<any>((resolve, reject) => { + const runtime = (globalThis as any).chrome.runtime; + runtime.sendMessage( + { + channel: "wallet", + operation, + payload, + id: `upgrade-${Date.now()}-${Math.random()}`, + }, + (result: unknown) => { + const error = runtime.lastError; + if (error) reject(Error(error.message)); + else resolve(result); + }, + ); + }), + { operation, payload }, + ); + if (response?.type !== "response") { + throw Error( + `legacy wallet call ${operation} failed: ${JSON.stringify(response)}`, + ); + } + return response.result; +} + +async function walletWebUiChromeCall( + page: Page, + operation: string, + args: unknown, +): Promise<any> { + const response = await page.evaluate( + async ({ operation, args }) => + new Promise<any>((resolve, reject) => { + const runtime = (globalThis as any).chrome.runtime; + const port = runtime.connect({ name: "wallet-ui-v1" }); + const id = `upgrade-${Date.now()}-${Math.random()}`; + const timeout = setTimeout(() => { + port.disconnect(); + reject(Error(`wallet-webui call ${operation} timed out`)); + }, 30_000); + port.onMessage.addListener((message: any) => { + if (message?.kind !== "response" || message.response?.id !== id) + return; + clearTimeout(timeout); + port.disconnect(); + resolve(message.response); + }); + port.postMessage({ + version: 1, + kind: "request", + mutation: false, + request: { id, operation, args }, + }); + }), + { operation, args }, + ); + if (response?.type !== "response") { + throw Error( + `wallet-webui call ${operation} failed: ${JSON.stringify(response)}`, + ); + } + return response.result; +} + +async function seedLegacyChromeWallet( + page: Page, + talerWithdrawUri: string, + exchangeBaseUrl: string, +): Promise<string> { + await legacyChromeCall(page, "setWalletRunConfig", { + config: { + testing: { devModeActive: true }, + features: { allowHttp: true }, + }, + }); + const accepted = await legacyChromeCall( + page, + "acceptBankIntegratedWithdrawal", + { + talerWithdrawUri, + exchangeBaseUrl, + }, + ); + await legacyChromeCall(page, "testingWaitTransactionState", { + transactionId: accepted.transactionId, + txState: { + major: "pending", + minor: "bank-confirm-transfer", + }, + }); + return accepted.transactionId; +} + +async function finishLegacyChromeWithdrawal(page: Page): Promise<void> { + await legacyChromeCall(page, "testingWaitTransactionsFinal", {}); + const balances = await legacyChromeCall(page, "getBalances", {}); + assert.ok( + balances.balances.some((balance: any) => + String(balance.available).startsWith("TESTKUDOS:"), + ), + "legacy extension did not create the balance used by the upgrade test", + ); +} + +async function createBankWithdrawal( + bankClient: any, +): Promise<{ user: any; withdrawal: any }> { + const user = await bankClient.createRandomBankUser(); + bankClient.setAuth(user); + const withdrawal = await bankClient.createWithdrawalOperation( + user.username, + "TESTKUDOS:10", + ); + return { user, withdrawal }; +} + +async function confirmBankWithdrawal( + bankClient: any, + user: any, + withdrawal: any, +): Promise<void> { + await bankClient.confirmWithdrawalOperation(user.username, { + withdrawalOperationId: withdrawal.withdrawal_id, + }); +} + +async function seedLegacyFirefoxWallet( + driver: Firefox.Driver, + talerWithdrawUri: string, + exchangeBaseUrl: string, +): Promise<void> { + await firefoxCall(driver, "legacy", "setWalletRunConfig", { + config: { + testing: { devModeActive: true }, + features: { allowHttp: true }, + }, + }); + const accepted = await firefoxCall( + driver, + "legacy", + "acceptBankIntegratedWithdrawal", + { + talerWithdrawUri, + exchangeBaseUrl, + }, + ); + await firefoxCall(driver, "legacy", "testingWaitTransactionState", { + transactionId: accepted.transactionId, + txState: { + major: "pending", + minor: "bank-confirm-transfer", + }, + }); +} + +function assertPreservedWalletState(balances: any, transactions: any): void { + assert.ok( + balances.balances.some((balance: any) => + String(balance.available).startsWith("TESTKUDOS:"), + ), + "the replacement extension opened an empty balance database", + ); + assert.ok( + transactions.transactions.some( + (transaction: any) => transaction.type === "withdrawal", + ), + "the replacement extension lost the legacy transaction history", + ); +} + +async function launchChrome( + profile: string, + extension: string, +): Promise<BrowserContext> { + const playwright = await import("playwright-core"); + return playwright.chromium.launchPersistentContext(profile, { + headless: true, + executablePath: findBrowserBinary(), + args: [ + `--disable-extensions-except=${extension}`, + `--load-extension=${extension}`, + ], + }); +} + +async function chromeExtensionId(context: BrowserContext): Promise<string> { + const worker = + context.serviceWorkers()[0] ?? + (await context.waitForEvent("serviceworker", { timeout: 30_000 })); + return new nodeUrl.URL(worker.url()).hostname; +} + +/** A real same-profile Chrome replacement keeps the legacy wallet database. */ +export async function runWalletWebUiChromeExtensionUpgradeTest( + t: GlobalTestState, +) { + const { bankClient, exchange } = await createSimpleTestkudosEnvironmentV3(t); + const { user, withdrawal } = await createBankWithdrawal(bankClient); + const artifacts = await prepareArtifacts(t, "chrome"); + const profile = path.join(t.testDir, "chromium-extension-upgrade-profile"); + // Both unpacked test artifacts carry the same generated public key, the + // local equivalent of Chrome Web Store signing them as the same item. + + const savedUrl = globalThis.URL; + const savedUrlSearchParams = globalThis.URLSearchParams; + Object.assign(globalThis, { + URL: nodeUrl.URL, + URLSearchParams: nodeUrl.URLSearchParams, + }); + try { + const legacyContext = await launchChrome(profile, artifacts.legacy); + let extensionId: string; + try { + extensionId = await chromeExtensionId(legacyContext); + const page = await legacyContext.newPage(); + await page.goto(`chrome-extension://${extensionId}/wallet.html`); + await seedLegacyChromeWallet( + page, + withdrawal.taler_withdraw_uri, + exchange.baseUrl, + ); + await confirmBankWithdrawal(bankClient, user, withdrawal); + await finishLegacyChromeWithdrawal(page); + } finally { + await legacyContext.close(); + } + + // Chromium persists an unpacked extension service-worker registration by + // script URL and can reuse it even when a second signed test package is + // supplied on the next launch. A Web Store update refreshes that + // registration. Remove only this temporary profile's worker cache to model + // that refresh; IndexedDB remains untouched. + fs.rmSync(path.join(profile, "Default", "Service Worker"), { + recursive: true, + force: true, + }); + const nextContext = await launchChrome(profile, artifacts.next); + try { + const nextId = await chromeExtensionId(nextContext); + assert.equal( + nextId, + extensionId, + "Chrome extension origin changed during upgrade", + ); + const nextPage = await nextContext.newPage(); + await nextPage.goto(`chrome-extension://${nextId}/wallet.html`); + const nextManifest = await nextPage.evaluate(() => + (globalThis as any).chrome.runtime.getManifest(), + ); + assert.equal(nextManifest.name, "GNU Taler Wallet (Web UI)"); + const [balances, transactions, databaseNames] = await Promise.all([ + walletWebUiChromeCall(nextPage, "getBalances", {}), + walletWebUiChromeCall(nextPage, "getTransactions", {}), + nextPage.evaluate(async () => + (await (globalThis as any).indexedDB.databases()).map( + (database: { name?: string }) => database.name, + ), + ) as Promise<Array<string | undefined>>, + ]); + assertPreservedWalletState(balances, transactions); + assert.ok(databaseNames.includes("taler-wallet-meta")); + assert.ok(databaseNames.includes("taler-wallet-main-v10")); + assert.equal( + databaseNames.some((name: string | undefined) => + name?.includes("generation-0--"), + ), + false, + "the extension must open the legacy physical database directly", + ); + } finally { + await nextContext.close(); + } + } finally { + Object.assign(globalThis, { + URL: savedUrl, + URLSearchParams: savedUrlSearchParams, + }); + } +} + +runWalletWebUiChromeExtensionUpgradeTest.suites = ["wallet-webui"]; +runWalletWebUiChromeExtensionUpgradeTest.timeoutMs = 300_000; + +function firefoxOptions(profile: string): Firefox.Options { + const options = new Firefox.Options() + .addArguments("-headless", "-profile", profile) + .setPreference( + "extensions.webextensions.uuids", + JSON.stringify({ [FIREFOX_EXTENSION_ID]: FIREFOX_EXTENSION_UUID }), + ); + if (process.env.BROWSER_BINARY) options.setBinary(process.env.BROWSER_BINARY); + return options; +} + +async function launchFirefox(profile: string): Promise<Firefox.Driver> { + const service = new Firefox.ServiceBuilder().addArguments( + "--allow-system-access", + ); + return (await new Builder() + .forBrowser("firefox") + .setFirefoxOptions(firefoxOptions(profile)) + .setFirefoxService(service) + .build()) as Firefox.Driver; +} + +async function openFirefoxExtensionPage( + driver: Firefox.Driver, + pathName: string, +): Promise<void> { + await driver.setContext(Firefox.Context.CHROME); + await driver.executeScript( + "window.openTrustedLinkIn(arguments[0], 'current')", + `moz-extension://${FIREFOX_EXTENSION_UUID}/${pathName}`, + ); + await driver.setContext(Firefox.Context.CONTENT); + await driver.wait( + async () => + (await driver.getCurrentUrl()).startsWith( + `moz-extension://${FIREFOX_EXTENSION_UUID}/`, + ), + 30_000, + ); +} + +async function firefoxCall( + driver: Firefox.Driver, + protocol: "legacy" | "wallet-webui", + operation: string, + args: unknown, +): Promise<any> { + const response = (await driver.executeAsyncScript( + `const protocol = arguments[0]; + const operation = arguments[1]; + const args = arguments[2]; + const done = arguments[arguments.length - 1]; + const id = "upgrade-" + Date.now() + "-" + Math.random(); + if (protocol === "legacy") { + chrome.runtime.sendMessage( + { channel: "wallet", operation, payload: args, id }, + (response) => done({ response, runtimeError: chrome.runtime.lastError?.message }), + ); + return; + } + const port = chrome.runtime.connect({ name: "wallet-ui-v1" }); + const timeout = setTimeout(() => { + port.disconnect(); + done({ runtimeError: "wallet-webui RPC timeout" }); + }, 30000); + port.onMessage.addListener((message) => { + if (message?.kind !== "response" || message.response?.id !== id) return; + clearTimeout(timeout); + port.disconnect(); + done({ response: message.response }); + }); + port.postMessage({ + version: 1, + kind: "request", + mutation: false, + request: { id, operation, args }, + });`, + protocol, + operation, + args, + )) as any; + if (response?.runtimeError || response?.response?.type !== "response") { + throw Error( + `${protocol} wallet call ${operation} failed: ${JSON.stringify(response)}`, + ); + } + return response.response.result; +} + +/** Firefox's signed identity replacement keeps the same origin and wallet. */ +export async function runWalletWebUiFirefoxExtensionUpgradeTest( + t: GlobalTestState, +) { + const { bankClient, exchange } = await createSimpleTestkudosEnvironmentV3(t); + const { user, withdrawal } = await createBankWithdrawal(bankClient); + const artifacts = await prepareArtifacts(t, "firefox"); + const profile = path.join(t.testDir, "firefox-extension-upgrade-profile"); + fs.mkdirSync(profile, { recursive: true }); + const legacyDriver = await launchFirefox(profile); + try { + await legacyDriver + .manage() + .setTimeouts({ pageLoad: 30_000, script: 60_000 }); + assert.equal( + await legacyDriver.installAddon(artifacts.legacy, true), + FIREFOX_EXTENSION_ID, + ); + await openFirefoxExtensionPage(legacyDriver, "wallet.html"); + await seedLegacyFirefoxWallet( + legacyDriver, + withdrawal.taler_withdraw_uri, + exchange.baseUrl, + ); + await confirmBankWithdrawal(bankClient, user, withdrawal); + await firefoxCall( + legacyDriver, + "legacy", + "testingWaitTransactionsFinal", + {}, + ); + } finally { + await legacyDriver.quit().catch(() => undefined); + } + + const nextDriver = await launchFirefox(profile); + try { + await nextDriver.manage().setTimeouts({ pageLoad: 30_000, script: 60_000 }); + assert.equal( + await nextDriver.installAddon(artifacts.next, true), + FIREFOX_EXTENSION_ID, + "Firefox rejected the wallet-webui package as an in-place update", + ); + await openFirefoxExtensionPage(nextDriver, "wallet.html"); + const balances = await firefoxCall( + nextDriver, + "wallet-webui", + "getBalances", + {}, + ); + const transactions = await firefoxCall( + nextDriver, + "wallet-webui", + "getTransactions", + {}, + ); + assertPreservedWalletState(balances, transactions); + } finally { + await nextDriver.quit().catch(() => undefined); + } +} + +runWalletWebUiFirefoxExtensionUpgradeTest.suites = ["wallet-webui"]; +runWalletWebUiFirefoxExtensionUpgradeTest.timeoutMs = 300_000; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-firefox-extension-withdrawal.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-firefox-extension-withdrawal.ts @@ -0,0 +1,264 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import path from "node:path"; +import { BrowsingContext, Builder, By, until } from "selenium-webdriver"; +import * as Firefox from "selenium-webdriver/firefox.js"; +import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; +import { GlobalTestState } from "../harness/harness.js"; + +const EXTENSION_ID = "wallet@taler.net"; +const EXTENSION_UUID = "5e0d7cc0-49c7-4d18-8433-7b3c3e39de4d"; + +function findWalletWebUiDirectory(): string { + const candidates = [ + process.env.TALER_WALLET_WEBUI_DIR, + path.resolve(process.cwd(), "packages/wallet-webui"), + path.resolve(process.cwd(), "../wallet-webui"), + ].filter((candidate): candidate is string => Boolean(candidate)); + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, "dev.mjs"))) return candidate; + } + throw Error( + "wallet-webui sources not found; run from the wallet-core checkout or set TALER_WALLET_WEBUI_DIR", + ); +} + +async function reserveTcpPort(): Promise<number> { + const server = net.createServer(); + await new Promise<void>((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") + throw Error("could not reserve a TCP port"); + await new Promise<void>((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + return address.port; +} + +async function waitForBuild(url: string, timeoutMs = 90_000): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const status = await new Promise<number | undefined>( + (resolve, reject) => { + const request = http.get(url, (response) => { + response.resume(); + resolve(response.statusCode); + }); + request.once("error", reject); + }, + ); + if (status && status >= 200 && status < 300) return; + } catch { + // The build server is not listening yet. + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw Error("wallet-webui extension build did not become ready"); +} + +async function screenshot( + driver: Firefox.Driver, + directory: string, + name: string, +): Promise<void> { + fs.writeFileSync( + path.join(directory, `${name}.png`), + await driver.takeScreenshot(), + "base64", + ); +} + +async function waitForXpath(driver: Firefox.Driver, xpath: string) { + const element = await driver.wait( + until.elementLocated(By.xpath(xpath)), + 30_000, + ); + await driver.wait(until.elementIsVisible(element), 30_000); + return element; +} + +async function clickXpath( + driver: Firefox.Driver, + xpath: string, +): Promise<void> { + const element = await waitForXpath(driver, xpath); + await driver.executeScript( + "arguments[0].scrollIntoView({ block: 'center', inline: 'nearest' })", + element, + ); + await element.click(); +} + +async function setWalletRoute( + driver: Firefox.Driver, + route: string, +): Promise<void> { + await driver.executeScript( + "window.location.hash = arguments[0]", + `#${route}`, + ); +} + +/** Load the temporary Firefox MV3 extension and withdraw from the harness exchange. */ +export async function runWalletWebUiFirefoxExtensionWithdrawalTest( + t: GlobalTestState, +) { + const { exchange } = await createSimpleTestkudosEnvironmentV3(t); + const walletWebUiDirectory = findWalletWebUiDirectory(); + const buildPort = await reserveTcpPort(); + t.spawnService( + process.execPath, + [path.join(walletWebUiDirectory, "dev.mjs"), `--port=${buildPort}`], + "wallet-webui-build", + ); + await waitForBuild(`http://127.0.0.1:${buildPort}/`); + + const extensionDirectory = path.join( + walletWebUiDirectory, + "dist/extension/firefox", + ); + const screenshotDirectory = path.join( + t.testDir, + "wallet-webui-firefox-extension-screenshots", + ); + fs.mkdirSync(screenshotDirectory, { recursive: true }); + + const options = new Firefox.Options() + .addArguments("-headless") + .setPreference( + "extensions.webextensions.uuids", + JSON.stringify({ [EXTENSION_ID]: EXTENSION_UUID }), + ); + if (process.env.BROWSER_BINARY) options.setBinary(process.env.BROWSER_BINARY); + options.enableBidi(); + const service = new Firefox.ServiceBuilder().addArguments( + "--allow-system-access", + ); + const driver = (await new Builder() + .forBrowser("firefox") + .setFirefoxOptions(options) + .setFirefoxService(service) + .build()) as Firefox.Driver; + + try { + await driver + .manage() + .setTimeouts({ implicit: 0, pageLoad: 30_000, script: 30_000 }); + await driver.manage().window().setRect({ width: 390, height: 844 }); + const addonId = await driver.installAddon(extensionDirectory, true); + if (addonId !== EXTENSION_ID) { + throw Error(`unexpected Firefox extension id ${addonId}`); + } + + await driver.setContext(Firefox.Context.CHROME); + await driver.executeScript( + "window.openTrustedLinkIn(arguments[0], 'current')", + `moz-extension://${EXTENSION_UUID}/popup.html`, + ); + await driver.setContext(Firefox.Context.CONTENT); + const openWallet = await waitForXpath( + driver, + "//button[normalize-space(.)='Open full wallet']", + ); + await screenshot(driver, screenshotDirectory, "01-popup"); + const popupHandle = await driver.getWindowHandle(); + await openWallet.click(); + await driver.wait( + async () => (await driver.getAllWindowHandles()).length > 1, + 30_000, + ); + const walletHandle = (await driver.getAllWindowHandles()).find( + (handle) => handle !== popupHandle, + ); + if (!walletHandle) throw Error("popup did not open the full wallet tab"); + await driver.switchTo().window(walletHandle); + await waitForXpath( + driver, + "//*[normalize-space(.)='Welcome to Taler Wallet!']", + ); + const contexts = await ( + await BrowsingContext(driver, { browsingContextId: walletHandle }) + ).getTopLevelContexts(); + if (!contexts.some((context) => context.url.includes("/wallet.html"))) { + throw Error("WebDriver BiDi did not observe the full wallet context"); + } + + await setWalletRoute(driver, "/exchanges"); + await waitForXpath(driver, "//h1[normalize-space(.)='Exchanges']"); + const exchangeInput = await driver.wait( + until.elementLocated( + By.css('input[placeholder="https://exchange.example/"]'), + ), + 30_000, + ); + await exchangeInput.sendKeys(exchange.baseUrl); + await clickXpath(driver, "//button[normalize-space(.)='Add exchange']"); + await waitForXpath(driver, "//h1[normalize-space(.)='Exchange details']"); + await waitForXpath(driver, "//*[normalize-space(.)='TESTKUDOS']"); + await screenshot(driver, screenshotDirectory, "02-local-exchange"); + + await clickXpath(driver, "//button[normalize-space(.)='Review terms']"); + await waitForXpath(driver, "//h1[normalize-space(.)='Terms of service']"); + await clickXpath(driver, "//button[normalize-space(.)='Accept terms']"); + await waitForXpath(driver, "//h1[normalize-space(.)='Exchange details']"); + + await setWalletRoute(driver, "/withdraw?kind=demo"); + await waitForXpath(driver, "//h1[normalize-space(.)='Withdraw']"); + const amountInput = await driver.wait( + until.elementLocated(By.css('input[inputmode="decimal"]')), + 30_000, + ); + await amountInput.sendKeys("10"); + await clickXpath(driver, "//button[normalize-space(.)='Withdraw']"); + await waitForXpath(driver, "//h1[normalize-space(.)='Review withdrawal']"); + await clickXpath(driver, "//button[normalize-space(.)='Withdraw']"); + await waitForXpath(driver, "//h1[normalize-space(.)='Pending withdrawal']"); + await waitForXpath( + driver, + "//*[normalize-space(.)='Wire transfer instructions']", + ); + await waitForXpath(driver, "//*[normalize-space(.)='TESTKUDOS:10']"); + await screenshot(driver, screenshotDirectory, "03-pending-withdrawal"); + + await setWalletRoute(driver, "/transactions"); + await waitForXpath(driver, "//h1[normalize-space(.)='History']"); + await clickXpath(driver, "//button[contains(., 'localhost')]"); + await clickXpath( + driver, + "//button[normalize-space(.)='View bank transfer instructions']", + ); + await waitForXpath(driver, "//h1[normalize-space(.)='Pending withdrawal']"); + await waitForXpath( + driver, + "//*[normalize-space(.)='Wire transfer instructions']", + ); + await driver.executeScript("window.scrollTo(0, 0)"); + await screenshot(driver, screenshotDirectory, "04-continued-withdrawal"); + } finally { + await driver.quit(); + } +} + +runWalletWebUiFirefoxExtensionWithdrawalTest.suites = ["wallet-webui"]; +runWalletWebUiFirefoxExtensionWithdrawalTest.timeoutMs = 180_000; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-cash-acceptor.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-cash-acceptor.ts @@ -0,0 +1,121 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import path from "node:path"; +import { succeedOrThrow, TalerCoreBankHttpClient } from "@gnu-taler/taler-util"; +import { + createSimpleTestkudosEnvironmentV3, + registerHarnessBankTestUser, +} from "../harness/environments.js"; +import { GlobalTestState, harnessHttpLib } from "../harness/harness.js"; +import { Stage } from "../stagefright/stage.js"; +import { + acknowledgeBestEffortStorageForTest, + confirmWithdrawalWhenReady, + findWalletWebUiDirectory, + reserveTcpPort, + startCorsBridge, + waitForPwa, +} from "./wallet-web-ui-test-support.js"; + +/** Confirm a cash-acceptor withdrawal whose amount is deliberately unknown. */ +export async function runWalletWebUiPwaCashAcceptorTest(t: GlobalTestState) { + const { bank } = await createSimpleTestkudosEnvironmentV3(t, undefined, { + forceLibeufin: true, + }); + const bankClient = new TalerCoreBankHttpClient( + bank.corebankApiBaseUrl, + harnessHttpLib, + ); + const bankUser = await registerHarnessBankTestUser(bankClient); + const withdrawal = succeedOrThrow( + await bankClient.createWithdrawal(bankUser, { no_amount_to_wallet: true }), + ); + const bankCorsBridge = await startCorsBridge(bank.corebankApiBaseUrl); + const browserWithdrawalUri = + `${withdrawal.taler_withdraw_uri}?external-confirmation=1`.replace( + new URL(bank.corebankApiBaseUrl).host, + new URL(bankCorsBridge.baseUrl).host, + ); + + const walletWebUiDirectory = findWalletWebUiDirectory(); + const port = await reserveTcpPort(); + const pwaUrl = `http://127.0.0.1:${port}/`; + t.spawnService( + process.execPath, + [path.join(walletWebUiDirectory, "dev.mjs"), `--port=${port}`], + "wallet-webui-pwa-cash-acceptor", + ); + await waitForPwa(pwaUrl); + + const stage = await Stage.create("wallet-web-ui-pwa-cash-acceptor", { + browserType: "chromium", + screenshotDir: path.join( + t.testDir, + "wallet-webui-cash-acceptor-screenshots", + ), + timeoutMs: 90_000, + viewport: { width: 390, height: 844 }, + }); + try { + await stage.run(async () => { + await stage.page.addInitScript(acknowledgeBestEffortStorageForTest); + await stage.step("prepare amount-less withdrawal", async (page) => { + await page.goto( + `${pwaUrl}?talerUri=${encodeURIComponent(browserWithdrawalUri)}#/`, + ); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("heading", { name: "Withdraw from your bank" }) + .waitFor(); + await page + .getByRole("heading", { name: "Amount selected at cash acceptor" }) + .waitFor(); + t.assertTrue( + (await page.getByLabel(/Amount to withdraw/).count()) === 0, + ); + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Confirm with your bank" }) + .waitFor(); + await page + .getByText("Amount selected at cash acceptor", { exact: true }) + .waitFor(); + }); + + await stage.step( + "cash acceptor reports the final amount", + async (page) => { + await confirmWithdrawalWhenReady(() => + bankClient.confirmWithdrawalById( + bankUser, + { amount: "TESTKUDOS:10" }, + withdrawal.withdrawal_id, + ), + ); + await page.goto(pwaUrl); + await page.getByRole("heading", { name: "Balances" }).waitFor(); + await page + .getByRole("heading", { + name: "Available balance TESTKUDOS:9.85", + }) + .waitFor(); + }, + ); + }); + } finally { + await bankCorsBridge.close(); + } +} + +runWalletWebUiPwaCashAcceptorTest.suites = ["wallet-webui"]; +runWalletWebUiPwaCashAcceptorTest.timeoutMs = 180_000; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-deposit-kyc-auth-swiss.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-deposit-kyc-auth-swiss.ts @@ -0,0 +1,334 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import path from "node:path"; +import { IbanString, Paytos } from "@gnu-taler/taler-util"; +import { defaultCoinConfig } from "../harness/denomStructures.js"; +import { startFakeChallenger } from "../harness/fake-challenger.js"; +import { + ExchangeService, + GlobalTestState, + LibeufinNexusService, + NexusBankAccountInfo, + setupDb, +} from "../harness/harness.js"; +import { topsKycRulesConf, topsProvidersTestConf } from "../harness/tops.js"; +import { Stage } from "../stagefright/stage.js"; +import { + acknowledgeBestEffortStorageForTest, + findWalletWebUiDirectory, + reserveTcpPort, + startCorsBridge, + waitForPwa, +} from "./wallet-web-ui-test-support.js"; + +function withdrawExchangeUri(baseUrl: string, amount: string): string { + const exchangeUrl = new URL(baseUrl); + const scheme = exchangeUrl.protocol === "http:" ? "taler+http" : "taler"; + const pathname = exchangeUrl.pathname.endsWith("/") + ? exchangeUrl.pathname + : `${exchangeUrl.pathname}/`; + return `${scheme}://withdraw-exchange/${exchangeUrl.host}${pathname}?a=${encodeURIComponent(amount)}`; +} + +async function visibleChfBalance( + page: import("playwright-core").Page, +): Promise<number> { + const balance = page.getByRole("heading", { + name: /^Available balance CHF:[1-9]/, + }); + await balance.waitFor({ timeout: 90_000 }); + const text = (await balance.getAttribute("aria-label")) ?? ""; + const value = Number(text.slice(text.indexOf(":") + 1)); + if (!Number.isFinite(value)) + throw Error(`invalid CHF wallet balance: ${text}`); + return value; +} + +/** Exercise real multi-account Swiss deposit-auth transfer options in the PWA. */ +export async function runWalletWebUiPwaDepositKycAuthSwissTest( + t: GlobalTestState, +) { + const db = await setupDb(t); + const challenger = await startFakeChallenger({ + port: 6001, + addressType: "postal-ch", + }); + const accountInfo: NexusBankAccountInfo = { + iban: "CH7347363QVFHHFR8BWWB", + bic: "POFICHBEXXX", + name: "Harness Test Exchange", + qrIban: "CH1130000001166556117", + }; + const nexus = await LibeufinNexusService.create(t, { + currency: "CHF", + database: db.connStr, + httpPort: 8085, + bankAccountInfo: accountInfo, + }); + await nexus.dbinit(); + await nexus.start(); + const nexusCorsBridge = await startCorsBridge(nexus.baseUrl); + + const qrPayto = Paytos.toFullString( + Paytos.createIban(accountInfo.qrIban as IbanString, undefined, { + "receiver-name": accountInfo.name, + }), + ); + const manualPayto = Paytos.toFullString( + Paytos.createIban(accountInfo.iban as IbanString, undefined, { + "receiver-name": accountInfo.name, + }), + ); + const exchange = ExchangeService.create(t, { + name: "testexchange-webui-swiss", + currency: "CHF", + httpPort: 8081, + database: db.connStr, + extraProcEnv: { + EXCHANGE_AML_PROGRAM_TOPS_ENABLE_DEPOSITS_TOS_NAME: "v1", + EXCHANGE_AML_PROGRAM_TOPS_ENABLE_DEPOSITS_THRESHOLD: "CHF:0", + EXCHANGE_AML_PROGRAM_TOPS_POSTAL_CHECK_COUNTRY_REGEX: "ch|CH|Ch", + }, + }); + const wireGateway = { + wireGatewayApiBaseUrl: nexus.wireGatewayApiBaseUrl, + wireGatewayAuth: { + type: "basic" as const, + username: "exchange-test", + password: "exchange-test", + }, + }; + await exchange.addBankAccount("nexus-qr", { + ...wireGateway, + accountPaytoUri: qrPayto, + preparedTransferUrl: new URL( + "taler-prepared-transfer/", + nexusCorsBridge.baseUrl, + ).href, + }); + await exchange.addBankAccount("nexus-manual", { + ...wireGateway, + accountPaytoUri: manualPayto, + }); + exchange.addCoinConfigList(defaultCoinConfig.map((coin) => coin("CHF"))); + await exchange.modifyConfig(async (config) => { + config.loadFromString(topsKycRulesConf); + config.loadFromString(topsProvidersTestConf); + }); + await exchange.start(); + + const walletWebUiDirectory = findWalletWebUiDirectory(); + const port = await reserveTcpPort(); + const pwaUrl = `http://127.0.0.1:${port}/`; + t.spawnService( + process.execPath, + [path.join(walletWebUiDirectory, "dev.mjs"), `--port=${port}`], + "wallet-webui-pwa-deposit-kyc-auth-swiss", + ); + await waitForPwa(pwaUrl); + + const stage = await Stage.create("wallet-web-ui-pwa-deposit-kyc-auth-swiss", { + browserType: "chromium", + screenshotDir: path.join( + t.testDir, + "wallet-webui-deposit-kyc-swiss-screenshots", + ), + timeoutMs: 90_000, + viewport: { width: 390, height: 844 }, + }); + const walletPayto = Paytos.toFullString( + Paytos.createIban("CH5210395CX7GTDCRC3C4" as IbanString, undefined, { + "receiver-name": "Alice", + }), + ); + try { + await stage.run(async () => { + await stage.page.addInitScript(acknowledgeBestEffortStorageForTest); + await stage.step("add the Swiss exchange", async (page) => { + await page.goto(`${pwaUrl}#/exchanges`); + await page + .getByLabel("Exchange URL or Taler add-exchange URI") + .fill(exchange.baseUrl); + await page.getByRole("button", { name: "Add exchange" }).click(); + await page.getByRole("heading", { name: "Exchange details" }).waitFor(); + await page.getByText("CHF", { exact: true }).waitFor(); + }); + + await stage.step("fund through a Swiss QR-bill option", async (page) => { + await page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"], { + origin: new URL(pwaUrl).origin, + }); + await page.goto(`${pwaUrl}#/enter`); + await page + .getByLabel("Wallet link") + .fill(withdrawExchangeUri(exchange.baseUrl, "CHF:10")); + await page.getByLabel("Wallet link").press("Enter"); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("heading", { name: "Withdraw from exchange" }) + .waitFor(); + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Review withdrawal" }) + .waitFor(); + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Pending withdrawal" }) + .waitFor(); + await page.getByRole("heading", { name: "Transfer options" }).waitFor(); + await page.getByRole("tab", { name: /Swiss QR bill/ }).click(); + const swissOption = page.getByRole("tabpanel", { + name: /Swiss QR bill/, + }); + await swissOption + .locator("[data-testid='swiss-payment-cross']") + .waitFor(); + if ( + await swissOption.locator("[data-testid='taler-qr-logo']").count() + ) { + throw Error("Swiss QR-bill withdrawal option used Taler QR branding"); + } + await swissOption + .getByRole("button", { name: /^More actions for/ }) + .click(); + await swissOption + .getByRole("menuitem", { name: "Copy payto URI" }) + .click(); + const creditPayto = await page.evaluate<string>( + "navigator.clipboard.readText()", + ); + if ( + !creditPayto.startsWith("payto://iban/CH11") || + !creditPayto.includes("ch-qrr=") + ) { + throw Error( + `wallet copied an unexpected Swiss transfer target: ${creditPayto}`, + ); + } + await nexus.fakeIncoming({ creditPayto }); + await exchange.runWirewatchOnce("exchange-account-nexus-qr"); + await page.goto(pwaUrl); + await page.getByRole("heading", { name: "Balances" }).waitFor(); + if ((await visibleChfBalance(page)) < 9) { + throw Error( + "Swiss QR-bill withdrawal did not fund the browser wallet", + ); + } + }); + + await stage.step( + "create a Swiss deposit requiring authentication", + async (page) => { + await page.goto(`${pwaUrl}#/accounts`); + await page.getByRole("button", { name: /Add bank account/ }).click(); + await page.getByLabel("Account type").selectOption("advanced"); + await page.getByLabel("Label").fill("Swiss deposit account"); + await page.getByLabel("Payto URI", { exact: true }).fill(walletPayto); + await page.getByLabel("Currencies (optional)").fill("CHF"); + await page.getByRole("button", { name: "Add account" }).click(); + await page.goto(`${pwaUrl}#/deposit?currency=CHF`); + await page.getByLabel("Bank account").selectOption({ + label: "Swiss deposit account", + }); + await page.getByLabel("Amount").fill("5"); + await page.getByRole("button", { name: "Review deposit" }).click(); + await page.getByRole("button", { name: "Confirm deposit" }).click(); + await page + .getByText("Bank account verification required", { exact: true }) + .waitFor(); + await page + .getByRole("button", { name: "View authentication transfer" }) + .click(); + }, + ); + + await stage.step( + "show every Swiss authentication transfer option", + async (page) => { + await page + .getByRole("heading", { name: "Verify bank account" }) + .waitFor(); + await page.getByText(walletPayto, { exact: true }).first().waitFor(); + await page + .getByRole("heading", { name: "Transfer options" }) + .waitFor(); + const optionTabs = page.getByRole("tab"); + const optionNames = (await optionTabs.allTextContents()).map((name) => + name.trim(), + ); + if (optionNames.length < 2) { + throw Error( + "wallet did not expose every Swiss authentication transfer option as a tab", + ); + } + const swissTabIndex = optionNames.findIndex((name) => + name.includes("Swiss QR bill"), + ); + const manualTabIndex = optionNames.findIndex( + (name) => !name.includes("Swiss QR bill"), + ); + if (swissTabIndex < 0 || manualTabIndex < 0) { + throw Error( + `wallet exposed unexpected authentication transfer tabs: ${optionNames.join(", ")}`, + ); + } + await optionTabs.nth(swissTabIndex).click(); + const swissOption = page.getByRole("tabpanel"); + await swissOption + .locator("[data-qr-style='swiss-payment']") + .waitFor(); + await swissOption + .locator("[data-testid='swiss-payment-cross']") + .waitFor(); + if ( + await swissOption.locator("[data-testid='taler-qr-logo']").count() + ) { + throw Error("Swiss deposit-auth QR code used Taler QR branding"); + } + await swissOption + .getByRole("button", { name: /^More actions for/ }) + .click(); + await swissOption + .getByRole("menuitem", { name: "Copy payto URI" }) + .waitFor(); + await optionTabs.nth(manualTabIndex).click(); + await page.getByRole("menu").waitFor({ state: "detached" }); + const manualOption = page.getByRole("tabpanel"); + await manualOption + .getByRole("button", { name: /^More actions for/ }) + .click(); + await manualOption + .getByRole("menuitem", { name: "Copy payto URI" }) + .waitFor(); + await manualOption + .getByRole("heading", { name: "Wire transfer instructions" }) + .waitFor(); + }, + ); + }); + } finally { + challenger.stop(); + await nexusCorsBridge.close(); + } +} + +runWalletWebUiPwaDepositKycAuthSwissTest.suites = [ + "wallet-webui", + "kyc", + "tops", + "libeufin", +]; +runWalletWebUiPwaDepositKycAuthSwissTest.timeoutMs = 240_000; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-deposit-kyc-auth.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-deposit-kyc-auth.ts @@ -0,0 +1,278 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import path from "node:path"; +import { Configuration } from "@gnu-taler/taler-util"; +import type { Page } from "playwright-core"; +import { + configureCommonKyc, + createKycTestkudosEnvironmentFull, +} from "../harness/environments.js"; +import { + getTestHarnessPaytoForLabel, + GlobalTestState, +} from "../harness/harness.js"; +import { Stage } from "../stagefright/stage.js"; +import { + acknowledgeBestEffortStorageForTest, + confirmWithdrawalWhenReady, + findWalletWebUiDirectory, + reserveTcpPort, + startCorsBridge, + waitForPwa, +} from "./wallet-web-ui-test-support.js"; + +function adjustExchangeConfig(config: Configuration): void { + configureCommonKyc(config); + config.setString("KYC-RULE-R1", "operation_type", "deposit"); + config.setString("KYC-RULE-R1", "enabled", "yes"); + config.setString("KYC-RULE-R1", "exposed", "yes"); + config.setString("KYC-RULE-R1", "is_and_combinator", "yes"); + config.setString("KYC-RULE-R1", "threshold", "TESTKUDOS:5"); + config.setString("KYC-RULE-R1", "timeframe", "1d"); + config.setString("KYC-RULE-R1", "next_measures", "M1"); + config.setString("KYC-MEASURE-M1", "check_name", "C1"); + config.setString("KYC-MEASURE-M1", "context", "{}"); + config.setString("KYC-MEASURE-M1", "program", "NONE"); + config.setString("KYC-CHECK-C1", "type", "INFO"); + config.setString("KYC-CHECK-C1", "description", "PWA deposit check"); + config.setString("KYC-CHECK-C1", "fallback", "FREEZE"); +} + +async function visibleTestkudosBalance(page: Page): Promise<number> { + const balance = page.getByRole("heading", { + name: /^Available balance TESTKUDOS:[1-9]/, + }); + await balance.waitFor(); + const text = (await balance.getAttribute("aria-label")) ?? ""; + const value = Number(text.slice(text.indexOf(":") + 1)); + if (!Number.isFinite(value)) throw Error(`invalid wallet balance: ${text}`); + return value; +} + +/** Exercise the distinct deposit KYC authentication bank-transfer state. */ +export async function runWalletWebUiPwaDepositKycAuthTest(t: GlobalTestState) { + const { bankClient, bank, wireGatewayApi } = + await createKycTestkudosEnvironmentFull(t, { adjustExchangeConfig }); + + const depositPayto = getTestHarnessPaytoForLabel("browser-kyc-deposit"); + await bankClient.registerAccountExtended({ + name: "browser-kyc-deposit", + password: "deposit-password", + username: "browser-kyc-deposit", + is_taler_exchange: false, + payto_uri: depositPayto, + }); + + const bankUser = await bankClient.createRandomBankUser(); + bankClient.setAuth(bankUser); + const withdrawal = await bankClient.createWithdrawalOperation( + bankUser.username, + "TESTKUDOS:30", + ); + const bankCorsBridge = await startCorsBridge(bankClient.baseUrl); + const browserWithdrawalUri = withdrawal.taler_withdraw_uri.replace( + new URL(bankClient.baseUrl).host, + new URL(bankCorsBridge.baseUrl).host, + ); + + const walletWebUiDirectory = findWalletWebUiDirectory(); + const port = await reserveTcpPort(); + const pwaUrl = `http://127.0.0.1:${port}/`; + t.spawnService( + process.execPath, + [path.join(walletWebUiDirectory, "dev.mjs"), `--port=${port}`], + "wallet-webui-pwa-deposit-kyc-auth", + ); + await waitForPwa(pwaUrl); + + const stage = await Stage.create("wallet-web-ui-pwa-deposit-kyc-auth", { + browserType: "chromium", + screenshotDir: path.join(t.testDir, "wallet-webui-deposit-kyc-screenshots"), + timeoutMs: 90_000, + viewport: { width: 390, height: 844 }, + }); + let fundedBalance = 0; + try { + await stage.run(async () => { + await stage.page.addInitScript(acknowledgeBestEffortStorageForTest); + await stage.step("fund browser wallet", async (page) => { + await page.goto( + `${pwaUrl}?talerUri=${encodeURIComponent(browserWithdrawalUri)}#/`, + ); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("heading", { name: "Withdraw from your bank" }) + .waitFor(); + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Review withdrawal" }) + .waitFor(); + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Confirm with your bank" }) + .waitFor(); + await confirmWithdrawalWhenReady(() => + bankClient.confirmWithdrawalOperation(bankUser.username, { + withdrawalOperationId: withdrawal.withdrawal_id, + }), + ); + await page.goto(pwaUrl); + await page.getByRole("heading", { name: "Balances" }).waitFor(); + fundedBalance = await visibleTestkudosBalance(page); + if (fundedBalance <= 10) + throw Error("wallet did not show the funded balance"); + }); + + await stage.step( + "create deposit that needs authentication", + async (page) => { + await page.goto(`${pwaUrl}#/accounts`); + await page.getByRole("button", { name: /Add bank account/ }).click(); + await page.getByLabel("Account type").selectOption("advanced"); + await page.getByLabel("Label").fill("KYC deposit account"); + await page + .getByLabel("Payto URI", { exact: true }) + .fill(depositPayto); + await page.getByLabel("Currencies (optional)").fill("TESTKUDOS"); + await page.getByRole("button", { name: "Add account" }).click(); + await page.goto(`${pwaUrl}#/deposit?currency=TESTKUDOS`); + await page + .getByRole("heading", { name: "Deposit to a bank account" }) + .waitFor(); + await page + .getByLabel("Bank account") + .selectOption({ label: "KYC deposit account" }); + await page.getByLabel("Amount").fill("10"); + await page.getByRole("button", { name: "Review deposit" }).click(); + await page.getByRole("button", { name: "Confirm deposit" }).click(); + await page + .getByText("Bank account verification required", { exact: true }) + .waitFor(); + await page + .getByRole("button", { name: "View authentication transfer" }) + .click(); + }, + ); + + await stage.step( + "inspect and abort the authentication transfer", + async (page) => { + await page + .getByRole("heading", { name: "Verify bank account" }) + .waitFor(); + await page.getByText(depositPayto, { exact: true }).first().waitFor(); + await page + .getByRole("heading", { name: /Transfer option/ }) + .waitFor(); + await page + .getByRole("button", { name: /^More actions for/ }) + .first() + .click(); + await page + .getByRole("menuitem", { name: "Copy payto URI" }) + .waitFor(); + + const transactionUrl = page.url(); + await page.goto(pwaUrl); + await page.getByRole("heading", { name: "Balances" }).waitFor(); + const pendingBalance = await visibleTestkudosBalance(page); + if (pendingBalance >= fundedBalance - 9) { + throw Error( + "deposit funds were not reserved while KYC auth was pending", + ); + } + + await page.goto(transactionUrl); + await page + .getByText("Bank account verification required", { exact: true }) + .waitFor(); + await page + .getByRole("button", { name: "Abort", exact: true }) + .click(); + await page.getByRole("dialog", { name: "Confirm abort" }).waitFor(); + await page + .getByRole("button", { name: "Confirm", exact: true }) + .click(); + await page.getByText("Aborted", { exact: true }).waitFor(); + await page.goto(pwaUrl); + await page.getByRole("heading", { name: "Balances" }).waitFor(); + const recoveredBalance = await visibleTestkudosBalance(page); + if (recoveredBalance <= pendingBalance + 9) { + throw Error( + "aborted deposit funds did not return to the available balance", + ); + } + if (recoveredBalance > fundedBalance) { + throw Error( + "aborting a deposit unexpectedly increased the wallet balance", + ); + } + }, + ); + + await stage.step( + "continue a second deposit after authentication", + async (page) => { + await page.goto(`${pwaUrl}#/deposit?currency=TESTKUDOS`); + await page + .getByLabel("Bank account") + .selectOption({ label: "KYC deposit account" }); + await page.getByLabel("Amount").fill("10"); + await page.getByRole("button", { name: "Review deposit" }).click(); + await page.getByRole("button", { name: "Confirm deposit" }).click(); + await page + .getByText("Bank account verification required", { exact: true }) + .waitFor(); + await page + .getByRole("button", { name: "View authentication transfer" }) + .click(); + await page + .getByRole("heading", { name: "Verify bank account" }) + .waitFor(); + + const accountPub = await page + .locator("details", { + hasText: "Technical authentication identifier", + }) + .locator("p") + .textContent(); + if (!accountPub) + throw Error("wallet did not expose the KYC account key"); + + await wireGatewayApi.addKycAuth({ + auth: bank.getAdminAuth(), + body: { + amount: "TESTKUDOS:0.1", + debit_account: depositPayto, + account_pub: accountPub, + }, + }); + await page.getByRole("button", { name: "Back to deposit" }).click(); + await page + .getByText("Legitimization required", { exact: true }) + .waitFor({ timeout: 60_000 }); + await page + .getByRole("button", { name: "Complete verification" }) + .waitFor(); + }, + ); + }); + } finally { + await bankCorsBridge.close(); + } +} + +runWalletWebUiPwaDepositKycAuthTest.suites = ["wallet-webui", "kyc"]; +runWalletWebUiPwaDepositKycAuthTest.timeoutMs = 240_000; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-donau.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-donau.ts @@ -0,0 +1,459 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import fs from "node:fs"; +import path from "node:path"; +import { + AccessToken, + DonauHttpClient, + OrderOutputType, + OrderVersion, + succeedOrThrow, + TalerMerchantInstanceHttpClient, +} from "@gnu-taler/taler-util"; +import { defaultCoinConfig } from "../harness/denomStructures.js"; +import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; +import { DonauService } from "../harness/harness-donau.js"; +import { delayMs, GlobalTestState } from "../harness/harness.js"; +import { Stage } from "../stagefright/stage.js"; +import { + acknowledgeBestEffortStorageForTest, + confirmWithdrawalWhenReady, + findWalletWebUiDirectory, + reserveTcpPort, + startCorsBridge, + waitForPwa, +} from "./wallet-web-ui-test-support.js"; + +/** Configure and reopen a real local Donau service through the browser PWA. */ +export async function runWalletWebUiPwaDonauTest(t: GlobalTestState) { + const termsDirectory = path.join( + t.testDir, + "wallet-webui-integrated-withdrawal-terms", + ); + const englishTermsDirectory = path.join(termsDirectory, "en"); + fs.mkdirSync(englishTermsDirectory, { recursive: true }); + fs.writeFileSync( + path.join(englishTermsDirectory, "v1.txt"), + "Local exchange terms for the integrated wallet-webui withdrawal test.\n", + ); + const { commonDb, bankClient, exchange, merchant, merchantAdminAccessToken } = + await createSimpleTestkudosEnvironmentV3(t, undefined, { + merchantUseDonau: true, + additionalExchangeConfig: (localExchange) => { + localExchange.changeConfig((config) => { + config.setString("exchange", "terms_etag", "v1"); + config.setString("exchange", "terms_dir", termsDirectory); + }); + }, + }); + const donauPort = await reserveTcpPort(); + const donau = DonauService.create(t, { + currency: "TESTKUDOS", + database: commonDb.connStr, + httpPort: donauPort, + name: "wallet-webui-donau", + domain: "Bern", + }); + donau.addCoinConfigList(defaultCoinConfig.map((entry) => entry("TESTKUDOS"))); + await donau.start(); + const donauCorsBridge = await startCorsBridge(donau.baseUrl); + const browserDonauBaseUrl = donauCorsBridge.baseUrl; + + const merchantClient = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + ); + const merchantDetails = succeedOrThrow( + await merchantClient.getCurrentInstanceDetails(merchantAdminAccessToken), + ); + const donauClient = new DonauHttpClient(donau.baseUrl); + const currentYear = new Date().getFullYear(); + const charity = succeedOrThrow( + await donauClient.createCharity("" as AccessToken, { + charity_pub: merchantDetails.merchant_pub, + current_year: currentYear, + max_per_year: "TESTKUDOS:1000", + charity_name: "Browser Test Charity", + receipts_to_date: "TESTKUDOS:0", + charity_url: merchant.makeInstanceBaseUrl(), + }), + ); + succeedOrThrow( + await merchantClient.postDonau({ + body: { charity_id: charity.charity_id, donau_url: browserDonauBaseUrl }, + token: merchantAdminAccessToken, + }), + ); + await delayMs(2000); + + const bankUser = await bankClient.createRandomBankUser(); + bankClient.setAuth(bankUser); + const withdrawal = await bankClient.createWithdrawalOperation( + bankUser.username, + "TESTKUDOS:6", + ); + const bankCorsBridge = await startCorsBridge(bankClient.baseUrl); + const browserWithdrawalUri = withdrawal.taler_withdraw_uri.replace( + new URL(bankClient.baseUrl).host, + new URL(bankCorsBridge.baseUrl).host, + ); + const order = succeedOrThrow( + await merchantClient.createOrder(merchantAdminAccessToken, { + order: { + version: OrderVersion.V1, + summary: "Browser donation", + choices: [ + { + amount: "TESTKUDOS:5", + outputs: [ + { + type: OrderOutputType.TaxReceipt, + amount: "TESTKUDOS:5", + donau_urls: [browserDonauBaseUrl], + }, + ], + }, + ], + }, + }), + ); + const unpaidOrder = succeedOrThrow( + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + order.order_id, + ), + ); + if (unpaidOrder.order_status !== "unpaid") { + throw Error("browser donation order was not created as unpaid"); + } + + const walletWebUiDirectory = findWalletWebUiDirectory(); + const pwaPort = await reserveTcpPort(); + const pwaUrl = `http://127.0.0.1:${pwaPort}/`; + t.spawnService( + process.execPath, + [path.join(walletWebUiDirectory, "dev.mjs"), `--port=${pwaPort}`], + "wallet-webui-pwa", + ); + await waitForPwa(pwaUrl); + + const stage = await Stage.create("wallet-web-ui-pwa-donau", { + browserType: "chromium", + screenshotDir: path.join(t.testDir, "wallet-webui-donau-screenshots"), + timeoutMs: 90_000, + viewport: { width: 390, height: 844 }, + }); + try { + await stage.run(async () => { + await stage.page.addInitScript(acknowledgeBestEffortStorageForTest); + await stage.page.addInitScript(() => { + const NativeWorker = (globalThis as any).Worker; + const confirmPayRequests: Array<{ + id: string; + useDonau?: boolean; + responseType?: string; + }> = []; + Object.defineProperty(globalThis, "__walletConfirmPayRequests", { + configurable: true, + value: confirmPayRequests, + }); + (globalThis as any).Worker = new Proxy(NativeWorker, { + construct(target, argumentsList) { + const worker = Reflect.construct(target, argumentsList) as any; + const nativePostMessage = worker.postMessage.bind(worker); + worker.addEventListener("message", (event: any) => { + const response = event.data; + if (response?.kind !== "response") return; + const request = confirmPayRequests.find( + (item) => item.id === response.response?.id, + ); + if (!request) return; + request.responseType = response.response?.type; + }); + worker.postMessage = (message: unknown, transfer?: unknown[]) => { + const request = message as { + kind?: string; + request?: { + id?: string; + operation?: string; + args?: { useDonau?: boolean }; + }; + }; + if ( + request.kind === "request" && + request.request?.id && + request.request.operation === "confirmPay" + ) { + confirmPayRequests.push({ + id: request.request.id, + ...(request.request.args?.useDonau === true + ? { useDonau: true } + : {}), + }); + } + if (transfer === undefined) nativePostMessage(message); + else nativePostMessage(message, transfer); + }; + return worker; + }, + }); + }); + await stage.step("open donation receipt settings", async (page) => { + await page.goto(`${pwaUrl}#/settings`); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + await page.getByRole("button", { name: /Donation receipts/ }).click(); + await page + .getByRole("heading", { name: "Donation receipts" }) + .waitFor(); + }); + + await stage.step("configure local donation authority", async (page) => { + await page + .getByLabel("Donation authority URL") + .fill(browserDonauBaseUrl); + await page.getByLabel("Taxpayer identifier").fill("test-tax-payer"); + await page.getByRole("button", { name: "Save" }).click(); + await page.getByText("Donation receipt settings saved.").waitFor(); + await page + .getByRole("button", { name: "Show donation statements" }) + .waitFor(); + }); + + await stage.step( + "open empty statements from local service", + async (page) => { + await page + .getByRole("button", { name: "Show donation statements" }) + .click(); + await page + .getByRole("heading", { name: "Donation statements" }) + .waitFor(); + await page + .getByText("No donation statement is available yet.") + .waitFor(); + }, + ); + + await stage.step("reopen persisted donation settings", async (page) => { + await page.goto(`${pwaUrl}#/donations`); + await page.getByLabel("Donation authority URL").waitFor(); + t.assertDeepEqual( + await page.getByLabel("Donation authority URL").inputValue(), + browserDonauBaseUrl, + ); + t.assertDeepEqual( + await page.getByLabel("Taxpayer identifier").inputValue(), + "test-tax-payer", + ); + }); + + await stage.step("review donation settings on desktop", async (page) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(`${pwaUrl}#/donations`); + await page + .getByRole("heading", { name: "Donation receipts" }) + .waitFor(); + await page + .getByRole("button", { name: "Show donation statements" }) + .waitFor(); + }); + + await stage.step( + "open shop discovery without provider URLs", + async (page) => { + await page.goto(`${pwaUrl}#/shopping?currency=TESTKUDOS`); + await page + .getByRole("heading", { name: "Where to pay with TESTKUDOS" }) + .waitFor(); + await page + .getByText( + "No shopping directories are available for this currency.", + ) + .waitFor(); + }, + ); + + await stage.step( + "prepare an integrated browser withdrawal", + async (page) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto( + `${pwaUrl}?talerUri=${encodeURIComponent(browserWithdrawalUri)}#/`, + ); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("heading", { name: "Withdraw from your bank" }) + .waitFor(); + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Review withdrawal" }) + .waitFor(); + await page + .getByText( + "Review and accept this exchange's terms of service before withdrawing.", + { exact: true }, + ) + .waitFor(); + const blocked = page.getByRole("button", { + name: "Withdraw", + exact: true, + }); + if (!(await blocked.isDisabled())) { + throw Error( + "integrated withdrawal was not blocked on exchange terms", + ); + } + await page.getByRole("button", { name: "Review terms" }).click(); + await page + .getByRole("heading", { name: "Terms of service" }) + .waitFor(); + await page.getByRole("button", { name: "Accept terms" }).click(); + await page + .getByRole("heading", { name: "Review withdrawal" }) + .waitFor(); + if ( + await page + .getByRole("heading", { name: "Withdraw from your bank" }) + .count() + ) { + throw Error( + "integrated withdrawal amount entry reappeared after accepting terms", + ); + } + if ( + await page.getByRole("button", { name: "Review terms" }).count() + ) { + throw Error( + "integrated withdrawal stayed blocked after accepting terms", + ); + } + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Confirm with your bank" }) + .waitFor(); + }, + ); + + await stage.step( + "fund the browser wallet from the local bank", + async (page) => { + await confirmWithdrawalWhenReady(() => + bankClient.confirmWithdrawalOperation(bankUser.username, { + withdrawalOperationId: withdrawal.withdrawal_id, + }), + ); + await page.goto(`${pwaUrl}#/`); + await page.getByRole("heading", { name: "Balances" }).waitFor(); + await page + .getByRole("heading", { + name: /^Available balance TESTKUDOS:[1-9]/, + }) + .waitFor(); + }, + ); + + await stage.step( + "review donation receipt payment in the browser", + async (page) => { + await page.goto( + `${pwaUrl}?talerUri=${encodeURIComponent(unpaidOrder.taler_pay_uri)}#/`, + ); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("heading", { name: "Review payment", exact: true }) + .waitFor(); + await page.getByText("Browser donation", { exact: true }).waitFor(); + const receipt = page.getByRole("checkbox", { + name: "Collect a donation receipt for this payment", + }); + await receipt.waitFor(); + await receipt.check(); + t.assertTrue(await receipt.isChecked()); + }, + ); + + await stage.step( + "pay with donation receipt collection enabled", + async (page) => { + await page.getByRole("button", { name: "Pay TESTKUDOS:5" }).click(); + const submitted = page.getByText("Payment submitted", { + exact: true, + }); + const complete = page.getByText("Payment complete", { exact: true }); + await submitted.or(complete).waitFor(); + await page.waitForFunction( + () => + Boolean( + ( + globalThis as typeof globalThis & { + __walletConfirmPayRequests?: Array<{ + responseType?: string; + }>; + } + ).__walletConfirmPayRequests?.at(-1)?.responseType, + ), + undefined, + { timeout: 30_000 }, + ); + const confirmPayRequests = await page.evaluate( + () => + ( + globalThis as typeof globalThis & { + __walletConfirmPayRequests?: Array<{ + id: string; + useDonau?: boolean; + responseType?: string; + }>; + } + ).__walletConfirmPayRequests ?? [], + ); + t.assertDeepEqual(confirmPayRequests.at(-1)?.useDonau, true); + t.assertDeepEqual( + confirmPayRequests.at(-1)?.responseType, + "response", + ); + if (await submitted.isVisible()) await page.reload(); + await page.getByText("Payment complete", { exact: true }).waitFor(); + const paidOrder = succeedOrThrow( + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + order.order_id, + ), + ); + t.assertDeepEqual(paidOrder.order_status, "paid"); + }, + ); + + await stage.step("open generated donation statement", async (page) => { + await page.goto( + `${pwaUrl}#/donation-statements?baseUrl=${encodeURIComponent(browserDonauBaseUrl)}`, + ); + await page + .getByRole("heading", { name: "Donation statements" }) + .waitFor(); + await page + .getByRole("img", { name: `Donation statement for ${currentYear}` }) + .waitFor({ timeout: 30_000 }); + await page.getByText("TESTKUDOS:5", { exact: true }).waitFor(); + await page.getByTestId("taler-qr-logo").waitFor(); + }); + }); + } finally { + await Promise.all([bankCorsBridge.close(), donauCorsBridge.close()]); + } +} + +runWalletWebUiPwaDonauTest.suites = ["wallet-webui", "donau"]; +runWalletWebUiPwaDonauTest.timeoutMs = 180_000; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-errors.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-errors.ts @@ -0,0 +1,356 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import path from "node:path"; +import { InsufficientBalanceHint, TalerErrorCode } from "@gnu-taler/taler-util"; +import { GlobalTestState } from "../harness/harness.js"; +import { Stage } from "../stagefright/stage.js"; +import { + findWalletWebUiDirectory, + reserveTcpPort, + waitForPwa, +} from "./wallet-web-ui-test-support.js"; + +const PAY_URI = "taler://pay/merchant.example/order/session"; + +function apiErrorExperiment(detail: Record<string, unknown>): string { + const encodedDetail = Buffer.from(JSON.stringify(detail), "utf8").toString( + "base64url", + ); + return `taler://dev-experiment/api-error?operation=preparePayForUriV2&detail=${encodedDetail}`; +} + +function apiResponseExperiment( + operation: string, + response: unknown, + count = 1, +): string { + const encodedResponse = Buffer.from( + JSON.stringify(response), + "utf8", + ).toString("base64url"); + return `taler://dev-experiment/api-response?operation=${operation}&response=${encodedResponse}&count=${count}`; +} + +/** Check deterministic wallet-core errors in the real PWA, including screenshots. */ +export async function runWalletWebUiPwaErrorsTest(t: GlobalTestState) { + const walletWebUiDirectory = findWalletWebUiDirectory(); + const port = await reserveTcpPort(); + const pwaUrl = `http://127.0.0.1:${port}/`; + t.spawnService( + process.execPath, + [path.join(walletWebUiDirectory, "dev.mjs"), `--port=${port}`], + "wallet-webui-pwa-errors", + ); + await waitForPwa(pwaUrl); + + const stage = await Stage.create("wallet-web-ui-pwa-errors", { + screenshotDir: path.join(t.testDir, "wallet-webui-error-screenshots"), + timeoutMs: 90_000, + viewport: { width: 390, height: 844 }, + }); + + const claimedDetail = { + code: TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED, + hint: "The order was already claimed by another wallet", + orderId: "claimed-order-fixture", + claimUrl: "https://merchant.example/claim/claimed-order-fixture", + futureWalletCoreField: { nested: [1, true, "preserved"] }, + }; + const insufficientDetail = { + code: TalerErrorCode.WALLET_PAY_MERCHANT_INSUFFICIENT_BALANCE, + hint: "Insufficient balance to pay merchant", + insufficientBalanceDetails: { + amountRequested: "TESTKUDOS:10", + balanceAvailable: "TESTKUDOS:0", + causeHint: InsufficientBalanceHint.WalletBalanceAvailableInsufficient, + }, + merchantOrderId: "empty-wallet-fixture", + }; + const unexpectedDetail = { + code: TalerErrorCode.WALLET_DB_UNAVAILABLE, + hint: "Injected database failure", + database: "wallet-main", + retryable: true, + }; + + async function openWalletLink(page: typeof stage.page, uri: string) { + await page.goto(`${pwaUrl}#/enter`); + await page.getByLabel("Wallet link").fill(uri); + await page.getByLabel("Wallet link").press("Enter"); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + } + + async function applyExperimentUri(page: typeof stage.page, uri: string) { + await openWalletLink(page, uri); + await page.getByRole("button", { name: "Apply experiment" }).click(); + await page.getByText(/was applied/).waitFor(); + } + + async function applyErrorExperiment( + page: typeof stage.page, + detail: Record<string, unknown>, + ) { + await applyExperimentUri(page, apiErrorExperiment(detail)); + } + + async function assertError( + page: typeof stage.page, + detail: Record<string, unknown>, + friendlyMessage: RegExp, + ) { + await openWalletLink(page, PAY_URI); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .locator('[role="alert"]') + .filter({ hasText: friendlyMessage }) + .waitFor(); + const technicalSummary = page.getByText("Technical details", { + exact: true, + }); + if ( + (await technicalSummary.locator("xpath=..").getAttribute("open")) === null + ) { + await technicalSummary.click(); + } + await page.getByText(detail.hint as string, { exact: true }).waitFor(); + await page.getByRole("button", { name: "Copy error details" }).click(); + const copied = await page.evaluate( + () => + (globalThis as { __copiedWalletError?: string }).__copiedWalletError, + ); + const expected = JSON.stringify(detail, undefined, 2); + if (copied !== expected) { + throw Error( + `copied wallet error did not preserve the exact detail\n${copied}`, + ); + } + await page.getByText("Error details copied.", { exact: true }).waitFor(); + } + + await stage.run(async () => { + await stage.page.addInitScript(() => { + const browserGlobal = globalThis as unknown as { + navigator: object; + __copiedWalletError?: string; + }; + Object.defineProperty(browserGlobal.navigator, "clipboard", { + configurable: true, + value: { + writeText: async (value: string) => { + browserGlobal.__copiedWalletError = value; + }, + }, + }); + }); + + await stage.step("enable developer experiments", async (page) => { + await openWalletLink(page, "taler://dev-experiment/clear-api-errors"); + await page + .getByRole("button", { name: "Review developer settings" }) + .click(); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + const enableDeveloperMode = page.getByRole("button", { + name: "Enable developer mode", + }); + if (!(await enableDeveloperMode.isVisible())) { + await page + .locator("summary", { hasText: "Developer settings" }) + .click(); + } + await enableDeveloperMode.click(); + await page + .getByRole("dialog", { name: "Enable wallet-core developer mode?" }) + .getByRole("button", { name: "Enable developer mode" }) + .click(); + await page.getByRole("button", { name: "Apply experiment" }).click(); + await page.getByText(/was applied/).waitFor(); + }); + + await stage.step("show empty-wallet payment error", async (page) => { + const transactionId = "txn:payment:empty-wallet-fixture"; + await applyExperimentUri( + page, + apiResponseExperiment("preparePayForUriV2", { transactionId }), + ); + await applyExperimentUri( + page, + apiResponseExperiment( + "getTransactionById", + { + transactionId, + localTransactionId: "#payment:empty-wallet-fixture", + type: "payment", + timestamp: { t_s: 1_700_000_000 }, + scopes: [], + txState: { + major: "pending", + minor: "claim-proposal", + working: true, + }, + stId: 0, + txActions: ["retry", "suspend", "abort"], + amountRaw: "UNKNOWN:0", + amountEffective: "UNKNOWN:0", + totalRefundRaw: "UNKNOWN:0", + totalRefundEffective: "UNKNOWN:0", + refundPending: "UNKNOWN:0", + refunds: [], + refundQueryActive: false, + }, + 2, + ), + ); + await applyExperimentUri( + page, + apiResponseExperiment( + "getTransactionById", + { + transactionId, + localTransactionId: "#payment:empty-wallet-fixture", + type: "payment", + timestamp: { t_s: 1_700_000_000 }, + scopes: [], + txState: { major: "dialog", minor: "proposed", working: false }, + stId: 1, + txActions: ["abort"], + amountRaw: "TESTKUDOS:10", + amountEffective: "TESTKUDOS:10", + info: { + orderId: "empty-wallet-order", + contractTermsHash: "fixture-hash", + merchant: { name: "Empty Wallet Merchant" }, + summary: "Empty-wallet purchase", + }, + contractTerms: { + summary: "Empty-wallet purchase", + merchant: { name: "Empty Wallet Merchant" }, + order_id: "empty-wallet-order", + amount: "TESTKUDOS:10", + products: [], + }, + totalRefundRaw: "TESTKUDOS:0", + totalRefundEffective: "TESTKUDOS:0", + refunds: [], + refundQueryActive: false, + }, + 3, + ), + ); + await applyExperimentUri( + page, + apiResponseExperiment( + "getChoicesForPayment", + { + choices: [ + { + status: "insufficient-balance", + amountRaw: "TESTKUDOS:10", + balanceDetails: insufficientDetail.insufficientBalanceDetails, + perExchange: {}, + }, + ], + defaultChoiceIndex: 0, + automaticExecution: false, + contractTerms: {}, + }, + 3, + ), + ); + await openWalletLink(page, PAY_URI); + await page.getByRole("button", { name: "Continue" }).click(); + const loading = page.getByText("Loading payment details…", { + exact: true, + }); + await loading.waitFor(); + await page.waitForTimeout(100); + if (!(await loading.isVisible())) { + throw Error( + "payment contract loading state was replaced before the claim completed", + ); + } + if ( + await page + .getByText("The payment contract details are unavailable.", { + exact: true, + }) + .isVisible() + ) { + throw Error("pending payment claim exposed a contract-details error"); + } + await page.getByRole("heading", { name: "Review payment" }).waitFor(); + await page + .locator('[role="alert"]') + .filter({ + hasText: + "The wallet has TESTKUDOS:0 available, which is not enough for TESTKUDOS:10.", + }) + .waitFor(); + await page.getByRole("button", { name: "Withdraw funds" }).waitFor(); + if ( + !(await page + .getByRole("button", { name: "Pay TESTKUDOS:10" }) + .isDisabled()) + ) { + throw Error("empty-wallet payment was still confirmable"); + } + if ( + await page.getByRole("button", { name: "Copy error details" }).count() + ) { + throw Error( + "normal insufficient-balance result exposed fake diagnostics", + ); + } + }); + + await stage.step( + "show insufficient-balance wallet-core error", + async (page) => { + await applyErrorExperiment(page, insufficientDetail); + await assertError( + page, + insufficientDetail, + /TESTKUDOS:0 available, which is not enough for TESTKUDOS:10/i, + ); + }, + ); + + await stage.step("show already-claimed order error", async (page) => { + await applyErrorExperiment(page, claimedDetail); + await assertError( + page, + claimedDetail, + /already claimed by another wallet/i, + ); + await page + .getByText(/return to the merchant and request a new order/i) + .waitFor(); + await page.getByText("futureWalletCoreField", { exact: false }).waitFor(); + }); + + await stage.step("show unexpected wallet-core error", async (page) => { + await applyErrorExperiment(page, unexpectedDetail); + await assertError( + page, + unexpectedDetail, + /wallet database is currently unavailable/i, + ); + }); + }); +} + +runWalletWebUiPwaErrorsTest.suites = ["wallet-webui"]; +runWalletWebUiPwaErrorsTest.timeoutMs = 120_000; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-lifecycle.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-lifecycle.ts @@ -0,0 +1,165 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import path from "node:path"; +import { GlobalTestState } from "../harness/harness.js"; +import { Stage } from "../stagefright/stage.js"; +import { + findWalletWebUiDirectory, + reserveTcpPort, + waitForPwa, +} from "./wallet-web-ui-test-support.js"; + +/** Exercise browser lifecycle hints and a sent mutation losing its worker. */ +export async function runWalletWebUiPwaLifecycleTest(t: GlobalTestState) { + const walletWebUiDirectory = findWalletWebUiDirectory(); + const port = await reserveTcpPort(); + const pwaUrl = `http://127.0.0.1:${port}/`; + t.spawnService( + process.execPath, + [path.join(walletWebUiDirectory, "dev.mjs"), `--port=${port}`], + "wallet-webui-pwa-lifecycle", + ); + await waitForPwa(pwaUrl); + + const stage = await Stage.create("wallet-web-ui-pwa-lifecycle", { + browserType: "chromium", + screenshotDir: path.join(t.testDir, "wallet-webui-lifecycle-screenshots"), + timeoutMs: 45_000, + viewport: { width: 390, height: 844 }, + }); + await stage.page.addInitScript(() => { + const browserGlobal = globalThis as any; + const NativeWorker = browserGlobal.Worker; + const requests: Array<{ operation?: string; args?: unknown }> = []; + browserGlobal.__walletLifecycleRequests = requests; + browserGlobal.__walletInjectMutationDisconnect = false; + browserGlobal.Worker = new Proxy(NativeWorker, { + construct(target, argumentsList) { + const worker = Reflect.construct(target, argumentsList) as any; + const nativePostMessage = worker.postMessage.bind(worker); + worker.postMessage = (message: any, transfer?: unknown[]) => { + if (message?.kind === "request") { + requests.push({ + operation: message.request?.operation, + args: message.request?.args, + }); + } + if (transfer === undefined) nativePostMessage(message); + else nativePostMessage(message, transfer); + if ( + message?.kind === "request" && + message.request?.operation === "addExchange" && + browserGlobal.__walletInjectMutationDisconnect + ) { + browserGlobal.__walletInjectMutationDisconnect = false; + setTimeout(() => { + worker.terminate(); + worker.dispatchEvent( + new browserGlobal.ErrorEvent("error", { + message: "injected lifecycle test disconnect", + }), + ); + }, 0); + } + }; + return worker; + }, + }); + }); + + await stage.run(async () => { + await stage.step("hint initial and changed network availability", async (page) => { + await page.goto(pwaUrl); + await page + .getByRole("heading", { name: /Balances|Welcome to Taler Wallet!/ }) + .waitFor(); + await page.waitForFunction(() => + (globalThis as any).__walletLifecycleRequests.some( + (request: any) => + request.operation === "hintNetworkAvailability" && + request.args?.isNetworkAvailable === true, + ), + ); + await page.context().setOffline(true); + await page.waitForFunction(() => + (globalThis as any).__walletLifecycleRequests.some( + (request: any) => + request.operation === "hintNetworkAvailability" && + request.args?.isNetworkAvailable === false, + ), + ); + await page.context().setOffline(false); + await page.waitForFunction( + () => + (globalThis as any).__walletLifecycleRequests.filter( + (request: any) => + request.operation === "hintNetworkAvailability" && + request.args?.isNetworkAvailable === true, + ).length >= 2, + ); + }); + + await stage.step("resume wallet-core when the document becomes visible", async (page) => { + await page.evaluate(() => { + const browserGlobal = globalThis as any; + Object.defineProperty(browserGlobal.document, "visibilityState", { + configurable: true, + value: "hidden", + }); + browserGlobal.document.dispatchEvent( + new browserGlobal.Event("visibilitychange"), + ); + Object.defineProperty(browserGlobal.document, "visibilityState", { + configurable: true, + value: "visible", + }); + browserGlobal.document.dispatchEvent( + new browserGlobal.Event("visibilitychange"), + ); + }); + await page.waitForFunction(() => + (globalThis as any).__walletLifecycleRequests.some( + (request: any) => request.operation === "hintApplicationResumed", + ), + ); + }); + + await stage.step("report an interrupted mutation without replay", async (page) => { + await page.goto(`${pwaUrl}#/exchanges`); + await page.getByRole("heading", { name: "Exchanges" }).waitFor(); + await page.evaluate(() => { + (globalThis as any).__walletInjectMutationDisconnect = true; + }); + await page + .getByLabel("Exchange URL or Taler add-exchange URI") + .fill("https://exchange.example/"); + await page.getByRole("button", { name: "Add exchange" }).click(); + await page.getByText(/wallet host disappeared: wallet worker crashed/).waitFor(); + await page.getByText(/may have completed, so its outcome is unknown/).waitFor(); + const hostStatus = page.locator('header [role="status"]'); + await hostStatus.waitFor({ state: "attached" }); + t.assertDeepEqual( + (await hostStatus.textContent())?.trim(), + "Wallet host: Disconnected", + ); + await page.waitForTimeout(500); + const attempts = await page.evaluate( + () => + (globalThis as any).__walletLifecycleRequests.filter( + (request: any) => request.operation === "addExchange", + ).length, + ); + t.assertDeepEqual(attempts, 1); + }); + }); +} + +runWalletWebUiPwaLifecycleTest.suites = ["wallet-webui"]; +runWalletWebUiPwaLifecycleTest.timeoutMs = 120_000; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-peer-deposit.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-peer-deposit.ts @@ -0,0 +1,310 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import path from "node:path"; +import type { Page } from "playwright-core"; +import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; +import { + getTestHarnessPaytoForLabel, + GlobalTestState, +} from "../harness/harness.js"; +import { Stage } from "../stagefright/stage.js"; +import { + acknowledgeBestEffortStorageForTest, + confirmWithdrawalWhenReady, + findWalletWebUiDirectory, + reserveTcpPort, + startCorsBridge, + waitForPwa, +} from "./wallet-web-ui-test-support.js"; + +async function readShareUri(page: Page): Promise<string> { + const value = await page + .locator("p.break-all") + .filter({ hasText: /^taler/ }) + .textContent(); + if (!value?.startsWith("taler")) { + throw Error("wallet did not render an actionable peer URI"); + } + return value; +} + +async function redactShareArtifact( + page: Page, + imageName: string, +): Promise<void> { + await page.addStyleTag({ + content: ` + img[alt=${JSON.stringify(imageName)}] { opacity: 0 !important; } + p.break-all { font-size: 0 !important; } + p.break-all::after { + content: "Action URI redacted from test artifact"; + color: var(--color-on-surface, #202124); + font-size: 0.875rem; + } + `, + }); + await page.evaluate("window.scrollTo(0, 0)"); +} + +async function openPwaAction( + page: Page, + pwaUrl: string, + uri: string, +): Promise<void> { + await page.goto(`${pwaUrl}?talerUri=${encodeURIComponent(uri)}#/`); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + await page.getByRole("button", { name: "Continue" }).click(); +} + +async function waitForSpendablePeerFunds( + page: Page, + pwaUrl: string, +): Promise<void> { + await page.goto(pwaUrl); + await page.getByRole("heading", { name: "Balances" }).waitFor(); + await page + .getByRole("heading", { + name: /^Available balance TESTKUDOS:(?:[2-9]|[1-9][0-9])/, + }) + .waitFor(); +} + +/** Exercise peer push, peer pull, account management and deposit in real PWAs. */ +export async function runWalletWebUiPwaPeerDepositTest(t: GlobalTestState) { + const { bankClient } = await createSimpleTestkudosEnvironmentV3(t); + const bankUser = await bankClient.createRandomBankUser(); + bankClient.setAuth(bankUser); + const withdrawal = await bankClient.createWithdrawalOperation( + bankUser.username, + "TESTKUDOS:30", + ); + const bankCorsBridge = await startCorsBridge(bankClient.baseUrl); + const browserWithdrawalUri = withdrawal.taler_withdraw_uri.replace( + new URL(bankClient.baseUrl).host, + new URL(bankCorsBridge.baseUrl).host, + ); + + const walletWebUiDirectory = findWalletWebUiDirectory(); + const senderPort = await reserveTcpPort(); + const receiverPort = await reserveTcpPort(); + const senderUrl = `http://127.0.0.1:${senderPort}/`; + const receiverUrl = `http://127.0.0.1:${receiverPort}/`; + t.spawnService( + process.execPath, + [path.join(walletWebUiDirectory, "dev.mjs"), `--port=${senderPort}`], + "wallet-webui-pwa-sender", + ); + await waitForPwa(senderUrl); + t.spawnService( + process.execPath, + [path.join(walletWebUiDirectory, "dev.mjs"), `--port=${receiverPort}`], + "wallet-webui-pwa-receiver", + ); + await waitForPwa(receiverUrl); + + const stage = await Stage.create("wallet-web-ui-pwa-peer-deposit", { + browserType: "chromium", + screenshotDir: path.join(t.testDir, "wallet-webui-peer-screenshots"), + timeoutMs: 90_000, + viewport: { width: 390, height: 844 }, + }); + let pushUri = ""; + let pullUri = ""; + const depositPayto = getTestHarnessPaytoForLabel("browser-pwa-deposit"); + try { + await stage.run(async () => { + await stage.page.addInitScript(acknowledgeBestEffortStorageForTest); + await stage.step("prepare sender bank withdrawal", async (page) => { + await page.goto( + `${senderUrl}?talerUri=${encodeURIComponent(browserWithdrawalUri)}#/`, + ); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("heading", { name: "Withdraw from your bank" }) + .waitFor(); + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Review withdrawal" }) + .waitFor(); + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Confirm with your bank" }) + .waitFor(); + }); + + await stage.step("fund sender browser wallet", async (page) => { + await confirmWithdrawalWhenReady(() => + bankClient.confirmWithdrawalOperation(bankUser.username, { + withdrawalOperationId: withdrawal.withdrawal_id, + }), + ); + await page.goto(senderUrl); + await page.getByRole("heading", { name: "Balances" }).waitFor(); + await page + .getByRole("heading", { + name: /^Available balance TESTKUDOS:[1-9]/, + }) + .waitFor(); + }); + + await stage.step("create peer push link", async (page) => { + await page.goto(`${senderUrl}#/peer/send?currency=TESTKUDOS`); + await page.getByRole("heading", { name: "Send money" }).waitFor(); + await page.getByLabel("Amount").fill("3"); + await page.getByLabel("Subject").fill("Browser push transfer"); + await page.getByRole("button", { name: "Review" }).click(); + await page.getByText("Recipient gets", { exact: true }).waitFor(); + await page + .getByText("Browser push transfer", { exact: true }) + .waitFor(); + await page.getByRole("button", { name: "Create link" }).click(); + await page.getByRole("heading", { name: "Share money" }).waitFor(); + await page + .getByRole("img", { name: "QR code for the peer payment link" }) + .waitFor(); + pushUri = await readShareUri(page); + await redactShareArtifact(page, "QR code for the peer payment link"); + }); + + await stage.step("receive peer push in second wallet", async (page) => { + await openPwaAction(page, receiverUrl, pushUri); + await page.getByRole("heading", { name: "Receive money" }).waitFor(); + await page + .getByText("Browser push transfer", { exact: true }) + .waitFor(); + await page.getByRole("button", { name: "Receive money" }).click(); + await page + .getByRole("heading", { name: "Browser push transfer" }) + .waitFor(); + await page + .getByRole("heading", { name: "Transaction details" }) + .waitFor(); + await page.getByText("Completed", { exact: true }).waitFor(); + }); + + await stage.step("settle sender peer push", async (page) => { + await page.goto(`${senderUrl}#/transactions`); + await page.getByRole("heading", { name: "History" }).waitFor(); + await page + .getByRole("button", { name: /Browser push transfer/i }) + .click(); + await page + .getByRole("heading", { name: "Browser push transfer" }) + .waitFor(); + await page.getByText("Completed", { exact: true }).waitFor(); + }); + + await stage.step("create peer pull request", async (page) => { + await page.goto(`${receiverUrl}#/peer/request?currency=TESTKUDOS`); + await page.getByRole("heading", { name: "Request money" }).waitFor(); + await page.getByLabel("Currency and payment scope").waitFor(); + await page.getByLabel("Amount (TESTKUDOS)").fill("2"); + await page.getByLabel("Subject").fill("Browser pull request"); + await page.getByRole("button", { name: "Review" }).click(); + await page + .getByText("You receive", { exact: true }) + .waitFor({ timeout: 15_000 }); + await page + .getByText("Browser pull request", { exact: true }) + .waitFor({ timeout: 15_000 }); + await page.getByRole("button", { name: "Create link" }).click(); + await page.getByRole("heading", { name: "Share request" }).waitFor(); + await page + .getByRole("img", { name: "QR code for the peer request link" }) + .waitFor(); + pullUri = await readShareUri(page); + await redactShareArtifact(page, "QR code for the peer request link"); + }); + + await stage.step("pay peer pull from sender wallet", async (page) => { + await waitForSpendablePeerFunds(page, senderUrl); + await openPwaAction(page, senderUrl, pullUri); + await page.getByRole("heading", { name: "Pay request" }).waitFor(); + await page.getByText("Browser pull request", { exact: true }).waitFor(); + await page.getByRole("button", { name: "Pay", exact: true }).click(); + await page + .getByRole("heading", { name: "Browser pull request" }) + .waitFor(); + await page + .getByRole("heading", { name: "Transaction details" }) + .waitFor(); + await page.getByText("Completed", { exact: true }).waitFor(); + }); + + await stage.step("save browser deposit account", async (page) => { + await page.goto(`${senderUrl}#/accounts`); + await page.getByRole("heading", { name: "Bank accounts" }).waitFor(); + await page.getByRole("button", { name: /Add bank account/ }).click(); + await page.getByLabel("Account type").selectOption("advanced"); + await page.getByLabel("Label").fill("Browser deposit account"); + await page.getByLabel("Payto URI", { exact: true }).fill(depositPayto); + await page.getByLabel("Currencies (optional)").fill("TESTKUDOS"); + await page.getByRole("button", { name: "Add account" }).click(); + await page + .getByRole("heading", { name: "Browser deposit account" }) + .waitFor(); + }); + + await stage.step("create deposit from saved account", async (page) => { + await waitForSpendablePeerFunds(page, senderUrl); + await page.goto(`${senderUrl}#/deposit?currency=TESTKUDOS`); + await page + .getByRole("heading", { name: "Deposit to a bank account" }) + .waitFor(); + await page + .getByLabel("Bank account") + .selectOption({ label: "Browser deposit account" }); + const currencySelector = page.getByLabel("Currency"); + if ( + (await currencySelector.evaluate((element) => element.tagName)) !== + "SELECT" + ) { + throw Error("deposit currency control is not a dropdown"); + } + if ( + !( + await currencySelector.locator("option:checked").textContent() + )?.includes("TESTKUDOS") + ) { + throw Error( + "deposit currency dropdown did not select the compatible wallet scope", + ); + } + await page.getByLabel("Amount").fill("1"); + await page.getByRole("button", { name: "Review deposit" }).click(); + await page + .getByText("Bank account receives", { exact: true }) + .waitFor(); + await page + .getByText("browser-pwa-deposit", { exact: true }) + .first() + .waitFor(); + if (await page.getByText(depositPayto, { exact: true }).count()) { + throw Error("deposit review exposed the raw payto URI"); + } + await page.getByRole("button", { name: "Confirm deposit" }).click(); + await page + .getByRole("heading", { name: "Transaction details" }) + .waitFor(); + await page.getByText("Account", { exact: true }).waitFor(); + }); + }); + } finally { + await bankCorsBridge.close(); + } +} + +runWalletWebUiPwaPeerDepositTest.suites = ["wallet-webui"]; +runWalletWebUiPwaPeerDepositTest.timeoutMs = 240_000; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-peer-tos.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-peer-tos.ts @@ -0,0 +1,226 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import fs from "node:fs"; +import path from "node:path"; +import { + AbsoluteTime, + Duration, + TransactionMajorState, + TransactionMinorState, + TransactionType, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { + createSimpleTestkudosEnvironmentV3, + withdrawViaBankV3, +} from "../harness/environments.js"; +import { GlobalTestState } from "../harness/harness.js"; +import { Stage } from "../stagefright/stage.js"; +import { + acknowledgeBestEffortStorageForTest, + findWalletWebUiDirectory, + reserveTcpPort, + waitForPwa, +} from "./wallet-web-ui-test-support.js"; + +/** Prove that a prepared peer request survives exchange ToS acceptance. */ +export async function runWalletWebUiPwaPeerTosTest(t: GlobalTestState) { + const termsDirectory = path.join(t.testDir, "wallet-webui-peer-terms"); + const englishTermsDirectory = path.join(termsDirectory, "en"); + fs.mkdirSync(englishTermsDirectory, { recursive: true }); + fs.writeFileSync( + path.join(englishTermsDirectory, "v1.txt"), + "Local exchange terms for the wallet-webui peer request test.\n", + ); + const { exchange, walletClient, bankClient } = + await createSimpleTestkudosEnvironmentV3(t, undefined, { + additionalExchangeConfig: (localExchange) => { + localExchange.changeConfig((config) => { + config.setString("exchange", "terms_etag", "v1"); + config.setString("exchange", "terms_dir", termsDirectory); + }); + }, + }); + await walletClient.call(WalletApiOperation.AddExchange, { + exchangeBaseUrl: exchange.baseUrl, + }); + await walletClient.call(WalletApiOperation.SetExchangeTosAccepted, { + exchangeBaseUrl: exchange.baseUrl, + }); + const senderWithdrawal = await withdrawViaBankV3(t, { + walletClient, + bankClient, + exchange, + amount: "TESTKUDOS:10", + }); + await senderWithdrawal.withdrawalFinishedCond; + const peerPush = await walletClient.call( + WalletApiOperation.InitiatePeerPushDebit, + { + partialContractTerms: { + amount: "TESTKUDOS:2", + summary: "First incoming browser payment", + purse_expiration: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ days: 2 }), + ), + ), + }, + }, + ); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: peerPush.transactionId, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Ready, + }, + }); + const peerPushTransaction = await walletClient.call( + WalletApiOperation.GetTransactionById, + { transactionId: peerPush.transactionId }, + ); + if ( + peerPushTransaction.type !== TransactionType.PeerPushDebit || + !peerPushTransaction.talerUri + ) { + throw Error("sender did not produce a peer-push URI"); + } + const walletWebUiDirectory = findWalletWebUiDirectory(); + const port = await reserveTcpPort(); + const receiverPort = await reserveTcpPort(); + const pwaUrl = `http://127.0.0.1:${port}/`; + const receiverUrl = `http://127.0.0.1:${receiverPort}/`; + t.spawnService( + process.execPath, + [path.join(walletWebUiDirectory, "dev.mjs"), `--port=${port}`], + "wallet-webui-pwa-peer-tos", + ); + await waitForPwa(pwaUrl); + t.spawnService( + process.execPath, + [path.join(walletWebUiDirectory, "dev.mjs"), `--port=${receiverPort}`], + "wallet-webui-pwa-peer-tos-receiver", + ); + await waitForPwa(receiverUrl); + + const stage = await Stage.create("wallet-web-ui-pwa-peer-tos", { + browserType: "chromium", + screenshotDir: path.join(t.testDir, "wallet-webui-peer-tos-screenshots"), + timeoutMs: 90_000, + viewport: { width: 390, height: 844 }, + }); + await stage.run(async () => { + await stage.page.addInitScript(acknowledgeBestEffortStorageForTest); + await stage.step( + "accept terms before first incoming peer payment", + async (page) => { + await page.goto( + `${receiverUrl}?talerUri=${encodeURIComponent(peerPushTransaction.talerUri!)}#/`, + ); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("heading", { name: "Receive money" }).waitFor(); + await page + .getByText( + "Review and accept this exchange's terms of service before receiving this payment.", + { exact: true }, + ) + .waitFor(); + const receive = page.getByRole("button", { name: "Receive money" }); + if (!(await receive.isDisabled())) { + throw Error( + "incoming peer payment was not blocked on exchange terms", + ); + } + await page.getByRole("button", { name: "Review terms" }).click(); + await page.getByRole("heading", { name: "Terms of service" }).waitFor(); + await page.getByRole("button", { name: "Accept terms" }).click(); + await page.getByRole("heading", { name: "Receive money" }).waitFor(); + await page + .getByText("First incoming browser payment", { exact: true }) + .waitFor(); + const resumedReceive = page.getByRole("button", { + name: "Receive money", + }); + if (await resumedReceive.isDisabled()) { + throw Error( + "incoming peer payment stayed blocked after accepting terms", + ); + } + await resumedReceive.click(); + await page + .getByRole("heading", { name: "First incoming browser payment" }) + .waitFor(); + await page.getByText("Completed", { exact: true }).waitFor(); + }, + ); + + await stage.step("add exchange with unaccepted terms", async (page) => { + await page.goto(`${pwaUrl}#/exchanges`); + await page.getByRole("heading", { name: "Exchanges" }).waitFor(); + await page + .getByLabel("Exchange URL or Taler add-exchange URI") + .fill(exchange.baseUrl); + await page.getByRole("button", { name: "Add exchange" }).click(); + await page.getByRole("heading", { name: "Exchange details" }).waitFor(); + await page.getByText("proposed", { exact: true }).waitFor(); + }); + + await stage.step( + "block prepared request on exchange terms", + async (page) => { + await page.goto(`${pwaUrl}#/peer/request?currency=TESTKUDOS`); + await page.getByRole("heading", { name: "Request money" }).waitFor(); + await page.getByLabel("Currency and payment scope").waitFor(); + await page.getByLabel("Amount (TESTKUDOS)").fill("2"); + await page.getByLabel("Subject").fill("Terms-gated browser request"); + await page.getByRole("button", { name: "Review" }).click(); + await page + .getByText( + "Review and accept this exchange's terms of service before creating this request.", + { exact: true }, + ) + .waitFor(); + const create = page.getByRole("button", { name: "Create link" }); + if (!(await create.isDisabled())) { + throw Error("peer request was not blocked on exchange terms"); + } + }, + ); + + await stage.step( + "accept terms and resume the same request", + async (page) => { + await page.getByRole("button", { name: "Review terms" }).click(); + await page.getByRole("heading", { name: "Terms of service" }).waitFor(); + await page.getByRole("button", { name: "Accept terms" }).click(); + await page.getByRole("heading", { name: "Request money" }).waitFor(); + await page + .getByText("Terms-gated browser request", { exact: true }) + .waitFor(); + await page.getByText("You receive", { exact: true }).waitFor(); + const create = page.getByRole("button", { name: "Create link" }); + await create.waitFor(); + if (await create.isDisabled()) { + throw Error("peer request stayed blocked after accepting terms"); + } + await create.click(); + await page.getByRole("heading", { name: "Share request" }).waitFor(); + await page + .getByRole("img", { name: "QR code for the peer request link" }) + .waitFor(); + }, + ); + }); +} + +runWalletWebUiPwaPeerTosTest.suites = ["wallet-webui"]; +runWalletWebUiPwaPeerTosTest.timeoutMs = 240_000; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-sqlite.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-sqlite.ts @@ -0,0 +1,1241 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import assert from "node:assert"; +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import path from "node:path"; +import type { Page } from "playwright-core"; +import { + TALER_WALLET_MAIN_DB_NAME, + TALER_WALLET_META_DB_NAME, +} from "@gnu-taler/taler-wallet-core"; +import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; +import { GlobalTestState } from "../harness/harness.js"; +import { Stage } from "../stagefright/stage.js"; +import { WebDriverStage } from "../stagefright/webdriver-stage.js"; + +async function createPwaStage( + scenarioName: string, + options: NonNullable<Parameters<typeof Stage.create>[1]>, +) { + return options.browserType === "firefox" + ? WebDriverStage.create(scenarioName, options) + : Stage.create(scenarioName, options); +} + +function findWalletWebUiDirectory(): string { + const candidates = [ + process.env.TALER_WALLET_WEBUI_DIR, + path.resolve(process.cwd(), "packages/wallet-webui"), + path.resolve(process.cwd(), "../wallet-webui"), + ].filter((candidate): candidate is string => Boolean(candidate)); + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, "dev.mjs"))) return candidate; + } + throw Error( + "wallet-webui sources not found; run from the wallet-core checkout or set TALER_WALLET_WEBUI_DIR", + ); +} + +async function reserveTcpPort(): Promise<number> { + const server = net.createServer(); + await new Promise<void>((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw Error("could not reserve a TCP port"); + } + await new Promise<void>((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + return address.port; +} + +async function waitForPwa(url: string, timeoutMs = 90_000): Promise<void> { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const status = await new Promise<number | undefined>( + (resolve, reject) => { + const request = http.get(url, (response) => { + response.resume(); + resolve(response.statusCode); + }); + request.once("error", reject); + }, + ); + if (status && status >= 200 && status < 300) return; + lastError = Error(`HTTP ${status ?? "unknown"}`); + } catch (cause) { + lastError = cause; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw Error(`wallet-webui PWA did not become ready: ${lastError}`); +} + +async function injectInterruptedStorageState(page: Page): Promise<void> { + await page.evaluate(async () => { + const browser = globalThis as any; + const database = await new Promise<any>((resolve, reject) => { + const request = browser.indexedDB.open("gnu-taler-wallet-control", 1); + request.onupgradeneeded = () => + request.result.createObjectStore("metadata"); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + await new Promise<void>((resolve, reject) => { + const transaction = database.transaction("metadata", "readwrite"); + const store = transaction.objectStore("metadata"); + const read = store.get("active"); + read.onsuccess = () => { + const current = read.result ?? { + schemaVersion: 1, + active: { backend: "indexeddb", generation: 0 }, + migration: { phase: "idle" }, + }; + const migration = { + phase: "converting", + direction: "forward", + source: current.active, + target: { backend: "sqlite", generation: 1 }, + startedAt: Date.now(), + }; + store.put({ ...current, migration }, "active"); + }; + read.onerror = () => reject(read.error); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); + database.close(); + }); +} + +async function armStorageFault( + page: Page, + fault: + | "target-write-quota" + | "target-verification" + | "post-activation-reopen", +): Promise<void> { + await page.evaluate(async (requestedFault) => { + const browser = globalThis as any; + const database = await new Promise<any>((resolve, reject) => { + const request = browser.indexedDB.open("gnu-taler-wallet-test-faults", 1); + request.onupgradeneeded = () => + request.result.createObjectStore("faults"); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + await new Promise<void>((resolve, reject) => { + const transaction = database.transaction("faults", "readwrite"); + transaction.objectStore("faults").put(true, requestedFault); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); + database.close(); + }, fault); +} + +async function expireStorageCleanup(page: Page): Promise<void> { + await page.evaluate(async () => { + const browser = globalThis as any; + const database = await new Promise<any>((resolve, reject) => { + const request = browser.indexedDB.open("gnu-taler-wallet-control", 1); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + await new Promise<void>((resolve, reject) => { + const transaction = database.transaction("metadata", "readwrite"); + const store = transaction.objectStore("metadata"); + const read = store.get("active"); + read.onsuccess = () => { + const current = read.result; + if (current?.migration?.phase !== "cleanup-pending") { + transaction.abort(); + reject(new Error("storage cleanup is not pending")); + return; + } + store.put( + { + ...current, + migration: { + ...current.migration, + backupExpiresAt: Date.now() - 1, + }, + }, + "active", + ); + }; + read.onerror = () => reject(read.error); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); + database.close(); + }); +} + +async function deleteStorageControlDatabase(page: Page): Promise<void> { + await page.evaluate(async () => { + const browser = globalThis as any; + await new Promise<void>((resolve, reject) => { + const request = browser.indexedDB.deleteDatabase( + "gnu-taler-wallet-control", + ); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + request.onblocked = () => + reject(new Error("storage control database deletion was blocked")); + }); + }); +} + +async function clearCompleteOriginStorage( + page: Page, + pwaUrl: string, +): Promise<{ deletedDatabases: string[]; deletedOpfsEntries: string[] }> { + await page.goto(`${pwaUrl}__wallet_webui_test_empty.html`); + assert.equal(await page.title(), "Wallet storage test"); + return page.evaluate(async () => { + const browser = globalThis as any; + for (const registration of await browser.navigator.serviceWorker.getRegistrations()) { + await registration.unregister(); + } + for (const key of await browser.caches.keys()) { + await browser.caches.delete(key); + } + browser.localStorage.clear(); + browser.sessionStorage.clear(); + + const deletedDatabases: string[] = []; + for (const database of await browser.indexedDB.databases()) { + if (!database.name) continue; + await new Promise<void>((resolve, reject) => { + const request = browser.indexedDB.deleteDatabase(database.name); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + request.onblocked = () => + reject(new Error(`database deletion blocked: ${database.name}`)); + }); + deletedDatabases.push(database.name); + } + + const deletedOpfsEntries: string[] = []; + const root = await browser.navigator.storage.getDirectory(); + let lastError: unknown; + for (let attempt = 0; attempt < 20; attempt++) { + lastError = undefined; + const entries: string[] = []; + for await (const entry of root.values()) entries.push(entry.name); + if (entries.length === 0) break; + for (const name of entries) { + try { + await root.removeEntry(name, { recursive: true }); + if (!deletedOpfsEntries.includes(name)) { + deletedOpfsEntries.push(name); + } + } catch (error) { + lastError = error; + } + } + if (lastError) { + await new Promise((resolve) => browser.setTimeout(resolve, 100)); + } + } + const remainingOpfsEntries: string[] = []; + for await (const entry of root.values()) { + remainingOpfsEntries.push(entry.name); + } + if (remainingOpfsEntries.length > 0) { + throw ( + lastError ?? + new Error( + `OPFS entries survived clearing: ${remainingOpfsEntries.join(", ")}`, + ) + ); + } + if ((await browser.indexedDB.databases()).length > 0) { + throw new Error("IndexedDB databases survived complete origin clearing"); + } + return { deletedDatabases, deletedOpfsEntries }; + }); +} + +async function readStoragePhase(page: Page): Promise<string | undefined> { + return page.evaluate(async () => { + const browser = globalThis as any; + const database = await new Promise<any>((resolve, reject) => { + const request = browser.indexedDB.open("gnu-taler-wallet-control", 1); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const phase = await new Promise<string | undefined>((resolve, reject) => { + const transaction = database.transaction("metadata", "readonly"); + const request = transaction.objectStore("metadata").get("active"); + request.onsuccess = () => resolve(request.result?.migration?.phase); + request.onerror = () => reject(request.error); + }); + database.close(); + return phase; + }); +} + +async function reloadUntilCleanupFinishes(page: Page): Promise<void> { + let phase: string | undefined; + for (let attempt = 0; attempt < 6; attempt++) { + await page.reload(); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + phase = await readStoragePhase(page); + if (phase === "idle") return; + await page.waitForTimeout(150); + } + assert.equal(phase, "idle"); +} + +async function requestCleanupFromCurrentLeader(page: Page): Promise<void> { + await page.evaluate(() => { + (globalThis as any).location.hash = "#/"; + }); + await page.getByRole("heading", { name: "Balances" }).waitFor(); + await page.evaluate(() => { + (globalThis as any).location.hash = "#/settings"; + }); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + if ((await readStoragePhase(page)) !== "idle") { + await reloadUntilCleanupFinishes(page); + } +} + +async function runPrivateBrowsingGate( + t: GlobalTestState, + pwaUrl: string, + browserType: "chromium" | "firefox", +): Promise<void> { + const stage = await createPwaStage( + `wallet-web-ui-pwa-${browserType}-sqlite-private`, + { + browserType, + privateBrowsing: true, + screenshotDir: path.join( + t.testDir, + "wallet-webui-sqlite-private-screenshots", + ), + timeoutMs: 90_000, + viewport: { width: 390, height: 844 }, + }, + ); + await stage.run(async () => { + await stage.step("keep the private wallet on IndexedDB", async (page) => { + await page.goto(`${pwaUrl}#/settings`); + await page.waitForFunction(() => { + const browser = globalThis as any; + const heading = browser.document.querySelector("h1")?.textContent; + const statuses = [ + ...browser.document.querySelectorAll('[role="status"]'), + ].map((element: any) => element.textContent?.trim()); + return ( + heading === "Storage recovery required" || + statuses.includes("Wallet host: Connected") + ); + }); + const before = await page.evaluate(async () => { + const storage = (globalThis as any).navigator.storage; + let opfsUsable = false; + try { + await storage.getDirectory(); + opfsUsable = true; + } catch { + // Firefox private browsing exposes OPFS but denies opening it. + } + return { + opfsApi: typeof storage.getDirectory === "function", + opfsUsable, + persisted: await storage.persisted(), + persistenceRequest: await storage.persist(), + }; + }); + assert.equal(before.persisted, false); + assert.equal(before.persistenceRequest, false); + assert.equal(before.opfsApi, true); + + if ( + (await page + .getByRole("heading", { name: "Storage recovery required" }) + .count()) > 0 + ) { + assert.equal(before.opfsUsable, false); + await page + .getByText( + "Starting an empty wallet is disabled until every storage backend can be checked safely.", + { exact: true }, + ) + .waitFor(); + assert.equal( + await page + .getByRole("button", { name: "Start a new IndexedDB wallet" }) + .count(), + 0, + ); + fs.writeFileSync( + path.join( + t.testDir, + `wallet-webui-${browserType}-sqlite-private.json`, + ), + `${JSON.stringify( + { + browserType, + opfsApiAvailable: before.opfsApi, + opfsUsable: before.opfsUsable, + persistedBeforeRequest: before.persisted, + persistenceRequest: before.persistenceRequest, + recoveryRequired: true, + }, + null, + 2, + )}\n`, + ); + return; + } + await page.getByRole("heading", { name: "Settings" }).waitFor(); + + if (before.opfsUsable) { + await page.getByRole("switch", { name: /Use SQLite storage/ }).click(); + await page + .getByText( + "Durable browser storage was not granted. Migration was not started.", + { exact: true }, + ) + .waitFor(); + } else { + assert.equal( + await page + .getByRole("switch", { name: /Use SQLite storage/ }) + .count(), + 0, + ); + await page + .getByText( + "SQLite storage is unavailable in this browser or build. The wallet continues to use IndexedDB.", + { exact: true }, + ) + .waitFor(); + } + await page + .locator("dd") + .filter({ hasText: /^IndexedDB$/ }) + .waitFor(); + await page.locator("dd").filter({ hasText: /^0$/ }).waitFor(); + await page + .locator("dd") + .filter({ hasText: /^Idle$/ }) + .waitFor(); + const persistedAfterRequest = await page.evaluate(() => + (globalThis as any).navigator.storage.persisted(), + ); + assert.equal(persistedAfterRequest, false); + fs.writeFileSync( + path.join(t.testDir, `wallet-webui-${browserType}-sqlite-private.json`), + `${JSON.stringify( + { + browserType, + opfsApiAvailable: before.opfsApi, + opfsUsable: before.opfsUsable, + persistedBeforeRequest: before.persisted, + persistenceRequest: before.persistenceRequest, + persistedAfterRequest, + activeBackend: "indexeddb", + migrationPhase: "idle", + }, + null, + 2, + )}\n`, + ); + }); + }); +} + +async function runRealPersistencePolicyGate( + t: GlobalTestState, + pwaUrl: string, + browserType: "chromium" | "firefox", +): Promise<void> { + const stage = await createPwaStage( + `wallet-web-ui-pwa-${browserType}-real-persistence-policy`, + { + browserType, + screenshotDir: path.join( + t.testDir, + "wallet-webui-real-persistence-policy-screenshots", + ), + timeoutMs: 90_000, + viewport: { width: 390, height: 844 }, + }, + ); + await stage.run(async () => { + await stage.step( + "record the browser's real persistence decision", + async (page) => { + await page.goto(`${pwaUrl}#/settings`); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + const before = await page.evaluate(async () => ({ + persisted: await (globalThis as any).navigator.storage.persisted(), + standalone: (globalThis as any).matchMedia( + "(display-mode: standalone)", + ).matches, + userAgent: (globalThis as any).navigator.userAgent, + })); + if (!before.persisted) { + await page + .getByRole("button", { name: "Protect wallet data" }) + .click(); + await page.waitForTimeout(500); + } + const after = await page.evaluate(async () => ({ + persisted: await (globalThis as any).navigator.storage.persisted(), + databases: (await (globalThis as any).indexedDB.databases()).map( + (database: any) => database.name, + ), + })); + await page + .locator("dd") + .filter({ hasText: /^IndexedDB$/ }) + .waitFor(); + await page.locator("dd").filter({ hasText: /^0$/ }).waitFor(); + assert.ok( + after.databases.includes("gnu-taler-wallet-control"), + "the real policy check unexpectedly removed IndexedDB storage", + ); + fs.writeFileSync( + path.join( + t.testDir, + `wallet-webui-${browserType}-real-persistence-policy.json`, + ), + `${JSON.stringify( + { + browserType, + installedOrStandalone: before.standalone, + userAgent: before.userAgent, + persistedBeforeRequest: before.persisted, + persistedAfterRequest: after.persisted, + requestOutcome: after.persisted ? "granted" : "denied-or-pending", + activeBackend: "indexeddb", + }, + null, + 2, + )}\n`, + ); + }, + ); + }); +} + +async function runChromiumConstrainedQuotaGate( + t: GlobalTestState, + pwaUrl: string, + exchangeBaseUrl: string, +): Promise<void> { + const stage = await Stage.create( + "wallet-web-ui-pwa-chromium-sqlite-constrained-quota", + { + browserType: "chromium", + screenshotDir: path.join( + t.testDir, + "wallet-webui-sqlite-constrained-quota-screenshots", + ), + timeoutMs: 90_000, + viewport: { width: 390, height: 844 }, + }, + ); + await stage.run(async () => { + await stage.page.addInitScript(() => { + const browserNavigator = (globalThis as any).navigator; + Object.defineProperty(browserNavigator.storage, "persist", { + configurable: true, + value: async () => true, + }); + Object.defineProperty(browserNavigator.storage, "persisted", { + configurable: true, + value: async () => true, + }); + }); + const cdp = await stage.page.context().newCDPSession(stage.page); + const origin = new URL(pwaUrl).origin; + let quotaOverridden = false; + try { + await stage.step( + "populate the quota-constrained IndexedDB wallet", + async (page) => { + await page.goto(`${pwaUrl}#/exchanges`); + await page.getByRole("heading", { name: "Exchanges" }).waitFor(); + await page + .getByLabel("Exchange URL or Taler add-exchange URI") + .fill(exchangeBaseUrl); + await page.getByRole("button", { name: "Add exchange" }).click(); + await page + .getByRole("heading", { name: "Exchange details" }) + .waitFor(); + await page.getByText("TESTKUDOS", { exact: true }).waitFor(); + }, + ); + + await stage.step( + "reject SQLite when the browser quota is exhausted", + async (page) => { + const before = await cdp.send("Storage.getUsageAndQuota", { origin }); + const constrainedQuota = Math.ceil(before.usage) + 64 * 1024; + await cdp.send("Storage.overrideQuotaForOrigin", { + origin, + quotaSize: constrainedQuota, + }); + quotaOverridden = true; + + await page.goto(`${pwaUrl}#/settings`); + await page + .getByRole("switch", { name: /Use SQLite storage/ }) + .click(); + const alert = page.getByRole("alert"); + await alert.waitFor(); + assert.match( + await alert.innerText(), + /quota|storage|disk|database|I\/O/i, + ); + await page + .locator("dd") + .filter({ hasText: /^IndexedDB$/ }) + .waitFor(); + await page.locator("dd").filter({ hasText: /^0$/ }).waitFor(); + const metadata = await page.evaluate(async () => { + const browser = globalThis as any; + const database = await new Promise<any>((resolve, reject) => { + const request = browser.indexedDB.open( + "gnu-taler-wallet-control", + 1, + ); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const value = await new Promise<any>((resolve, reject) => { + const transaction = database.transaction("metadata", "readonly"); + const request = transaction.objectStore("metadata").get("active"); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + database.close(); + return value; + }); + assert.deepEqual(metadata.active, { + backend: "indexeddb", + generation: 0, + }); + assert.equal(metadata.migration.phase, "idle"); + assert.deepEqual(metadata.migration.target, { + backend: "sqlite", + generation: 1, + }); + assert.match( + metadata.migration.error ?? "", + /quota|storage|disk|database|I\/O/i, + ); + + const after = await cdp.send("Storage.getUsageAndQuota", { origin }); + fs.writeFileSync( + path.join( + t.testDir, + "wallet-webui-chromium-sqlite-constrained-quota.json", + ), + `${JSON.stringify( + { + usageBeforeBytes: before.usage, + browserQuotaBeforeBytes: before.quota, + constrainedQuotaBytes: constrainedQuota, + usageAfterFailureBytes: after.usage, + active: metadata.active, + migration: metadata.migration, + }, + null, + 2, + )}\n`, + ); + }, + ); + + await cdp.send("Storage.overrideQuotaForOrigin", { origin }); + quotaOverridden = false; + await stage.step( + "reopen the source without a stranded SQLite generation", + async (page) => { + await deleteStorageControlDatabase(page); + await page.reload(); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + assert.equal( + await page + .getByRole("heading", { name: "Storage recovery required" }) + .count(), + 0, + ); + await page + .locator("dd") + .filter({ hasText: /^IndexedDB$/ }) + .waitFor(); + await page.locator("dd").filter({ hasText: /^0$/ }).waitFor(); + await page.goto(`${pwaUrl}#/exchanges`); + await page.getByText("TESTKUDOS", { exact: true }).waitFor(); + }, + ); + } finally { + if (quotaOverridden) { + await cdp.send("Storage.overrideQuotaForOrigin", { origin }); + } + await cdp.detach(); + } + }); +} + +async function runPwaSqliteTest( + t: GlobalTestState, + browserType: "chromium" | "firefox", +): Promise<void> { + const { exchange } = await createSimpleTestkudosEnvironmentV3(t); + const walletWebUiDirectory = findWalletWebUiDirectory(); + const port = await reserveTcpPort(); + const pwaUrl = `http://127.0.0.1:${port}/`; + t.spawnService( + process.execPath, + [ + path.join(walletWebUiDirectory, "dev.mjs"), + `--port=${port}`, + "--test-pwa-sqlite", + ], + "wallet-webui-pwa-sqlite", + ); + await waitForPwa(pwaUrl); + // Every development host rebuilds the shared package dist directory. Keep + // the artifact measurements from this SQLite build before a later harness + // host can replace dist with an ordinary PWA or extension build. + const sqliteWorkerBytes = fs.statSync( + path.join(walletWebUiDirectory, "dist/pwa/sqlite-opfs-worker.js"), + ).size; + const sqliteWasmBytes = fs.statSync( + path.join(walletWebUiDirectory, "dist/pwa/sqlite3.wasm"), + ).size; + await runRealPersistencePolicyGate(t, pwaUrl, browserType); + await runPrivateBrowsingGate(t, pwaUrl, browserType); + if (browserType === "chromium") { + await runChromiumConstrainedQuotaGate(t, pwaUrl, exchange.baseUrl); + } + + const stage = await createPwaStage( + `wallet-web-ui-pwa-${browserType}-sqlite`, + { + browserType, + screenshotDir: path.join(t.testDir, "wallet-webui-sqlite-screenshots"), + timeoutMs: 90_000, + viewport: { width: 390, height: 844 }, + }, + ); + await stage.run(async () => { + await stage.page.addInitScript(() => { + const browserNavigator = (globalThis as any).navigator; + if ( + (globalThis as any).sessionStorage.getItem( + "wallet-webui-test-disable-opfs", + ) === "yes" + ) { + Object.defineProperty(browserNavigator.storage, "getDirectory", { + configurable: true, + value: undefined, + }); + } + Object.defineProperty(browserNavigator.storage, "persist", { + configurable: true, + value: async () => true, + }); + Object.defineProperty(browserNavigator.storage, "persisted", { + configurable: true, + value: async () => true, + }); + }); + await stage.step("reopen SQLite after a worker crash", async (page) => { + await page.goto(pwaUrl); + const probe = await page.evaluate( + async ({ workerUrl, generation }) => { + const WorkerConstructor = (globalThis as any).Worker; + const call = ( + worker: any, + kind: string, + value?: string, + ): Promise<any> => + new Promise((resolve, reject) => { + const id = globalThis.crypto.randomUUID(); + const timeout = globalThis.setTimeout( + () => reject(new Error(`SQLite probe ${kind} timed out`)), + kind === "benchmark" ? 180_000 : 20_000, + ); + const listener = (event: any) => { + if (event.data?.id !== id) return; + worker.removeEventListener("message", listener); + globalThis.clearTimeout(timeout); + if (event.data.ok) resolve(event.data.result); + else reject(new Error(event.data.error)); + }; + worker.addEventListener("message", listener); + worker.postMessage({ id, generation, kind, value }); + }); + + const first = new WorkerConstructor(workerUrl, { type: "module" }); + const opened = await call(first, "open"); + await call(first, "write", "survived-worker-termination"); + first.terminate(); + + let second: any; + let reopened: any; + let lastError: unknown; + for (let attempt = 0; attempt < 20; attempt++) { + second = new WorkerConstructor(workerUrl, { type: "module" }); + try { + reopened = await call(second, "open"); + lastError = undefined; + break; + } catch (error) { + lastError = error; + second.terminate(); + await new Promise((resolve) => + globalThis.setTimeout(resolve, 100), + ); + } + } + if (lastError || !second) { + throw lastError ?? new Error("SQLite reopen failed"); + } + const row = await call(second, "read"); + await call(second, "destroy"); + second.terminate(); + + const conversionWorker = new WorkerConstructor(workerUrl, { + type: "module", + }); + const conversion = await call( + conversionWorker, + "roundtrip", + undefined, + ); + conversionWorker.terminate(); + const benchmarkWorker = new WorkerConstructor(workerUrl, { + type: "module", + }); + const benchmark = await call(benchmarkWorker, "benchmark", undefined); + benchmarkWorker.terminate(); + return { opened, reopened, row, conversion, benchmark }; + }, + { + workerUrl: `${pwaUrl}sqlite-opfs-worker.js`, + generation: globalThis.crypto.randomUUID(), + }, + ); + assert.equal(probe.row?.value, "survived-worker-termination"); + assert.equal(probe.opened.sqliteVersion, probe.reopened.sqliteVersion); + assert.equal(probe.opened.capacity, 6); + assert.equal(probe.conversion.sqliteValue?.value, 42); + assert.equal(probe.conversion.idbValue?.value, 42); + assert.deepEqual(probe.conversion.active, { + backend: "indexeddb", + generation: 2, + }); + assert.ok(probe.conversion.forwardRecords > 0); + assert.equal( + probe.conversion.forwardRecords, + probe.conversion.rollbackRecords, + ); + assert.equal(probe.benchmark.options.numCoins, 20_000); + assert.ok(probe.benchmark.migrationRecords >= 20_000); + assert.equal( + probe.benchmark.idb.queries.length, + probe.benchmark.sqlite.queries.length, + ); + for (let index = 0; index < probe.benchmark.idb.queries.length; index++) { + assert.equal( + probe.benchmark.idb.queries[index].rows, + probe.benchmark.sqlite.queries[index].rows, + ); + } + const artifact = { + browserType, + ...probe, + workerBytes: sqliteWorkerBytes, + wasmBytes: sqliteWasmBytes, + }; + fs.writeFileSync( + path.join(t.testDir, `wallet-webui-${browserType}-sqlite-test.json`), + `${JSON.stringify(artifact, null, 2)}\n`, + ); + }); + + await stage.step("populate the live IndexedDB wallet", async (page) => { + await page.goto(`${pwaUrl}#/exchanges`); + await page.getByRole("heading", { name: "Exchanges" }).waitFor(); + await page + .getByLabel("Exchange URL or Taler add-exchange URI") + .fill(exchange.baseUrl); + await page.getByRole("button", { name: "Add exchange" }).click(); + await page.getByRole("heading", { name: "Exchange details" }).waitFor(); + await page.getByText("TESTKUDOS", { exact: true }).waitFor(); + }); + + await stage.step( + "recover the source after target quota exhaustion", + async (page) => { + await page.goto(`${pwaUrl}#/settings`); + await armStorageFault(page, "target-write-quota"); + await page.getByRole("switch", { name: /Use SQLite storage/ }).click(); + await page + .getByText( + "Browser storage quota was exhausted during wallet conversion.", + { exact: true }, + ) + .waitFor(); + await page + .locator("dd") + .filter({ hasText: /^IndexedDB$/ }) + .waitFor(); + await page.locator("dd").filter({ hasText: /^0$/ }).waitFor(); + assert.equal(await readStoragePhase(page), "idle"); + await page.goto(`${pwaUrl}#/exchanges`); + await page.getByText("TESTKUDOS", { exact: true }).waitFor(); + }, + ); + + await stage.step( + "discard a target that fails live verification", + async (page) => { + await page.goto(`${pwaUrl}#/settings`); + await armStorageFault(page, "target-verification"); + await page.getByRole("switch", { name: /Use SQLite storage/ }).click(); + await page + .getByText("injected target verification failure", { exact: true }) + .waitFor(); + await page + .locator("dd") + .filter({ hasText: /^IndexedDB$/ }) + .waitFor(); + await page.locator("dd").filter({ hasText: /^0$/ }).waitFor(); + assert.equal(await readStoragePhase(page), "idle"); + await page.goto(`${pwaUrl}#/exchanges`); + await page.getByText("TESTKUDOS", { exact: true }).waitFor(); + }, + ); + + await stage.step( + "recover an interrupted pre-activation conversion", + async (page) => { + await injectInterruptedStorageState(page); + await page.reload(); + await page + .locator('[role="status"]', { + hasText: /^Wallet host: Connected$/, + }) + .waitFor({ state: "attached" }); + await page.getByText("TESTKUDOS", { exact: true }).waitFor(); + await page.goto(`${pwaUrl}#/settings`); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + await page + .getByText( + "interrupted before activation; source remains authoritative", + { exact: true }, + ) + .waitFor(); + await page + .locator("dd") + .filter({ hasText: /^IndexedDB$/ }) + .waitFor(); + await page.locator("dd").filter({ hasText: /^0$/ }).waitFor(); + }, + ); + + await stage.step("opt in to the live SQLite wallet", async (page) => { + await page.goto(`${pwaUrl}#/settings`); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + await page.getByText("Optional", { exact: true }).waitFor(); + await page + .getByRole("switch", { + name: /Use SQLite storage/, + }) + .click(); + await page + .getByText("SQLite migration completed and wallet-core reopened.") + .waitFor(); + await page + .locator("dd") + .filter({ hasText: /^SQLite$/ }) + .waitFor(); + await page.locator("dd").filter({ hasText: /^2$/ }).waitFor(); + }); + + await stage.step( + "retain the IndexedDB recovery generation", + async (page) => { + const databaseNames = await page.evaluate(async () => { + const browser = globalThis as any; + return (await browser.indexedDB.databases()).map( + (database: any) => database.name, + ); + }); + assert.ok( + databaseNames.includes(TALER_WALLET_MAIN_DB_NAME), + `${TALER_WALLET_MAIN_DB_NAME} was not retained`, + ); + }, + ); + + await stage.step( + "reopen the authoritative SQLite generation", + async (page) => { + await page.reload(); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + await page + .locator("dd") + .filter({ hasText: /^SQLite$/ }) + .waitFor(); + await page.goto(`${pwaUrl}#/exchanges`); + await page.getByText("TESTKUDOS", { exact: true }).waitFor(); + }, + ); + + await stage.step( + "turn SQLite off without losing wallet data", + async (page) => { + await page.goto(`${pwaUrl}#/settings`); + await page.getByRole("switch", { name: /Use SQLite storage/ }).click(); + await page + .getByText("IndexedDB rollback completed and wallet-core reopened.") + .waitFor(); + await page + .locator("dd") + .filter({ hasText: /^IndexedDB$/ }) + .waitFor(); + await page.locator("dd").filter({ hasText: /^3$/ }).waitFor(); + const databaseNames = await page.evaluate(async () => { + const browser = globalThis as any; + return (await browser.indexedDB.databases()).map( + (database: any) => database.name, + ); + }); + for (const name of [ + TALER_WALLET_MAIN_DB_NAME, + TALER_WALLET_META_DB_NAME, + ]) { + assert.ok( + !databaseNames.includes(name), + `${name} survived recovery-copy replacement`, + ); + } + await page.reload(); + await page.goto(`${pwaUrl}#/exchanges`); + await page.getByText("TESTKUDOS", { exact: true }).waitFor(); + }, + ); + + await stage.step( + "expire and remove the retained SQLite generation", + async (page) => { + await page.goto(`${pwaUrl}#/settings`); + await expireStorageCleanup(page); + await requestCleanupFromCurrentLeader(page); + await page.goto(`${pwaUrl}#/exchanges`); + await page.getByText("TESTKUDOS", { exact: true }).waitFor(); + }, + ); + + await stage.step("remain on IndexedDB without OPFS", async (page) => { + await page.evaluate(() => { + const browser = globalThis as any; + browser.sessionStorage.setItem("wallet-webui-test-disable-opfs", "yes"); + }); + await page.goto(`${pwaUrl}?test-opfs=disabled#/settings`); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + await page + .locator("dd") + .filter({ hasText: /^IndexedDB$/ }) + .waitFor(); + await page.locator("dd").filter({ hasText: /^3$/ }).waitFor(); + await page.waitForTimeout(100); + await page + .getByText( + "SQLite storage is unavailable in this browser or build. The wallet continues to use IndexedDB.", + ) + .waitFor(); + assert.equal( + await page.getByRole("switch", { name: /Use SQLite storage/ }).count(), + 0, + ); + + await page.evaluate(() => { + const browser = globalThis as any; + browser.sessionStorage.removeItem("wallet-webui-test-disable-opfs"); + }); + await page.goto(`${pwaUrl}#/exchanges`); + await page.getByRole("heading", { name: "Exchanges" }).waitFor(); + await page.evaluate(() => { + (globalThis as any).location.hash = "#/settings"; + }); + await page.getByRole("switch", { name: /Use SQLite storage/ }).waitFor(); + }); + + await stage.step( + "reject SQLite opt-in without durable storage", + async (page) => { + await page.evaluate(() => { + const browser = globalThis as any; + Object.defineProperty(browser.navigator.storage, "persist", { + configurable: true, + value: async () => false, + }); + }); + await page.getByRole("switch", { name: /Use SQLite storage/ }).click(); + await page + .getByText( + "Durable browser storage was not granted. Migration was not started.", + ) + .waitFor(); + await page + .locator("dd") + .filter({ hasText: /^IndexedDB$/ }) + .waitFor(); + await page.locator("dd").filter({ hasText: /^3$/ }).waitFor(); + assert.equal(await readStoragePhase(page), "idle"); + await page.evaluate(() => { + const browser = globalThis as any; + Object.defineProperty(browser.navigator.storage, "persist", { + configurable: true, + value: async () => true, + }); + }); + }, + ); + + await stage.step("fail closed after SQLite activation", async (page) => { + await armStorageFault(page, "post-activation-reopen"); + await page.getByRole("switch", { name: /Use SQLite storage/ }).click(); + await page + .getByRole("heading", { name: "Storage recovery required" }) + .waitFor(); + await page + .getByText( + "post-activation reopen failed: injected post-activation reopen failure", + { exact: true }, + ) + .waitFor(); + await page.getByText("sqlite", { exact: true }).waitFor(); + await page.getByText("4", { exact: true }).waitFor(); + }); + + await stage.step( + "restore the retained IndexedDB generation", + async (page) => { + await page + .getByRole("button", { + name: "Restore indexeddb generation 3", + }) + .click(); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + await page + .locator("dd") + .filter({ hasText: /^IndexedDB$/ }) + .waitFor(); + await page.locator("dd").filter({ hasText: /^3$/ }).waitFor(); + await page.reload(); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + await page + .locator("dd") + .filter({ hasText: /^IndexedDB$/ }) + .waitFor(); + await page.goto(`${pwaUrl}#/exchanges`); + await page.getByText("TESTKUDOS", { exact: true }).waitFor(); + }, + ); + + await stage.step( + "recover explicitly after storage control clearing", + async (page) => { + await deleteStorageControlDatabase(page); + await page.reload(); + await page + .getByRole("heading", { name: "Storage recovery required" }) + .waitFor(); + await page + .getByText(/Wallet storage control metadata is missing/) + .waitFor(); + await page + .getByRole("button", { + name: "Verify and use SQLite generation 4", + }) + .waitFor(); + await page + .getByRole("button", { + name: "Verify and use IndexedDB generation 3", + }) + .click(); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + await page + .locator("dd") + .filter({ hasText: /^IndexedDB$/ }) + .waitFor(); + await page.locator("dd").filter({ hasText: /^3$/ }).waitFor(); + await page.goto(`${pwaUrl}#/exchanges`); + await page.getByText("TESTKUDOS", { exact: true }).waitFor(); + }, + ); + + await stage.step( + "start a new wallet after complete origin clearing", + async (page) => { + const cleared = await clearCompleteOriginStorage(page, pwaUrl); + assert.ok(cleared.deletedDatabases.length > 0); + assert.ok(cleared.deletedOpfsEntries.length > 0); + await page.goto(`${pwaUrl}#/settings`); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + await page + .locator("dd") + .filter({ hasText: /^IndexedDB$/ }) + .waitFor(); + await page.locator("dd").filter({ hasText: /^0$/ }).waitFor(); + await page + .locator("dd") + .filter({ hasText: /^Idle$/ }) + .waitFor(); + await page.goto(`${pwaUrl}#/exchanges`); + await page.getByRole("heading", { name: "Exchanges" }).waitFor(); + assert.equal( + await page.getByText("TESTKUDOS", { exact: true }).count(), + 0, + ); + }, + ); + }); +} + +export async function runWalletWebUiPwaSqliteTest(t: GlobalTestState) { + await runPwaSqliteTest(t, "chromium"); +} + +export async function runWalletWebUiPwaFirefoxSqliteTest(t: GlobalTestState) { + await runPwaSqliteTest(t, "firefox"); +} + +runWalletWebUiPwaSqliteTest.suites = ["wallet-webui"]; +runWalletWebUiPwaSqliteTest.timeoutMs = 360_000; +runWalletWebUiPwaFirefoxSqliteTest.suites = ["wallet-webui"]; +runWalletWebUiPwaFirefoxSqliteTest.timeoutMs = 360_000; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-startup.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-startup.ts @@ -0,0 +1,158 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import path from "node:path"; +import * as nodeUrl from "node:url"; +import type { Page } from "playwright-core"; +import { GlobalTestState } from "../harness/harness.js"; +import { findBrowserBinary } from "../stagefright/stage.js"; +import { + findWalletWebUiDirectory, + reserveTcpPort, + waitForPwa, +} from "./wallet-web-ui-test-support.js"; + +const DOCUMENT_LOAD_KEY = "wallet-webui-startup-document-loads"; +const RESTORE_ROOT_KEY = "GJKG23V4ZBHEH45YRK7TWQE8ZTY7JWTY5094TQJSRZN5DSDBX8E0"; + +async function assertRestoreActionGate( + page: Page, + pwaUrl: string, +): Promise<void> { + const restoreUri = `taler://restore/${RESTORE_ROOT_KEY}/https%3A%2F%2Fsync.example%2F`; + await page.goto(`${pwaUrl}#/enter`); + await page.getByLabel("Wallet link").fill(restoreUri); + await page.getByLabel("Wallet link").press("Enter"); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + await page + .getByText(/require provider-backed backup synchronization and merging/i) + .waitFor(); + if (page.url().includes(RESTORE_ROOT_KEY)) { + throw Error("restore sync root key leaked into browser history"); + } + await page.getByRole("button", { name: "Back to wallet" }).click(); + await page + .getByRole("heading", { name: /Balances|Welcome to Taler Wallet!/ }) + .waitFor(); +} + +async function assertStableStartup( + page: Page, + pwaUrl: string, + expectedDocumentLoads: number, +): Promise<void> { + await page.goto(pwaUrl); + const heading = page.getByRole("heading", { + name: /Balances|Welcome to Taler Wallet!/, + }); + await heading.waitFor(); + const mountedWalletView = await page.locator("main").elementHandle(); + if (!mountedWalletView) throw Error("wallet view did not mount"); + + // On the first run this waits for install + clients.claim(). On the second + // run it proves that this startup is served under the persisted worker. + await page.waitForFunction("navigator.serviceWorker.controller !== null"); + await page.waitForTimeout(2_000); + + const documentLoads = await page.evaluate((key) => { + const browserGlobal = globalThis as unknown as { + localStorage: { getItem(name: string): string | null }; + }; + return Number(browserGlobal.localStorage.getItem(key) ?? "0"); + }, DOCUMENT_LOAD_KEY); + if (documentLoads !== expectedDocumentLoads) { + throw Error( + `PWA loaded ${documentLoads} documents, expected ${expectedDocumentLoads}; startup reloaded the page`, + ); + } + if (!(await mountedWalletView.evaluate((element) => element.isConnected))) { + throw Error("PWA replaced the mounted wallet screen during startup"); + } + if ( + (await page.locator("body").innerText()).includes("wallet host disappeared") + ) { + throw Error("wallet worker disappeared during PWA startup"); + } +} + +/** Verify first-install and persisted-service-worker PWA startup without flashes. */ +export async function runWalletWebUiPwaStartupTest(t: GlobalTestState) { + const walletWebUiDirectory = findWalletWebUiDirectory(); + const port = await reserveTcpPort(); + const pwaUrl = `http://127.0.0.1:${port}/`; + t.spawnService( + process.execPath, + [path.join(walletWebUiDirectory, "dev.mjs"), `--port=${port}`], + "wallet-webui-pwa-startup", + ); + await waitForPwa(pwaUrl); + + const playwright = await import("playwright-core"); + const profileDirectory = path.join(t.testDir, "wallet-webui-pwa-profile"); + const savedUrl = globalThis.URL; + const savedUrlSearchParams = globalThis.URLSearchParams; + Object.assign(globalThis, { + URL: nodeUrl.URL, + URLSearchParams: nodeUrl.URLSearchParams, + }); + try { + for (const expectedDocumentLoads of [1, 2]) { + const context = await playwright.chromium.launchPersistentContext( + profileDirectory, + { + headless: true, + executablePath: findBrowserBinary(), + viewport: { width: 390, height: 844 }, + }, + ); + try { + await context.addInitScript( + ({ origin, key }) => { + const browserGlobal = globalThis as unknown as { + location: { origin: string }; + localStorage: { + getItem(name: string): string | null; + setItem(name: string, value: string): void; + }; + }; + if (browserGlobal.location.origin !== origin) return; + const previous = Number( + browserGlobal.localStorage.getItem(key) ?? "0", + ); + browserGlobal.localStorage.setItem(key, String(previous + 1)); + }, + { origin: new nodeUrl.URL(pwaUrl).origin, key: DOCUMENT_LOAD_KEY }, + ); + const page = context.pages()[0] ?? (await context.newPage()); + page.setDefaultTimeout(30_000); + await assertStableStartup(page, pwaUrl, expectedDocumentLoads); + if (expectedDocumentLoads === 1) { + await assertRestoreActionGate(page, pwaUrl); + } + } finally { + await context.close(); + } + } + } finally { + Object.assign(globalThis, { + URL: savedUrl, + URLSearchParams: savedUrlSearchParams, + }); + } +} + +runWalletWebUiPwaStartupTest.suites = ["wallet-webui"]; +runWalletWebUiPwaStartupTest.timeoutMs = 120_000; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-storybook.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-storybook.ts @@ -0,0 +1,121 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import path from "node:path"; +import { GlobalTestState } from "../harness/harness.js"; +import { Stage } from "../stagefright/stage.js"; +import { + findWalletWebUiDirectory, + reserveTcpPort, + waitForPwa, +} from "./wallet-web-ui-test-support.js"; + +/** Retain reviewable narrow/desktop and light/dark catalogue screenshots. */ +export async function runWalletWebUiPwaStorybookTest(t: GlobalTestState) { + const walletWebUiDirectory = findWalletWebUiDirectory(); + const port = await reserveTcpPort(); + const pwaUrl = `http://127.0.0.1:${port}/`; + t.spawnService( + process.execPath, + [path.join(walletWebUiDirectory, "dev.mjs"), `--port=${port}`], + "wallet-webui-pwa-storybook", + ); + await waitForPwa(pwaUrl); + + const stage = await Stage.create("wallet-web-ui-pwa-storybook", { + browserType: "chromium", + screenshotDir: path.join(t.testDir, "wallet-webui-storybook-screenshots"), + timeoutMs: 45_000, + viewport: { width: 390, height: 844 }, + }); + await stage.page.addInitScript(() => { + const browserGlobal = globalThis as any; + const NativeWorker = browserGlobal.Worker; + browserGlobal.__walletStorybookWorkerCount = 0; + browserGlobal.Worker = new Proxy(NativeWorker, { + construct(target, argumentsList) { + browserGlobal.__walletStorybookWorkerCount++; + return Reflect.construct(target, argumentsList); + }, + }); + }); + + await stage.run(async () => { + await stage.page.goto(`${pwaUrl}#/storybook`); + const reviewedStories = await stage.page + .locator('[data-reviewed-story="true"]') + .evaluateAll((buttons) => + buttons.map((button) => ({ + id: button.getAttribute("data-story-id") ?? "", + title: button.textContent?.trim() ?? "", + widths: (button.getAttribute("data-story-widths") ?? "").split(","), + themes: (button.getAttribute("data-story-themes") ?? "").split(","), + })), + ); + t.assertDeepEqual(reviewedStories.length, 25); + for (const story of reviewedStories) { + for (const width of story.widths) { + for (const theme of story.themes) { + await stage.step(`${story.id}-${width}-${theme}`, async (page) => { + await page.setViewportSize( + width === "narrow" + ? { width: 390, height: 844 } + : { width: 1440, height: 1000 }, + ); + await page.emulateMedia({ colorScheme: theme }); + await page.goto(`${pwaUrl}#/storybook/${story.id}`); + await page + .getByRole("heading", { name: "Component stories" }) + .waitFor(); + const selected = page.locator( + `[data-reviewed-story="true"][data-story-id="${story.id}"]`, + ); + await selected.waitFor(); + t.assertDeepEqual( + await selected.getAttribute("aria-current"), + "page", + ); + t.assertDeepEqual( + await page.evaluate( + () => (globalThis as any).__walletStorybookWorkerCount, + ), + 0, + ); + t.assertDeepEqual( + await page + .getByRole("navigation", { name: "Wallet actions" }) + .count(), + 0, + ); + if (story.id === "peer-create") { + const scope = page.getByLabel("Currency and payment scope"); + await scope.focus(); + const scopeBox = await scope.boundingBox(); + const amountBox = await page + .getByText("Amount (CHF)", { exact: true }) + .boundingBox(); + if (!scopeBox || !amountBox) { + throw Error("send-money fields have no measurable layout"); + } + const clearance = amountBox.y - (scopeBox.y + scopeBox.height); + if (clearance < 12) { + throw Error( + `focused scope selector leaves only ${clearance}px before the amount label`, + ); + } + } + }); + } + } + } + }); +} + +runWalletWebUiPwaStorybookTest.suites = ["wallet-webui"]; +runWalletWebUiPwaStorybookTest.timeoutMs = 180_000; diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-withdrawal.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-pwa-withdrawal.ts @@ -0,0 +1,1274 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import path from "node:path"; +import { + AbsoluteTime, + Duration, + OrderInputType, + OrderOutputType, + OrderVersion, + randomRfc3548Base32Key, + succeedOrThrow, + TalerMerchantInstanceHttpClient, + TalerProtocolTimestamp, + TalerWireGatewayHttpClient, + TemplateType, + TokenFamilyKind, +} from "@gnu-taler/taler-util"; +import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; +import { defaultCoinConfig } from "../harness/denomStructures.js"; +import { + ExchangeService, + GlobalTestState, + setupDb, +} from "../harness/harness.js"; +import { Stage } from "../stagefright/stage.js"; +import { WebDriverStage } from "../stagefright/webdriver-stage.js"; +import { + acknowledgeBestEffortStorageForTest, + startCorsBridge, +} from "./wallet-web-ui-test-support.js"; + +function findWalletWebUiDirectory(): string { + const candidates = [ + process.env.TALER_WALLET_WEBUI_DIR, + path.resolve(process.cwd(), "packages/wallet-webui"), + path.resolve(process.cwd(), "../wallet-webui"), + ].filter((candidate): candidate is string => Boolean(candidate)); + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, "dev.mjs"))) return candidate; + } + throw Error( + "wallet-webui sources not found; run from the wallet-core checkout or set TALER_WALLET_WEBUI_DIR", + ); +} + +async function reserveTcpPort(): Promise<number> { + const server = net.createServer(); + await new Promise<void>((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") + throw Error("could not reserve a TCP port"); + await new Promise<void>((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + return address.port; +} + +async function waitForPwa(url: string, timeoutMs = 90_000): Promise<void> { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const status = await new Promise<number | undefined>( + (resolve, reject) => { + const request = http.get(url, (response) => { + response.resume(); + resolve(response.statusCode); + }); + request.once("error", reject); + }, + ); + if (status && status >= 200 && status < 300) return; + lastError = Error(`HTTP ${status ?? "unknown"}`); + } catch (cause) { + lastError = cause; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw Error(`wallet-webui PWA did not become ready: ${lastError}`); +} + +function withdrawExchangeUri(baseUrl: string, amount?: string): string { + const exchangeUrl = new URL(baseUrl); + const talerScheme = exchangeUrl.protocol === "http:" ? "taler+http" : "taler"; + const path = exchangeUrl.pathname.endsWith("/") + ? exchangeUrl.pathname + : `${exchangeUrl.pathname}/`; + const uri = `${talerScheme}://withdraw-exchange/${exchangeUrl.host}${path}`; + return amount ? `${uri}?a=${encodeURIComponent(amount)}` : uri; +} + +function addExchangeUri(baseUrl: string): string { + const exchangeUrl = new URL(baseUrl); + const talerScheme = exchangeUrl.protocol === "http:" ? "taler+http" : "taler"; + const path = exchangeUrl.pathname.endsWith("/") + ? exchangeUrl.pathname + : `${exchangeUrl.pathname}/`; + return `${talerScheme}://add-exchange/${exchangeUrl.host}${path}`; +} + +function withdrawalTransferResultUri( + reservePub: string, + status: "success" | "aborted", +): string { + const ref = encodeURIComponent(`wallet-webui-bank-${reservePub}`); + return `taler://withdrawal-transfer-result?ref=${ref}&status=${status}`; +} + +/** Drive a manual withdrawal against the harness exchange in the real PWA. */ +async function runPwaWithdrawalTest( + t: GlobalTestState, + browserType: "chromium" | "firefox", +) { + const termsDirectory = path.join(t.testDir, "wallet-webui-exchange-terms"); + const englishTermsDirectory = path.join(termsDirectory, "en"); + fs.mkdirSync(englishTermsDirectory, { recursive: true }); + fs.writeFileSync( + path.join(englishTermsDirectory, "v1.txt"), + "Local exchange terms for the wallet-webui browser test.\n", + ); + const { + exchange, + exchangeBankAccount, + bank, + bankClient, + merchant, + merchantAdminAccessToken, + } = await createSimpleTestkudosEnvironmentV3(t, undefined, { + ageMaskSpec: "8:10:12:14:16:18:21", + additionalExchangeConfig: (localExchange) => { + localExchange.changeConfig((config) => { + config.setString("exchange", "terms_etag", "v1"); + config.setString("exchange", "terms_dir", termsDirectory); + }); + }, + }); + const bankUser = await bankClient.createRandomBankUser(); + const wireGatewayApi = new TalerWireGatewayHttpClient( + exchangeBankAccount.wireGatewayApiBaseUrl, + ); + const merchantClient = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + ); + succeedOrThrow( + await merchantClient.addOtpDevice(merchantAdminAccessToken, { + otp_algorithm: 1, + otp_device_description: "Browser POS confirmation", + otp_device_id: "wallet-webui-pos", + otp_key: randomRfc3548Base32Key(), + }), + ); + succeedOrThrow( + await merchantClient.addTemplate(merchantAdminAccessToken, { + template_description: "Wallet WebUI POS order", + template_id: "wallet-webui-pos", + otp_id: "wallet-webui-pos", + template_contract: { + template_type: TemplateType.FIXED_ORDER, + summary: "Browser POS purchase", + amount: "TESTKUDOS:1", + minimum_age: 0, + pay_duration: Duration.toTalerProtocolDuration( + Duration.fromSpec({ hours: 1 }), + ), + }, + }), + ); + succeedOrThrow( + await merchantClient.createTokenFamily(merchantAdminAccessToken, { + kind: TokenFamilyKind.Discount, + slug: "wallet-webui-discount", + name: "Browser member discount", + description: "Reduced admission on a future browser-wallet visit", + valid_after: TalerProtocolTimestamp.now(), + valid_before: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ years: 1 }), + ), + ), + duration: Duration.toTalerProtocolDuration( + Duration.fromSpec({ days: 90 }), + ), + validity_granularity: Duration.toTalerProtocolDuration( + Duration.fromSpec({ days: 1 }), + ), + }), + ); + const choiceOrder = succeedOrThrow( + await merchantClient.createOrder(merchantAdminAccessToken, { + order: { + version: OrderVersion.V1, + summary: "Browser choice details", + choices: [ + { + amount: "TESTKUDOS:1", + description: "Buy two future discounts", + inputs: [], + outputs: [ + { + type: OrderOutputType.Token, + token_family_slug: "wallet-webui-discount", + count: 2, + }, + ], + }, + { + amount: "TESTKUDOS:0.5", + description: "Use an existing discount", + inputs: [ + { + type: OrderInputType.Token, + token_family_slug: "wallet-webui-discount", + count: 1, + }, + ], + outputs: [], + }, + ], + }, + }), + ); + const unpaidChoiceOrder = succeedOrThrow( + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + choiceOrder.order_id, + ), + ); + if (unpaidChoiceOrder.order_status !== "unpaid") { + throw Error("payment-choice browser order was not created as unpaid"); + } + const ageOrder = succeedOrThrow( + await merchantClient.createOrder(merchantAdminAccessToken, { + order: { + summary: "Age-restricted browser purchase", + amount: "TESTKUDOS:1", + fulfillment_url: "taler://fulfillment-success/age-restricted", + minimum_age: 13, + products: [ + { + product_id: "museum-ticket", + product_name: "Museum ticket", + description: "Age-restricted museum admission", + quantity: 1, + unit: "ticket", + price: "TESTKUDOS:1", + }, + ], + }, + }), + ); + const unpaidAgeOrder = succeedOrThrow( + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + ageOrder.order_id, + ), + ); + if (unpaidAgeOrder.order_status !== "unpaid") { + throw Error("age-restricted browser order was not created as unpaid"); + } + const walletWebUiDirectory = findWalletWebUiDirectory(); + const port = await reserveTcpPort(); + const pwaUrl = `http://127.0.0.1:${port}/`; + const exportedWallet = path.join( + t.testDir, + `wallet-webui-${browserType}-export.json`, + ); + const preImportBackup = path.join( + t.testDir, + `wallet-webui-${browserType}-pre-import.json`, + ); + let reservePub = ""; + let notificationReservePub = ""; + let keyChangeBankBridge: + | Awaited<ReturnType<typeof startCorsBridge>> + | undefined; + let browserKeyChangeWithdrawalUri: string | undefined; + if (browserType === "chromium") { + const keyChangeBankUser = await bankClient.createRandomBankUser(); + bankClient.setAuth(keyChangeBankUser); + const keyChangeWithdrawal = await bankClient.createWithdrawalOperation( + keyChangeBankUser.username, + "TESTKUDOS:3", + ); + keyChangeBankBridge = await startCorsBridge(bankClient.baseUrl); + browserKeyChangeWithdrawalUri = + keyChangeWithdrawal.taler_withdraw_uri.replace( + new URL(bankClient.baseUrl).host, + new URL(keyChangeBankBridge.baseUrl).host, + ); + } + + t.spawnService( + process.execPath, + [path.join(walletWebUiDirectory, "dev.mjs"), `--port=${port}`], + "wallet-webui-pwa", + ); + await waitForPwa(pwaUrl); + + const stageOptions = { + browserType, + screenshotDir: path.join(t.testDir, "wallet-webui-screenshots"), + timeoutMs: 90_000, + viewport: { width: 390, height: 844 }, + } as const; + const stage = + browserType === "firefox" + ? await WebDriverStage.create( + `wallet-web-ui-pwa-${browserType}-withdrawal`, + stageOptions, + ) + : await Stage.create( + `wallet-web-ui-pwa-${browserType}-withdrawal`, + stageOptions, + ); + let replacementExchange: ExchangeService | undefined; + try { + await stage.run(async () => { + await stage.page.addInitScript(acknowledgeBestEffortStorageForTest); + await stage.page.addInitScript(() => { + const browserGlobal = globalThis as any; + const NativeWorker = browserGlobal.Worker; + let walletWorker: any; + let nextRequest = 0; + const pending = new Map< + string, + { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; + } + >(); + Object.defineProperty(globalThis, "__walletHarnessCoreCall", { + configurable: true, + value: (operation: string, args: unknown) => { + if (!walletWorker) { + return Promise.reject(Error("wallet worker is not connected")); + } + const id = `harness-${Date.now().toString(36)}-${++nextRequest}`; + const result = new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + }); + walletWorker.postMessage({ + version: 1, + kind: "request", + mutation: true, + request: { id, operation, args }, + }); + return result; + }, + }); + browserGlobal.Worker = new Proxy(NativeWorker, { + construct(target, argumentsList) { + const worker = Reflect.construct(target, argumentsList) as any; + const options = argumentsList[1] as any; + if (options?.name === "gnu-taler-wallet") { + walletWorker = worker; + worker.addEventListener("message", (event: any) => { + const response = event.data; + if (response?.version !== 1 || response.kind !== "response") + return; + const request = pending.get(response.response?.id); + if (!request) return; + pending.delete(response.response.id); + if (response.response.type === "response") { + request.resolve(response.response.result); + } else { + request.reject( + Error( + response.response.error?.hint ?? + "wallet-core harness call failed", + ), + ); + } + }); + } + return worker; + }, + }); + }); + + await stage.step("enter and validate a wallet link", async (page) => { + await page.goto(`${pwaUrl}#/enter`); + await page + .getByRole("heading", { name: "Enter a wallet link" }) + .waitFor(); + const input = page.getByLabel("Wallet link"); + await input.fill("not a Taler link"); + await page.getByRole("button", { name: "Open link" }).click(); + await page.getByText("Enter a valid taler:// wallet link.").waitFor(); + await input.fill("taler://pay/merchant.example/order/session"); + await input.press("Enter"); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + await page.getByRole("button", { name: "Cancel" }).click(); + }); + + await stage.step("add local exchange", async (page) => { + await page.goto(`${pwaUrl}#/enter`); + await page + .getByLabel("Wallet link") + .fill(addExchangeUri(exchange.baseUrl)); + await page.getByLabel("Wallet link").press("Enter"); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + await page + .getByText(`Add ${exchange.baseUrl} as a wallet exchange.`, { + exact: true, + }) + .waitFor(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("heading", { name: "Exchange details" }).waitFor(); + await page.getByText("TESTKUDOS", { exact: true }).waitFor(); + }); + + await stage.step("block on exchange terms and resume", async (page) => { + await page.goto( + `${pwaUrl}#/withdraw?currency=TESTKUDOS&exchange=${encodeURIComponent(exchange.baseUrl)}`, + ); + await page.getByRole("heading", { name: "Withdraw" }).waitFor(); + await page.getByLabel("Amount to withdraw in TESTKUDOS").fill("2"); + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Review withdrawal" }) + .waitFor(); + await page + .getByText( + "Review and accept this exchange's terms of service before withdrawing.", + ) + .waitFor(); + const blockedWithdraw = page.getByRole("button", { + name: "Withdraw", + exact: true, + }); + if (!(await blockedWithdraw.isDisabled())) { + throw Error( + "withdrawal was not blocked on unaccepted exchange terms", + ); + } + await page.getByRole("button", { name: "Review terms" }).click(); + await page.getByRole("heading", { name: "Terms of service" }).waitFor(); + const accept = page.getByRole("button", { name: "Accept terms" }); + await accept.waitFor(); + await accept.click(); + await page + .getByRole("heading", { name: "Review withdrawal" }) + .waitFor(); + await page.getByText("TESTKUDOS:2", { exact: true }).waitFor(); + if ( + await page + .getByRole("heading", { name: "Withdraw", exact: true }) + .count() + ) { + throw Error( + "withdrawal amount entry reappeared after accepting terms", + ); + } + if (await page.getByRole("button", { name: "Review terms" }).count()) { + throw Error( + "withdrawal stayed blocked after accepting exchange terms", + ); + } + await page.getByLabel("Edit withdrawal").click(); + await page.getByRole("button", { name: "Cancel" }).click(); + }); + + await stage.step("open an exchange withdrawal link", async (page) => { + await page.goto(`${pwaUrl}#/enter`); + await page + .getByLabel("Wallet link") + .fill(withdrawExchangeUri(exchange.baseUrl)); + await page.getByLabel("Wallet link").press("Enter"); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("heading", { name: "Withdraw from exchange" }) + .waitFor(); + const amount = page.locator('input[inputmode="decimal"]'); + if ((await amount.inputValue()) !== "") { + throw Error( + "amount-less withdraw-exchange unexpectedly supplied an amount", + ); + } + if ((await amount.getAttribute("readonly")) !== null) { + throw Error("amount-less withdraw-exchange amount was not editable"); + } + await page.getByRole("button", { name: "Cancel" }).click(); + }); + + await stage.step("create manual withdrawal", async (page) => { + await page.goto( + `${pwaUrl}#/withdraw?currency=TESTKUDOS&exchange=${encodeURIComponent(exchange.baseUrl)}`, + ); + await page.getByRole("heading", { name: "Withdraw" }).waitFor(); + await page.locator('input[inputmode="decimal"]').fill("10"); + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Review withdrawal" }) + .waitFor(); + const age = page.getByLabel("Minimum age for withdrawn coins"); + await age.selectOption("14"); + await page.waitForFunction( + (element) => { + const select = element as { value: string; disabled: boolean }; + return select.value === "14" && !select.disabled; + }, + await age.elementHandle(), + ); + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Pending withdrawal" }) + .waitFor(); + await page + .getByText("Wire transfer instructions", { exact: true }) + .waitFor(); + await page.getByText("TESTKUDOS:10", { exact: true }).first().waitFor(); + await page + .getByRole("button", { name: /More actions for/ }) + .first() + .click(); + await page.getByRole("menuitem", { name: "Copy payto URI" }).click(); + if (await page.getByRole("menu").count()) { + throw Error("payto copy menu stayed open after selection"); + } + }); + + await stage.step("continue from transaction details", async (page) => { + await page.goto(`${pwaUrl}#/transactions`); + await page.getByRole("heading", { name: "History" }).waitFor(); + await page + .getByRole("button", { name: /localhost/i }) + .first() + .click(); + const detailHeading = await page + .getByRole("heading", { name: /localhost/i }) + .elementHandle(); + if (!detailHeading) + throw Error("transaction detail heading was not mounted"); + await page.getByRole("button", { name: "Try now" }).click(); + await page + .getByText("Transaction action accepted.", { exact: true }) + .waitFor(); + if (!(await detailHeading.evaluate((element) => element.isConnected))) { + throw Error( + "transaction action replaced the detail screen while revalidating", + ); + } + await page + .getByRole("button", { name: "View bank transfer instructions" }) + .waitFor(); + await page + .getByRole("button", { name: "View bank transfer instructions" }) + .click(); + await page + .getByRole("heading", { name: "Pending withdrawal" }) + .waitFor(); + await page + .getByText("Wire transfer instructions", { exact: true }) + .waitFor(); + await page + .getByRole("button", { name: /More actions for/ }) + .first() + .click(); + await page.getByRole("menuitem", { name: "Copy payto URI" }).waitFor(); + const transferSubject = + ( + await page + .locator("dt", { hasText: /^Subject$/ }) + .first() + .locator("xpath=following-sibling::dd[1]/span[1]") + .textContent() + )?.trim() ?? ""; + reservePub = transferSubject.match(/[0-9A-Z]{52}/)?.[0] ?? ""; + if (reservePub.length !== 52) { + throw Error( + `withdrawal instructions exposed an invalid reserve-subject length (${reservePub.length})`, + ); + } + }); + + await stage.step( + "notice background withdrawal completion in place", + async (page) => { + await wireGatewayApi.addIncoming({ + auth: bank.getAdminAuth(), + body: { + amount: "TESTKUDOS:10", + debit_account: bankUser.accountPaytoUri, + reserve_pub: reservePub, + }, + }); + await exchange.runWirewatchOnce(); + await page + .getByRole("heading", { name: "Withdrawal completed" }) + .waitFor({ timeout: 90_000 }); + await page + .getByText("Transfer instructions are no longer needed", { + exact: true, + }) + .waitFor(); + await page + .getByRole("button", { name: "View transaction" }) + .waitFor(); + await page.getByRole("button", { name: "View wallet" }).waitFor(); + }, + ); + + await stage.step( + "return from the bank to authoritative withdrawal details", + async (page) => { + await page.goto(`${pwaUrl}#/enter`); + await page + .getByLabel("Wallet link") + .fill(withdrawalTransferResultUri(reservePub, "success")); + await page.getByLabel("Wallet link").press("Enter"); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + await page + .getByText( + "Your bank reports that the transfer succeeded. The wallet will verify the withdrawal independently.", + { exact: true }, + ) + .waitFor(); + await page.getByRole("button", { name: "View withdrawal" }).click(); + await page.getByRole("heading", { name: /localhost/i }).waitFor(); + await page.getByText("Completed", { exact: true }).waitFor(); + }, + ); + + await stage.step( + "refresh newly accepted withdrawal when its transaction completes", + async (page) => { + await page.goto( + `${pwaUrl}#/withdraw?currency=TESTKUDOS&exchange=${encodeURIComponent(exchange.baseUrl)}`, + ); + await page.getByRole("heading", { name: "Withdraw" }).waitFor(); + await page.locator('input[inputmode="decimal"]').fill("1"); + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Review withdrawal" }) + .waitFor(); + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Pending withdrawal" }) + .waitFor(); + const transferSubject = + ( + await page + .locator("dt", { hasText: /^Subject$/ }) + .first() + .locator("xpath=following-sibling::dd[1]/span[1]") + .textContent() + )?.trim() ?? ""; + notificationReservePub = + transferSubject.match(/[0-9A-Z]{52}/)?.[0] ?? ""; + if (notificationReservePub.length !== 52) { + throw Error( + `notification withdrawal exposed an invalid reserve-subject length (${notificationReservePub.length})`, + ); + } + await wireGatewayApi.addIncoming({ + auth: bank.getAdminAuth(), + body: { + amount: "TESTKUDOS:1", + debit_account: bankUser.accountPaytoUri, + reserve_pub: notificationReservePub, + }, + }); + await exchange.runWirewatchOnce(); + await page + .getByRole("heading", { name: "Withdrawal completed" }) + .waitFor({ timeout: 90_000 }); + await page.getByRole("button", { name: "View transaction" }).click(); + await page.getByText("Completed", { exact: true }).waitFor(); + }, + ); + + await stage.step( + "use a wallet-core global currency scope in the send dialog", + async (page) => { + const balances = await page.evaluate( + async ({ exchangeBaseUrl, exchangeMasterPub }) => { + const call = ( + globalThis as typeof globalThis & { + __walletHarnessCoreCall: ( + operation: string, + args: unknown, + ) => Promise<unknown>; + } + ).__walletHarnessCoreCall; + await call("addGlobalCurrencyExchange", { + currency: "TESTKUDOS", + exchangeBaseUrl, + exchangeMasterPub, + }); + return (await call("getBalances", {})) as { + balances: Array<{ + scopeInfo: { type: string; currency: string }; + }>; + }; + }, + { + exchangeBaseUrl: exchange.baseUrl, + exchangeMasterPub: exchange.masterPub, + }, + ); + t.assertDeepEqual(balances.balances.length, 1); + t.assertDeepEqual(balances.balances[0]?.scopeInfo, { + type: "global", + currency: "TESTKUDOS", + }); + + await page.goto(`${pwaUrl}#/peer/send?currency=TESTKUDOS`); + await page.getByRole("heading", { name: "Send money" }).waitFor(); + const scope = page.getByLabel("Currency and payment scope"); + await scope.waitFor(); + const selected = + (await scope.locator("option:checked").textContent()) ?? ""; + if (!selected.startsWith("TESTKUDOS — TESTKUDOS:")) { + throw Error(`global currency scope was not selected: ${selected}`); + } + if (selected.includes("localhost")) { + throw Error( + `global scope was mislabeled as an exchange: ${selected}`, + ); + } + await page.locator('input[inputmode="decimal"]').fill("1"); + await page.getByLabel("Subject").fill("Global scope transfer"); + await page.getByRole("button", { name: "Review" }).click(); + await page + .getByText("Global scope transfer", { exact: true }) + .waitFor(); + await page.getByRole("button", { name: "Change" }).click(); + await page.getByRole("button", { name: "Cancel" }).click(); + }, + ); + + await stage.step("spend the age-restricted coins", async (page) => { + await page.goto( + `${pwaUrl}?talerUri=${encodeURIComponent(unpaidAgeOrder.taler_pay_uri)}#/`, + ); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("heading", { name: "Review payment", exact: true }) + .waitFor(); + await page + .getByText("Age-restricted browser purchase", { exact: true }) + .waitFor(); + await page.getByText("13+", { exact: true }).waitFor(); + await page.getByText("Order summary", { exact: true }).waitFor(); + await page.getByText("Museum ticket", { exact: true }).waitFor(); + await page.getByText("1 ticket", { exact: true }).waitFor(); + await page.getByRole("button", { name: "Pay TESTKUDOS:1" }).click(); + await page.getByText("Payment complete", { exact: true }).waitFor({ + timeout: 90_000, + }); + const paidOrder = succeedOrThrow( + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + ageOrder.order_id, + ), + ); + if (paidOrder.order_status !== "paid") { + throw Error( + "age-restricted browser payment did not reach the merchant", + ); + } + await page.goto(`${pwaUrl}#/transactions`); + await page + .getByRole("button", { name: /Age-restricted browser purchase/ }) + .click(); + await page.getByText("Order details", { exact: true }).waitFor(); + await page.getByText("Museum ticket", { exact: true }).waitFor(); + await page.getByText("1 ticket", { exact: true }).waitFor(); + }); + + await stage.step( + "show authoritative payment choice inputs and outputs", + async (page) => { + await page.goto( + `${pwaUrl}?talerUri=${encodeURIComponent(unpaidChoiceOrder.taler_pay_uri)}#/`, + ); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("heading", { name: "Review payment", exact: true }) + .waitFor(); + await page + .getByText("Browser choice details", { exact: true }) + .waitFor(); + await page.getByText("Spend now:", { exact: true }).waitFor(); + await page + .getByText("Get with your purchase:", { exact: true }) + .waitFor(); + await page + .getByText("2 × Browser member discount", { exact: true }) + .waitFor(); + await page + .getByText("Reduced admission on a future browser-wallet visit", { + exact: true, + }) + .first() + .waitFor(); + await page.getByText("0 of 1 available", { exact: true }).waitFor(); + await page + .getByText("Not enough of this token is available.", { + exact: true, + }) + .waitFor(); + await page + .getByRole("radio", { name: /Buy two future discounts/ }) + .click(); + await page.getByRole("button", { name: "Pay TESTKUDOS:1" }).click(); + await page + .getByText("Payment complete", { exact: true }) + .waitFor({ timeout: 90_000 }); + const order = succeedOrThrow( + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + choiceOrder.order_id, + ), + ); + if (order.order_status !== "paid") { + throw Error("authoritative output-token choice was not paid"); + } + }, + ); + + await stage.step( + "pay a template and show its POS confirmation", + async (page) => { + await page.goto(`${pwaUrl}#/enter`); + await page + .getByLabel("Wallet link") + .fill( + `taler+http://pay-template/localhost:${merchant.port}/wallet-webui-pos`, + ); + await page.getByLabel("Wallet link").press("Enter"); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("heading", { name: /^(Create payment|Review payment)$/ }) + .waitFor(); + await page + .getByText("Browser POS purchase", { exact: true }) + .waitFor(); + const review = page.getByRole("button", { name: "Review payment" }); + if (await review.isVisible().catch(() => false)) await review.click(); + await page + .getByRole("heading", { name: "Review payment", exact: true }) + .waitFor(); + await page.getByRole("button", { name: "Pay TESTKUDOS:1" }).click(); + await page.getByText("Payment complete", { exact: true }).waitFor({ + timeout: 90_000, + }); + await page + .getByRole("heading", { name: "Merchant confirmation code" }) + .waitFor(); + const confirmation = + (await page + .locator("[data-testid='pos-confirmation']") + .textContent()) ?? ""; + if (confirmation.trim().length <= 10) { + throw Error( + "completed template payment did not expose its POS code", + ); + } + await page.getByRole("button", { name: "Copy code" }).waitFor(); + }, + ); + + await stage.step("collect a merchant refund link", async (page) => { + const refund = succeedOrThrow( + await merchantClient.addRefund( + merchantAdminAccessToken, + ageOrder.order_id, + { refund: "TESTKUDOS:1", reason: "Browser refund test" }, + ), + ); + await page.goto(`${pwaUrl}#/enter`); + await page.getByLabel("Wallet link").fill(refund.taler_refund_uri); + await page.getByLabel("Wallet link").press("Enter"); + await page.getByRole("heading", { name: "Wallet action" }).waitFor(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("heading", { name: "Default Instance" }).waitFor(); + await page.getByText("Refunded", { exact: true }).waitFor({ + timeout: 90_000, + }); + await page + .getByText(/^TESTKUDOS:/) + .last() + .waitFor(); + const order = succeedOrThrow( + await merchantClient.getOrderDetails( + merchantAdminAccessToken, + ageOrder.order_id, + ), + ); + if (order.order_status !== "paid" || order.refund_pending) { + throw Error("browser wallet did not collect the merchant refund"); + } + }); + + await stage.step("export the populated wallet", async (page) => { + await page.goto(`${pwaUrl}#/settings`); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "Export database" }).click(); + const download = await downloadPromise; + await download.saveAs(exportedWallet); + await page.getByText("Database export created.").waitFor(); + }); + + await stage.step("review the database import", async (page) => { + await page.locator('input[type="file"]').setInputFiles(exportedWallet); + await page + .getByRole("heading", { name: "Import wallet database" }) + .waitFor(); + await page.getByText("This replaces the current wallet").waitFor(); + await page.getByText("indexeddb", { exact: true }).waitFor(); + }); + + await stage.step("back up and import the database", async (page) => { + const downloadPromise = page.waitForEvent("download"); + await page + .getByRole("button", { name: "Download backup and import" }) + .click(); + const download = await downloadPromise; + await download.saveAs(preImportBackup); + await page.getByText("Import verified", { exact: true }).waitFor(); + await page + .getByText(/completed diagnostic and balance reads/) + .waitFor(); + }); + + await stage.step("reopen the imported transaction", async (page) => { + await page.getByRole("button", { name: "Return to wallet" }).click(); + await page.goto(`${pwaUrl}#/transactions`); + await page.getByRole("heading", { name: "History" }).waitFor(); + await page + .getByRole("button", { name: /localhost/i }) + .first() + .waitFor(); + }); + + await stage.step("open discounts and passes", async (page) => { + await page.goto(`${pwaUrl}#/discounts`); + await page.getByRole("heading", { name: "Discounts" }).waitFor(); + await page + .getByText("Browser member discount", { exact: true }) + .waitFor(); + await page.getByText("×2", { exact: true }).waitFor(); + await page.goto(`${pwaUrl}#/passes`); + await page.getByRole("heading", { name: "Passes" }).waitFor(); + await page.getByText("You don't have any available passes.").waitFor(); + }); + + if (browserType === "chromium") { + await stage.step( + "block on an exchange key change and resume", + async (page) => { + await exchange.stop(); + const replacementDb = await setupDb(t, { + nameSuffix: "webui-key-change", + }); + replacementExchange = ExchangeService.create(t, { + name: "testexchange-webui-key-change", + currency: "TESTKUDOS", + httpPort: exchange.port, + database: replacementDb.connStr, + }); + await replacementExchange.addBankAccount("1", exchangeBankAccount); + replacementExchange.addCoinConfigList( + defaultCoinConfig.map((coin) => coin("TESTKUDOS")), + ); + replacementExchange.changeConfig((config) => { + config.setString("exchange", "terms_etag", "v1"); + config.setString("exchange", "terms_dir", termsDirectory); + }); + await replacementExchange.start(); + + await page.goto(`${pwaUrl}#/exchanges`); + await page.getByRole("heading", { name: "Exchanges" }).waitFor(); + await page + .getByRole("button", { name: /TESTKUDOS.*localhost/s }) + .first() + .click(); + await page + .getByRole("heading", { name: "Exchange details" }) + .waitFor(); + await page.getByRole("button", { name: "Update now" }).click(); + await page + .getByRole("heading", { name: "Exchange identity changed" }) + .waitFor({ timeout: 90_000 }); + await page + .getByText("Exchange update started.") + .waitFor({ state: "detached" }); + + await page.goto(`${pwaUrl}#/peer/send?currency=TESTKUDOS`); + await page.getByRole("heading", { name: "Send money" }).waitFor(); + const legacyScope = page + .getByLabel("Currency and payment scope") + .locator("option") + .filter({ hasText: "legacy keys" }); + await legacyScope.waitFor({ state: "attached" }); + if ( + !((await legacyScope.textContent()) ?? "").includes("TESTKUDOS") + ) { + throw Error("legacy-key scope lost its currency label"); + } + + if (!browserKeyChangeWithdrawalUri) { + throw Error("integrated key-change withdrawal was not created"); + } + await page.goto( + `${pwaUrl}?talerUri=${encodeURIComponent(browserKeyChangeWithdrawalUri)}#/`, + ); + await page + .getByRole("heading", { name: "Wallet action" }) + .waitFor(); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("heading", { name: "Withdraw from your bank" }) + .waitFor(); + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Review withdrawal" }) + .waitFor(); + await page + .getByText( + "This exchange changed its signing keys. Verify that change before withdrawing.", + ) + .waitFor(); + const blockedWithdraw = page.getByRole("button", { + name: "Withdraw", + exact: true, + }); + if (!(await blockedWithdraw.isDisabled())) { + throw Error( + "withdrawal was not blocked on an unconfirmed exchange key change", + ); + } + await page + .getByRole("button", { name: "Verify key change" }) + .click(); + await page + .getByRole("heading", { name: "Exchange identity changed" }) + .waitFor(); + await page + .getByRole("button", { name: "I verified this key change" }) + .click(); + await page + .getByRole("dialog", { name: "Confirm exchange key change?" }) + .waitFor(); + await page + .getByRole("button", { name: "Confirm key change" }) + .click(); + await page + .getByRole("heading", { name: "Review withdrawal" }) + .waitFor(); + if ( + await page + .getByRole("button", { name: "Verify key change" }) + .count() + ) { + throw Error( + "withdrawal stayed blocked after confirming the exchange key change", + ); + } + await page + .getByText( + "Review and accept this exchange's terms of service before withdrawing.", + ) + .waitFor(); + await page.getByRole("button", { name: "Review terms" }).click(); + await page + .getByRole("heading", { name: "Terms of service" }) + .waitFor(); + await page.getByRole("button", { name: "Accept terms" }).click(); + await page + .getByRole("heading", { name: "Review withdrawal" }) + .waitFor(); + if ( + await page + .getByRole("heading", { name: "Withdraw from your bank" }) + .count() + ) { + throw Error( + "integrated withdrawal amount entry reappeared after resolving exchange gates", + ); + } + if ( + await page.getByRole("button", { name: "Review terms" }).count() + ) { + throw Error( + "integrated withdrawal stayed blocked after accepting the replacement exchange's terms", + ); + } + if ( + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .isDisabled() + ) { + throw Error( + "integrated withdrawal confirmation stayed disabled after resolving exchange gates", + ); + } + await page.getByRole("button", { name: "Cancel" }).click(); + await page.getByRole("heading", { name: "Balances" }).waitFor(); + + await page.goto(`${pwaUrl}#/enter`); + await page + .getByLabel("Wallet link") + .fill(withdrawExchangeUri(exchange.baseUrl, "TESTKUDOS:3")); + await page.getByLabel("Wallet link").press("Enter"); + await page + .getByRole("heading", { name: "Wallet action" }) + .waitFor(); + await page.getByRole("button", { name: "Continue" }).click(); + await page + .getByRole("heading", { name: "Withdraw from exchange" }) + .waitFor(); + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Review withdrawal" }) + .waitFor(); + if ( + await page + .getByRole("button", { name: /Review terms|Verify key change/ }) + .count() + ) { + throw Error( + "resolved integrated gates did not carry over to the exchange-locked flow", + ); + } + if ( + await page + .getByRole("button", { name: "Withdraw", exact: true }) + .isDisabled() + ) { + throw Error( + "exchange-locked withdrawal stayed disabled after integrated gate recovery", + ); + } + }, + ); + + await stage.step( + "consent to a developer experiment through settings", + async (page) => { + await page.goto(`${pwaUrl}#/enter`); + await page + .getByLabel("Wallet link") + .fill("taler://dev-experiment/stop-block-refresh"); + await page.getByLabel("Wallet link").press("Enter"); + await page + .getByText(/This link requests the developer experiment/) + .waitFor(); + await page + .getByRole("button", { name: "Review developer settings" }) + .click(); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + const enableDeveloperMode = page.getByRole("button", { + name: "Enable developer mode", + }); + if (!(await enableDeveloperMode.isVisible())) { + await page + .locator("summary", { hasText: "Developer settings" }) + .click(); + } + await enableDeveloperMode.click(); + await page + .getByRole("dialog", { + name: "Enable wallet-core developer mode?", + }) + .getByRole("button", { name: "Enable developer mode" }) + .click(); + await page + .getByRole("button", { name: "Apply experiment" }) + .waitFor(); + await page + .getByText(/developer experiment “stop-block-refresh”/) + .waitFor(); + await page + .getByRole("button", { name: "Apply experiment" }) + .click(); + await page + .getByText(/was applied/) + .waitFor(); + }, + ); + + await stage.step( + "clear the wallet from developer settings", + async (page) => { + await page.goto(`${pwaUrl}#/settings`); + await page.getByRole("heading", { name: "Settings" }).waitFor(); + await page + .locator("summary", { hasText: "Developer settings" }) + .click(); + await page + .getByRole("button", { name: "Clear wallet database" }) + .click(); + const dialog = page.getByRole("dialog", { + name: "Clear the wallet database?", + }); + await dialog + .getByRole("checkbox", { name: /permanently destroys/ }) + .check(); + await dialog + .getByRole("button", { name: "Clear wallet database" }) + .click(); + await page + .getByText("Wallet database cleared.", { exact: true }) + .waitFor(); + await page.goto(`${pwaUrl}#/`); + await page + .getByRole("heading", { name: "Welcome to Taler Wallet!" }) + .waitFor(); + }, + ); + } + }); + } finally { + await keyChangeBankBridge?.close().catch(() => undefined); + await replacementExchange?.stop().catch(() => undefined); + } +} + +export async function runWalletWebUiPwaWithdrawalTest(t: GlobalTestState) { + await runPwaWithdrawalTest(t, "chromium"); +} + +export async function runWalletWebUiPwaFirefoxWithdrawalTest( + t: GlobalTestState, +) { + await runPwaWithdrawalTest(t, "firefox"); +} + +runWalletWebUiPwaWithdrawalTest.suites = ["wallet-webui"]; +runWalletWebUiPwaWithdrawalTest.timeoutMs = 240_000; +runWalletWebUiPwaFirefoxWithdrawalTest.suites = ["wallet-webui"]; +runWalletWebUiPwaFirefoxWithdrawalTest.timeoutMs = 180_000; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -216,6 +216,39 @@ import { runWalletTokensTest } from "./test-wallet-tokens.js"; import { runWalletTransactionsTest } from "./test-wallet-transactions.js"; import { runWalletWirefeesTest } from "./test-wallet-wirefees.js"; import { runWalletWithdrawalRedenominateTest } from "./test-wallet-withdrawal-redenominate.js"; +import { + runWalletWebUiPwaFirefoxWithdrawalTest, + runWalletWebUiPwaWithdrawalTest, +} from "./test-wallet-web-ui-pwa-withdrawal.js"; +import { runWalletWebUiPwaErrorsTest } from "./test-wallet-web-ui-pwa-errors.js"; +import { runWalletWebUiPwaDonauTest } from "./test-wallet-web-ui-pwa-donau.js"; +import { runWalletWebUiPwaPeerDepositTest } from "./test-wallet-web-ui-pwa-peer-deposit.js"; +import { runWalletWebUiPwaPeerTosTest } from "./test-wallet-web-ui-pwa-peer-tos.js"; +import { runWalletWebUiPwaDepositKycAuthTest } from "./test-wallet-web-ui-pwa-deposit-kyc-auth.js"; +import { runWalletWebUiPwaDepositKycAuthSwissTest } from "./test-wallet-web-ui-pwa-deposit-kyc-auth-swiss.js"; +import { runWalletWebUiPwaCashAcceptorTest } from "./test-wallet-web-ui-pwa-cash-acceptor.js"; +import { runWalletWebUiPwaStartupTest } from "./test-wallet-web-ui-pwa-startup.js"; +import { runWalletWebUiPwaLifecycleTest } from "./test-wallet-web-ui-pwa-lifecycle.js"; +import { runWalletWebUiPwaStorybookTest } from "./test-wallet-web-ui-pwa-storybook.js"; +import { runWalletWebUiDemoTest } from "./test-wallet-web-ui-demo.js"; +import { + runWalletWebUiPwaFirefoxSqliteTest, + runWalletWebUiPwaSqliteTest, +} from "./test-wallet-web-ui-pwa-sqlite.js"; +import { runWalletWebUiChromeExtensionWithdrawalTest } from "./test-wallet-web-ui-chrome-extension-withdrawal.js"; +import { runWalletWebUiFirefoxExtensionWithdrawalTest } from "./test-wallet-web-ui-firefox-extension-withdrawal.js"; +import { + runWalletWebUiChromeExtensionIntegrationTest, + runWalletWebUiFirefoxExtensionIntegrationTest, +} from "./test-wallet-web-ui-extension-integration.js"; +import { + runWalletWebUiChromeExtensionUpgradeTest, + runWalletWebUiFirefoxExtensionUpgradeTest, +} from "./test-wallet-web-ui-extension-upgrade.js"; +import { + runWalletWebUiChromeExtensionSqliteTest, + runWalletWebUiFirefoxExtensionSqliteTest, +} from "./test-wallet-web-ui-extension-sqlite.js"; import { runWallettestingTest } from "./test-wallettesting.js"; import { runMerchantWebuiMfaTest, @@ -458,6 +491,29 @@ const allTests: TestMainFunction[] = [ runMerchantKycNotificationsTest, runTopsMerchantTosTest, runWalletWithdrawalRedenominateTest, + runWalletWebUiPwaWithdrawalTest, + runWalletWebUiPwaErrorsTest, + runWalletWebUiPwaFirefoxWithdrawalTest, + runWalletWebUiPwaDonauTest, + runWalletWebUiPwaPeerDepositTest, + runWalletWebUiPwaPeerTosTest, + runWalletWebUiPwaDepositKycAuthTest, + runWalletWebUiPwaDepositKycAuthSwissTest, + runWalletWebUiPwaCashAcceptorTest, + runWalletWebUiPwaStartupTest, + runWalletWebUiPwaLifecycleTest, + runWalletWebUiPwaStorybookTest, + runWalletWebUiDemoTest, + runWalletWebUiPwaSqliteTest, + runWalletWebUiPwaFirefoxSqliteTest, + runWalletWebUiChromeExtensionWithdrawalTest, + runWalletWebUiFirefoxExtensionWithdrawalTest, + runWalletWebUiChromeExtensionIntegrationTest, + runWalletWebUiFirefoxExtensionIntegrationTest, + runWalletWebUiChromeExtensionUpgradeTest, + runWalletWebUiFirefoxExtensionUpgradeTest, + runWalletWebUiChromeExtensionSqliteTest, + runWalletWebUiFirefoxExtensionSqliteTest, runMerchantDepositLargeTest, runWireMetadataTest, runMerchantPaytoReuseTest, diff --git a/packages/taler-harness/src/integrationtests/wallet-web-ui-test-support.ts b/packages/taler-harness/src/integrationtests/wallet-web-ui-test-support.ts @@ -0,0 +1,164 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import path from "node:path"; +import { delayMs } from "../harness/harness.js"; + +/** Opt a workflow test into the PWA's explicit best-effort storage mode. */ +export function acknowledgeBestEffortStorageForTest(): void { + ( + globalThis as unknown as { + sessionStorage: { setItem(key: string, value: string): void }; + } + ).sessionStorage.setItem( + "gnu-taler-wallet-best-effort-acknowledged", + "yes", + ); +} + +export function findWalletWebUiDirectory(): string { + const candidates = [ + process.env.TALER_WALLET_WEBUI_DIR, + path.resolve(process.cwd(), "packages/wallet-webui"), + path.resolve(process.cwd(), "../wallet-webui"), + ].filter((candidate): candidate is string => Boolean(candidate)); + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, "dev.mjs"))) return candidate; + } + throw Error( + "wallet-webui sources not found; run from the wallet-core checkout or set TALER_WALLET_WEBUI_DIR", + ); +} + +export async function reserveTcpPort(): Promise<number> { + const server = net.createServer(); + await new Promise<void>((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw Error("could not reserve a TCP port"); + } + await new Promise<void>((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + return address.port; +} + +export async function waitForPwa( + url: string, + timeoutMs = 90_000, +): Promise<void> { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const status = await new Promise<number | undefined>( + (resolve, reject) => { + const request = http.get(url, (response) => { + response.resume(); + resolve(response.statusCode); + }); + request.once("error", reject); + }, + ); + if (status && status >= 200 && status < 300) return; + lastError = Error(`HTTP ${status ?? "unknown"}`); + } catch (cause) { + lastError = cause; + } + await delayMs(250); + } + throw Error(`wallet-webui PWA did not become ready: ${lastError}`); +} + +/** Add browser CORS responses in front of a local harness daemon. */ +export async function startCorsBridge(targetBaseUrl: string): Promise<{ + baseUrl: string; + close(): Promise<void>; +}> { + const target = new URL(targetBaseUrl); + const server = http.createServer((request, response) => { + const allowHeaders = request.headers["access-control-request-headers"]; + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + response.setHeader( + "Access-Control-Allow-Headers", + typeof allowHeaders === "string" ? allowHeaders : "Content-Type", + ); + if (request.method === "OPTIONS") { + response.writeHead(204); + response.end(); + return; + } + const upstream = http.request( + { + hostname: target.hostname, + port: target.port, + method: request.method, + path: request.url, + headers: { ...request.headers, host: target.host }, + }, + (upstreamResponse) => { + response.writeHead(upstreamResponse.statusCode ?? 502, { + ...upstreamResponse.headers, + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-headers": + typeof allowHeaders === "string" ? allowHeaders : "Content-Type", + }); + upstreamResponse.pipe(response); + }, + ); + upstream.once("error", (error) => { + if (!response.headersSent) response.writeHead(502); + response.end(error.message); + }); + request.pipe(upstream); + }); + await new Promise<void>((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw Error("could not start the local service CORS bridge"); + } + return { + baseUrl: `http://127.0.0.1:${address.port}/`, + close: () => + new Promise<void>((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +} + +export async function confirmWithdrawalWhenReady( + confirm: () => Promise<unknown>, + timeoutMs = 30_000, +): Promise<void> { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + await confirm(); + return; + } catch (cause) { + lastError = cause; + await delayMs(100); + } + } + throw Error( + `wallet did not register the bank withdrawal in time: ${lastError}`, + ); +}