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:
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}`,
+ );
+}