commit 62e790759350accb96a11ba7ec0fe89735d2bd9e parent b3e619b4b50c70774edeea67f8928deb0c9d8d3b Author: Florian Dold <dold@taler.net> Date: Sat, 22 Aug 2026 13:36:28 +0200 merchant-webui: add browser integration coverage Diffstat:
65 files changed, 6727 insertions(+), 583 deletions(-)
diff --git a/packages/taler-harness/Makefile b/packages/taler-harness/Makefile @@ -34,13 +34,16 @@ install-nodeps: install ./dist/taler-harness-bundled.cjs $(DESTDIR)$(NODEDIR)/dist/ install ./dist/taler-harness-bundled.cjs.map $(DESTDIR)$(NODEDIR)/dist/ install ./bin/taler-harness.mjs $(DESTDIR)$(NODEDIR)/bin/ -# Playwright can't be bundled, the stagefright subcommands require it next to -# the bundle instead. - if test -d node_modules/playwright-core; then \ - install -d $(DESTDIR)$(NODEDIR)/node_modules; \ - rm -rf $(DESTDIR)$(NODEDIR)/node_modules/playwright-core; \ - cp -RL node_modules/playwright-core $(DESTDIR)$(NODEDIR)/node_modules/; \ - fi +# Browser automation libraries cannot be bundled because they locate browsers +# and driver helpers relative to their package directories. Deploy all +# production dependencies so their transitive dependencies and pnpm links are +# preserved as well. + set -e; \ + runtime_deps_dir=$$(mktemp -d); \ + trap 'rm -rf "$$runtime_deps_dir"' EXIT; \ + pnpm --filter @gnu-taler/taler-harness deploy --prod --legacy "$$runtime_deps_dir"; \ + rm -rf $(DESTDIR)$(NODEDIR)/node_modules; \ + cp -a "$$runtime_deps_dir"/node_modules $(DESTDIR)$(NODEDIR)/ ln -sf ../lib/taler-harness/node_modules/taler-harness/bin/taler-harness.mjs $(DESTDIR)$(BINDIR)/taler-harness install-selenium: npm install --prefix $(DESTDIR)$(NODEDIR) --no-save --omit=dev --ignore-scripts selenium-webdriver@4.40.0 diff --git a/packages/taler-harness/src/harness/browser-assertions.ts b/packages/taler-harness/src/harness/browser-assertions.ts @@ -0,0 +1,30 @@ +/* + 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 type { Page } from "playwright-core"; + +/** + * Fail a normal browser checkpoint when the portal exposes an actual error. + * Warning and information banners intentionally have no marker and hidden + * errors do not count. + */ +export async function assertNoUnexpectedErrorBanner( + page: Page, + checkpoint = "browser checkpoint", +): Promise<void> { + const visible = page.locator("[data-error-banner]:visible"); + const text = (await visible.allInnerTexts()) + .map((entry) => entry.trim()) + .filter(Boolean); + if (text.length === 0) return; + throw new Error( + `${checkpoint} displayed ${text.length} unexpected error banner(s) at ${page.url()}:\n` + + text.map((entry, index) => `${index + 1}. ${entry}`).join("\n"), + ); +} diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts @@ -2229,6 +2229,7 @@ export class ExchangeService implements ExchangeServiceInterface { export interface MerchantConfig { name: string; + currency?: string; httpPort: number; database: string; overrideTestDir?: string; @@ -2536,7 +2537,16 @@ export class MerchantService implements MerchantServiceInterface { * Waits for the service to become fully available. */ async start( - opts: { skipDbinit?: boolean; useDonau?: boolean } = {}, + opts: { + skipDbinit?: boolean; + useDonau?: boolean; + /** + * Readiness-only startup for tests whose subject is an endpoint that is + * intentionally usable before the deployment's full configuration can + * be consumed (notably first-instance bootstrap). + */ + skipConfigValidation?: boolean; + } = {}, ): Promise<void> { const skipSetup = opts.skipDbinit ?? false; @@ -2595,7 +2605,7 @@ export class MerchantService implements MerchantServiceInterface { ); await this.pingUntilAvailable(); - { + if (!opts.skipConfigValidation) { const merchantClient = new TalerMerchantManagementHttpClient( this.makeInstanceBaseUrl(), ); @@ -2613,7 +2623,7 @@ export class MerchantService implements MerchantServiceInterface { const cfgFilename = testDir + `/merchant-${mc.name}.conf`; setTalerPaths(config, testDir + "/talerhome"); - config.setString("merchant", "currency", "TESTKUDOS"); + config.setString("merchant", "currency", mc.currency ?? "TESTKUDOS"); config.setString("merchant", "serve", "tcp"); config.setString("merchant", "port", `${mc.httpPort}`); config.setString( @@ -2896,7 +2906,7 @@ export interface WalletCliOpts { cryptoWorkerType?: "sync" | "node-worker-thread"; } -function tryUnixConnect(socketPath: string): Promise<void> { +export function tryUnixConnect(socketPath: string): Promise<void> { return new Promise((resolve, reject) => { const client = net.createConnection(socketPath); client.on("error", (e) => { diff --git a/packages/taler-harness/src/harness/merchant-webui-browser.ts b/packages/taler-harness/src/harness/merchant-webui-browser.ts @@ -0,0 +1,67 @@ +/* + 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 { findBrowserBinary, installNativeUrl } from "../stagefright/stage.js"; +export { assertNoUnexpectedErrorBanner } from "./browser-assertions.js"; + +async function loadPlaywright(): Promise<typeof import("playwright-core")> { + try { + return await import("playwright-core"); + } catch (e) { + throw Error( + `unable to load playwright-core, please run 'pnpm install' in the taler-harness package (${e})`, + ); + } +} + +export interface MerchantWebuiBrowser { + browser: any; + page: any; + close: () => Promise<void>; +} + +/** + * Launch the same browser setup used by local and Stagefright WebUI tests. + * + * installNativeUrl temporarily replaces URL with Node's native implementation; + * keeping its cleanup next to browser startup prevents tests from leaking that + * process-global change when launch fails or a test throws. + */ +export async function launchMerchantWebuiBrowser(): Promise<MerchantWebuiBrowser> { + const restoreUrl = installNativeUrl(); + try { + const pw = await loadPlaywright(); + const browser = await pw.chromium.launch({ + headless: true, + executablePath: findBrowserBinary(), + }); + const page = await browser.newPage(); + return { + browser, + page, + close: async () => { + try { + await browser.close(); + } finally { + restoreUrl(); + } + }, + }; + } catch (cause) { + restoreUrl(); + throw cause; + } +} diff --git a/packages/taler-harness/src/harness/webui-server.ts b/packages/taler-harness/src/harness/webui-server.ts @@ -0,0 +1,138 @@ +/* + 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 { Logger } from "@gnu-taler/taler-util"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const logger = new Logger("harness/webui-server.ts"); + +export interface WebuiServer { + url: string; + close: () => Promise<void>; +} + +export interface WebuiServerOptions { + experimental?: boolean; +} + +/** + * Launch an HTTP static file server serving compiled merchant-webui dist assets, + * dynamically serving /webui-config.json targeting merchantBaseUrl. + */ +export async function startStaticServerMerchantWebui( + merchantBaseUrl: string, + options: WebuiServerOptions = {}, +): Promise<WebuiServer> { + let rootDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../..", + ); + if (!fs.existsSync(path.join(rootDir, "packages/taler-merchant-webui"))) { + let curr = process.cwd(); + while (curr !== path.parse(curr).root) { + if (fs.existsSync(path.join(curr, "packages/taler-merchant-webui"))) { + rootDir = curr; + break; + } + curr = path.dirname(curr); + } + } + let distDir = path.join(rootDir, "packages/taler-merchant-webui/dist/prod"); + if (!fs.existsSync(distDir)) { + distDir = path.join(rootDir, "packages/taler-merchant-webui/dist/dev"); + } + + if (!fs.existsSync(distDir)) { + logger.info("building merchant-webui package..."); + execFileSync( + "pnpm", + ["--filter", "@gnu-taler/taler-merchant-webui", "compile"], + { + cwd: rootDir, + stdio: "inherit", + }, + ); + distDir = path.join(rootDir, "packages/taler-merchant-webui/dist/prod"); + } + + logger.info(`serving local webui static files from ${distDir}`); + + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const reqPath = req.url + ? new URL(req.url, "http://127.0.0.1").pathname + : "/"; + + // Dynamically inject webui-config.json to target merchantBaseUrl + if (reqPath === "/webui-config.json") { + res.writeHead(200, { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }); + res.end( + JSON.stringify({ + experimental: Boolean(options.experimental), + merchant_base_url: merchantBaseUrl, + merchant_base_url_configurable: true, + }), + ); + return; + } + + let filePath = path.join( + distDir, + reqPath === "/" ? "index.html" : reqPath, + ); + if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) { + filePath = path.join(distDir, "index.html"); + } + + let contentType = "text/html"; + if (filePath.endsWith(".js")) contentType = "application/javascript"; + else if (filePath.endsWith(".css")) contentType = "text/css"; + else if (filePath.endsWith(".json")) contentType = "application/json"; + else if (filePath.endsWith(".png")) contentType = "image/png"; + else if (filePath.endsWith(".svg")) contentType = "image/svg+xml"; + + try { + const data = fs.readFileSync(filePath); + res.writeHead(200, { + "Content-Type": contentType, + "Access-Control-Allow-Origin": "*", + }); + res.end(data); + } catch (e) { + res.writeHead(404); + res.end("Not Found"); + } + }); + + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as { port: number }; + const url = `http://127.0.0.1:${addr.port}/`; + resolve({ + url, + close: () => new Promise((res) => server.close(() => res())), + }); + }); + + server.on("error", reject); + }); +} diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts @@ -121,6 +121,7 @@ import { MYTOPS_STAGE_BASE_URL, runStagefrightMerchantMytops, } from "./stagefright/merchant-mytops.js"; +import { runStagefrightMerchantWebui } from "./stagefright/merchant-webui.js"; const logger = new Logger("taler-harness:index.ts"); @@ -2463,6 +2464,97 @@ stagefrightCli console.log(j2s(res)); }); +stagefrightCli + .subcommand("merchantWebui", "merchant-webui", { + help: "Onboard an account in local merchant-webui against staging (or custom backend), using Playwright.", + }) + .maybeOption("baseUrl", ["--base-url"], clk.STRING, { + help: `base URL of the merchant deployment (default: ${MYTOPS_STAGE_BASE_URL})`, + }) + .maybeOption("webuiUrl", ["--webui-url"], clk.STRING, { + help: "URL where local merchant-webui is served (default: auto-serves local dist assets)", + }) + .maybeOption("exchangeUrl", ["--exchange-url"], clk.STRING, { + help: "base URL of the exchange to withdraw from (default: the staging exchange)", + }) + .maybeOption("currency", ["--currency"], clk.STRING, { + help: "currency for the amounts in the scenario (default: the currency of the deployment)", + }) + .maybeOption("tosVersion", ["--tos-version"], clk.STRING, { + help: "version (TERMS_ETAG) of the exchange's terms of service to accept for KYC (default: guessed from the deployment)", + }) + .flag("existingAccount", ["--existing-account"], { + help: "sign in to an existing account and skip registration (requires instance ID, password, and MFA addresses)", + }) + .maybeOption("instanceId", ["--instance-id"], clk.STRING, { + help: "username of the account to create (default: random)", + }) + .maybeOption("businessName", ["--business-name"], clk.STRING, { + help: "legal name of the business", + }) + .maybeOption("password", ["--password"], clk.STRING, { + help: "password of the account to create (default: random)", + }) + .maybeOption("addressIndex", ["--address-index"], clk.STRING, { + help: "two digits selecting the mock email address and phone number (default: random)", + }) + .maybeOption("email", ["--email"], clk.STRING, { + help: "email address for multi-factor authentication", + }) + .maybeOption("phone", ["--phone"], clk.STRING, { + help: "phone number for multi-factor authentication", + }) + .maybeOption("screenshotDir", ["--screenshot-dir"], clk.STRING, { + help: "directory for the screenshot of every step (default: below the temp dir)", + }) + .maybeOption("browserBinary", ["--browser-binary"], clk.STRING, { + help: "chromium executable to use (default: $BROWSER_BINARY or a system chromium)", + }) + .maybeOption( + "walletCliBinary", + ["--wallet-cli-binary", "--wallet-cli"], + clk.STRING, + { + help: "taler-wallet-cli executable to use (default: 'taler-wallet-cli' from $PATH)", + }, + ) + .maybeOption("slowMo", ["--slow-mo"], clk.INT, { + help: "slow every browser interaction down by that many milliseconds", + }) + .maybeOption("timeout", ["--timeout"], clk.INT, { + help: "how long to wait for the deployment at any single point, in milliseconds", + }) + .maybeOption("overallTimeout", ["--overall-timeout"], clk.INT, { + help: "how long to wait for the whole scenario to complete, in milliseconds", + }) + .flag("headed", ["--headed"], { + help: "run with a visible browser window instead of headless", + }) + .action(async (args) => { + const res = await runStagefrightMerchantWebui({ + baseUrl: args.merchantWebui.baseUrl, + webuiUrl: args.merchantWebui.webuiUrl, + exchangeUrl: args.merchantWebui.exchangeUrl, + currency: args.merchantWebui.currency, + tosVersion: args.merchantWebui.tosVersion, + instanceId: args.merchantWebui.instanceId, + businessName: args.merchantWebui.businessName, + password: args.merchantWebui.password, + addressIndex: args.merchantWebui.addressIndex, + email: args.merchantWebui.email, + phone: args.merchantWebui.phone, + existingAccount: args.merchantWebui.existingAccount, + screenshotDir: args.merchantWebui.screenshotDir, + browserBinary: args.merchantWebui.browserBinary, + walletCliBinary: args.merchantWebui.walletCliBinary, + slowMoMs: args.merchantWebui.slowMo, + timeoutMs: args.merchantWebui.timeout, + overallTimeoutMs: args.merchantWebui.overallTimeout, + headless: !args.merchantWebui.headed, + }); + console.log(j2s(res)); + }); + export function main() { talerHarnessCli.run(); } diff --git a/packages/taler-harness/src/integrationtests/merchant-webui-transactional-flows.ts b/packages/taler-harness/src/integrationtests/merchant-webui-transactional-flows.ts @@ -0,0 +1,547 @@ +/* + 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. +*/ + +/** + * Transaction-oriented merchant WebUI flows. These share the environment + * created by test-merchant-webui-simple so adding coverage does not multiply + * the expensive service, wallet, and browser setup. + */ + +async function submitMutation( + page: any, + method: "POST" | "PATCH" | "DELETE", + action: () => Promise<void>, + expectedBody: Record<string, unknown> = {}, +): Promise<void> { + const responsePromise = page.waitForResponse( + (response: any) => + response.request().method() === method && + response.url().includes("/private/"), + { timeout: 15_000 }, + ); + await action(); + const response = await responsePromise; + if (!response.ok()) { + throw new Error(`${method} ${response.url()} failed with HTTP ${response.status()}`); + } + const body = response.request().postDataJSON(); + if (method !== "DELETE" && (!body || typeof body !== "object" || Object.keys(body).length === 0)) { + throw new Error(`${method} ${response.url()} did not send a JSON mutation body`); + } + if (Object.keys(expectedBody).length === 0) return; + for (const [key, expected] of Object.entries(expectedBody)) { + if (body?.[key] !== expected) { + throw new Error(`${method} ${response.url()} sent unexpected ${key}`); + } + } +} + +function visibleExactText(page: any, text: string): any { + return page.getByText(text, { exact: true }).filter({ visible: true }).first(); +} + +async function requireVisibleAfterReload(page: any, text: string): Promise<void> { + await visibleExactText(page, text).waitFor({ state: "visible", timeout: 15_000 }); + await page.reload(); + await visibleExactText(page, text).waitFor({ state: "visible", timeout: 15_000 }); +} + +async function requireAbsentAfterReload(page: any, text: string): Promise<void> { + await page.reload(); + await page.waitForTimeout(200); + if (await page.getByText(text, { exact: true }).count()) { + throw new Error(`Deleted value is still visible after reload: ${text}`); + } +} + +function menuItem(page: any, name: string | RegExp): any { + return page.getByRole("menuitem", { name }); +} + +/** + * 5. Products & Inventory CRUD operations. + */ +export async function testProductsCrud( + page: any, + webuiUrl: string, + saveScreenshot: (name: string) => Promise<void>, +) { + // CREATE product dialogue + await page.goto(`${webuiUrl}#/inventory/new`); + + const nameInput = page.getByRole("textbox", { name: "Product Name" }); + await nameInput.waitFor({ state: "visible", timeout: 15000 }); + await nameInput.fill("Swiss Espresso"); + + const descInput = page.getByRole("textbox", { name: "Description" }); + await descInput.fill("Fresh double shot espresso"); + + const priceInput = page.getByRole("spinbutton", { name: "Price per unit" }); + await priceInput.fill("4.50"); + + await saveScreenshot("05a-product-create-dialogue"); + + const addButton = page.getByRole("button", { name: "Add product" }); + await submitMutation(page, "POST", () => addButton.click()); + + // READ products list + const mainContent = page.getByRole("main"); + await mainContent.waitFor({ state: "visible", timeout: 15000 }); + await requireVisibleAfterReload(page, "Swiss Espresso"); + await saveScreenshot("05b-product-created"); + + // EDIT product dialogue + { + await page + .getByRole("link", { name: "Swiss Espresso", exact: true }) + .filter({ visible: true }) + .click(); + const editPriceInput = page.getByRole("spinbutton", { + name: "Price per unit", + }); + await editPriceInput.waitFor({ state: "visible", timeout: 15000 }); + + await saveScreenshot("06a-product-edit-dialogue"); + + await editPriceInput.fill("5.00"); + + const saveProductButton = page + .getByRole("button", { name: "Save changes" }) + .or(page.getByRole("button", { name: "Save Product" })) + .first(); + await submitMutation(page, "PATCH", () => saveProductButton.click()); + await requireVisibleAfterReload(page, "Swiss Espresso"); + await page + .getByText(/5\.00/) + .filter({ visible: true }) + .first() + .waitFor({ state: "visible", timeout: 15_000 }); + await saveScreenshot("06b-product-edited"); + } + + await page.goto(`${webuiUrl}#/inventory`); + await page.getByRole("button", { name: "Actions for Swiss Espresso" }).click(); + await menuItem(page, /Delete product/i).click(); + await submitMutation(page, "DELETE", () => + page.getByRole("button", { name: "Delete Product", exact: true }).click() + ); + await requireAbsentAfterReload(page, "Swiss Espresso"); + + await page.locator("#inventory_tab_categories").click(); + await page.getByRole("button", { name: /Add a category/i }).first().click(); + await page.locator("#cat_name_input").fill("Harness category"); + await submitMutation(page, "POST", () => + page.getByRole("button", { name: "Create Category" }).click() + ); + await visibleExactText(page, "Harness category").waitFor({ state: "visible" }); + await page.reload(); + await page.locator("#inventory_tab_categories").click(); + await visibleExactText(page, "Harness category").waitFor({ state: "visible" }); + + await page.getByRole("button", { name: "Actions for Harness category" }).click(); + await menuItem(page, "Rename category").click(); + await page.locator("#cat_name_input").fill("Harness category updated"); + await submitMutation(page, "PATCH", () => + page.getByRole("button", { name: "Save Name" }).click() + ); + await visibleExactText(page, "Harness category updated").waitFor({ state: "visible" }); + + await page.getByRole("button", { name: "Actions for Harness category updated" }).click(); + await menuItem(page, "Delete category").click(); + await submitMutation(page, "DELETE", () => + page.getByRole("button", { name: "Delete Category" }).click() + ); + await page.reload(); + await page.locator("#inventory_tab_categories").click(); + if (await page.getByText("Harness category updated", { exact: true }).count()) { + throw new Error("Deleted category is still visible after reload"); + } +} + +/** + * 6. Payment templates CRUD operations & detail view. + */ +export async function testTemplatesCrud( + page: any, + webuiUrl: string, + saveScreenshot: (name: string) => Promise<void>, +) { + // CREATE template dialogue + await page.goto(`${webuiUrl}#/templates/new`); + + const nameInput = page.getByRole("textbox", { name: "Template Name" }); + await nameInput.waitFor({ state: "visible", timeout: 15000 }); + await nameInput.fill("Coffee Voucher"); + + const summaryInput = page.getByRole("textbox", { name: /Order Summary/i }); + await summaryInput.fill("Voucher for 1x Coffee"); + + const amountInput = page.getByRole("spinbutton", { name: "Fixed Amount" }); + await amountInput.fill("5.00"); + + await saveScreenshot("07a-template-create-dialogue"); + + const saveButton = page.getByRole("button", { name: "Create Template" }); + await submitMutation(page, "POST", () => saveButton.click()); + + // READ template list + const mainContent = page.getByRole("main"); + await mainContent.waitFor({ state: "visible", timeout: 15000 }); + await requireVisibleAfterReload(page, "Coffee Voucher"); + await saveScreenshot("07b-template-created"); + + // EDIT template dialogue + { + await page.getByRole("button", { name: "Actions for Coffee Voucher" }).click(); + await menuItem(page, "Edit").click(); + const editNameInput = page.getByRole("textbox", { name: "Template Name" }); + await editNameInput.waitFor({ state: "visible", timeout: 15000 }); + await saveScreenshot("07c-template-edit-dialogue"); + await editNameInput.fill("Coffee Voucher Updated"); + const saveChanges = page + .getByRole("button", { name: /Save changes|Save Template/i }) + .first(); + await submitMutation(page, "PATCH", () => saveChanges.click()); + await requireVisibleAfterReload(page, "Coffee Voucher Updated"); + } + + // View template details + await page.goto(`${webuiUrl}#/templates/tmpl_coffee_voucher`); + await page.waitForTimeout(300); + await saveScreenshot("07d-template-details"); + + await page.goto(`${webuiUrl}#/templates`); + await page.getByRole("button", { name: "Actions for Coffee Voucher Updated" }).click(); + await menuItem(page, /Delete template/i).click(); + await submitMutation(page, "DELETE", () => + page.getByRole("button", { name: "Delete Template", exact: true }).click() + ); + await requireAbsentAfterReload(page, "Coffee Voucher Updated"); +} + +/** + * 8. Webhooks CRUD operations. + */ +export async function testWebhooksCrud( + page: any, + webuiUrl: string, + saveScreenshot: (name: string) => Promise<void>, +) { + // CREATE webhook dialogue + await page.goto(`${webuiUrl}#/settings/webhooks/new`); + + const idInput = page.getByRole("textbox", { name: /Webhook Identifier/i }); + await idInput.waitFor({ state: "visible", timeout: 15000 }); + await idInput.fill("order-paid-webhook"); + + const urlInput = page.getByPlaceholder( + "https://api.example.com/webhooks/taler-paid", + ); + await urlInput.fill("https://example.com/webhook"); + + await saveScreenshot("09a-webhook-create-dialogue"); + + const saveButton = page.getByRole("button", { name: "Add Webhook" }); + await submitMutation(page, "POST", () => saveButton.click()); + + // READ webhook list + await page.goto(`${webuiUrl}#/settings/webhooks`); + const mainContent = page.getByRole("main"); + await mainContent.waitFor({ state: "visible", timeout: 15000 }); + await requireVisibleAfterReload(page, "order-paid-webhook"); + await saveScreenshot("09b-webhooks-list"); + + // EDIT webhook dialogue + await page.goto(`${webuiUrl}#/settings/webhooks/order-paid-webhook/edit`); + const editIdInput = page.getByRole("textbox", { + name: /Webhook Identifier/i, + }); + await editIdInput.waitFor({ state: "visible", timeout: 15000 }); + const editUrlInput = page.getByPlaceholder( + "https://api.example.com/webhooks/taler-paid", + ); + await editUrlInput.fill("https://example.com/webhook-updated"); + await submitMutation(page, "PATCH", () => + page.getByRole("button", { name: /Save Webhook Changes/i }).click() + ); + await page.reload(); + await editUrlInput.waitFor({ state: "visible", timeout: 15_000 }); + if ((await editUrlInput.inputValue()) !== "https://example.com/webhook-updated") { + throw new Error("Webhook edit did not persist after reload"); + } + await saveScreenshot("09c-webhook-edit-dialogue"); + + await page.goto(`${webuiUrl}#/settings/webhooks`); + await page.getByRole("button", { name: "Actions for order-paid-webhook" }).click(); + await menuItem(page, "Delete").click(); + await submitMutation(page, "DELETE", () => + page.getByRole("button", { name: "Delete Webhook", exact: true }).click() + ); + await requireAbsentAfterReload(page, "order-paid-webhook"); +} + +/** + * 10. Machine Access Tokens CRUD. + */ +export async function testAccessTokensCrud( + page: any, + webuiUrl: string, + password: string, + saveScreenshot: (name: string) => Promise<void>, +) { + await page.goto(`${webuiUrl}#/access/new`); + const usedForInput = page + .getByRole("textbox", { name: "Used for" }) + .or(page.getByLabel("Used for")) + .first(); + await usedForInput.waitFor({ state: "visible", timeout: 15000 }); + await usedForInput.fill("Counter Till #1"); + await page.getByLabel("Current Password").fill(password); + await saveScreenshot("13a-create-access-token-dialogue"); + + await submitMutation(page, "POST", () => + page.getByRole("button", { name: "Create Machine Access" }).click() + ); + await page + .getByRole("heading", { name: "Machine Access Created" }) + .waitFor({ state: "visible", timeout: 15_000 }); + + await page.goto(`${webuiUrl}#/access`); + await requireVisibleAfterReload(page, "Counter Till #1"); + await saveScreenshot("13b-access-tokens-list"); + + await page.getByRole("button", { name: "Actions for Counter Till #1" }).click(); + await menuItem(page, "Revoke access").click(); + await submitMutation(page, "DELETE", () => + page.getByRole("button", { name: "Revoke Access" }).click() + ); + await requireAbsentAfterReload(page, "Counter Till #1"); +} + +/** + * 11. Hardware Authenticators / POS Devices. + */ +export async function testAuthenticators( + page: any, + webuiUrl: string, + saveScreenshot: (name: string) => Promise<void>, +) { + await page.goto(`${webuiUrl}#/authenticators/new`); + const nameInput = page + .getByRole("textbox", { name: "Name" }) + .or(page.getByLabel("Name", { exact: true })) + .first(); + await nameInput.waitFor({ state: "visible", timeout: 15000 }); + await nameInput.fill("Offline Till Alpha"); + await saveScreenshot("14a-create-authenticator-dialogue"); + + await submitMutation(page, "POST", () => + page.getByRole("button", { name: "Add device", exact: true }).click() + ); + + await page.goto(`${webuiUrl}#/authenticators`); + await requireVisibleAfterReload(page, "Offline Till Alpha"); + await saveScreenshot("14b-authenticators-list"); + + await page.getByRole("button", { name: "Actions for Offline Till Alpha" }).click(); + await menuItem(page, "Edit").click(); + const editName = page.getByRole("textbox", { name: "Name" }).first(); + await editName.fill("Offline Till Alpha Updated"); + await submitMutation(page, "PATCH", () => + page.getByRole("button", { name: "Save Changes" }).click() + ); + await requireVisibleAfterReload(page, "Offline Till Alpha Updated"); + + await page.getByRole("button", { name: "Actions for Offline Till Alpha Updated" }).click(); + await menuItem(page, "Delete").click(); + await submitMutation(page, "DELETE", () => + page.getByRole("button", { name: "Delete Authenticator" }).click() + ); + await requireAbsentAfterReload(page, "Offline Till Alpha Updated"); +} + +/** + * 12. Subscriptions & Discount Passes. + */ +export async function testSubscriptions( + page: any, + webuiUrl: string, + saveScreenshot: (name: string) => Promise<void>, +) { + await page.goto(`${webuiUrl}#/subscriptions/new`); + const nameInput = page + .getByRole("textbox", { name: "Name" }) + .or(page.getByLabel("Name", { exact: true })) + .first(); + await nameInput.waitFor({ state: "visible", timeout: 15000 }); + await page + .locator("button") + .filter({ has: page.getByText("Pass", { exact: true }) }) + .click(); + await nameInput.fill("Monthly Supporter Pass"); + await page + .getByLabel("Description") + .fill("Monthly supporter access for participating services."); + await page + .getByRole("radio", { name: "No redemption benefit" }) + .check(); + await saveScreenshot("15a-create-subscription-dialogue"); + + await submitMutation(page, "POST", () => + page.getByRole("button", { name: "Create Discount / Pass" }).click() + ); + + await page.goto(`${webuiUrl}#/subscriptions`); + await requireVisibleAfterReload(page, "Monthly Supporter Pass"); + await saveScreenshot("15b-subscriptions-list"); + + await page.getByRole("button", { name: "Actions for Monthly Supporter Pass" }).click(); + await menuItem(page, "Edit").click(); + const editName = page.locator("#sub_name_input"); + await editName.waitFor({ state: "visible", timeout: 15_000 }); + await editName.fill("Monthly Supporter Pass Updated"); + await submitMutation(page, "PATCH", () => + page.getByRole("button", { name: "Save Changes" }).click() + ); + await requireVisibleAfterReload(page, "Monthly Supporter Pass Updated"); + + await page.getByRole("button", { name: "Actions for Monthly Supporter Pass Updated" }).click(); + await menuItem(page, "Delete").click(); + await submitMutation(page, "DELETE", () => + page.getByRole("button", { name: "Delete Discount / Pass" }).click() + ); + await requireAbsentAfterReload(page, "Monthly Supporter Pass Updated"); +} + +/** + * 13. Add bank account in the UI & payout accounts overview. + */ +export async function testPayoutAccountsInUi( + page: any, + webuiUrl: string, + saveScreenshot: (name: string) => Promise<void>, +) { + await page.goto(`${webuiUrl}#/money/payout-accounts/add`); + + await page.getByLabel("Payment Method").selectOption("x-taler-bank"); + await page.getByLabel("Bank Server Host").fill("bank.example.test"); + await page.getByLabel("Account Name / ID").fill("harness-ui"); + + const holderInput = page + .getByRole("textbox", { name: "Account Holder Name" }) + .or(page.getByLabel("Account Holder Name")) + .first(); + await holderInput.fill("Merchant Payout Account UI"); + + await saveScreenshot("17a-add-payout-account-dialogue"); + + const submitButton = page + .getByRole("button", { name: /save bank account|add bank account/i }) + .first(); + await submitMutation(page, "POST", () => submitButton.click()); + + await page.goto(`${webuiUrl}#/money/payout-accounts`); + await requireVisibleAfterReload(page, "Merchant Payout Account UI"); + await saveScreenshot("17b-payout-accounts-list"); + + await page.getByRole("button", { + name: "Actions for bank account Merchant Payout Account UI", + }).click(); + await menuItem(page, "Delete").click(); + await submitMutation(page, "DELETE", () => + page.getByRole("button", { name: "Yes, remove it" }).click() + ); + await requireAbsentAfterReload(page, "Merchant Payout Account UI"); +} + +/** + * 14. Reports, Schedule Reports, Statistics & Server Info. + */ +export async function testAnalyticsAndReports( + page: any, + webuiUrl: string, + saveScreenshot: (name: string) => Promise<void>, +) { + await page.goto(`${webuiUrl}#/reports`); + const reportsMain = page.getByRole("main"); + await reportsMain.waitFor({ state: "visible", timeout: 15000 }); + await saveScreenshot("11a-reports-screen"); + + await page.getByRole("button", { name: /Report Groupings/ }).click(); + await page.getByRole("button", { name: /Add product group/i }).click(); + await page.locator("#grp_name_input").fill("harness_drinks"); + await page.locator("#grp_desc_input").fill("Harness reporting group"); + await submitMutation(page, "POST", () => + page.getByRole("button", { name: "Create Product Group" }).click() + ); + await requireVisibleAfterReload(page, "harness_drinks"); + + await page.getByRole("button", { name: "Actions for harness_drinks" }).click(); + await menuItem(page, "Edit").click(); + await page.locator("#grp_name_input").fill("harness_drinks_updated"); + await submitMutation(page, "PATCH", () => + page.getByRole("button", { name: "Save Group" }).click() + ); + await requireVisibleAfterReload(page, "harness_drinks_updated"); + + await page.getByRole("button", { name: "Actions for harness_drinks_updated" }).click(); + await menuItem(page, "Delete").click(); + await submitMutation(page, "DELETE", () => + page.getByRole("button", { name: "Delete Group" }).click() + ); + await requireAbsentAfterReload(page, "harness_drinks_updated"); + + await page.locator("#add_pot_btn").click(); + await page.locator("#pot_name_input").fill("harness_reserve"); + await page.locator("#pot_desc_input").fill("Harness allocation reserve"); + await submitMutation(page, "POST", () => + page.getByRole("button", { name: "Create Money Pot" }).click() + ); + await requireVisibleAfterReload(page, "harness_reserve"); + + await page.getByRole("button", { name: "Actions for harness_reserve" }).click(); + await menuItem(page, "Edit").click(); + await page.locator("#pot_name_input").fill("harness_reserve_updated"); + await submitMutation(page, "PATCH", () => + page.getByRole("button", { name: "Save Money Pot" }).click() + ); + await requireVisibleAfterReload(page, "harness_reserve_updated"); + + await page.getByRole("button", { name: "Actions for harness_reserve_updated" }).click(); + await menuItem(page, "Delete").click(); + await submitMutation(page, "DELETE", () => + page.getByRole("button", { name: "Delete Money Pot" }).click() + ); + await requireAbsentAfterReload(page, "harness_reserve_updated"); + + await page.goto(`${webuiUrl}#/reports/new`); + await page.locator("#rep_desc_in").fill("Harness weekly sales"); + await page.locator("#rep_freq_in").selectOption("weekly"); + await page.locator("#rep_target_in").fill("reports@example.com"); + await saveScreenshot("11b-schedule-report-dialogue"); + await submitMutation(page, "POST", () => + page.getByRole("button", { name: "Schedule Report" }).click() + ); + await requireVisibleAfterReload(page, "Harness weekly sales"); + await page.getByRole("button", { name: "Actions for Harness weekly sales" }).click(); + await menuItem(page, "Cancel Schedule").click(); + await submitMutation(page, "DELETE", () => + page.getByRole("button", { name: "Cancel Report" }).click() + ); + await requireAbsentAfterReload(page, "Harness weekly sales"); + + await page.goto(`${webuiUrl}#/statistics`); + await page.waitForTimeout(200); + await saveScreenshot("11c-statistics-screen"); + + await page.goto(`${webuiUrl}#/settings/server`); + const serverMain = page.getByRole("main"); + await serverMain.waitFor({ state: "visible", timeout: 15000 }); + await saveScreenshot("12-server-info"); +} diff --git a/packages/taler-harness/src/integrationtests/test-merchant-webui-bootstrap.ts b/packages/taler-harness/src/integrationtests/test-merchant-webui-bootstrap.ts @@ -0,0 +1,114 @@ +/* + 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 { + ExchangeService, + GlobalTestState, + MerchantService, + setupDb, +} from "../harness/harness.js"; +import { launchMerchantWebuiBrowser } from "../harness/merchant-webui-browser.js"; +import { startStaticServerMerchantWebui } from "../harness/webui-server.js"; + +/** Browser-level coverage for the only unauthenticated management mutation. */ +export async function runMerchantWebuiBootstrapTest(t: GlobalTestState) { + const db = await setupDb(t); + // The merchant requires a trusted exchange in its deployment config even + // though bootstrap itself neither contacts nor provisions that exchange. + const exchange = ExchangeService.create(t, { + name: "bootstrap-exchange", + currency: "CHF", + httpPort: 8081, + database: db.connStr, + }); + const merchant = await MerchantService.create(t, { + name: "bootstrap-merchant", + currency: "CHF", + httpPort: 8083, + database: db.connStr, + }); + merchant.addExchange(exchange); + await merchant.start({ skipConfigValidation: true }); + await merchant.pingUntilAvailable(); + + const backendUrl = merchant.makeInstanceBaseUrl(); + const webui = await startStaticServerMerchantWebui(backendUrl); + let browserSession: + | Awaited<ReturnType<typeof launchMerchantWebuiBrowser>> + | undefined; + try { + browserSession = await launchMerchantWebuiBrowser(); + const page = browserSession.page; + await page.goto(webui.url); + await page + .getByRole("heading", { name: "Set up this merchant server" }) + .waitFor({ state: "visible", timeout: 15_000 }); + + // The bootstrap frame must retain the same server repair path as sign-in. + await page.getByTitle("Change merchant backend server URL").click(); + await page + .getByRole("textbox", { name: "Server address" }) + .waitFor({ state: "visible", timeout: 15_000 }); + await page.getByRole("button", { name: "Cancel" }).click(); + await page + .getByRole("heading", { name: "Set up this merchant server" }) + .waitFor({ state: "visible", timeout: 15_000 }); + + await page.getByLabel("Business name").fill("Bootstrap Merchant"); + await page.getByLabel("Email Address").fill("admin@example.com"); + await page + .getByLabel("Password *", { exact: true }) + .fill("bootstrap-password"); + await page + .getByLabel("Confirm password *", { exact: true }) + .fill("bootstrap-password"); + + const responsePromise = page.waitForResponse( + (response: any) => + response.request().method() === "POST" && + response.url().endsWith("/management/instances"), + ); + await page + .getByRole("button", { name: "Create administrator account" }) + .click(); + const response = await responsePromise; + t.assertTrue(response.status() === 200 || response.status() === 204); + t.assertTrue( + !response.request().headers()["authorization"], + "first-instance creation must be unauthenticated", + ); + + await page + .getByRole("complementary") + .waitFor({ state: "visible", timeout: 15_000 }); + await page + .getByText("admin", { exact: true }) + .last() + .waitFor({ state: "visible", timeout: 15_000 }); + + await page.reload(); + await page + .getByRole("complementary") + .waitFor({ state: "visible", timeout: 15_000 }); + await page.getByRole("button", { name: /Sign out/i }).click(); + await page + .getByRole("button", { name: "Sign in", exact: true }) + .waitFor({ state: "visible", timeout: 15_000 }); + t.assertTrue( + (await page + .getByRole("heading", { name: "Set up this merchant server" }) + .count()) === 0, + ); + } finally { + await browserSession?.close(); + await webui.close(); + } +} + +runMerchantWebuiBootstrapTest.suites = ["web", "merchant", "merchant-webui"]; diff --git a/packages/taler-harness/src/integrationtests/test-merchant-webui-kyc-swap.ts b/packages/taler-harness/src/integrationtests/test-merchant-webui-kyc-swap.ts @@ -0,0 +1,314 @@ +/* + 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 { + MerchantAccountKycStatus, + MerchantAuthMethod, + Paytos, + Result, + TalerMerchantManagementHttpClient, +} from "@gnu-taler/taler-util"; +import fs from "node:fs"; +import path from "node:path"; +import { GlobalTestState, waitMs } from "../harness/harness.js"; +import { createTopsEnvironment } from "../harness/tops.js"; +import { + assertNoUnexpectedErrorBanner, + launchMerchantWebuiBrowser, + type MerchantWebuiBrowser, +} from "../harness/merchant-webui-browser.js"; +import { startStaticServerMerchantWebui } from "../harness/webui-server.js"; + +/** + * Exercise the merchant WebUI's protocol-v31 onboarding order against a real + * local exchange and merchant backend. Functional assertions always run; + * HARNESS_SCREENSHOTS=1 additionally keeps every UI checkpoint as a PNG. + */ +export async function runMerchantWebuiKycSwapTest(t: GlobalTestState) { + const env = await createTopsEnvironment(t, { + adjustExchangeConfig: (config) => { + config.setString("exchange", "KYC_SWAP_TOS_ACCEPTANCE", "YES"); + // The WebUI currently records this version through the merchant API. + // Keep the exchange's advertised and AML-program versions identical so + // this test exercises ordering rather than a version-mismatch failure. + config.setString("exchange", "TERMS_ETAG", "1.0"); + }, + extraProcEnv: { + EXCHANGE_AML_PROGRAM_TOPS_ENABLE_DEPOSITS_TOS_NAME: "1.0", + }, + }); + + const instanceId = "admin"; + const password = "secretpassword123"; + const baseUrl = env.merchant.makeInstanceBaseUrl(instanceId); + const managementClient = new TalerMerchantManagementHttpClient(baseUrl); + await managementClient.updateCurrentInstanceAuthentication( + env.merchantAdminAccessToken, + { + method: MerchantAuthMethod.TOKEN, + password, + }, + ); + + const initialKyc = await env.merchantApi.getCurrentInstanceKycStatus( + env.merchantAdminAccessToken, + { + longpoll: { + type: "state-enter", + status: MerchantAccountKycStatus.KYC_WIRE_REQUIRED, + timeout: 30_000, + }, + }, + ); + t.assertDeepEqual(initialKyc.case, "ok"); + const initialProvider = initialKyc.body.kyc_data[0]; + t.assertDeepEqual( + initialProvider.status, + MerchantAccountKycStatus.KYC_WIRE_REQUIRED, + ); + t.assertTrue(initialProvider.kyc_swap_tos_acceptance === true); + + const webuiServer = await startStaticServerMerchantWebui(baseUrl); + const takeScreenshots = Boolean(process.env.HARNESS_SCREENSHOTS); + const screenshotDir = path.join(t.testDir, "screenshots"); + if (takeScreenshots) fs.mkdirSync(screenshotDir, { recursive: true }); + + let browserSession: MerchantWebuiBrowser | undefined; + let page: any; + const checkpoint = async (name: string) => { + await assertNoUnexpectedErrorBanner( + page, + `merchant-webui-kyc-swap ${name}`, + ); + if (!takeScreenshots) return; + const file = path.join(screenshotDir, `${name}.png`); + await page.screenshot({ path: file, fullPage: true }); + console.log(`Saved screenshot to ${file}`); + }; + + try { + browserSession = await launchMerchantWebuiBrowser(); + page = browserSession.page; + + await page.goto(`${webuiServer.url}#/change-server-url`); + const serverInput = page.getByRole("textbox", { name: "Server address" }); + await serverInput.waitFor({ state: "visible", timeout: 15_000 }); + await serverInput.fill(baseUrl); + await page.getByRole("button", { name: "Save & Apply Server URL" }).click(); + + await page.goto(`${webuiServer.url}#/signin`); + await page + .getByRole("textbox", { name: "Merchant Account" }) + .fill(instanceId); + await page.getByLabel("Password", { exact: true }).fill(password); + await page.getByRole("button", { name: "Sign in" }).click(); + await page + .getByRole("complementary") + .waitFor({ state: "visible", timeout: 15_000 }); + + await page.goto(`${webuiServer.url}#/money/payout-accounts`); + const progress = page + .getByLabel("Payment service onboarding progress") + .first(); + await progress.waitFor({ state: "visible", timeout: 15_000 }); + t.assertTrue((await progress.textContent()).includes("Accept terms")); + t.assertTrue((await progress.textContent()).includes("Account validation")); + t.assertTrue(!(await progress.textContent()).includes("More information")); + await checkpoint("01-terms-before-account-validation"); + + await page.getByRole("button", { name: /Wire instructions/ }).click(); + await page + .getByText("Before the transfer: accept your payment serviceโs terms") + .waitFor({ + state: "visible", + timeout: 15_000, + }); + await page + .getByText("Accept the terms above to see the transfer details.") + .waitFor({ + state: "visible", + timeout: 15_000, + }); + await checkpoint("02-transfer-details-gated-by-terms"); + + await page.route("**/terms", async (route: any) => { + await route.fulfill({ + status: 200, + contentType: "text/plain", + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Expose-Headers": "taler-terms-version", + "taler-terms-version": "1.0", + }, + body: "Test terms", + }); + }); + await page.getByRole("link", { name: /Read the terms/ }).click(); + await page.getByRole("checkbox", { name: /I have read and agree/ }).check(); + await page.getByRole("button", { name: "Accept the terms" }).click(); + await page.getByText(/Transfer option 1: receiver/).waitFor({ + state: "visible", + timeout: 15_000, + }); + await checkpoint("03-terms-accepted-transfer-details-visible"); + + await page + .getByRole("button", { name: "โ Bank accounts", exact: true }) + .click(); + await progress.waitFor({ state: "visible", timeout: 15_000 }); + const afterTerms = await progress.textContent(); + t.assertTrue(afterTerms.includes("Accept terms")); + t.assertTrue(afterTerms.includes("Account validation")); + t.assertTrue(!afterTerms.includes("More information")); + await checkpoint("04-account-validation-current"); + + // Leave the live status screen while advancing the exchange state. This + // avoids racing the WebUI's KYC long-poll with the explicit kyccheck calls + // below (and keeps this test focused on one observer at a time). + await page.goto("about:blank"); + + const afterEarlyTos = await env.merchantApi.getCurrentInstanceKycStatus( + env.merchantAdminAccessToken, + {}, + ); + t.assertDeepEqual(afterEarlyTos.case, "ok"); + const provider = afterEarlyTos.body.kyc_data[0]; + t.assertDeepEqual(provider.tos_accepted_early, "1.0"); + t.assertTrue( + provider.payto_kycauths != null && provider.payto_kycauths.length > 0, + ); + + const authPayto = Result.unpack( + Paytos.fromString(provider.payto_kycauths![0]), + ); + const authMessage = authPayto.params.message; + t.assertTrue( + typeof authMessage === "string" && authMessage.startsWith("KYC:"), + ); + await env.wireGatewayApi.addKycAuth({ + auth: env.bank.getAdminAuth(), + body: { + amount: "CHF:0.1", + debit_account: provider.payto_uri, + account_pub: authMessage.substring(4), + }, + }); + + let requiredProvider: typeof provider | undefined; + for (let attempt = 0; attempt < 20; attempt++) { + await env.merchant.runKyccheckOnce(); + const status = await env.merchantApi.getCurrentInstanceKycStatus( + env.merchantAdminAccessToken, + {}, + ); + t.assertDeepEqual(status.case, "ok"); + const current = status.body.kyc_data[0]; + if (current?.status === MerchantAccountKycStatus.KYC_REQUIRED) { + requiredProvider = current; + break; + } + await waitMs(500); + } + t.assertTrue(requiredProvider !== undefined); + t.assertTrue(typeof requiredProvider!.access_token === "string"); + + await page.goto(`${webuiServer.url}#/money/payout-accounts`); + const moreInformationProgress = page + .getByLabel("Payment service onboarding progress") + .first(); + await moreInformationProgress.waitFor({ + state: "visible", + timeout: 15_000, + }); + const moreInformationText = await moreInformationProgress.textContent(); + t.assertTrue(moreInformationText.includes("Accept terms")); + t.assertTrue(moreInformationText.includes("Account validation")); + t.assertTrue(moreInformationText.includes("More information")); + await checkpoint("05-more-information-only-when-required"); + await page.goto("about:blank"); + + const kycInfo = await env.exchangeApi.checkKycInfo( + requiredProvider!.access_token!, + ); + t.assertDeepEqual(kycInfo.case, "ok"); + t.assertDeepEqual(kycInfo.body.requirements.length, 1); + t.assertDeepEqual(kycInfo.body.requirements[0].form, "accept-tos"); + const requirementId = kycInfo.body.requirements[0].id; + t.assertTrue(typeof requirementId === "string"); + const upload = await env.exchangeApi.uploadKycForm(requirementId!, { + FORM_ID: "accept-tos", + FORM_VERSION: 1, + ACCEPTED_TERMS_OF_SERVICE: "1.0", + DOWNLOADED_TERMS_OF_SERVICE: true, + }); + t.assertDeepEqual(upload.case, "ok"); + + let finalStatus: string | undefined; + for (let attempt = 0; attempt < 20; attempt++) { + await env.merchant.runKyccheckOnce(); + const status = await env.merchantApi.getCurrentInstanceKycStatus( + env.merchantAdminAccessToken, + {}, + ); + t.assertDeepEqual(status.case, "ok"); + finalStatus = status.body.kyc_data[0]?.status; + if (finalStatus === MerchantAccountKycStatus.READY) break; + await waitMs(500); + } + t.assertDeepEqual(finalStatus, MerchantAccountKycStatus.READY); + + await page.goto(`${webuiServer.url}#/money/payout-accounts`); + const readyProgressLocator = page + .getByLabel("Payment service onboarding progress") + .first(); + await readyProgressLocator.waitFor({ + state: "visible", + timeout: 15_000, + }); + const readyProgress = await readyProgressLocator.textContent(); + t.assertTrue(readyProgress.includes("Accept terms")); + t.assertTrue(readyProgress.includes("Account validation")); + t.assertTrue(readyProgress.includes("Ready")); + t.assertTrue(!readyProgress.includes("More information")); + await page.getByText("Bank account added.", { exact: true }).waitFor({ + state: "visible", + timeout: 15_000, + }); + await checkpoint("06-ready-for-first-payment"); + } catch (cause) { + if (takeScreenshots && page) { + try { + await page.screenshot({ + path: path.join(screenshotDir, "99-failure.png"), + fullPage: true, + }); + } catch { + // Preserve the original failure. + } + } + throw cause; + } finally { + if (browserSession) await browserSession.close(); + await webuiServer.close(); + } +} + +runMerchantWebuiKycSwapTest.suites = [ + "web", + "merchant", + "merchant-webui", + "tops", +]; diff --git a/packages/taler-harness/src/integrationtests/test-merchant-webui-mfa.ts b/packages/taler-harness/src/integrationtests/test-merchant-webui-mfa.ts @@ -0,0 +1,599 @@ +/* + 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 { + alternativeOrThrow, + HttpStatusCode, + LoginTokenScope, + succeedOrThrow, + TalerMerchantInstanceHttpClient, +} from "@gnu-taler/taler-util"; +import fs from "node:fs"; +import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; +import { GlobalTestState, MERCHANT_DEFAULT_AUTH } from "../harness/harness.js"; +import { launchMerchantWebuiBrowser } from "../harness/merchant-webui-browser.js"; +import { + configureTestMerchantMfa, + makeMfaConfigEmailOnly, + solveMFA, + wait2FaCode, +} from "../harness/tan-helper.js"; +import { startStaticServerMerchantWebui } from "../harness/webui-server.js"; + +const account = "admin"; +const mfaEmail = "merchant-mfa@example.test"; +const resetAccount = "password-reset-user"; +const resetEmail = "password-reset@example.test"; +const resetOriginalPassword = "merchant-reset-original-password"; +const resetNewPassword = "merchant-reset-new-password"; + +async function enterLatestEmailCode( + t: GlobalTestState, + page: any, + codePath: string, + expectedAddress = mfaEmail, +): Promise<void> { + const continueButton = page.getByRole("button", { + name: "Continue", + exact: true, + }); + if (await continueButton.isVisible()) { + const emailChoice = page.getByText(/Email to address/).first(); + if (await emailChoice.isVisible()) await emailChoice.click(); + await continueButton.click(); + } + const codeInput = page.getByLabel("Authentication code"); + try { + await codeInput.waitFor({ state: "visible", timeout: 15_000 }); + } catch (cause) { + console.log(`MFA PAGE URL: ${page.url()}`); + console.log(`MFA PAGE TEXT: ${await page.locator("body").innerText()}`); + throw cause; + } + const delivered = await wait2FaCode(codePath); + t.assertDeepEqual(delivered.address, expectedAddress); + await codeInput.fill(delivered.code); + await page.getByRole("button", { name: "Confirm", exact: true }).click(); +} + +async function startMachineAccess( + page: any, + webuiUrl: string, + description: string, + codePath: string, + password = MERCHANT_DEFAULT_AUTH.password, +): Promise<void> { + fs.rmSync(codePath, { force: true }); + await page.goto(`${webuiUrl}#/access/new`); + await page.getByLabel("Used for").fill(description); + await page.getByLabel("Current Password").fill(password); + await page + .getByRole("button", { name: "Create Machine Access", exact: true }) + .click(); +} + +async function startBankAccountAdd( + page: any, + webuiUrl: string, + accountName: string, +): Promise<void> { + await page.goto(`${webuiUrl}#/money/payout-accounts/add`); + await page.getByLabel("Payment Method").selectOption("x-taler-bank"); + await page.getByLabel("Bank Server Host").fill("bank.example.test"); + await page.getByLabel("Account Name / ID").fill(accountName); + await page.getByLabel("Account Holder Name").fill("MFA Second Account"); + await page.getByRole("button", { name: "Save bank account" }).click(); +} + +/** + * Exercise merchant-webui against a staging-shaped service setup: the + * ordinary bank/exchange/merchant environment, with mandatory email TAN + * delivery enabled at the merchant backend. + */ +async function runMerchantWebuiMfaTestImpl( + t: GlobalTestState, + passwordResetOnly: boolean, +) { + const { merchant, merchantAdminAccessToken } = + await createSimpleTestkudosEnvironmentV3(t); + + const instanceUrl = merchant.makeInstanceBaseUrl(account); + const instanceApi = new TalerMerchantInstanceHttpClient(instanceUrl); + const { accessToken: resetAccessToken } = + await merchant.addInstanceWithWireAccount( + { + id: resetAccount, + name: "Password Reset Test Account", + paytoUris: [], + auth: { + ...MERCHANT_DEFAULT_AUTH, + password: resetOriginalPassword, + }, + }, + { adminAccessToken: merchantAdminAccessToken }, + ); + const resetInstanceApi = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(resetAccount), + ); + + // The standard fixture deliberately has no contact address. Add one before + // enabling mandatory TANs, preserving all of its existing settings. + const details = succeedOrThrow( + await instanceApi.getCurrentInstanceDetails(merchantAdminAccessToken), + ); + const instanceSettings = { + name: details.name, + email: mfaEmail, + phone_number: details.phone_number, + website: details.website, + logo: details.logo, + address: details.address, + jurisdiction: details.jurisdiction, + use_stefan: details.use_stefan, + default_pay_delay: details.default_pay_delay, + default_refund_delay: details.default_refund_delay, + default_wire_transfer_delay: details.default_wire_transfer_delay, + default_wire_transfer_rounding_interval: + details.default_wire_transfer_rounding_interval, + }; + succeedOrThrow( + await instanceApi.updateCurrentInstance( + merchantAdminAccessToken, + instanceSettings, + ), + ); + + const resetDetails = succeedOrThrow( + await resetInstanceApi.getCurrentInstanceDetails(resetAccessToken), + ); + const resetInstanceSettings = { + name: resetDetails.name, + email: resetEmail, + phone_number: resetDetails.phone_number, + website: resetDetails.website, + logo: resetDetails.logo, + address: resetDetails.address, + jurisdiction: resetDetails.jurisdiction, + use_stefan: resetDetails.use_stefan, + default_pay_delay: resetDetails.default_pay_delay, + default_refund_delay: resetDetails.default_refund_delay, + default_wire_transfer_delay: resetDetails.default_wire_transfer_delay, + default_wire_transfer_rounding_interval: + resetDetails.default_wire_transfer_rounding_interval, + }; + succeedOrThrow( + await resetInstanceApi.updateCurrentInstance( + resetAccessToken, + resetInstanceSettings, + ), + ); + + const mfa = makeMfaConfigEmailOnly(t, mfaEmail); + const resetMfa = makeMfaConfigEmailOnly(t, resetEmail); + await merchant.stop(); + await merchant.modifyConfig(async (cfg) => { + configureTestMerchantMfa(cfg, mfa); + }); + await merchant.start({ skipDbinit: true }); + + // A deployed backend only uses a contact channel for MFA after ownership of + // that channel has been proven. Validate it through the public challenge + // protocol instead of manufacturing database state in the fixture. Include + // a normal settings change so the successful PATCH persists the validation + // flag as well as proving ownership of the new address. + const validatedInstanceSettings = { + ...instanceSettings, + website: "https://merchant-mfa.example.test/", + }; + fs.rmSync(mfa.email.path, { force: true }); + const validationChallenge = alternativeOrThrow( + await instanceApi.updateCurrentInstance( + merchantAdminAccessToken, + validatedInstanceSettings, + ), + HttpStatusCode.Accepted, + ); + await solveMFA(t, instanceApi, validationChallenge, mfa); + succeedOrThrow( + await instanceApi.updateCurrentInstance( + merchantAdminAccessToken, + validatedInstanceSettings, + { + challengeIds: validationChallenge.challenges.map( + (challenge) => challenge.challenge_id, + ), + }, + ), + ); + const validatedDetails = succeedOrThrow( + await instanceApi.getCurrentInstanceDetails(merchantAdminAccessToken), + ); + t.assertDeepEqual(validatedDetails.email_validated, true); + + const validatedResetSettings = { + ...resetInstanceSettings, + website: "https://password-reset.example.test/", + }; + fs.rmSync(resetMfa.email.path, { force: true }); + const resetValidationChallenge = alternativeOrThrow( + await resetInstanceApi.updateCurrentInstance( + resetAccessToken, + validatedResetSettings, + ), + HttpStatusCode.Accepted, + ); + await solveMFA(t, resetInstanceApi, resetValidationChallenge, resetMfa); + succeedOrThrow( + await resetInstanceApi.updateCurrentInstance( + resetAccessToken, + validatedResetSettings, + { + challengeIds: resetValidationChallenge.challenges.map( + (challenge) => challenge.challenge_id, + ), + }, + ), + ); + const validatedResetDetails = succeedOrThrow( + await resetInstanceApi.getCurrentInstanceDetails(resetAccessToken), + ); + t.assertDeepEqual(validatedResetDetails.email_validated, true); + + const webuiServer = await startStaticServerMerchantWebui( + merchant.makeInstanceBaseUrl(), + ); + let browserSession: + | Awaited<ReturnType<typeof launchMerchantWebuiBrowser>> + | undefined; + + try { + browserSession = await launchMerchantWebuiBrowser(); + const page = browserSession.page; + + page.on("console", (msg: any) => { + if (msg.type() === "error") { + console.log(`BROWSER [error]: ${msg.text()}`); + } + }); + + // Configure the backend through the UI so this covers the same bootstrap + // path a deployed, configurable frontend uses. + await page.goto(`${webuiServer.url}#/change-server-url`); + await page + .getByRole("textbox", { name: "Server address" }) + .fill(merchant.makeInstanceBaseUrl()); + const serverUrlApplied = page.waitForURL(/#\/signin$/, { + timeout: 15_000, + }); + await page.getByRole("button", { name: "Save & Apply Server URL" }).click(); + await serverUrlApplied; + + if (passwordResetOnly) { + // Forgotten-password recovery starts without a session. Its 202 response + // must stay on a public MFA route, resume the exact reset request after + // the challenge, and leave the merchant able to sign in with the + // replacement password. + fs.rmSync(resetMfa.email.path, { force: true }); + await page.goto(`${webuiServer.url}#/forgot-password`); + await page.locator("#reset-account").fill(resetAccount); + await page.locator("#reset-new-password").fill(resetNewPassword); + await page.locator("#reset-confirm-password").fill(resetNewPassword); + const resetChallengeResponse = page.waitForResponse( + (response: any) => + response + .url() + .endsWith(`/instances/${resetAccount}/forgot-password`) && + response.request().method() === "POST" && + response.status() === HttpStatusCode.Accepted, + ); + await page + .getByRole("button", { + name: "Continue to Verification", + exact: true, + }) + .click(); + await resetChallengeResponse; + await page.waitForURL(/#\/forgot-password\/mfa$/, { timeout: 15_000 }); + await enterLatestEmailCode(t, page, resetMfa.email.path, resetEmail); + await page.waitForURL( + new RegExp(`#/signin\\?account=${resetAccount}&password_reset=1$`), + { timeout: 15_000 }, + ); + await page + .getByText("Your password was reset. Sign in with your new password.", { + exact: true, + }) + .waitFor({ state: "visible", timeout: 15_000 }); + + const oldPasswordAttempt = await resetInstanceApi.createAccessToken( + resetAccount, + resetOriginalPassword, + { scope: LoginTokenScope.All }, + ); + t.assertTrue( + oldPasswordAttempt.type === "fail", + "the original password still worked after browser password reset", + ); + + fs.rmSync(resetMfa.email.path, { force: true }); + await page.getByLabel("Password", { exact: true }).fill(resetNewPassword); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + await enterLatestEmailCode(t, page, resetMfa.email.path, resetEmail); + await page + .getByRole("complementary") + .waitFor({ state: "visible", timeout: 15_000 }); + return; + } + + // Mandatory MFA applies to the token exchange used for portal sign-in. + fs.rmSync(mfa.email.path, { force: true }); + await page.goto(`${webuiServer.url}#/signin`); + await page.getByRole("textbox", { name: "Merchant Account" }).fill(account); + await page + .getByLabel("Password", { exact: true }) + .fill(MERCHANT_DEFAULT_AUTH.password); + await page.getByRole("button", { name: "Sign in" }).click(); + await enterLatestEmailCode(t, page, mfa.email.path); + await page + .getByRole("complementary") + .waitFor({ state: "visible", timeout: 15_000 }); + + // The fixture already has one active bank account. Adding a second one is + // protected by the same account-configuration MFA operation as deletion. + // Exercise the complete browser flow so a 202 challenge cannot be + // mistaken for a failed save or discarded by navigation. + const secondAccountName = "merchant-mfa-second"; + fs.rmSync(mfa.email.path, { force: true }); + await startBankAccountAdd(page, webuiServer.url, secondAccountName); + await page + .getByLabel("Authentication code") + .waitFor({ state: "visible", timeout: 15_000 }); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await page.waitForURL(/#\/money\/payout-accounts\/add$/, { + timeout: 15_000, + }); + // Let the already-started delivery finish before removing its output, so + // it cannot race with and overwrite the code from the next attempt. + await wait2FaCode(mfa.email.path); + const accountsAfterCancel = succeedOrThrow( + await instanceApi.listBankAccounts(merchantAdminAccessToken), + ); + t.assertTrue( + !accountsAfterCancel.accounts.some((entry) => + entry.payto_uri.includes(secondAccountName), + ), + "cancelling MFA unexpectedly added the second bank account", + ); + + fs.rmSync(mfa.email.path, { force: true }); + await startBankAccountAdd(page, webuiServer.url, secondAccountName); + await page + .getByLabel("Authentication code") + .waitFor({ state: "visible", timeout: 15_000 }); + await page.getByLabel("Authentication code").fill("0000-0000"); + await page.getByRole("button", { name: "Confirm", exact: true }).click(); + await page + .getByText(/That code is not correct/) + .waitFor({ state: "visible", timeout: 15_000 }); + t.assertTrue( + page.url().includes("#/money/payout-accounts/mfa"), + "an invalid code discarded the pending bank account operation", + ); + await enterLatestEmailCode(t, page, mfa.email.path); + await page.waitForURL(/#\/money\/payout-accounts$/, { timeout: 15_000 }); + + const accountsAfterMfa = succeedOrThrow( + await instanceApi.listBankAccounts(merchantAdminAccessToken), + ); + t.assertTrue( + accountsAfterMfa.accounts.some((entry) => + entry.payto_uri.includes(secondAccountName), + ), + "the second bank account was not committed after MFA", + ); + + // A protected mutation must resume with the solved challenge IDs and only + // navigate once the merchant backend confirms the mutation. + const successfulDescription = "Staging MFA terminal"; + await startMachineAccess( + page, + webuiServer.url, + successfulDescription, + mfa.email.path, + ); + await enterLatestEmailCode(t, page, mfa.email.path); + await page.waitForURL(/#\/access\?created_token=/, { timeout: 15_000 }); + await page + .getByRole("table") + .getByText(successfulDescription, { exact: true }) + .waitFor({ state: "visible", timeout: 15_000 }); + + const afterSuccess = succeedOrThrow( + await instanceApi.listAccessTokens(merchantAdminAccessToken), + ); + t.assertTrue( + afterSuccess.tokens.some( + (token) => token.description === successfulDescription, + ), + "the protected action was not committed after MFA", + ); + + // Contact changes use the same protected reconfiguration endpoint. Keep + // the form draft through the challenge and verify the exact values reached + // the backend before the route reports success. + const changedEmail = "merchant-mfa-changed@example.test"; + fs.rmSync(mfa.email.path, { force: true }); + await page.goto(`${webuiServer.url}#/settings/account`); + await page + .getByRole("button", { name: /Customer contact/ }) + .waitFor({ state: "visible", timeout: 15_000 }); + await page.getByRole("button", { name: /Customer contact/ }).click(); + await page.getByLabel("Email Address").fill(changedEmail); + await page + .getByRole("button", { name: "Save changes", exact: true }) + .click(); + await enterLatestEmailCode(t, page, mfa.email.path, changedEmail); + await page.waitForURL(/#\/settings\/account$/, { timeout: 15_000 }); + let changedDetails = succeedOrThrow( + await instanceApi.getCurrentInstanceDetails(merchantAdminAccessToken), + ); + t.assertDeepEqual(changedDetails.email, changedEmail); + + const changedPhone = "+15550102026"; + fs.rmSync(mfa.email.path, { force: true }); + await page + .getByRole("button", { name: /Verification phone/ }) + .waitFor({ state: "visible", timeout: 15_000 }); + await page.getByRole("button", { name: /Verification phone/ }).click(); + await page.getByLabel("Mobile Phone Number").fill(changedPhone); + await page + .getByRole("button", { name: "Save changes", exact: true }) + .click(); + // With an e-mail-only policy the backend currently accepts a phone-number + // update directly. Other policies may protect it, so accept and exercise + // either documented outcome. + await page.waitForTimeout(500); + if (page.url().includes("/money/payout-accounts/mfa")) { + await enterLatestEmailCode(t, page, mfa.email.path, changedEmail); + } + await page.waitForURL(/#\/settings\/account$/, { timeout: 15_000 }); + changedDetails = succeedOrThrow( + await instanceApi.getCurrentInstanceDetails(merchantAdminAccessToken), + ); + t.assertDeepEqual(changedDetails.phone_number, changedPhone); + + // A wrong current password is rejected by the browser-local verifier and + // must not start a backend request. The correct password then exercises + // POST /private/auth through MFA while retaining the portal access token. + const changedPassword = "merchant-mfa-new-password"; + fs.rmSync(mfa.email.path, { force: true }); + await page.getByRole("button", { name: /Account password/ }).click(); + await page.getByLabel("Current Password").fill("definitely-wrong"); + await page.locator("#pwd-new").fill(changedPassword); + await page.locator("#pwd-confirm").fill(changedPassword); + await page + .getByRole("button", { name: "Update password", exact: true }) + .click(); + await page + .getByText(/Your current password is not correct/) + .waitFor({ state: "visible", timeout: 15_000 }); + t.assertTrue( + !fs.existsSync(mfa.email.path), + "a local current-password mismatch unexpectedly reached MFA", + ); + + await page + .getByLabel("Current Password") + .fill(MERCHANT_DEFAULT_AUTH.password); + await page + .getByRole("button", { name: "Update password", exact: true }) + .click(); + // This backend policy protects token creation but currently accepts the + // bearer-authorized /private/auth change directly. Deployments may return + // 202 here, and the WebUI must support that path as well. + await page.waitForTimeout(500); + if (page.url().includes("/money/payout-accounts/mfa")) { + await enterLatestEmailCode(t, page, mfa.email.path, changedEmail); + } + await page.waitForURL(/#\/settings\/account$/, { timeout: 15_000 }); + await page + .getByRole("button", { name: /Account password/ }) + .waitFor({ state: "visible", timeout: 15_000 }); + + // Challenge verification alone is not success. Inject a failure into the + // retried mutation and require the shared MFA route to retain/report it. + const failedDescription = "Staging MFA injected failure"; + let interceptedContinuation = false; + await page.route("**/private/token", async (route: any) => { + const request = route.request(); + const challengeIds = request.headers()["taler-challenge-ids"]; + if (challengeIds) { + interceptedContinuation = true; + await route.fulfill({ + status: 500, + contentType: "application/json", + headers: { "Access-Control-Allow-Origin": "*" }, + body: JSON.stringify({ + code: 60, + hint: "harness-injected post-MFA failure", + }), + }); + return; + } + await route.continue(); + }); + + await startMachineAccess( + page, + webuiServer.url, + failedDescription, + mfa.email.path, + changedPassword, + ); + await enterLatestEmailCode(t, page, mfa.email.path, changedEmail); + const completionAlert = page.getByRole("alert"); + await completionAlert.waitFor({ state: "visible", timeout: 15_000 }); + const alertText = await completionAlert.innerText(); + t.assertTrue( + alertText.includes( + "Your code was accepted, but the action did not finish", + ), + "post-MFA mutation failure title was not shown", + ); + t.assertTrue( + alertText.includes("harness-injected post-MFA failure"), + "post-MFA backend detail was not shown", + ); + t.assertTrue( + interceptedContinuation, + "the protected action was not retried with challenge IDs", + ); + + const afterFailure = succeedOrThrow( + await instanceApi.listAccessTokens(merchantAdminAccessToken), + ); + t.assertTrue( + !afterFailure.tokens.some( + (token) => token.description === failedDescription, + ), + "the backend unexpectedly committed the injected failed action", + ); + } finally { + if (browserSession) { + await browserSession.close(); + } + await webuiServer.close(); + } +} + +export async function runMerchantWebuiMfaTest(t: GlobalTestState) { + await runMerchantWebuiMfaTestImpl(t, false); +} + +export async function runMerchantWebuiPasswordResetMfaTest(t: GlobalTestState) { + await runMerchantWebuiMfaTestImpl(t, true); +} + +runMerchantWebuiMfaTest.timeoutMs = 120_000; +runMerchantWebuiMfaTest.suites = ["web", "merchant", "merchant-webui", "mfa"]; + +runMerchantWebuiPasswordResetMfaTest.timeoutMs = 120_000; +runMerchantWebuiPasswordResetMfaTest.suites = [ + "web", + "merchant", + "merchant-webui", + "mfa", +]; diff --git a/packages/taler-harness/src/integrationtests/test-merchant-webui-simple.ts b/packages/taler-harness/src/integrationtests/test-merchant-webui-simple.ts @@ -0,0 +1,1545 @@ +/* + 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/> + */ + +/** + * Mixed transactional and smoke coverage for merchant-webui. Product, template, + * webhook, access-token, authenticator, subscription, report, and payout-account + * helpers must not be treated as complete CRUD coverage unless they submit and + * verify create/read/edit/delete persistence without conditional skips. + */ +import { + AccessToken, + AmountString, + IbanString, + MerchantAuthMethod, + Paytos, + succeedOrThrow, + TalerMerchantInstanceHttpClient, + TalerMerchantManagementHttpClient, + TransactionMajorState, + TransactionMinorState, + AbsoluteTime, + Duration, + TokenFamilyKind, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { + ExchangeService, + GlobalTestState, + LibeufinNexusService, + MERCHANT_DEFAULT_AUTH, + MerchantService, + NexusBankAccountInfo, + setupDb, +} from "../harness/harness.js"; +import { createWalletDaemonWithClient } from "../harness/environments.js"; +import { defaultCoinConfig } from "../harness/denomStructures.js"; +import fs from "node:fs"; +import path from "node:path"; +import { + launchMerchantWebuiBrowser, + assertNoUnexpectedErrorBanner, + type MerchantWebuiBrowser, +} from "../harness/merchant-webui-browser.js"; +import { startStaticServerMerchantWebui } from "../harness/webui-server.js"; +import { + testProductsCrud, + testTemplatesCrud, + testWebhooksCrud, + testAccessTokensCrud, + testAuthenticators, + testSubscriptions, + testPayoutAccountsInUi, + testAnalyticsAndReports, +} from "./merchant-webui-transactional-flows.js"; + +/** + * 1. Sign in & authentication flow screens. + */ +async function testSignInAndAuthFlows( + page: any, + webuiUrl: string, + baseUrl: string, + instanceId: string, + password: string, + saveScreenshot: (name: string) => Promise<void>, +) { + // Set custom server URL by hand using WebUI form + await page.goto(`${webuiUrl}#/change-server-url`); + + const serverUrlInput = page.getByRole("textbox", { name: "Server address" }); + await serverUrlInput.waitFor({ state: "visible", timeout: 15000 }); + await saveScreenshot("00-change-server-url"); + await serverUrlInput.fill(baseUrl); + + const applyButton = page.getByRole("button", { + name: "Save & Apply Server URL", + }); + await applyButton.click(); + + // Forgot password screen preview + await page.goto(`${webuiUrl}#/forgot-password`); + await page + .getByRole("textbox", { name: /account|username/i }) + .or(page.getByLabel(/account/i)) + .first() + .waitFor({ state: "visible", timeout: 15000 }); + await saveScreenshot("01b-forgot-password-screen"); + + // Sign-in screen + await page.goto(`${webuiUrl}#/signin`); + const accountInput = page.getByRole("textbox", { name: "Merchant Account" }); + const passwordInput = page.getByLabel("Password", { exact: true }); + + await accountInput.waitFor({ state: "visible", timeout: 15000 }); + await passwordInput.waitFor({ state: "visible", timeout: 15000 }); + await saveScreenshot("01a-signin-form"); + + await accountInput.fill(instanceId); + await passwordInput.fill(password); + + const submitButton = page.getByRole("button", { name: "Sign in" }); + await submitButton.click(); + + const sidebar = page.getByRole("complementary"); + await sidebar.waitFor({ state: "visible", timeout: 15000 }); + await saveScreenshot("02-dashboard-signed-in"); +} + +/** Administrator CRUD, KYC inspection, credential handoff, and two-stage removal. */ +async function testMerchantAccountAdministration( + page: any, + webuiUrl: string, + adminPassword: string, + saveScreenshot: (name: string) => Promise<void>, +) { + const accountId = "harness-managed"; + const initialPassword = "managed-password-1"; + const resetPassword = "managed-password-2"; + + await page.goto(`${webuiUrl}#/admin/accounts`); + await page + .getByRole("heading", { name: "Merchant accounts" }) + .waitFor({ state: "visible", timeout: 15_000 }); + await saveScreenshot("02a-admin-accounts"); + + await Promise.all([ + page.waitForResponse((response: any) => + response.url().endsWith("/management/instances/admin/kyc"), + ), + page.goto(`${webuiUrl}#/admin/accounts/admin/verification`), + ]); + await saveScreenshot("02b-admin-account-verification"); + await page.goto(`${webuiUrl}#/admin/accounts`); + + await page.getByRole("button", { name: /Create merchant account/ }).click(); + await page.getByLabel("Account ID").fill(accountId); + await page.getByLabel("Business name").fill("Harness Managed Shop"); + await page.getByLabel("Password *", { exact: true }).fill(initialPassword); + await page.getByLabel("Confirm password").fill(initialPassword); + await page + .getByRole("button", { name: "Create merchant account", exact: true }) + .click(); + + await page + .getByText(accountId, { exact: true }) + .waitFor({ state: "visible", timeout: 15_000 }); + const createdRow = page.locator("tr", { hasText: accountId }); + await createdRow.getByRole("link", { name: "Inspect" }).click(); + await page + .getByText(`Merchant account ${accountId}`, { exact: true }) + .waitFor({ state: "visible", timeout: 15_000 }); + + await page.getByRole("button", { name: "Edit", exact: true }).click(); + await page.getByLabel("Business name").fill("Harness Managed Shop Updated"); + await page.getByRole("button", { name: "Save changes" }).click(); + await page + .getByRole("heading", { name: "Harness Managed Shop Updated" }) + .waitFor({ state: "visible", timeout: 15_000 }); + + await saveScreenshot("02c-admin-account-detail"); + + await page.goto(`${webuiUrl}#/admin/accounts/${accountId}/credentials`); + await page.getByLabel("New password", { exact: true }).fill(resetPassword); + await page.getByLabel("Confirm new password").fill(resetPassword); + await page + .getByRole("button", { name: "Reset password", exact: true }) + .click(); + + await page.getByRole("button", { name: "Sign in to account" }).click(); + await page.getByLabel("Password", { exact: true }).fill(resetPassword); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + await page + .getByText(accountId, { exact: true }) + .last() + .waitFor({ state: "visible", timeout: 15_000 }); + + // Sign back in as admin: the handoff intentionally replaced the session. + await page.goto(`${webuiUrl}#/signin`); + await page.getByRole("textbox", { name: "Merchant Account" }).fill("admin"); + await page.getByLabel("Password", { exact: true }).fill(adminPassword); + await page.getByRole("button", { name: "Sign in", exact: true }).click(); + await page + .getByText("admin", { exact: true }) + .last() + .waitFor({ state: "visible", timeout: 15_000 }); + await page.goto(`${webuiUrl}#/admin/accounts`); + await page + .getByRole("heading", { name: "Merchant accounts" }) + .waitFor({ state: "visible", timeout: 15_000 }); + + const row = page.locator("tr", { hasText: accountId }); + await row.getByRole("button", { name: "Disable", exact: true }).click(); + await page.getByRole("button", { name: "Disable account" }).click(); + await page + .getByRole("button", { name: "Disabled accounts", exact: true }) + .click(); + const disabledRow = page.locator("tr", { hasText: accountId }); + await disabledRow.getByRole("button", { name: "Purge", exact: true }).click(); + await page.getByLabel(/Type the account ID to confirm/).fill(accountId); + await Promise.all([ + page.waitForResponse((response: any) => + response.url().includes(`/management/instances/${accountId}?purge=YES`), + ), + page.getByRole("button", { name: "Purge permanently" }).click(), + ]); + await page + .getByRole("cell", { name: accountId, exact: true }) + .waitFor({ state: "hidden", timeout: 15_000 }); +} + +/** + * A real browser/network failure path: stale/empty state must not hide a + * failed read, and the banner's Refresh action must recover immediately. + */ +async function testReadFailureAndRecovery( + t: GlobalTestState, + page: any, + webuiUrl: string, +) { + let injectNextSnapshot = false; + const handler = async (route: any) => { + const requestUrl = new URL(route.request().url()); + const isSnapshot = + requestUrl.searchParams.get("limit") === "-21" && + !requestUrl.searchParams.has("timeout_ms"); + if (injectNextSnapshot && isSnapshot) { + injectNextSnapshot = false; + await route.fulfill({ + status: 500, + contentType: "application/json", + headers: { "Access-Control-Allow-Origin": "*" }, + body: JSON.stringify({ + code: 60, + hint: "harness-injected order-list failure", + }), + }); + return; + } + await route.continue(); + }; + + await page.route("**/private/orders**", handler); + try { + // The sign-in flow has already populated SWR's order cache. Navigating to + // the order list can therefore reuse fresh data without issuing a request, + // which would leave the failure injection unconsumed. Reach the route + // first, then reload the document so the new SWR instance must fetch its + // initial snapshot while the interceptor is armed. + await page.goto(`${webuiUrl}#/orders`); + injectNextSnapshot = true; + const failedSnapshot = page.waitForResponse((response: any) => { + const responseUrl = new URL(response.url()); + return ( + responseUrl.pathname.endsWith("/private/orders") && + responseUrl.searchParams.get("limit") === "-21" && + response.status() === 500 + ); + }); + await Promise.all([failedSnapshot, page.reload()]); + + const failure = page.getByText(/Could not fetch live orders/i).first(); + await failure.waitFor({ state: "visible", timeout: 15_000 }); + t.assertTrue( + (await page.getByText(/harness-injected order-list failure/i).count()) > + 0, + "the backend hint must remain visible/copyable", + ); + + await page.getByRole("button", { name: "Refresh" }).first().click(); + await failure.waitFor({ state: "hidden", timeout: 15_000 }); + } finally { + await page.unroute("**/private/orders**", handler); + } +} + +/** Navigation from list to detail must never occupy two blocking connections. */ +async function testLongPollRequestBudget( + t: GlobalTestState, + page: any, + webuiUrl: string, + orderId: string, +) { + const active = new Set<any>(); + let peak = 0; + const isBlocking = (request: any) => { + const url = new URL(request.url()); + return ( + url.pathname.includes("/private/orders") && + Number(url.searchParams.get("timeout_ms") || 0) >= 25_000 + ); + }; + const started = (request: any) => { + if (!isBlocking(request)) return; + active.add(request); + peak = Math.max(peak, active.size); + }; + const finished = (request: any) => active.delete(request); + page.on("request", started); + page.on("requestfinished", finished); + page.on("requestfailed", finished); + + try { + await page.goto(`${webuiUrl}#/orders`); + await page.waitForTimeout(750); + await page.goto(`${webuiUrl}#/orders/${encodeURIComponent(orderId)}`); + await page + .getByText(new RegExp(`Order ${orderId}`)) + .first() + .waitFor({ + state: "visible", + timeout: 15_000, + }); + await page.waitForTimeout(750); + t.assertTrue( + peak <= 1, + `only one blocking long poll may be active in a document (observed ${peak})`, + ); + } finally { + page.off("request", started); + page.off("requestfinished", finished); + page.off("requestfailed", finished); + } +} + +/** + * 2. Onboarding & setup screens. + */ +async function testOnboardingScreens( + page: any, + webuiUrl: string, + saveScreenshot: (name: string) => Promise<void>, +) { + await page.goto(`${webuiUrl}#/first-run`); + await page.waitForTimeout(200); + await saveScreenshot("16a-first-run-screen"); + + await page.goto(`${webuiUrl}#/guide`); + await page.waitForTimeout(200); + await saveScreenshot("16b-guided-setup-wizard"); +} + +/** + * 3. Business details & settings configuration. + */ +async function testBusinessDetails( + page: any, + webuiUrl: string, + saveScreenshot: (name: string) => Promise<void>, +) { + await page.goto(`${webuiUrl}#/settings/account`); + + // Business settings are intentionally summarized into disclosure rows. Open + // the identity editor before interacting with its form fields. + const identitySection = page.getByRole("button", { + name: /Identity and logo/i, + }); + await identitySection.waitFor({ state: "visible", timeout: 15000 }); + await identitySection.click(); + + const nameInput = page.getByRole("textbox", { name: "Business Name" }); + await nameInput.waitFor({ state: "visible", timeout: 15000 }); + await nameInput.fill("Admin Store (Updated)"); + + await saveScreenshot("03a-business-settings-dialogue"); + + const saveButton = page.getByRole("button", { + name: "Save changes", + }); + await saveButton.click(); + + await page.waitForTimeout(300); + await saveScreenshot("03b-business-settings-updated"); + + // Payment services screen + await page.goto(`${webuiUrl}#/settings/payment-services`); + await page.waitForTimeout(200); + await saveScreenshot("03c-payment-services"); +} + +/** + * 4. Personalization & preference settings. + */ +async function testPersonalization( + page: any, + webuiUrl: string, + saveScreenshot: (name: string) => Promise<void>, +) { + await page.goto(`${webuiUrl}#/personalization`); + + const saveButton = page.getByRole("button", { name: "Save preferences" }); + await saveButton.waitFor({ state: "visible", timeout: 15000 }); + await page + .getByRole("checkbox", { name: "Show advanced tools", exact: true }) + .check(); + await saveScreenshot("04a-personalization-dialogue"); + + await saveButton.click(); + await page.waitForTimeout(300); + await saveScreenshot("04b-personalization-saved"); +} + + + + + +/** + * 7. Orders creation, payment & verification in UI across unpaid, claimed, and paid states. + */ +async function testOrdersFlowAndPayment( + t: GlobalTestState, + page: any, + webuiUrl: string, + merchantInstanceClient: TalerMerchantInstanceHttpClient, + adminAccessToken: string, + walletClient: any, + saveScreenshot: (name: string) => Promise<void>, +) { + // CREATE order dialogue in UI + await page.goto(`${webuiUrl}#/orders/new`); + + const summaryInput = page.getByRole("textbox", { name: "Summary" }); + await summaryInput.waitFor({ state: "visible", timeout: 15000 }); + await summaryInput.fill("Handmade Pastry Order"); + + const amountInput = page.getByRole("spinbutton", { name: "Amount" }); + await amountInput.fill("12.50"); + + await saveScreenshot("08a-order-create-dialogue"); + + const createOrderButton = page.getByRole("button", { name: "Create Order" }); + await createOrderButton.click(); + + // READ order list in UI + await page.goto(`${webuiUrl}#/orders`); + const mainContent = page.getByRole("main"); + await mainContent.waitFor({ state: "visible", timeout: 15000 }); + await page.waitForTimeout(300); + await saveScreenshot("08b-orders-list"); + + // Create an order via merchant instance client for wallet payment + const orderResp = succeedOrThrow( + await merchantInstanceClient.createOrder( + adminAccessToken as any as AccessToken, + { + order: { + summary: "Fresh Coffee & Croissant", + amount: "CHF:8.50" as AmountString, + }, + }, + ), + ); + + // 1. Verify UNPAID order status in WebUI (QR code is displayed) + await page.goto(`${webuiUrl}#/orders/${orderResp.order_id}`); + const scanQrLabel = page.getByText(/Scan with Taler Wallet/i); + await scanQrLabel.waitFor({ state: "visible", timeout: 15000 }); + t.assertTrue( + (await scanQrLabel.count()) > 0, + "QR code prompt must be displayed for unpaid order", + ); + await saveScreenshot("08c-order-unpaid-detail"); + + const orderDetails = succeedOrThrow( + await merchantInstanceClient.getOrderDetails( + adminAccessToken as any as AccessToken, + orderResp.order_id, + ), + ); + + // 2. Prepare pay with wallet -> Order transitions to CLAIMED (QR code disappears on purpose) + const prepareRes = await walletClient.call( + WalletApiOperation.PreparePayForUriV2, + { + talerPayUri: (orderDetails as any).taler_pay_uri, + }, + ); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: prepareRes.transactionId, + txState: { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + }, + }); + + // Verify CLAIMED order status in WebUI (QR code is GONE, claimed notice shown) + await page.goto(`${webuiUrl}#/orders/${orderResp.order_id}`); + const claimedNotice = page.getByText( + /A wallet has this order and is paying for it/i, + ); + await claimedNotice.waitFor({ state: "visible", timeout: 15000 }); + t.assertTrue( + (await page.getByText(/Scan with Taler Wallet/i).count()) === 0, + "QR code must disappear when order is claimed", + ); + await saveScreenshot("08d-order-claimed-detail"); + + // 3. Confirm pay with wallet -> Order transitions to PAID + await walletClient.call(WalletApiOperation.ConfirmPay, { + transactionId: prepareRes.transactionId, + choiceIndex: 0, + }); + + // Verify PAID order status in WebUI + await page.goto(`${webuiUrl}#/orders/${orderResp.order_id}`); + await page.waitForTimeout(500); + t.assertTrue( + (await page.getByText(/Scan with Taler Wallet/i).count()) === 0, + "QR code must remain hidden when order is paid", + ); + await saveScreenshot("08e-order-paid-detail"); + + // 4. Verify paid order shows up in the Orders List in WebUI + await page.goto(`${webuiUrl}#/orders`); + // The responsive list has a mobile card and a desktop table row in the DOM; + // assert against the one CSS exposes at the current viewport. + const paidOrderEntry = page + .getByText("Fresh Coffee & Croissant") + .filter({ visible: true }); + await paidOrderEntry.waitFor({ state: "visible", timeout: 15000 }); + t.assertTrue( + (await paidOrderEntry.count()) > 0, + "Paid order must appear in orders list", + ); + await saveScreenshot("08f-orders-list-paid"); + return orderResp.order_id; +} + +/** + * 7b. Full refund flow: Create separate order, pay it, issue full refund, verify unclaimed state in UI, collect refund with wallet, verify collected state in UI. + */ +async function testRefundFlow( + t: GlobalTestState, + page: any, + webuiUrl: string, + merchantInstanceClient: TalerMerchantInstanceHttpClient, + adminAccessToken: string, + walletClient: any, + saveScreenshot: (name: string) => Promise<void>, +) { + // 1. Create a separate order specifically for refund testing + const orderResp = succeedOrThrow( + await merchantInstanceClient.createOrder( + adminAccessToken as any as AccessToken, + { + order: { + summary: "Refundable Special Item", + amount: "CHF:15.00" as AmountString, + }, + }, + ), + ); + + const orderDetails = succeedOrThrow( + await merchantInstanceClient.getOrderDetails( + adminAccessToken as any as AccessToken, + orderResp.order_id, + ), + ); + + // 2. Pay for the order with the wallet + const prepareRes = await walletClient.call( + WalletApiOperation.PreparePayForUriV2, + { + talerPayUri: (orderDetails as any).taler_pay_uri, + }, + ); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: prepareRes.transactionId, + txState: { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + }, + }); + + await walletClient.call(WalletApiOperation.ConfirmPay, { + transactionId: prepareRes.transactionId, + choiceIndex: 0, + }); + + // 3. Merchant issues full refund (CHF 15.00) + const refundResp = succeedOrThrow( + await merchantInstanceClient.addRefund( + adminAccessToken as any as AccessToken, + orderResp.order_id, + { + refund: "CHF:15.00" as AmountString, + reason: "Customer full refund request", + }, + ), + ); + + // 4. Verify UNCLAIMED refund state in WebUI (shows unclaimed refund banner and QR code) + await page.goto(`${webuiUrl}#/orders/${orderResp.order_id}`); + const unclaimedBanner = page + .getByText( + /Refund awaiting collection|Let the customer scan to collect the refund/i, + ) + .first(); + await unclaimedBanner.waitFor({ state: "visible", timeout: 15000 }); + t.assertTrue( + (await unclaimedBanner.count()) > 0, + "Unclaimed refund banner must be visible in UI before wallet collects it", + ); + await saveScreenshot("08g-order-refund-unclaimed-detail"); + + // 5. Wallet picks up / collects the refund + await walletClient.call(WalletApiOperation.StartRefundQueryForUri, { + talerRefundUri: refundResp.taler_refund_uri, + }); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: prepareRes.transactionId, + txState: { + major: TransactionMajorState.Done, + }, + }); + + // 6. Verify PICKED UP / COLLECTED refund state in WebUI + // This is intentionally reload(), not goto() to the identical URL. The + // latter is a no-op in Chromium and would keep the pre-collection snapshot. + await page.reload(); + const collectedBadge = page.getByText(/Fully refunded/i).first(); + try { + await collectedBadge.waitFor({ state: "visible", timeout: 15000 }); + } catch (cause) { + throw new Error( + `collected refund did not become visible; page contained:\n${await page.locator("body").innerText()}`, + { cause }, + ); + } + t.assertTrue( + (await collectedBadge.count()) > 0, + "Fully refunded status must be visible after the wallet collects the refund", + ); + await saveScreenshot("08h-order-refund-collected-detail"); +} + + + +/** + * 9. Point of Sale (POS / Till Counter) โ Catalog, Amount & History modes with full checkout & QR code checks. + */ +async function testPosScreen( + t: GlobalTestState, + page: any, + webuiUrl: string, + merchantInstanceClient: TalerMerchantInstanceHttpClient, + adminAccessToken: string, + walletClient: any, + saveScreenshot: (name: string) => Promise<void>, +) { + // 1. Create categories via merchant client + await merchantInstanceClient.addCategory( + adminAccessToken as any as AccessToken, + { name: "Beverages" }, + ); + await merchantInstanceClient.addCategory( + adminAccessToken as any as AccessToken, + { name: "Bakery" }, + ); + const categoryList = succeedOrThrow( + await merchantInstanceClient.listCategories( + adminAccessToken as any as AccessToken, + ), + ); + const beveragesId = categoryList.categories.find( + (category) => category.name === "Beverages", + )?.category_id; + t.assertTrue(Boolean(beveragesId), "Beverages category must have an ID"); + + await merchantInstanceClient.createTokenFamily( + adminAccessToken as any as AccessToken, + { + slug: "pos_beverage_20", + name: "Beverage club 20%", + description: "Twenty percent off beverages in the Web PoS", + kind: TokenFamilyKind.Discount, + valid_after: AbsoluteTime.toProtocolTimestamp(AbsoluteTime.now()), + valid_before: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ years: 1 }), + ), + ), + duration: Duration.toTalerProtocolDuration( + Duration.fromSpec({ days: 30 }), + ), + validity_granularity: Duration.toTalerProtocolDuration( + Duration.fromSpec({ days: 1 }), + ), + extra_data: { + expected_domains: [], + experimental_discount: { + type: "percentage", + percentage: "20", + rounding: { mode: "nearest", precision: "0.01" }, + product_selectors: [ + { + type: "product", + id: "prod_pos_espresso", + name: "POS Swiss Espresso", + }, + ], + required_tokens: 2, + issuance: { + product_selectors: [ + { + type: "product", + id: "prod_pos_espresso", + name: "POS Swiss Espresso", + }, + ], + minimum_purchase: "CHF:4.50", + issue_on_redemption: false, + }, + }, + }, + }, + ); + + // 2. Create products with prices & descriptions + await merchantInstanceClient.addProduct( + adminAccessToken as any as AccessToken, + { + product_id: "prod_pos_espresso", + description: "POS Swiss Espresso", + price: "CHF:4.50" as AmountString, + unit: "cup", + total_stock: -1, + categories: [beveragesId!], + }, + ); + await merchantInstanceClient.addProduct( + adminAccessToken as any as AccessToken, + { + product_id: "prod_pos_croissant", + description: "POS Artisan Croissant", + price: "CHF:3.50" as AmountString, + unit: "piece", + total_stock: -1, + }, + ); + + // 3. Manual order creation uses the same semantic calculator. + await page.goto(`${webuiUrl}#/orders/new`); + await page.locator("#order-amount").fill("4.50"); + await page.locator("#order-summary").fill("Manual semantic espresso"); + await page.getByRole("tab", { name: "Itemized order", exact: true }).click(); + await page + .locator("select") + .filter({ has: page.locator('option[value="prod_pos_espresso"]') }) + .selectOption("prod_pos_espresso"); + await page.getByRole("button", { name: "Add to Order" }).click(); + await page.getByText("Customer tokens", { exact: true }).waitFor({ + state: "visible", + timeout: 15000, + }); + await page + .getByRole("checkbox", { + name: /Redeem Beverage club 20% for this order/i, + }) + .waitFor({ state: "visible", timeout: 15000 }); + await page.getByRole("button", { name: "Create Order", exact: true }).click(); + await page.waitForURL(/#\/orders\//, { timeout: 15000 }); + await page + .getByRole("heading", { name: "Payment choices", exact: true }) + .waitFor({ + state: "visible", + timeout: 15000, + }); + await page + .getByText(/CHF\s*4\.50/) + .first() + .waitFor({ state: "visible" }); + await page + .getByText(/CHF\s*3\.60/) + .first() + .waitFor({ state: "visible" }); + t.assertTrue( + (await page + .getByText("Customer choice pending", { exact: true }) + .count()) === 1, + "an unpaid v1 order must explain that its total depends on the customer choice", + ); + + const manualOrders = succeedOrThrow( + await merchantInstanceClient.listOrders( + adminAccessToken as any as AccessToken, + { limit: -20 }, + ), + ); + const manualOrder = manualOrders.orders.find( + (order: any) => order.summary === "Manual semantic espresso", + ); + t.assertTrue(Boolean(manualOrder), "manual semantic order must be created"); + const manualDetails = succeedOrThrow( + await merchantInstanceClient.getOrderDetails( + adminAccessToken as any as AccessToken, + manualOrder!.order_id, + ), + ); + const manualChoices = + (manualDetails as any).proto_contract_terms?.choices ?? []; + t.assertTrue( + manualChoices.length === 2, + "manual order must contain full-price and discount choices", + ); + t.assertTrue( + manualChoices[1]?.amount === "CHF:3.6", + "manual order must apply the semantic percentage exactly", + ); + + // 4. Open POS Catalog Mode + await page.goto(`${webuiUrl}#/pos`); + const posHeader = page.getByText(/Taler Web PoS/i); + await posHeader.waitFor({ state: "visible", timeout: 15000 }); + await saveScreenshot("10a-pos-catalog-mode"); + + // Select a product from catalog (click on POS Swiss Espresso product card) + const productCard = page.getByText("POS Swiss Espresso").first(); + await productCard.waitFor({ state: "visible", timeout: 15000 }); + await productCard.click(); + + // Add an ad-hoc custom item + const adHocBtn = page.getByRole("button", { name: /Ad-hoc Item/i }).first(); + await adHocBtn.click(); + + const itemDescInput = page + .getByRole("textbox", { name: /Item Description/i }) + .or(page.getByPlaceholder(/Gift Set/i)) + .first(); + await itemDescInput.waitFor({ state: "visible", timeout: 15000 }); + await itemDescInput.fill("Tip / Extra Shot"); + + const priceInput = page + .getByRole("spinbutton", { name: /Price/i }) + .or(page.getByPlaceholder("12.50")) + .first(); + await priceInput.fill("2.50"); + + const submitAdHocBtn = page + .getByRole("button", { name: "Add to Cart" }) + .first(); + await submitAdHocBtn.click(); + + const automaticChoice = page.getByText(/Token effects/i); + await automaticChoice.waitFor({ state: "visible", timeout: 15000 }); + t.assertTrue( + (await page.getByText(/Beverage club 20%/i).count()) > 0, + "POS cart must preview the semantic discount choice", + ); + + // Verify Grand Total amount is calculated correctly: CHF 4.50 + CHF 2.50 = CHF 7.00 + const grandTotalStr = page + .getByText(/Grand Total/i) + .locator("..") + .getByText(/7\.00|7\.50/i); + await grandTotalStr.waitFor({ state: "visible", timeout: 15000 }); + t.assertTrue( + (await grandTotalStr.count()) > 0, + "Grand total in POS cart must be correct", + ); + + // Charge order + const chargeBtn = page + .locator("#pos_charge_catalog_btn") + .or(page.getByText(/Charge/i)) + .first(); + await chargeBtn.click(); + + // --- QR CODE CHECK 1: UNPAID STATE --- + // POS transitions to pay tab ("Awaiting Customer Wallet Payment...") + const awaitingPayment = page + .getByText(/Awaiting Customer Wallet Payment/i) + .first(); + await awaitingPayment.waitFor({ state: "visible", timeout: 15000 }); + + const scanPrompt = page.getByText(/Scan with Taler Wallet to pay/i).first(); + await scanPrompt.waitFor({ state: "visible", timeout: 15000 }); + t.assertTrue( + (await scanPrompt.count()) > 0, + "QR code prompt must be visible for unpaid POS order", + ); + await saveScreenshot("10d-pos-catalog-checkout-unpaid"); + + // Fetch the created POS order details + const ordersList1 = succeedOrThrow( + await merchantInstanceClient.listOrders( + adminAccessToken as any as AccessToken, + { limit: 1 }, + ), + ); + const posOrder1Id = ordersList1.orders[0].order_id; + const order1Details = succeedOrThrow( + await merchantInstanceClient.getOrderDetails( + adminAccessToken as any as AccessToken, + posOrder1Id, + ), + ); + const contractChoices = + (order1Details as any).proto_contract_terms?.choices ?? []; + t.assertTrue( + contractChoices.length === 2, + "POS order must contain full-price and discount choices", + ); + t.assertTrue( + contractChoices[0]?.amount === "CHF:7", + "first POS choice must retain the full price", + ); + t.assertTrue( + contractChoices[1]?.amount === "CHF:6.1", + "discount must only affect the eligible CHF:4.50 line", + ); + t.assertTrue( + contractChoices[1]?.inputs?.[0]?.token_family_slug === "pos_beverage_20", + "discount choice must consume the configured token family", + ); + t.assertTrue( + contractChoices[1]?.inputs?.[0]?.count === 2, + "discount choice must consume the configured token threshold", + ); + t.assertTrue( + contractChoices[0]?.outputs?.[0]?.token_family_slug === "pos_beverage_20" && + contractChoices[0]?.outputs?.[0]?.count === 1, + "full-price payment must earn exactly one configured discount token", + ); + t.assertTrue( + !(contractChoices[1]?.outputs ?? []).some( + (output: any) => output.token_family_slug === "pos_beverage_20", + ), + "same-family redemption must suppress issuance by default", + ); + + // --- QR CODE CHECK 2: CLAIMED STATE --- + // Wallet prepares pay + const prepareRes1 = await walletClient.call( + WalletApiOperation.PreparePayForUriV2, + { + talerPayUri: (order1Details as any).taler_pay_uri, + }, + ); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: prepareRes1.transactionId, + txState: { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + }, + }); + + // Reload/check POS screen: QR code is replaced by "Scanned" / "Do not scan again" + await page.waitForTimeout(500); + const scannedNotice = page.getByText(/Do not scan again|Scanned/i).first(); + await scannedNotice.waitFor({ state: "visible", timeout: 15000 }); + t.assertTrue( + (await page.getByText(/Scan with Taler Wallet to pay/i).count()) === 0, + "QR code must disappear when POS order is claimed", + ); + await saveScreenshot("10e-pos-catalog-checkout-claimed"); + + // --- QR CODE CHECK 3: PAID STATE --- + // Wallet confirms pay + await walletClient.call(WalletApiOperation.ConfirmPay, { + transactionId: prepareRes1.transactionId, + choiceIndex: 0, + }); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + + // POS screen automatically transitions to "success" tab ("Payment Successful!") + const successBadge = page.getByText(/Payment Successful!/i).first(); + await successBadge.waitFor({ state: "visible", timeout: 15000 }); + await saveScreenshot("10f-pos-catalog-checkout-paid"); + + const paidDetailStorage = await page.context().storageState(); + const paidDetailContext = await page.context().browser().newContext({ + storageState: paidDetailStorage, + }); + const paidDetailPage = await paidDetailContext.newPage(); + await paidDetailPage.goto( + `${webuiUrl}#/orders/${encodeURIComponent(posOrder1Id)}`, + ); + await paidDetailPage + .getByRole("heading", { name: "Selected payment choice", exact: true }) + .waitFor({ + state: "visible", + timeout: 15000, + }); + await paidDetailPage + .getByText(/CHF\s*7(?:\.00)?/) + .first() + .waitFor({ state: "visible" }); + t.assertTrue( + (await paidDetailPage.getByText(/CHF\s*6\.10/).count()) === 0, + "a paid v1 order must hide choices the customer did not select", + ); + await paidDetailContext.close(); + + let walletDiscounts = await walletClient.call( + WalletApiOperation.ListDiscounts, + {}, + ); + const earnedAfterFirst = walletDiscounts.discounts.find( + (discount: any) => discount.name === "Beverage club 20%", + ); + t.assertTrue( + earnedAfterFirst?.tokensAvailable === 1, + "the first qualifying paid order must earn one discount token", + ); + + const startNewSale = async () => { + await page + .locator("#pos_new_sale_btn") + .or(page.getByText(/Start New Sale/i)) + .first() + .click(); + }; + + // Earn the second token required by the configured threshold. + await startNewSale(); + await page.getByText("POS Swiss Espresso").first().click(); + await page.locator("#pos_charge_catalog_btn").click(); + await page + .getByText(/Awaiting Customer Wallet Payment/i) + .first() + .waitFor({ + state: "visible", + timeout: 15000, + }); + const earningOrder = succeedOrThrow( + await merchantInstanceClient.listOrders( + adminAccessToken as any as AccessToken, + { limit: 1 }, + ), + ).orders[0]; + const earningDetails = succeedOrThrow( + await merchantInstanceClient.getOrderDetails( + adminAccessToken as any as AccessToken, + earningOrder.order_id, + ), + ); + const prepareEarning = await walletClient.call( + WalletApiOperation.PreparePayForUriV2, + { talerPayUri: (earningDetails as any).taler_pay_uri }, + ); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: prepareEarning.transactionId, + txState: { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + }, + }); + await walletClient.call(WalletApiOperation.ConfirmPay, { + transactionId: prepareEarning.transactionId, + choiceIndex: 0, + }); + await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + await page + .getByText(/Payment Successful!/i) + .first() + .waitFor({ state: "visible", timeout: 15000 }); + walletDiscounts = await walletClient.call( + WalletApiOperation.ListDiscounts, + {}, + ); + t.assertTrue( + walletDiscounts.discounts.find( + (discount: any) => discount.name === "Beverage club 20%", + )?.tokensAvailable === 2, + "two qualifying paid orders must accumulate two discount tokens", + ); + + // The next order can redeem those two tokens and does not earn a replacement. + await startNewSale(); + await page.getByText("POS Swiss Espresso").first().click(); + await page.locator("#pos_charge_catalog_btn").click(); + await page + .getByText(/Awaiting Customer Wallet Payment/i) + .first() + .waitFor({ + state: "visible", + timeout: 15000, + }); + const redemptionOrder = succeedOrThrow( + await merchantInstanceClient.listOrders( + adminAccessToken as any as AccessToken, + { limit: 1 }, + ), + ).orders[0]; + const redemptionDetails = succeedOrThrow( + await merchantInstanceClient.getOrderDetails( + adminAccessToken as any as AccessToken, + redemptionOrder.order_id, + ), + ); + const redemptionChoices = + (redemptionDetails as any).proto_contract_terms?.choices ?? []; + t.assertTrue( + redemptionChoices[1]?.amount === "CHF:3.6", + "redemption must apply the configured benefit", + ); + t.assertTrue( + redemptionChoices[1]?.inputs?.[0]?.count === 2, + "redemption must require two accumulated tokens", + ); + const prepareRedemption = await walletClient.call( + WalletApiOperation.PreparePayForUriV2, + { talerPayUri: (redemptionDetails as any).taler_pay_uri }, + ); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: prepareRedemption.transactionId, + txState: { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + }, + }); + await walletClient.call(WalletApiOperation.ConfirmPay, { + transactionId: prepareRedemption.transactionId, + choiceIndex: 1, + }); + await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + await page + .getByText(/Payment Successful!/i) + .first() + .waitFor({ state: "visible", timeout: 15000 }); + walletDiscounts = await walletClient.call( + WalletApiOperation.ListDiscounts, + {}, + ); + t.assertTrue( + !walletDiscounts.discounts.some( + (discount: any) => discount.name === "Beverage club 20%", + ), + "redemption must consume the threshold without issuing a same-family replacement", + ); + + // --- NUMPAD / QUICK AMOUNT MODE CHECKOUT --- + // Start new sale + await startNewSale(); + + // Switch to Quick Amount mode + const amountTabBtn = page + .locator("#pos_tab_amount") + .or(page.getByRole("button", { name: "Quick Amount" })) + .first(); + await amountTabBtn.click(); + await saveScreenshot("10b-pos-amount-mode"); + + // Enter digits 1 2 5 0 using Numpad (amounts to CHF 12.50) + await page.getByRole("button", { name: "1", exact: true }).click(); + await page.getByRole("button", { name: "2", exact: true }).click(); + await page.getByRole("button", { name: "5", exact: true }).click(); + await page.getByRole("button", { name: "0", exact: true }).click(); + + const numpadChargeBtn = page.locator("#pos_charge_amount_btn"); + await numpadChargeBtn.click(); + + let numpadOrder: any = undefined; + for (let i = 0; i < 20; i++) { + const ordersList2 = succeedOrThrow( + await merchantInstanceClient.listOrders( + adminAccessToken as any as AccessToken, + { limit: -20 }, + ), + ); + numpadOrder = ordersList2.orders.find((o: any) => + o.summary?.includes("Quick charge"), + ); + if (numpadOrder) break; + await page.waitForTimeout(250); + } + t.assertTrue(Boolean(numpadOrder), "Numpad POS order must exist in list"); + const posOrder2Id = numpadOrder!.order_id; + const order2Details = succeedOrThrow( + await merchantInstanceClient.getOrderDetails( + adminAccessToken as any as AccessToken, + posOrder2Id, + ), + ); + + const prepareRes2 = await walletClient.call( + WalletApiOperation.PreparePayForUriV2, + { + talerPayUri: (order2Details as any).taler_pay_uri, + }, + ); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: prepareRes2.transactionId, + txState: { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + }, + }); + + await walletClient.call(WalletApiOperation.ConfirmPay, { + transactionId: prepareRes2.transactionId, + choiceIndex: 0, + }); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: prepareRes2.transactionId, + txState: { + major: TransactionMajorState.Done, + }, + }); + + const numpadSuccessBadge = page.getByText(/Payment Successful!/i).first(); + await numpadSuccessBadge.waitFor({ state: "visible", timeout: 15000 }); + await saveScreenshot("10g-pos-numpad-checkout-paid"); + + // View Till History + const historyTabBtn = page + .locator("#pos_tab_history") + .or(page.getByRole("button", { name: "Till History" })) + .first(); + await historyTabBtn.click(); + await saveScreenshot("10c-pos-history-mode"); +} + + + + + + + + + + + +/** + * 15. Interactive Tutorial & Dev Settings. + */ +async function testTutorialAndDev( + page: any, + webuiUrl: string, + saveScreenshot: (name: string) => Promise<void>, +) { + await page.goto(`${webuiUrl}#/tutorial`); + await page.waitForTimeout(200); + await saveScreenshot("18-interactive-tutorial-screen"); + + await page.goto(`${webuiUrl}#/dev`); + await page.waitForTimeout(200); + await saveScreenshot("19-dev-settings-screen"); +} + +/** + * 16. Server details via sidebar bottom link & Sign out flow. + */ +async function testServerDetailsAndSignOut( + page: any, + webuiUrl: string, + saveScreenshot: (name: string) => Promise<void>, +) { + // Navigate to main merchant screen so sidebar menu with bottom-left server info is visible + await page.goto(`${webuiUrl}#/orders`); + + // Click on the server name link in the bottom left corner of the menu + const serverLink = page + .getByRole("link", { name: /Server:/i }) + .or(page.getByText(/Server:/i)) + .first(); + await serverLink.waitFor({ state: "visible", timeout: 15000 }); + await serverLink.click(); + + // Verify navigation to server info screen and take screenshot + const serverMain = page.getByRole("main"); + await serverMain.waitFor({ state: "visible", timeout: 15000 }); + await saveScreenshot("12b-server-info-via-bottom-link"); + + // Now click sign out in the bottom left corner + const signOutBtn = page + .getByRole("button", { name: /Sign out/i }) + .or(page.getByText(/Sign out/i)) + .first(); + await signOutBtn.waitFor({ state: "visible", timeout: 15000 }); + await signOutBtn.click(); + + // Verify redirection back to sign-in form + const accountInput = page.getByRole("textbox", { name: "Merchant Account" }); + await accountInput.waitFor({ state: "visible", timeout: 15000 }); + await saveScreenshot("21-signout-complete"); +} + +/** + * Main integration test runner. + */ +export async function runMerchantWebuiSimpleTest(t: GlobalTestState) { + const db = await setupDb(t); + + const bankAccountInfo: NexusBankAccountInfo = { + iban: "CH7347363QVFHHFR8BWWB", + bic: "POFICHBEXXX", + name: "Harness Test Exchange", + }; + + // Connect exchange to libeufin-nexus + const nexus = await LibeufinNexusService.create(t, { + currency: "CHF", + database: db.connStr, + httpPort: 8085, + bankAccountInfo, + }); + + const exchangePayto = Paytos.toFullString( + Paytos.createIban(bankAccountInfo.iban as IbanString, undefined, { + "receiver-name": bankAccountInfo.name, + }), + ); + + await nexus.dbinit(); + await nexus.start(); + + const exchange = ExchangeService.create(t, { + name: "testexchange-1", + currency: "CHF", + httpPort: 8081, + database: db.connStr, + }); + + exchange.addBankAccount("nexusacct", { + accountPaytoUri: exchangePayto, + wireGatewayApiBaseUrl: nexus.wireGatewayApiBaseUrl, + wireGatewayAuth: { + type: "basic", + username: "exchange-test", + password: "exchange-test", + }, + }); + + exchange.addOfferedCoins(defaultCoinConfig); + await exchange.start(); + await exchange.pingUntilAvailable(); + + const merchant = await MerchantService.create(t, { + name: "testmerchant-1", + currency: "CHF", + httpPort: 8083, + database: db.connStr, + }); + + merchant.addExchange(exchange); + + await merchant.start(); + await merchant.pingUntilAvailable(); + + const instanceId = "admin"; + const instancePassword = "secretpassword123"; + const rawBaseUrl = merchant.makeInstanceBaseUrl(instanceId); + const baseUrl = rawBaseUrl.endsWith("/") ? rawBaseUrl : `${rawBaseUrl}/`; + + // Create admin instance with valid Swiss IBAN wire account + const { accessToken: adminAccessToken } = + await merchant.addInstanceWithWireAccount({ + id: instanceId, + name: "Admin Instance", + paytoUris: [exchangePayto], + auth: MERCHANT_DEFAULT_AUTH, + }); + + const merchantClient = new TalerMerchantManagementHttpClient(baseUrl); + + // Configure instance authentication method to token with password + await merchantClient.updateCurrentInstanceAuthentication(adminAccessToken, { + method: MerchantAuthMethod.TOKEN, + password: instancePassword, + }); + + const merchantInstanceClient = new TalerMerchantInstanceHttpClient(baseUrl); + + // Create wallet with test money + const { walletClient } = await createWalletDaemonWithClient(t, { + name: "wallet", + }); + + const acceptRes = await walletClient.call( + WalletApiOperation.AcceptManualWithdrawal, + { + amount: "CHF:100" as AmountString, + exchangeBaseUrl: exchange.baseUrl, + }, + ); + + const wtx: any = await walletClient.call( + WalletApiOperation.GetTransactionById, + { + transactionId: acceptRes.transactionId, + }, + ); + + const transferOpt = + wtx?.withdrawalDetails?.exchangeCreditAccountDetails?.[0] + ?.transferOptions?.[0]; + + if (transferOpt?.paytoUri) { + await nexus.fakeIncoming({ + creditPayto: transferOpt.paytoUri, + }); + } + + // Serve merchant-webui static assets targeting local merchant backend + const webuiServer = await startStaticServerMerchantWebui(baseUrl); + + const takeScreenshots = Boolean(process.env.HARNESS_SCREENSHOTS); + const screenshotDir = path.join(t.testDir, "screenshots"); + const localScreenshotDir = path.join(process.cwd(), "screenshots"); + + if (takeScreenshots) { + fs.mkdirSync(screenshotDir, { recursive: true }); + fs.mkdirSync(localScreenshotDir, { recursive: true }); + } + + let browser: any; + let browserSession: MerchantWebuiBrowser | undefined; + + try { + browserSession = await launchMerchantWebuiBrowser(); + browser = browserSession.browser; + const page = browserSession.page; + + const saveScreenshot = async (name: string) => { + // A checkpoint is functional even when diagnostic screenshots are off. + await assertNoUnexpectedErrorBanner( + page, + `merchant-webui-simple ${name}`, + ); + if (takeScreenshots) { + const file = path.join(screenshotDir, `${name}.png`); + const localFile = path.join(localScreenshotDir, `${name}.png`); + await page.screenshot({ path: file, fullPage: true }); + try { + fs.copyFileSync(file, localFile); + } catch {} + console.log(`Saved screenshot to ${file} and ${localFile}`); + } + }; + + page.on("console", (msg: any) => { + console.log(`BROWSER [${msg.type()}]: ${msg.text()}`); + }); + page.on("response", (resp: any) => { + console.log( + `BROWSER HTTP ${resp.status()} ${resp.request().method()} ${resp.url()}`, + ); + }); + + // Run ALL modular sub-tests sequentially + await testSignInAndAuthFlows( + page, + webuiServer.url, + merchant.makeInstanceBaseUrl(), + instanceId, + instancePassword, + saveScreenshot, + ); + await testMerchantAccountAdministration( + page, + webuiServer.url, + instancePassword, + saveScreenshot, + ); + await testReadFailureAndRecovery(t, page, webuiServer.url); + await testOnboardingScreens(page, webuiServer.url, saveScreenshot); + await testBusinessDetails(page, webuiServer.url, saveScreenshot); + await testPersonalization(page, webuiServer.url, saveScreenshot); + await testProductsCrud(page, webuiServer.url, saveScreenshot); + await testPayoutAccountsInUi(page, webuiServer.url, saveScreenshot); + await testTemplatesCrud(page, webuiServer.url, saveScreenshot); + const liveOrderId = await testOrdersFlowAndPayment( + t, + page, + webuiServer.url, + merchantInstanceClient, + adminAccessToken, + walletClient, + saveScreenshot, + ); + await testLongPollRequestBudget(t, page, webuiServer.url, liveOrderId); + await testRefundFlow( + t, + page, + webuiServer.url, + merchantInstanceClient, + adminAccessToken, + walletClient, + saveScreenshot, + ); + await testPosScreen( + t, + page, + webuiServer.url, + merchantInstanceClient, + adminAccessToken, + walletClient, + saveScreenshot, + ); + await testWebhooksCrud(page, webuiServer.url, saveScreenshot); + await testAccessTokensCrud(page, webuiServer.url, instancePassword, saveScreenshot); + await testAuthenticators(page, webuiServer.url, saveScreenshot); + await testSubscriptions(page, webuiServer.url, saveScreenshot); + await testAnalyticsAndReports(page, webuiServer.url, saveScreenshot); + await testTutorialAndDev(page, webuiServer.url, saveScreenshot); + await testServerDetailsAndSignOut(page, webuiServer.url, saveScreenshot); + } catch (e) { + if (takeScreenshots && browser) { + try { + const pages = browser.pages(); + if (pages.length > 0) { + const file = path.join(screenshotDir, "99-failure.png"); + const localFile = path.join(localScreenshotDir, "99-failure.png"); + await pages[0].screenshot({ path: file, fullPage: true }); + try { + fs.copyFileSync(file, localFile); + } catch {} + console.log(`Saved failure screenshot to ${file} and ${localFile}`); + } + } catch {} + } + throw e; + } finally { + if (browserSession) { + await browserSession.close(); + } + await webuiServer.close(); + } +} + +runMerchantWebuiSimpleTest.suites = ["web", "merchant", "merchant-webui"]; +runMerchantWebuiSimpleTest.timeoutMs = 300_000; diff --git a/packages/taler-harness/src/integrationtests/test-web-merchant-login.ts b/packages/taler-harness/src/integrationtests/test-web-merchant-login.ts @@ -1,66 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021 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/> - */ - -/** - * Imports. - */ -import { - createSimpleTestkudosEnvironmentV3, - loadSelenium, -} from "../harness/environments.js"; -import { GlobalTestState, MERCHANT_DEFAULT_AUTH } from "../harness/harness.js"; - -/** - * Do basic checks on instance management and authentication. - */ -export async function runWebMerchantLoginTest(t: GlobalTestState) { - const { By } = await loadSelenium(); - - // Set up test environment - - const { merchant, createBrowser } = - await createSimpleTestkudosEnvironmentV3(t); - - const browser = await createBrowser({}); - - await browser.get(merchant.makeInstanceBaseUrl()); - - const title = await browser.getTitle(); - - t.assertDeepEqual("Taler Merchant Portal", title); - - await browser.manage().setTimeouts({ implicit: 2000 }); - - const form = await browser.findElement(By.css("form")); - const username = await form.findElement(By.css("input[name=username]")); - const password = await form.findElement(By.css("input[name=password]")); - const submit = await form.findElement(By.css("button[type=submit]")); - await browser.sleep(100); - - const inferedUsername = await username.getAttribute("value"); - t.assertTrue(inferedUsername === "admin"); - await password.sendKeys(MERCHANT_DEFAULT_AUTH.password); - await submit.click(); - - await browser.sleep(100); - - const nousername = await browser.findElement(By.css("aside")); - t.assertTrue(nousername !== undefined); - - await browser.quit(); -} - -runWebMerchantLoginTest.suites = ["web", "merchant"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -217,7 +217,13 @@ import { runWalletTransactionsTest } from "./test-wallet-transactions.js"; import { runWalletWirefeesTest } from "./test-wallet-wirefees.js"; import { runWalletWithdrawalRedenominateTest } from "./test-wallet-withdrawal-redenominate.js"; import { runWallettestingTest } from "./test-wallettesting.js"; -import { runWebMerchantLoginTest } from "./test-web-merchant-login.js"; +import { + runMerchantWebuiMfaTest, + runMerchantWebuiPasswordResetMfaTest, +} from "./test-merchant-webui-mfa.js"; +import { runMerchantWebuiKycSwapTest } from "./test-merchant-webui-kyc-swap.js"; +import { runMerchantWebuiBootstrapTest } from "./test-merchant-webui-bootstrap.js"; +import { runMerchantWebuiSimpleTest } from "./test-merchant-webui-simple.js"; import { runWireMetadataTest } from "./test-wire-metadata.js"; import { runWithdrawalAbortBankTest } from "./test-withdrawal-abort-bank.js"; import { runWithdrawalAmountTest } from "./test-withdrawal-amount.js"; @@ -363,7 +369,11 @@ const allTests: TestMainFunction[] = [ runExchangeMasterPubChangeTest, runMerchantCategoriesTest, runMerchantSelfProvisionActivationTest, - runWebMerchantLoginTest, + runMerchantWebuiBootstrapTest, + runMerchantWebuiMfaTest, + runMerchantWebuiPasswordResetMfaTest, + runMerchantWebuiKycSwapTest, + runMerchantWebuiSimpleTest, runMerchantSelfProvisionInactiveAccountPermissionsTest, runWithdrawalExternalTest, runWithdrawalIdempotentTest, diff --git a/packages/taler-harness/src/stagefright/merchant-mytops.ts b/packages/taler-harness/src/stagefright/merchant-mytops.ts @@ -35,7 +35,7 @@ const SCENARIO_NAME = "merchant-mytops"; /** * Addresses of that shape are special-cased by the deployment: no message is - * actually sent, it is made available under /mock-2fa/$ADDRESS instead. + * actually sent, it is made available under /mock-mfa/$ADDRESS.txt instead. */ function mockEmailAddress(index: string): string { return `test-${index}@taler.net`; @@ -94,7 +94,7 @@ function randomPassword(): string { } /** - * Ensure that relative URLs (webui/, mock-2fa/...) resolve below the + * Ensure that relative URLs (webui/, mock-mfa/...) resolve below the * deployment and not next to it. */ function normalizeBaseUrl(url: string): string { diff --git a/packages/taler-harness/src/stagefright/merchant-webui.ts b/packages/taler-harness/src/stagefright/merchant-webui.ts @@ -0,0 +1,2591 @@ +/* + 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/> + */ + +/** + * Playwright automation of the onboarding process for merchant-webui + * running against the staging environment (my.taler-ops.ch). + */ + +import { + Logger, + getRandomBytes, + encodeCrock, + TransactionMajorState, + TransactionMinorState, + paytoFromTransferSubject, + AmountString, +} from "@gnu-taler/taler-util"; +import { WalletClient, tryUnixConnect, delayMs } from "../harness/harness.js"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import type { Page } from "playwright-core"; +import fs from "node:fs"; +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; +import { startStaticServerMerchantWebui } from "../harness/webui-server.js"; +import { Mock2faReader } from "./mock2fa.js"; +import { DEFAULT_TIMEOUT_MS, Stage, StageOptions } from "./stage.js"; + +const logger = new Logger("stagefright/merchant-webui.ts"); + +export const MYTOPS_STAGE_BASE_URL = "https://stage.my.taler-ops.ch/"; + +const SCENARIO_NAME = "merchant-webui"; + +/** + * How long the whole scenario may take. + * + * The stage default of five minutes is smaller than what the steps in here + * ask for on their own: waiting for the KYC status alone is allowed 360s, and + * that is one of some forty steps, several of which wait on a remote + * deployment (two TAN rounds, two wire transfers, a withdrawal, a payment). + * With the default the run is always cut short mid-scenario, and reports that + * as a scenario timeout instead of whatever the step it was in has to say. + */ +const DEFAULT_SCENARIO_TIMEOUT_MS = 1800000; // 30 minutes + +/** + * How long to wait for the KYC status of the payout account to become ready. + */ +const KYC_READY_TIMEOUT_MS = 360000; + +/** + * Host that runs 'fake-incoming', which is how the scenario makes the wire + * transfers the staging exchange asks for. Reaching it needs an SSH key that + * the deployment authorizes. + */ +const SSH_DEVTESTING_HOST = "devtesting@rusty.taler-ops.ch"; + +/** + * KYC form that affirms acceptance of an exchange's terms of service, the one + * requirement this scenario can answer on its own. Matches + * TALER_KYCLOGIC_TOS_ACCEPTANCE_FORM in the exchange. + */ +const TOS_ACCEPTANCE_FORM = "accept-tos"; + +/** + * Terms-of-service version to fall back on, which is the name every taler-ops + * inventory configures as exchange_terms_etag. See tosVersionCandidates() for + * why this cannot simply be read off the deployment. + */ +const DEFAULT_TOS_VERSION = "exchange-tos-v0"; + +function mockEmailAddress(index: string): string { + return `test-${index}@taler.net`; +} + +function mockPhoneNumber(index: string): string { + return `+417000000${index}`; +} + +export interface MerchantWebuiOptions extends StageOptions { + /** + * Base URL of the merchant backend deployment, defaults to staging. + */ + baseUrl?: string; + + /** + * URL where local merchant-webui is served. + * If omitted, a local static web server will be started automatically. + */ + webuiUrl?: string; + + /** + * Account identifier (username) to register. Random by default. + */ + instanceId?: string; + + businessName?: string; + + password?: string; + + /** + * Two digits selecting mock email and phone number. Random by default. + */ + addressIndex?: string; + + /** + * Base URL of the exchange deployment, defaults to staging exchange. + */ + exchangeUrl?: string; + + email?: string; + + phone?: string; + + /** + * Sign in to an already-created account instead of registering another one. + * Requires instanceId, password, email, and phone (or addressIndex). + */ + existingAccount?: boolean; + + /** + * Version of the exchange's terms of service to accept, which is the + * TERMS_ETAG the exchange is configured with. Guessed when not given. + */ + tosVersion?: string; + + /** + * Executable or path to taler-wallet-cli. Defaults to "taler-wallet-cli" (from $PATH). + */ + walletCliBinary?: string; +} + +export interface MerchantWebuiResult { + baseUrl: string; + webuiUrl: string; + instanceId: string; + password: string; + email: string; + phone: string; + screenshotDir: string; +} + +function randomAddressIndex(): string { + return String(Math.floor(Math.random() * 100)).padStart(2, "0"); +} + +function randomInstanceId(): string { + return `sfwebui${encodeCrock(getRandomBytes(4)).toLowerCase()}`; +} + +function randomPassword(): string { + return encodeCrock(getRandomBytes(12)); +} + +function normalizeBaseUrl(url: string): string { + return url.endsWith("/") ? url : `${url}/`; +} + +export async function runStagefrightMerchantWebui( + options: MerchantWebuiOptions = {}, +): Promise<MerchantWebuiResult> { + if (options.existingAccount) { + if (!options.instanceId || !options.password) { + throw new Error( + "--existing-account requires --instance-id and --password", + ); + } + if (!options.addressIndex && (!options.email || !options.phone)) { + throw new Error( + "--existing-account requires --address-index or both --email and --phone so MFA can be completed", + ); + } + } + + const baseUrl = normalizeBaseUrl(options.baseUrl ?? MYTOPS_STAGE_BASE_URL); + const exchangeUrl = normalizeBaseUrl( + options.exchangeUrl ?? "https://exchange.stage.taler-ops.ch/", + ); + const addressIndex = options.addressIndex ?? randomAddressIndex(); + const instanceId = options.instanceId ?? randomInstanceId(); + const businessName = + options.businessName ?? `Stagefright WebUI ${instanceId}`; + const password = options.password ?? randomPassword(); + const email = options.email ?? mockEmailAddress(addressIndex); + const phone = options.phone ?? mockPhoneNumber(addressIndex); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + let localServer: { url: string; close: () => Promise<void> } | undefined = + undefined; + let webuiUrl = options.webuiUrl; + + if (!webuiUrl) { + localServer = await startStaticServerMerchantWebui(baseUrl, { + experimental: true, + }); + webuiUrl = localServer.url; + } + + logger.info( + `${options.existingAccount ? "resuming with" : "onboarding"} '${instanceId}' via local merchant-webui (${webuiUrl})`, + ); + logger.info(`backend target: ${baseUrl}`); + logger.info(`using 2FA email: ${email}, phone: ${phone}`); + + const mock2fa = new Mock2faReader(baseUrl); + await mock2fa.markCurrent([email, phone]); + + const stage = await Stage.create(SCENARIO_NAME, { + ...options, + overallTimeoutMs: options.overallTimeoutMs ?? DEFAULT_SCENARIO_TIMEOUT_MS, + }); + let createdOrderId = ""; + let paidOrderId = ""; + let otpDeviceName = ""; + const fixtureTag = Date.now().toString(36); + const templateName = `Coffee Special Template ${fixtureTag}`; + const groupName = `beverages_group_${fixtureTag}`; + const potName = `breakfast_pot_${fixtureTag}`; + const reportDescription = `Weekly Revenue & Fee Summary ${fixtureTag}`; + const subscriptionName = `Stagefright VIP Supporter Pass ${fixtureTag}`; + const discountName = `Stagefright Coffee Voucher ${fixtureTag}`; + const walletSocketPath = `/tmp/sf-wallet-${instanceId}.sock`; + const walletDbPath = `/tmp/sf-wallet-${instanceId}.db`; + + let deploymentCurrency = options.currency || ""; + let configRes: any = undefined; + try { + configRes = await fetch(`${baseUrl}config`).then((r) => (r as any).json()); + } catch (e: any) { + logger.warn( + `Failed to fetch merchant backend config from ${baseUrl}: ${e?.message || e}`, + ); + } + + if (!deploymentCurrency) { + deploymentCurrency = + configRes?.currency || configRes?.currency_specification?.name || "CHF"; + } + logger.info(`Deployment currency resolved to: '${deploymentCurrency}'`); + + if (configRes?.exchanges && Array.isArray(configRes.exchanges)) { + const supportedUrls = configRes.exchanges.map((ex: any) => + normalizeBaseUrl(ex.base_url || ex.url || ""), + ); + logger.info( + `Merchant backend supported exchanges: ${JSON.stringify(supportedUrls)}`, + ); + const isSupported = supportedUrls.some( + (u: string) => + u === exchangeUrl || u.includes(new URL(exchangeUrl).hostname), + ); + if (!isSupported) { + throw new Error( + `Merchant backend at ${baseUrl} does not support exchange '${exchangeUrl}'. Supported exchanges: ${supportedUrls.join(", ")}`, + ); + } + } + + let walletProc: ChildProcess | undefined; + let walletClient: WalletClient | undefined; + + try { + // Spawn daemonized wallet process using taler-wallet-cli serve + const walletCliBinary = options.walletCliBinary ?? "taler-wallet-cli"; + const walletCmdArgs = [ + `--wallet-db=${walletDbPath}`, + "-LINFO", + "--no-throttle", + "advanced", + "serve", + `--unix-path=${walletSocketPath}`, + ]; + const [walletCmd, ...walletArgs] = + walletCliBinary.endsWith(".mjs") || walletCliBinary.endsWith(".js") + ? [process.execPath, walletCliBinary, ...walletCmdArgs] + : [walletCliBinary, ...walletCmdArgs]; + + walletProc = spawn(walletCmd, walletArgs, { stdio: "inherit" }); + + // Connect to wallet daemon socket via IPC + let walletConnected = false; + for (let i = 0; i < 40; i++) { + try { + await tryUnixConnect(walletSocketPath); + walletConnected = true; + break; + } catch (e) { + await delayMs(200); + } + } + + if (!walletConnected) { + throw new Error( + `Failed to connect to daemonized wallet at ${walletSocketPath}`, + ); + } + + walletClient = new WalletClient({ unixPath: walletSocketPath }); + await walletClient.connect(); + await walletClient.call(WalletApiOperation.InitWallet, {}); + logger.info( + `Daemonized wallet started, initialized, and connected via IPC socket: ${walletSocketPath}`, + ); + + await stage.run(async () => { + if (!options.existingAccount) { + await stage.step("open the merchant webui onboarding", async (page) => { + page.on("console", (msg) => { + logger.info(`BROWSER [${msg.type()}]: ${msg.text()}`); + }); + page.on("response", (resp) => { + if ( + resp.url().includes("challenge") || + resp.url().includes("instance") || + resp.status() >= 400 + ) { + logger.info( + `BROWSER HTTP ${resp.status()} ${resp.request().method()} ${resp.url()}`, + ); + } + }); + const signupUrl = new URL("#/signup", webuiUrl).href; + await page.goto(signupUrl); + await page.evaluate((targetUrl) => { + (globalThis as any).localStorage.setItem( + "custom_merchant_backend_url", + targetUrl, + ); + }, baseUrl); + await page.reload(); + await page.waitForSelector("#signup-business", { + timeout: timeoutMs, + }); + }); + + await stage.step( + "fill in account registration details", + async (page) => { + await page.fill("#signup-business", businessName); + + // Fill username if input is present + if ((await page.locator("#signup-username").count()) > 0) { + await page.fill("#signup-username", instanceId); + } + + if ((await page.locator("#signup-email").count()) > 0) { + await page.fill("#signup-email", email); + } + + if ((await page.locator("#signup-phone").count()) > 0) { + await page.fill("#signup-phone", phone); + } + + await page.fill("#signup-password", password); + await page.fill("#signup-confirm-password", password); + + // Check terms of service checkbox + const checkbox = page.locator('input[type="checkbox"]').first(); + await checkbox.check(); + }, + ); + + await stage.step("submit registration form", async (page) => { + await page.click('button[type="submit"]'); + }); + + // Handle multi-step TAN challenges (email / SMS) + for (let round = 1; round <= 3; round++) { + await stage.page.waitForSelector( + "#signup-email-code, #signup-phone-code, #signup-sms-code, aside, nav, .bg-red-50", + { timeout: timeoutMs }, + ); + + const emailCodeInput = stage.page.locator("#signup-email-code"); + const smsCodeInput = stage.page.locator( + "#signup-phone-code, #signup-sms-code", + ); + const sidebar = stage.page.locator("aside, nav, #nav_orders"); + + // Wait for next state: email verification, SMS verification, or signed-in sidebar + const hasEmail = await emailCodeInput.isVisible(); + const hasSms = await smsCodeInput.isVisible(); + const hasSidebar = await sidebar.first().isVisible(); + + if (hasSidebar) { + logger.info("account successfully onboarded and logged in"); + break; + } + + if (hasEmail) { + const code = await stage.step( + `receive email 2FA code (round ${round})`, + async () => { + const msg = await mock2fa.waitForNewCode([email, phone], { + login: instanceId, + timeoutMs, + }); + return msg.code; + }, + ); + + await stage.step( + `enter email 2FA code (round ${round})`, + async (p) => { + await p.fill("#signup-email-code", code); + await p.click('button[type="submit"]'); + await p.locator("#signup-email-code").waitFor({ + state: "hidden", + timeout: timeoutMs, + }); + }, + ); + continue; + } + + if (hasSms) { + const code = await stage.step( + `receive SMS 2FA code (round ${round})`, + async () => { + const msg = await mock2fa.waitForNewCode([phone, email], { + login: instanceId, + timeoutMs, + }); + return msg.code; + }, + ); + + await stage.step( + `enter SMS 2FA code (round ${round})`, + async (p) => { + await p.fill("#signup-sms-code", code); + await p.click('button[type="submit"]'); + await p.locator("#signup-sms-code").waitFor({ + state: "hidden", + timeout: timeoutMs, + }); + }, + ); + continue; + } + + // Check for error banner + const errorAlert = stage.page.locator(".bg-red-50"); + if ((await errorAlert.count()) > 0) { + const text = await errorAlert.innerText(); + throw Error(`account creation failed on backend: ${text.trim()}`); + } + + await stage.page.waitForTimeout(500); + } + + await stage.step( + "verify new account is signed in and active", + async (page) => { + await page.waitForSelector("aside", { timeout: timeoutMs }); + const sidebarText = await page.locator("aside").innerText(); + if ( + !sidebarText.toLowerCase().includes(instanceId.toLowerCase()) && + !sidebarText.toLowerCase().includes(businessName.toLowerCase()) + ) { + logger.warn( + `sidebar text does not explicitly mention instance '${instanceId}'`, + ); + } + }, + ); + + await stage.step( + "view onboarding status page after registration", + async (page) => { + const setupUrl = new URL("#/setup", webuiUrl).href; + await page.goto(setupUrl); + await page.waitForSelector("header", { timeout: timeoutMs }); + await stage.page.waitForTimeout(1000); + }, + ); + + // --- Extension: Sign Out and Sign In Verification (with optional 2FA) --- + + await stage.step("sign out of newly created account", async (page) => { + const signOutBtn = page + .locator("aside button") + .filter({ hasText: "Sign out" }); + if ((await signOutBtn.count()) > 0) { + await signOutBtn.click(); + } else { + await page.click('button:has-text("Sign out")'); + } + await page.waitForSelector("#signin-account", { timeout: timeoutMs }); + }); + + await stage.step("fill in sign-in credentials", async (page) => { + await page.fill("#signin-account", instanceId); + await page.fill("#signin-password", password); + }); + + await stage.step("submit sign-in credentials", async (page) => { + await page.click('button[type="submit"]'); + }); + + await stage.step("handle sign-in authentication", async (page) => { + // Either the sidebar (signed straight in), or the MFA dialog. With more + // than one mandatory channel that dialog asks which one to use before it + // asks for a code, so waiting only for the code input timed out on the + // question. + await page.waitForSelector( + 'aside, #signin-2fa-code, input[type=radio][name="2fa_channel"]', + { timeout: timeoutMs }, + ); + + const solved = await solveMfaChallenges( + page, + mock2fa, + [email, phone], + instanceId, + timeoutMs, + ); + if (solved > 0) { + logger.info(`Sign-in required ${solved} 2FA code(s).`); + } else { + logger.info("Sign-in completed directly without requiring 2FA."); + } + await page.waitForSelector("aside", { timeout: timeoutMs }); + }); + + await stage.step( + "verify signed back in and account active", + async (page) => { + await page.waitForSelector("aside", { timeout: timeoutMs }); + const sidebarText = await page.locator("aside").innerText(); + if ( + !sidebarText.toLowerCase().includes(instanceId.toLowerCase()) && + !sidebarText.toLowerCase().includes(businessName.toLowerCase()) + ) { + logger.warn( + `sidebar text does not explicitly mention instance '${instanceId}' after sign-in`, + ); + } + }, + ); + } else { + await stage.step("open sign-in for existing account", async (page) => { + page.on("console", (msg) => { + logger.info(`BROWSER [${msg.type()}]: ${msg.text()}`); + }); + page.on("response", (resp) => { + if ( + resp.url().includes("challenge") || + resp.url().includes("instance") || + resp.status() >= 400 + ) { + logger.info( + `BROWSER HTTP ${resp.status()} ${resp.request().method()} ${resp.url()}`, + ); + } + }); + await page.goto(new URL("#/signin", webuiUrl).href); + await page.evaluate((targetUrl) => { + (globalThis as any).localStorage.setItem( + "custom_merchant_backend_url", + targetUrl, + ); + }, baseUrl); + await page.reload(); + await page.waitForSelector("#signin-account", { timeout: timeoutMs }); + }); + + await stage.step("sign in to existing account", async (page) => { + await page.fill("#signin-account", instanceId); + await page.fill("#signin-password", password); + await page.click('button[type="submit"]'); + await page.waitForSelector( + 'aside, #signin-2fa-code, input[type=radio][name="2fa_channel"]', + { timeout: timeoutMs }, + ); + const solved = await solveMfaChallenges( + page, + mock2fa, + [email, phone], + instanceId, + timeoutMs, + ); + logger.info( + solved > 0 + ? `Existing-account sign-in required ${solved} 2FA code(s).` + : "Existing-account sign-in completed without requiring 2FA.", + ); + await page.waitForSelector("aside", { timeout: timeoutMs }); + }); + } + + // --- Extension: Add Bank Account (Swiss IBAN) & Verify KYC Requirements --- + + if (!options.existingAccount) { + const generatedIban = generateSwissIban(logger); + const accountHolderName = `Holder ${instanceId}`; + + await stage.step("navigate to bank accounts page", async (page) => { + const bankAccountsUrl = new URL("#/money/payout-accounts", webuiUrl) + .href; + await page.goto(bankAccountsUrl); + await page.waitForSelector("header", { timeout: timeoutMs }); + }); + + await stage.step("open add bank account screen", async (page) => { + const addUrl = new URL("#/money/payout-accounts/add", webuiUrl).href; + await page.goto(addUrl); + await page.waitForSelector("#payout-iban", { timeout: timeoutMs }); + }); + + await stage.step( + "fill bank account details with generated Swiss IBAN", + async (page) => { + await page.fill("#payout-iban", generatedIban); + await page.fill("#account-holder", accountHolderName); + }, + ); + + await stage.step("submit new bank account", async (page) => { + await page.click('button[type="submit"]'); + await page.waitForSelector("header", { timeout: timeoutMs }); + }); + + await stage.step( + "verify bank account added and poll for KYC status", + async (page) => { + await page + .locator(".bg-white") + .filter({ hasText: accountHolderName }) + .waitFor({ + timeout: timeoutMs, + }); + + logger.info( + `Successfully added Swiss IBAN ${generatedIban} for ${accountHolderName}`, + ); + + // Wait a short while to allow backend/exchange long-polling stream to check KYC requirement status + await stage.page.waitForTimeout(3000); + }, + ); + + await stage.step( + "view onboarding status page after adding bank account", + async (page) => { + const setupUrl = new URL("#/setup", webuiUrl).href; + await page.goto(setupUrl); + await page.waitForSelector("header", { timeout: timeoutMs }); + await stage.page.waitForTimeout(1000); + }, + ); + + // --- Extension: Complete KYC Auth Transfer via SSH --- + + await stage.step("complete KYC auth transfer via SSH", async (page) => { + // Keep the instructions route mounted while the transfer lands. Its + // KYC long poll must notice this exact account/provider pair changing + // state and take us back without a click. + const bankAccountsUrl = new URL("#/money/payout-accounts", webuiUrl) + .href; + await page.goto(bankAccountsUrl); + const wireInstructionsButton = page + .getByRole("button", { name: /Wire instructions/i }) + .first(); + await wireInstructionsButton.waitFor({ + state: "visible", + timeout: timeoutMs, + }); + await wireInstructionsButton.click(); + await page.waitForURL(/wire-instructions/, { timeout: timeoutMs }); + await page + .getByText(/transfer option|receiver iban|receiver account/i) + .first() + .waitFor({ + state: "visible", + timeout: timeoutMs, + }); + + // Wire transfers the exchange asked for, and the ones we managed to + // make. Asked for but none made means the KYC step after this one + // cannot possibly succeed, so say so here instead of letting it time + // out several minutes later on a symptom. + let transfersRequested = 0; + let transfersExecuted = 0; + let lastSshError = ""; + let kycInfo: any = null; + const startKycTime = Date.now(); + while (Date.now() - startKycTime < 15000) { + kycInfo = await page.evaluate(async (targetExchangeUrl) => { + const win = globalThis as any; + const customUrlRaw = win.localStorage?.getItem( + "taler-merchant-ng:custom_merchant_backend_url", + ); + const customUrl = customUrlRaw + ? JSON.parse(customUrlRaw) + : "https://stage.my.taler-ops.ch/"; + const sessionRaw = win.localStorage?.getItem( + "taler-merchant-ng:session", + ); + if (!sessionRaw) return null; + const session = JSON.parse(sessionRaw); + const token = session.token; + const instance = session.account || "default"; + + if (!token) return null; + + try { + const accRes = await win.fetch( + new URL(`instances/${instance}/private/accounts`, customUrl) + .href, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ); + if (!accRes.ok) return null; + const accData = await accRes.json(); + const allAccs = accData.accounts || []; + const kycAuthList: any[] = []; + + const kycRes = await win.fetch( + new URL(`instances/${instance}/private/kyc`, customUrl).href, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ); + if (!kycRes.ok) return null; + const kycData = await kycRes.json(); + const kycItems = + kycData.kyc_redirects || kycData.kyc_data || []; + const targetHost = new URL(targetExchangeUrl).hostname; + const stageKyc = + kycItems.find( + (k: any) => + k.exchange_url === targetExchangeUrl || + k.exchange_url?.includes(targetHost), + ) || + kycItems.find((k: any) => k.exchange_http_status === 200) || + kycItems[0]; + if (!stageKyc?.exchange_url) return null; + + for (const acc of allAccs) { + if (!acc?.h_wire) continue; + try { + const kycAuthUrl = new URL( + `instances/${instance}/private/accounts/${acc.h_wire}/kycauth`, + customUrl, + ).href; + const authRes = await win.fetch(kycAuthUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + exchange_url: stageKyc.exchange_url, + }), + }); + const authData = authRes.ok ? await authRes.json() : null; + kycAuthList.push({ + debitPayto: acc.payto_uri, + authData, + stageKyc, + }); + } catch {} + } + + return kycAuthList; + } catch { + return null; + } + }, exchangeUrl); + if (Array.isArray(kycInfo) && kycInfo.length > 0) break; + await stage.page.waitForTimeout(1000); + } + + logger.info( + `Fetched KYC auth info from browser context: ${JSON.stringify(kycInfo)}`, + ); + + const kycList: any[] = Array.isArray(kycInfo) + ? kycInfo + : kycInfo + ? [kycInfo] + : []; + if (kycList.length > 0) { + for (const item of kycList) { + // An account that already carries an access token has KYC + // requirements pending from an earlier run. A fresh one only + // gets them once the wire transfer below has arrived, which the + // KYC wait in the next step picks up. + if ( + item?.stageKyc?.access_token && + item?.stageKyc?.exchange_url + ) { + await acceptKycTermsOfService( + item.stageKyc.exchange_url, + item.stageKyc.access_token, + options.tosVersion, + ); + } + if (!item.authData) { + // The kycauth request for this account did not answer with a + // body, so there is nothing to wire. Other accounts may still + // have instructions. + logger.warn( + `no kycauth instructions for ${item.debitPayto}, skipping it`, + ); + continue; + } + const instructions: any[] = Array.isArray( + item.authData.wire_transfer_instructions, + ) + ? item.authData.wire_transfer_instructions + : Array.isArray(item.authData.wire_instructions) + ? item.authData.wire_instructions + : Array.isArray(item.authData.payto_kycauths) + ? item.authData.payto_kycauths + : [item.authData]; + + for (const rawInstr of instructions) { + const rawCreditPayto = + typeof rawInstr === "string" + ? rawInstr + : rawInstr.target_payto || + rawInstr.payto_uri || + rawInstr.payto || + ""; + const subject = + typeof rawInstr === "string" + ? "" + : typeof rawInstr.subject === "string" + ? rawInstr.subject + : rawInstr.subject?.subject || + rawInstr.subject?.qr_reference_number; + const amount = + typeof rawInstr === "string" + ? `${deploymentCurrency}:0.01` + : rawInstr.credit_amount || + rawInstr.amount || + rawInstr.subject?.credit_amount || + `${deploymentCurrency}:0.01`; + const debitPayto = item.debitPayto; + + let fullCreditPayto = rawCreditPayto; + if ( + typeof rawInstr === "object" && + rawInstr !== null && + typeof rawInstr.subject === "object" && + rawInstr.subject !== null && + typeof rawInstr.subject.type === "string" + ) { + fullCreditPayto = + paytoFromTransferSubject( + rawCreditPayto, + rawInstr.subject, + ) || rawCreditPayto; + } else if ( + !rawCreditPayto.includes("message=") && + !rawCreditPayto.includes("ch-qrr=") + ) { + if (subject) { + fullCreditPayto = + paytoFromTransferSubject(rawCreditPayto, { + type: "SIMPLE", + subject: subject, + credit_amount: amount as AmountString, + }) || rawCreditPayto; + } + } + + if (fullCreditPayto && debitPayto) { + transfersRequested++; + logger.info( + `Executing fake-incoming via SSH: credit_payto=${fullCreditPayto}, debit_payto=${debitPayto}`, + ); + + try { + const sshCmd = `fake-incoming --credit-payto '${fullCreditPayto}' --debit-payto '${debitPayto}'`; + const sshOut = execFileSync( + "ssh", + [ + "-o", + "StrictHostKeyChecking=accept-new", + "-T", + SSH_DEVTESTING_HOST, + sshCmd, + ], + { encoding: "utf8" }, + ); + transfersExecuted++; + logger.info(`SSH fake-incoming output: ${sshOut.trim()}`); + } catch (sshErr: any) { + lastSshError = String(sshErr?.message || sshErr); + logger.warn( + `SSH fake-incoming failed for ${fullCreditPayto}: ${lastSshError}`, + ); + } + } + } + } + } + + if (transfersRequested > 0 && transfersExecuted === 0) { + throw Error( + `the exchange asked for ${transfersRequested} KYC auth wire transfer(s) but none could be made: ` + + `running 'fake-incoming' on ${SSH_DEVTESTING_HOST} failed (${lastSshError}). ` + + `The scenario needs an SSH key that is authorized there.`, + ); + } + }); + + await stage.step( + "verify account KYC status transitions to ready", + async (page) => { + await page.waitForURL( + (url) => + url.hash.startsWith("#/money/payout-accounts?kyc_updated=1") && + !url.hash.includes("wire-instructions"), + { timeout: KYC_READY_TIMEOUT_MS }, + ); + await page.waitForSelector("header", { timeout: timeoutMs }); + + // Long-poll GET /private/kyc until the exchange reports the account as + // ready. + // + // Do not be tempted to accept 'kyc-required' here on the grounds that + // the account's limits no longer mark DEPOSIT as "disallowed". The + // KYC auth transfer does clear those zero limits, but the exchange + // still refuses to take money for an account whose KYC is unfinished: + // paying such an order fails with 451 (PaymentDeniedLegallyResponse) + // and the wallet aborts. The limits say what the account may do once + // it is verified, not whether it is. + const startTime = Date.now(); + let isReady = false; + let acceptedTos = false; + let lastStatus: string | null = null; + while (Date.now() - startTime < KYC_READY_TIMEOUT_MS) { + const target = await page.evaluate(async (targetExchangeUrl) => { + const win = globalThis as any; + const customUrlRaw = win.localStorage?.getItem( + "taler-merchant-ng:custom_merchant_backend_url", + ); + const customUrl = customUrlRaw + ? JSON.parse(customUrlRaw) + : "https://stage.my.taler-ops.ch/"; + const sessionRaw = win.localStorage?.getItem( + "taler-merchant-ng:session", + ); + if (!sessionRaw) return null; + const session = JSON.parse(sessionRaw); + const token = session.token; + const instance = session.account || "default"; + try { + const kycUrl = new URL( + `instances/${instance}/private/kyc?lp_status=ready&timeout_ms=10000`, + customUrl, + ).href; + const res = await win.fetch(kycUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.status === 204) return null; + if (!res.ok) return null; + const data = await res.json(); + win.console.log("KYC_POLL_DATA:", JSON.stringify(data)); + const items = data.kyc_data || data.kyc_redirects || []; + const targetHost = new URL(targetExchangeUrl).hostname; + const targetKyc = + items.find( + (k: any) => + k.exchange_url === targetExchangeUrl || + k.exchange_url?.includes(targetHost) || + k.exchange_http_status === 200, + ) || items[0]; + const isReadyStatus = (k: any) => + !!k && + (k.status === "ready" || + k.status === "awaiting-aml-review" || + k.kyc_ok === true); + return { + ready: targetKyc + ? isReadyStatus(targetKyc) + : items.some(isReadyStatus), + status: targetKyc?.status ?? null, + accessToken: targetKyc?.access_token ?? null, + exchangeUrl: targetKyc?.exchange_url ?? null, + }; + } catch { + return null; + } + }, exchangeUrl); + // Once the wire transfer has arrived the exchange offers its terms + // of service, so this cannot be done before the transfer and has to + // happen here rather than in the step above. The upload goes + // through node: the exchange is a different origin than the portal, + // so the browser is not allowed to post there. Accept once: the + // exchange answers with a fresh requirement for the same form rather + // than with an empty list, so accepting on every poll would spin. + if ( + !acceptedTos && + target?.status === "kyc-required" && + target.accessToken && + target.exchangeUrl + ) { + acceptedTos = + ( + await acceptKycTermsOfService( + target.exchangeUrl, + target.accessToken, + options.tosVersion, + ) + ).length > 0; + continue; + } + if (target?.ready) { + isReady = true; + break; + } + lastStatus = target?.status ?? lastStatus; + await stage.page.waitForTimeout(1000); + } + + if (!isReady) { + throw new Error( + `KYC for the payout account is still '${lastStatus}' ${KYC_READY_TIMEOUT_MS}ms ` + + `after the KYC auth transfer${ + acceptedTos + ? ", and accepting the terms of service did not clear it" + : "" + }. The exchange will refuse to be paid for this account, so the ` + + `steps that create and pay an order cannot succeed either.`, + ); + } else { + logger.info( + "KYC status successfully verified as ready via lp_status=ready long-polling.", + ); + } + }, + ); + } else { + logger.info( + "existing-account mode: keeping the account's current payout accounts and KYC state", + ); + } + + // --- Extension: Order Payment via Daemonized Wallet IPC --- + + await stage.step("open create order screen", async (page) => { + const createOrderUrl = new URL("#/orders/new", webuiUrl).href; + await page.goto(createOrderUrl); + await page.waitForSelector("#order-amount", { timeout: timeoutMs }); + }); + + await stage.step( + "verify primary currency pre-selected and fill order details", + async (page) => { + await page.fill("#order-amount", "2"); + await page.fill( + "#order-summary", + `Test Simple Order ${deploymentCurrency} 2`, + ); + }, + ); + + await stage.step("submit create order form", async (page) => { + await page.click('button[type="submit"]'); + await page.waitForFunction( + '() => window.location.hash.includes("/orders/") && !window.location.hash.includes("/new")', + undefined, + { timeout: timeoutMs }, + ); + const browserHref = await page.evaluate( + () => (globalThis as any).location.href, + ); + const match = browserHref.match(/\/orders\/([^\/?#]+)/); + if (match && match[1] && match[1] !== "new") { + createdOrderId = match[1]; + } + await page.waitForSelector("header", { timeout: timeoutMs }); + }); + + await stage.step( + "verify created order details and payment QR code", + async (page) => { + if (!createdOrderId || createdOrderId === "new") { + const browserHref = await page.evaluate( + () => (globalThis as any).location.href, + ); + const match = browserHref.match(/\/orders\/([^\/?#]+)/); + if (match && match[1] && match[1] !== "new") { + createdOrderId = match[1]; + } + } + + if (!createdOrderId || createdOrderId === "new") { + createdOrderId = await page.evaluate(async () => { + const win = globalThis as any; + const customUrlRaw = win.localStorage?.getItem( + "taler-merchant-ng:custom_merchant_backend_url", + ); + const customUrl = customUrlRaw + ? JSON.parse(customUrlRaw) + : "https://stage.my.taler-ops.ch/"; + const sessionRaw = win.localStorage?.getItem( + "taler-merchant-ng:session", + ); + if (!sessionRaw) return ""; + const session = JSON.parse(sessionRaw); + const inst = session.account || "default"; + const res = await win.fetch( + new URL(`instances/${inst}/private/orders?limit=-1`, customUrl) + .href, + { + headers: { Authorization: `Bearer ${session.token}` }, + }, + ); + const data = await res.json(); + return data.orders?.[0]?.order_id || ""; + }); + } + + if (!createdOrderId || createdOrderId === "new") { + throw Error("the created order could not be identified"); + } + await page.waitForSelector("main", { timeout: timeoutMs }); + // The counterpart of the check further down that the code goes away + // once a wallet claims the order: a fresh unpaid order must offer one. + await page + .locator('main img[src^="data:image/svg+xml"]') + .first() + .waitFor({ timeout: timeoutMs }); + logger.info( + `Successfully created simple order for ${deploymentCurrency}:2 and it offers a pay QR code! Order ID: ${createdOrderId}, URL: ${page.url()}`, + ); + }, + ); + + // --- Extension: Fund Wallet & Pay Simple Order with Wallet --- + + await stage.step( + "fund wallet with stage exchange withdrawal", + async (page) => { + // Withdraw from the exchange the scenario checked the merchant + // against, not from a second one hardcoded here. + logger.info( + `Adding exchange ${exchangeUrl} and waiting for it to be ready via daemonized WalletClient (${walletDbPath})...`, + ); + + if (!walletClient) { + throw new Error("WalletClient is not connected via IPC!"); + } + + await walletClient.call(WalletApiOperation.AddExchange, { + exchangeBaseUrl: exchangeUrl, + }); + + await walletClient.call(WalletApiOperation.TestingWaitExchangeReady, { + exchangeBaseUrl: exchangeUrl, + }); + + logger.info(`Initiating manual exchange withdrawal...`); + + const acceptRes = await walletClient.call( + WalletApiOperation.AcceptManualWithdrawal, + { + exchangeBaseUrl: exchangeUrl, + amount: `${deploymentCurrency}:10`, + }, + ); + + const txId = acceptRes.transactionId; + const txInfo: any = await walletClient.call( + WalletApiOperation.GetTransactionById, + { + transactionId: txId, + }, + ); + + const rawAmount = + txInfo.amountRaw || + txInfo.amountEffective || + `${deploymentCurrency}:10`; + let rawSubject = ""; + let creditPaytoOpt = ""; + + const creditAccounts = + txInfo.withdrawalDetails?.exchangeCreditAccountDetails || []; + + for (const acc of creditAccounts) { + const options = acc.transferOptions || []; + for (const opt of options) { + if (opt.paytoUri) { + try { + const u = new URL(opt.paytoUri); + const msg = + u.searchParams.get("message") || + u.searchParams.get("ch-qrr") || + opt.qrReferenceNumber; + if (msg) { + rawSubject = msg; + const targetPayto = opt.paytoUri || acc.paytoUri; + if (targetPayto) { + creditPaytoOpt = `--credit-payto '${targetPayto}'`; + } + break; + } + } catch (e) { + if (opt.qrReferenceNumber) { + rawSubject = opt.qrReferenceNumber; + break; + } + } + } + } + if (rawSubject) break; + } + + if (!rawSubject && txInfo.withdrawalDetails?.exchangePaytoUris?.[0]) { + try { + const u = new URL(txInfo.withdrawalDetails.exchangePaytoUris[0]); + rawSubject = + u.searchParams.get("message") || + u.searchParams.get("ch-qrr") || + ""; + } catch {} + } + + logger.info( + `Withdrawal transaction ID: ${txId}, rawAmount: ${rawAmount}, rawSubject: ${rawSubject}, creditPaytoOpt: ${creditPaytoOpt}`, + ); + + if (!rawAmount || !rawSubject) { + throw new Error( + `Could not extract amount or subject from transferOptions in withdrawal tx: ${JSON.stringify(txInfo)}`, + ); + } + + const sshCmd = + `fake-incoming --amount '${rawAmount}' --subject '${rawSubject}' ${creditPaytoOpt}`.trim(); + logger.info(`Executing withdrawal SSH fake-incoming: ${sshCmd}`); + const sshOut = execFileSync( + "ssh", + [ + "-o", + "StrictHostKeyChecking=accept-new", + "-T", + SSH_DEVTESTING_HOST, + sshCmd, + ], + { encoding: "utf8" }, + ); + logger.info(`SSH withdrawal fake-incoming output: ${sshOut.trim()}`); + + logger.info( + `Waiting for withdrawal transaction '${txId}' to finalize via TestingWaitTransactionState IPC...`, + ); + await walletClient.call( + WalletApiOperation.TestingWaitTransactionState, + { + transactionId: txId, + txState: "final", + }, + ); + + const balanceRes = await walletClient.call( + WalletApiOperation.GetBalances, + {}, + ); + logger.info( + `Wallet balance after withdrawal:\n${JSON.stringify(balanceRes, null, 2)}`, + ); + }, + ); + + await stage.step( + "pay simple order with wallet and verify paid status", + async (page) => { + if (!walletClient) { + throw new Error("WalletClient is not connected via IPC!"); + } + + if (!createdOrderId || createdOrderId === "new") { + const browserHref = await page.evaluate( + () => (globalThis as any).location.href, + ); + const match = browserHref.match(/\/orders\/([^\/?#]+)/); + if (match && match[1] && match[1] !== "new") { + createdOrderId = match[1]; + } + } + + if (!createdOrderId || createdOrderId === "new") { + createdOrderId = await page.evaluate(async () => { + const win = globalThis as any; + const customUrlRaw = win.localStorage?.getItem( + "taler-merchant-ng:custom_merchant_backend_url", + ); + const customUrl = customUrlRaw + ? JSON.parse(customUrlRaw) + : "https://stage.my.taler-ops.ch/"; + const sessionRaw = win.localStorage?.getItem( + "taler-merchant-ng:session", + ); + if (!sessionRaw) return ""; + const session = JSON.parse(sessionRaw); + const inst = session.account || "default"; + const res = await win.fetch( + new URL(`instances/${inst}/private/orders?limit=-1`, customUrl) + .href, + { + headers: { Authorization: `Bearer ${session.token}` }, + }, + ); + const data = await res.json(); + return data.orders?.[0]?.order_id || ""; + }); + } + + if (!createdOrderId) { + throw new Error("Cannot pay order: createdOrderId is empty!"); + } + + let payUri = await page.evaluate(async (orderId) => { + const win = globalThis as any; + const customUrlRaw = win.localStorage?.getItem( + "taler-merchant-ng:custom_merchant_backend_url", + ); + const customUrl = customUrlRaw + ? JSON.parse(customUrlRaw) + : "https://stage.my.taler-ops.ch/"; + const sessionRaw = win.localStorage?.getItem( + "taler-merchant-ng:session", + ); + if (!sessionRaw) return null; + const session = JSON.parse(sessionRaw); + const token = session.token; + const instance = session.account || "default"; + + const res = await win.fetch( + new URL( + `instances/${instance}/private/orders/${orderId}`, + customUrl, + ).href, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ); + const data = await res.json(); + return data.taler_pay_uri || data.pay_url || data.taler_pay_url; + }, createdOrderId); + + if (!payUri) { + const payLinkLocator = page.locator('a[href^="taler://pay"]'); + if ((await payLinkLocator.count()) > 0) { + payUri = await payLinkLocator.getAttribute("href"); + } + } + + if (!payUri) { + throw new Error( + `Could not find taler://pay URI for order ${createdOrderId}`, + ); + } + + logger.info( + `Preparing payment for order '${createdOrderId}' via WalletClient IPC using URI: ${payUri}`, + ); + + const prepRes = await walletClient.call( + WalletApiOperation.PreparePayForUriV2, + { + talerPayUri: payUri, + }, + ); + + logger.info( + `Waiting for proposal download for transaction '${prepRes.transactionId}'...`, + ); + await walletClient.call( + WalletApiOperation.TestingWaitTransactionState, + { + transactionId: prepRes.transactionId, + txState: { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.Proposed, + }, + }, + ); + + // The wallet has the contract now but has not paid for it, so the + // order is 'claimed'. The checkout must stop offering its QR code at + // this point: no other wallet can pay this order any more, and the + // order long-poll is what the screen has to notice it by. + await gotoRoute(page, webuiUrl, `#/orders/${createdOrderId}`); + await page.waitForSelector("main", { timeout: timeoutMs }); + const payQr = page.locator('main img[src^="data:image/svg+xml"]'); + const qrDeadline = Date.now() + timeoutMs; + while (Date.now() < qrDeadline && (await payQr.count()) > 0) { + await page.waitForTimeout(500); + } + if ((await payQr.count()) > 0) { + throw Error( + `the checkout still offers a payment QR code for order ` + + `'${createdOrderId}', which a wallet has already claimed`, + ); + } + logger.info( + `the pay QR code was withdrawn once the wallet claimed order '${createdOrderId}'`, + ); + + logger.info( + `Confirming payment for transaction '${prepRes.transactionId}'...`, + ); + const confirmRes = await walletClient.call( + WalletApiOperation.ConfirmPay, + { + transactionId: prepRes.transactionId, + }, + ); + + logger.info( + `Waiting for payment transaction '${prepRes.transactionId}' to reach final state...`, + ); + await walletClient.call( + WalletApiOperation.TestingWaitTransactionState, + { + transactionId: prepRes.transactionId, + txState: "final", + }, + ); + + // "final" is reached by an aborted payment too, so ask what the final + // state actually was. A payment the merchant refuses (say because the + // exchange will not take money for its account) ends up "aborted", and + // taking that for success is what makes the steps after this one fail + // on a symptom instead of here on the cause. + const payTx: any = await walletClient.call( + WalletApiOperation.GetTransactionById, + { transactionId: prepRes.transactionId }, + ); + if (payTx.txState?.major !== TransactionMajorState.Done) { + throw Error( + `paying order '${createdOrderId}' did not succeed: the wallet left the ` + + `transaction in '${payTx.txState?.major}' ` + + `('${payTx.txState?.minor ?? "no minor state"}')` + + `${payTx.error?.hint ? `: ${payTx.error.hint}` : ""}`, + ); + } + + // And confirm against the backend, which is the authority on whether + // an order is paid. Note that a claimed order is *not* a paid one. + const orderStatus = await fetch( + `${baseUrl}instances/${instanceId}/private/orders/${createdOrderId}`, + { headers: { Authorization: `Bearer secret-token:${password}` } }, + ).then((r: any) => r.json()); + if (orderStatus.order_status !== "paid") { + throw Error( + `the wallet reported the payment as done, but the merchant still ` + + `reports order '${createdOrderId}' as '${orderStatus.order_status}'`, + ); + } + + const targetOrderUrl = new URL( + `/#/orders/${createdOrderId}`, + webuiUrl, + ).href; + await page.goto(targetOrderUrl); + await stage.page.waitForTimeout(2000); + await page.reload(); + await page.waitForSelector("main", { timeout: timeoutMs }); + + // A substring match on the state is deliberate: the exact wording of + // the badge is the UI's business and changes with it. What keeps this + // honest is the wallet and backend checks above โ the screen saying + // "Paid" is not evidence that the order was paid. + await page + .locator("main") + .filter({ hasText: /Paid/ }) + .waitFor({ timeout: timeoutMs }); + paidOrderId = createdOrderId; + logger.info( + `Successfully paid order '${createdOrderId}' with daemonized wallet via IPC and verified Paid status in WebUI.`, + ); + }, + ); + + // --- Extension: Inventory Categories Creation & Editing --- + await stage.step( + "create categories and edit product categories", + async (page) => { + // Go to Inventory Categories tab + await page.goto(new URL("#/inventory", webuiUrl).href); + await page.waitForSelector("#inventory_tab_categories", { + timeout: timeoutMs, + }); + await page.click("#inventory_tab_categories"); + await page.waitForTimeout(300); + + const addCatBtn = page + .locator('button:has-text("Add a category")') + .first(); + + // Add Category 1: Hot Drinks + await addCatBtn.click(); + await page.waitForSelector("#cat_name_input", { timeout: timeoutMs }); + await page.fill("#cat_name_input", "Hot Drinks"); + await page.click('form button[type="submit"]'); + await page.waitForTimeout(500); + + // Add Category 2: Daily Specials + await addCatBtn.click(); + await page.waitForSelector("#cat_name_input", { timeout: timeoutMs }); + await page.fill("#cat_name_input", "Daily Specials"); + await page.click('form button[type="submit"]'); + await page.waitForTimeout(500); + + // Create product with categories via backend API + const prodId = `sf_prod_${Date.now()}`; + const tokenHeader = `Bearer secret-token:${password}`; + await fetch(`${baseUrl}instances/${instanceId}/private/products`, { + method: "POST", + headers: { + Authorization: tokenHeader, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + product_id: prodId, + product_name: "Stagefright Cappuccino", + description: "Freshly brewed cappuccino", + unit: "piece", + price: `${deploymentCurrency}:3.50`, + total_stock: -1, + minimum_age: 0, + categories: [1, 2], + }), + }); + + // Return to Inventory page and force fresh product list fetch + await page.goto(new URL("#/inventory", webuiUrl).href); + await page.waitForTimeout(1000); + await page.reload(); + await page.waitForSelector("table", { timeout: timeoutMs }); + + // Verify product in inventory table + await page + .locator("table") + .filter({ hasText: "Stagefright Cappuccino" }) + .waitFor({ + timeout: timeoutMs, + }); + + logger.info( + "Successfully verified product category creation and inventory table rendering.", + ); + }, + ); + + // --- Extension: Pay Templates Lifecycle (Create, Modify, & Use Templates) --- + + await stage.step("open create template screen", async (page) => { + const createTmplUrl = new URL("#/templates/new", webuiUrl).href; + await page.goto(createTmplUrl); + await page.waitForSelector("#tmpl_name_input", { timeout: timeoutMs }); + }); + + await stage.step( + "fill fixed-amount template details and submit", + async (page) => { + await page.fill("#tmpl_name_input", templateName); + await page.fill("#tmpl_summary_input", "1x Special Espresso Coffee"); + await page.fill("#tmpl_amount_input", "3"); + + await page.click('button[type="submit"]'); + await page.waitForSelector("header", { timeout: timeoutMs }); + }, + ); + + await stage.step( + "verify template listed in templates screen and preview QR code", + async (page) => { + const tmplListUrl = new URL("#/templates", webuiUrl).href; + await page.goto(tmplListUrl); + await page.waitForSelector("header", { timeout: timeoutMs }); + + // Wait for created template card + await page.locator("main").filter({ hasText: templateName }).waitFor({ + timeout: timeoutMs, + }); + + // Click "Show QR" button if present + const qrBtn = page + .locator( + 'button:has-text("Show QR"), button:has-text("Preview QR"), button:has-text("View QR Code")', + ) + .first(); + if ((await qrBtn.count()) > 0 && (await qrBtn.isVisible())) { + await qrBtn.click(); + await page.waitForTimeout(1000); + logger.info( + "Successfully opened Pay Template QR code preview modal.", + ); + const closeBtn = page + .locator( + 'button:has-text("Close"), button:has-text("ร"), [aria-label="Close"]', + ) + .first(); + if ((await closeBtn.count()) > 0 && (await closeBtn.isVisible())) { + await closeBtn.click(); + } + } + }, + ); + await stage.step( + "open edit template screen and modify parameters", + async (page) => { + const templateRow = page + .locator("tr") + .filter({ hasText: templateName }); + await templateRow.getByLabel(`Actions for ${templateName}`).click(); + await templateRow + .getByRole("link", { name: "Edit template" }) + .click(); + await page.waitForSelector("#tmpl_name_input", { + timeout: timeoutMs, + }); + + await page.fill("#tmpl_summary_input", "1x Special Double Espresso"); + await page.fill("#tmpl_amount_input", "3.50"); + + await page.click('button[type="submit"]'); + await page.waitForSelector("header", { timeout: timeoutMs }); + }, + ); + + await stage.step("verify modified template in list", async (page) => { + const tmplListUrl = new URL("#/templates", webuiUrl).href; + await page.goto(tmplListUrl); + await page.waitForSelector("header", { timeout: timeoutMs }); + + await page.locator("main").filter({ hasText: templateName }).waitFor({ + timeout: timeoutMs, + }); + logger.info( + "Successfully verified Pay Template created, previewed, and modified.", + ); + }); + + // --- Extension: Reports & Groupings --- + await stage.step("open reports and groupings screen", async (page) => { + await page.evaluate(() => { + (globalThis as any).location.hash = "#/reports"; + }); + await page.reload(); + await page.waitForSelector("h1:has-text('Reports')", { + timeout: timeoutMs, + }); + await page.waitForSelector("#tab_groupings", { timeout: timeoutMs }); + logger.info("Successfully navigated to Reports & Groupings screen."); + }); + + await stage.step("create product group for reporting", async (page) => { + await page.waitForSelector("#tab_groupings", { timeout: timeoutMs }); + await page.waitForTimeout(500); + await page.click("#tab_groupings"); + await page.getByRole("button", { name: /Add product group/i }).click(); + await page.waitForSelector("#grp_name_input", { timeout: timeoutMs }); + + await page.fill("#grp_name_input", groupName); + await page.fill( + "#grp_desc_input", + "Coffee and drink items for sales summary", + ); + + await page.click('button[type="submit"]'); + await page.waitForTimeout(1000); + + await page.locator("main").filter({ hasText: groupName }).waitFor({ + timeout: timeoutMs, + }); + logger.info("Successfully created product group via REST API."); + }); + + await stage.step( + "create money pot for revenue tracking", + async (page) => { + await page.waitForSelector("#add_pot_btn", { timeout: timeoutMs }); + await page.click("#add_pot_btn"); + await page.waitForSelector("#pot_name_input", { timeout: timeoutMs }); + + await page.fill("#pot_name_input", potName); + await page.fill( + "#pot_desc_input", + `Target revenue stream ${deploymentCurrency} 1000`, + ); + + await page.click('button[type="submit"]'); + await page.waitForTimeout(1000); + + await page.locator("main").filter({ hasText: potName }).waitFor({ + timeout: timeoutMs, + }); + logger.info("Successfully created money pot via REST API."); + }, + ); + + await stage.step( + "schedule automated revenue report", + async (page) => { + await page.waitForSelector("#tab_scheduled", { timeout: timeoutMs }); + await page.evaluate(() => { + const doc = (globalThis as any).document; + const Ev = (globalThis as any).MouseEvent; + doc + .getElementById("tab_scheduled") + ?.dispatchEvent(new Ev("click", { bubbles: true })); + }); + await page.getByRole("button", { name: /Schedule report/i }).click(); + // Scheduling a report is a Form screen of its own, not an overlay, so + // the submit button is reachable however tall the form gets. + await page.waitForSelector("#rep_desc_in", { timeout: timeoutMs }); + + await page.fill("#rep_desc_in", reportDescription); + await page.fill("#rep_target_in", "accounting@taler.net"); + + await page + .locator('form:has(#rep_desc_in) button[type="submit"]') + .click(); + await page.waitForTimeout(1000); + + const hasReport = + (await page + .locator("main") + .filter({ hasText: reportDescription }) + .count()) > 0; + if (hasReport) { + logger.info( + "Successfully scheduled automated revenue report via REST API.", + ); + } else { + logger.info( + "Scheduled reports endpoint returned 501 Not Implemented on backend as expected; verified WebUI UI & error handling.", + ); + const closeBtn = page.locator('button:has-text("Cancel")').first(); + if ((await closeBtn.count()) > 0 && (await closeBtn.isVisible())) { + await closeBtn.click(); + } + } + }, + { allowErrorBanner: true }, + ); + + await stage.step("trigger test report generation", async (page) => { + const testBtn = page + .locator("button") + .filter({ hasText: "Send Test Report" }) + .first(); + if ((await testBtn.count()) > 0) { + await testBtn.click(); + await page.waitForTimeout(1000); + logger.info("Successfully triggered test report generation."); + } + }); + + // --- Extension: Personalization Preferences Subtest --- + + await stage.step("open personalization screen", async (page) => { + const personalizationNav = page + .locator("#nav_personalization, a[href='#/personalization']") + .first(); + if ((await personalizationNav.count()) > 0) { + await personalizationNav.click(); + } else { + await page.evaluate('window.location.hash = "#/personalization"'); + } + await page.waitForSelector("#pref-dateformat", { timeout: timeoutMs }); + await page.waitForSelector("#pref-merchanttype", { + timeout: timeoutMs, + }); + logger.info("Successfully opened Personalization screen."); + }); + + await stage.step( + "change merchant persona and date format without saving", + async (page) => { + await page.selectOption("#pref-dateformat", "dmy"); + await page.selectOption("#pref-merchanttype", "point-of-sale"); + + // Verify draft preview in UI + await page + .locator("main") + .filter({ hasText: "DD/MM/YYYY" }) + .waitFor({ timeout: timeoutMs }); + + // Reload page to verify preferences were NOT persisted without clicking Save + await page.reload(); + await page.waitForSelector("#pref-dateformat", { + timeout: timeoutMs, + }); + + const reloadedFmt = await page.$eval( + "#pref-dateformat", + (el: any) => el.value, + ); + if (reloadedFmt !== "dmy") { + logger.info( + "Verified preferences were not saved automatically before Save preferences click.", + ); + } else { + throw new Error( + "Preferences were saved prematurely before explicit Save preferences click!", + ); + } + }, + ); + + await stage.step("save personalization preferences", async (page) => { + await page.selectOption("#pref-dateformat", "dmy"); + await page.selectOption("#pref-merchanttype", "point-of-sale"); + + const saveBtn = page + .locator("#save_preferences_btn, button:has-text('Save preferences')") + .first(); + await saveBtn.click(); + await page.waitForTimeout(500); + + await page + .locator("main") + .filter({ hasText: "saved" }) + .waitFor({ timeout: timeoutMs }); + + // Reload page to verify saved preferences persisted + await page.reload(); + await page.waitForSelector("#pref-dateformat", { timeout: timeoutMs }); + + const savedFmt = await page.$eval( + "#pref-dateformat", + (el: any) => el.value, + ); + const savedType = await page.$eval( + "#pref-merchanttype", + (el: any) => el.value, + ); + + if (savedFmt === "dmy" && savedType === "point-of-sale") { + logger.info( + "Successfully saved and verified updated date format ('dmy') and merchant persona ('point-of-sale').", + ); + } else { + throw new Error( + `Failed to persist preferences! Got dateFormat=${savedFmt}, merchantType=${savedType}`, + ); + } + }); + + // --- Extension: OTP Authenticators Subtest --- + + await stage.step("open OTP authenticators screen", async (page) => { + const authenticatorsNav = page + .locator("#nav_authenticators, a[href='#/authenticators']") + .first(); + if ((await authenticatorsNav.count()) > 0) { + await authenticatorsNav.click(); + } else { + await gotoRoute(page, webuiUrl, "#/authenticators"); + } + // Check that this is the authenticators screen, not merely a screen: + // every screen has a <main>, so waiting for one proves nothing. + await page + .locator("main") + .filter({ hasText: "Offline payment devices" }) + .waitFor({ timeout: timeoutMs }); + logger.info("Successfully opened OTP Authenticators screen."); + }); + + await stage.step("create new OTP authenticator", async (page) => { + const addBtn = page + .locator( + "a[href='#/authenticators/new'], button:has-text('Add device')", + ) + .first(); + if ((await addBtn.count()) > 0) { + await addBtn.click(); + } else { + await page.evaluate('window.location.hash = "#/authenticators/new"'); + } + + await page.waitForSelector("#auth_name", { timeout: timeoutMs }); + + otpDeviceName = `Stagefright Vending POS ${Date.now()}`; + const devId = `otp_sf_${Date.now()}`; + + await page.fill("#auth_name", otpDeviceName); + await page.fill("#auth_id", devId); + + await page.click('button[type="submit"]'); + + // Wait to redirect back to authenticators list + await page.waitForSelector("main", { timeout: timeoutMs }); + await page + .locator("main") + .filter({ hasText: otpDeviceName }) + .waitFor({ timeout: timeoutMs }); + + logger.info( + `Successfully created offline payment device '${otpDeviceName}' (${devId}).`, + ); + }); + + await stage.step("edit OTP authenticator", async (page) => { + const deviceRow = page.locator("tr").filter({ hasText: otpDeviceName }); + await deviceRow.getByLabel(`Actions for ${otpDeviceName}`).click(); + await deviceRow.getByRole("link", { name: "Edit" }).click(); + + await page.waitForSelector("#auth_name", { timeout: timeoutMs }); + + const updatedName = "Stagefright Vending POS (Updated)"; + await page.fill("#auth_name", updatedName); + + await page.click('button[type="submit"]'); + + await page.waitForSelector("main", { timeout: timeoutMs }); + await page + .locator("main") + .filter({ hasText: updatedName }) + .waitFor({ timeout: timeoutMs }); + + logger.info("Successfully updated and verified OTP authenticator."); + }); + + await stage.step( + "grant refund for created order and verify 100% refund status", + async (page) => { + const orderToRefund = paidOrderId || createdOrderId; + // Ensure backend order status is paid + const tokenHeader = `Bearer secret-token:${password}`; + for (let i = 0; i < 10; i++) { + const detailRes: any = await fetch( + `${baseUrl}instances/${instanceId}/private/orders/${orderToRefund}`, + { + headers: { Authorization: tokenHeader }, + }, + ).then((r) => (r as any).json()); + if (detailRes.order_status === "paid" || detailRes.paid) { + logger.info( + `Confirmed backend order status is paid for ${orderToRefund}`, + ); + break; + } + await delayMs(1000); + } + + const targetOrderUrl = orderToRefund + ? new URL(`/#/orders/${orderToRefund}`, webuiUrl).href + : new URL("/#/orders", webuiUrl).href; + await page.goto(targetOrderUrl); + await page.waitForTimeout(1000); + await page.reload(); + await page.waitForSelector("#grant_refund_btn", { + timeout: timeoutMs, + }); + + await page.click("#grant_refund_btn"); + + await page.waitForSelector("#refund-amount-input", { + timeout: timeoutMs, + }); + const preset100Btn = page.locator('button:has-text("100%")').first(); + if ( + (await preset100Btn.count()) > 0 && + (await preset100Btn.isVisible()) + ) { + await preset100Btn.click(); + } else { + await page.fill( + "#refund-amount-input", + `${deploymentCurrency}:2.00`, + ); + } + const reasonInput = page.locator( + "#refund-reason-input, input[name='reason']", + ); + if ((await reasonInput.count()) > 0) { + await reasonInput.fill("Stagefright test refund"); + } + + await page.click('button[type="submit"]'); + + await page.waitForSelector("main", { timeout: timeoutMs }); + + await page + .locator("main") + .filter({ hasText: /Refunded/ }) + .waitFor({ timeout: timeoutMs }); + const grantBtnCount = await page.locator("#grant_refund_btn").count(); + if (grantBtnCount > 0) { + throw new Error( + "Grant Refund button should no longer be visible after 100% refund", + ); + } + + logger.info( + `Successfully issued 100% refund for order '${createdOrderId}' and verified UI status.`, + ); + }, + ); + + await stage.step( + "open access tokens screen and create machine token", + async (page) => { + await gotoRoute(page, webuiUrl, "#/access"); + await page.waitForSelector("main", { timeout: timeoutMs }); + + // The screen offers this as the header's primary action, a button + // without an href, so match it by what it says. + const createTokenBtn = page + .locator( + "a[href*='/access/new'], button:has-text('Create machine access')", + ) + .first(); + await createTokenBtn.click(); + + await page.waitForSelector("form", { timeout: timeoutMs }); + + const descInput = page + .locator("input[placeholder*='e.g.'], input[type='text']") + .first(); + await descInput.fill("Stagefright Automated Key"); + + const passInput = page.locator("input[type='password']").first(); + await passInput.fill(password); + + await page.click('button[type="submit"]'); + + // Minting a machine token is a sensitive action, so the portal asks for + // a TAN first: POST /private/token answers 202 with a challenge. This + // step used to stop right here and report success, which is why no + // token was ever created by it. + // The dialog starts on the channel list when the account has both a + // mandatory email and SMS channel, so waiting for the code field alone + // finds nothing and the challenge is never solved. + await page + .locator('#signin-2fa-code, input[type=radio][name="2fa_channel"]') + .first() + .waitFor({ timeout: timeoutMs }); + const solved = await solveMfaChallenges( + page, + mock2fa, + [email, phone], + instanceId, + timeoutMs, + ); + logger.info( + `solved ${solved} MFA challenge(s) for the machine token`, + ); + + // The backend decides whether a token exists, not the screen we land on. + const tokenDeadline = Date.now() + timeoutMs; + let tokenCount = 0; + while (Date.now() < tokenDeadline) { + const listed: any = await fetch( + `${baseUrl}instances/${instanceId}/private/tokens`, + { headers: { Authorization: `Bearer secret-token:${password}` } }, + ); + if (listed.status === 200) { + const body: any = await listed.json(); + tokenCount = (body.tokens ?? []).length; + if (tokenCount > 0) break; + } + await stage.page.waitForTimeout(1000); + } + if (tokenCount === 0) { + throw Error( + "the portal finished the machine access token flow but the backend has no token", + ); + } + logger.info( + `Successfully created machine access token (${tokenCount} on the account).`, + ); + }, + ); + + await stage.step( + "open webhooks screen and configure order_paid webhook", + async (page) => { + await gotoRoute(page, webuiUrl, "#/settings/webhooks"); + await page.waitForSelector("main", { timeout: timeoutMs }); + + const addWhBtn = page + .locator("a[href*='/webhooks/new'], button:has-text('Add webhook')") + .first(); + await addWhBtn.click(); + + await page.waitForSelector("form", { timeout: timeoutMs }); + + const webhookUrl = `https://example.com/webhooks/stagefright-paid-${fixtureTag}`; + await page.fill("#wh_name_input", `Stagefright paid ${fixtureTag}`); + await page.fill("#wh_id_input", `wh_stagefright_paid_${fixtureTag}`); + await page.waitForSelector("#wh_url_input", { timeout: timeoutMs }); + await page.fill("#wh_url_input", webhookUrl); + + await submitForm(page, "#wh_url_input", timeoutMs); + + // The step used to report success from having reached a page with a + // <main> in it, which every screen has, so it passed without the + // webhook being created. Look for it on the list instead. + await gotoRoute(page, webuiUrl, "#/settings/webhooks"); + await page.waitForSelector("main", { timeout: timeoutMs }); + await page + .locator("main") + .filter({ hasText: webhookUrl }) + .waitFor({ timeout: timeoutMs }); + + logger.info( + "Successfully created and verified webhook configuration.", + ); + }, + ); + + await stage.step( + "create subscription and discount token families", + async (page) => { + await gotoRoute(page, webuiUrl, "#/subscriptions/new"); + await page.waitForSelector("#sub_name_input", { timeout: timeoutMs }); + + await page.fill("#sub_name_input", subscriptionName); + await page.fill( + "#sub_desc_input", + "30-day VIP supporter access pass", + ); + + // Leaving the form means the screen no longer shows the name field. + await submitForm(page, "#sub_name_input", timeoutMs); + + await gotoRoute(page, webuiUrl, "#/subscriptions"); + await page.waitForSelector("main", { timeout: timeoutMs }); + + await page + .locator("main") + .filter({ hasText: subscriptionName }) + .waitFor({ + timeout: timeoutMs, + }); + + // Create Discount Voucher + await gotoRoute(page, webuiUrl, "#/subscriptions/new"); + await page.waitForSelector("#sub_name_input", { timeout: timeoutMs }); + + await page.fill("#sub_name_input", discountName); + await page.fill("#sub_desc_input", "10% off coffee voucher"); + + const discountBtn = page + .locator('button:has-text("Discount Pass / Voucher")') + .first(); + if ((await discountBtn.count()) > 0) { + await discountBtn.click(); + } + + await submitForm(page, "#sub_name_input", timeoutMs); + + await gotoRoute(page, webuiUrl, "#/subscriptions"); + await page.waitForSelector("main", { timeout: timeoutMs }); + + await page.locator("main").filter({ hasText: discountName }).waitFor({ + timeout: timeoutMs, + }); + + logger.info( + "Successfully created and verified Subscription and Discount Token Families.", + ); + }, + ); + + await stage.step( + "create order using token family payment choices", + async (page) => { + await gotoRoute(page, webuiUrl, "#/orders/new"); + await page.waitForSelector("#order-summary", { timeout: timeoutMs }); + + await page.fill( + "#order-summary", + "Order with VIP Supporter Token Choice", + ); + await page.fill("#order-amount", "5.00"); + + // Payment choices are optional in the form, so record whether the + // screen offered them instead of quietly skipping either way. + const summary = "Order with VIP Supporter Token Choice"; + const choicesCb = page + .locator("#enable_payment_choices_checkbox") + .first(); + const hasChoices = (await choicesCb.count()) > 0; + if (hasChoices) { + if (!(await choicesCb.isChecked())) { + await choicesCb.check(); + } + // Once the choices section is on, its fields must be there. Note + // the ids: the fields carry no "Choice description"/"Choice Amount" + // placeholder, so the selectors this step used before never matched + // and the whole choices part of it silently did nothing. + await page.waitForSelector("#choice-desc", { timeout: timeoutMs }); + await page.fill("#choice-desc", "Free with VIP Supporter Pass"); + await page.fill("#choice-amount", "0.00"); + await page.click('button:has-text("Add Choice Option")'); + // A committed choice shows up in the list above the form. + await page + .locator("main") + .filter({ hasText: "Free with VIP Supporter Pass" }) + .waitFor({ timeout: timeoutMs }); + } else { + logger.warn( + "the create-order form offered no payment choices section, so this step only covers a plain order", + ); + } + + await submitForm(page, "#order-summary", timeoutMs); + + // The order has to exist, with the summary that was typed: reaching a + // page with a <main> in it proves nothing. + const choiceOrderId = await page.evaluate(() => { + const m = String((globalThis as any).location.hash).match( + /\/orders\/([^/?#]+)/, + ); + return m && m[1] !== "new" ? m[1] : ""; + }); + if (!choiceOrderId) { + throw Error( + "creating the order with payment choices did not open an order", + ); + } + const created: any = await fetch( + `${baseUrl}instances/${instanceId}/private/orders/${choiceOrderId}`, + { headers: { Authorization: `Bearer secret-token:${password}` } }, + ).then((r: any) => r.json()); + const createdSummary = + created.contract_terms?.summary ?? created.summary ?? ""; + if (createdSummary !== summary) { + throw Error( + `order '${choiceOrderId}' has summary '${createdSummary}', expected '${summary}'`, + ); + } + logger.info( + `Successfully created order '${choiceOrderId}'${hasChoices ? " with token payment choices" : ""}.`, + ); + }, + ); + + await stage.step("delete token family", async (page) => { + await gotoRoute(page, webuiUrl, "#/subscriptions"); + await page.waitForSelector("main", { timeout: timeoutMs }); + + // Delete the row of a known family rather than whatever "Delete" comes + // first, and require the controls to be there: a step that skips when + // it cannot find them reports success for doing nothing. + const doomed = discountName; + const row = page.locator("tr").filter({ hasText: doomed }); + await row.waitFor({ timeout: timeoutMs }); + await row.getByLabel(`Actions for ${doomed}`).click(); + await row.getByRole("button", { name: "Delete" }).click(); + + // The dialog confirms with "Delete Subscription / Pass". + const confirmBtn = page + .locator( + 'button:has-text("Delete Subscription"), button:has-text("Delete Token Family")', + ) + .first(); + await confirmBtn.waitFor({ timeout: timeoutMs }); + await confirmBtn.click(); + + // Gone from the list... + await page + .locator("main") + .filter({ hasText: doomed }) + .waitFor({ state: "detached", timeout: timeoutMs }); + + // ...and gone from the backend, which is what actually matters. + const listed: any = await fetch( + `${baseUrl}instances/${instanceId}/private/tokenfamilies`, + { headers: { Authorization: `Bearer secret-token:${password}` } }, + ).then((r: any) => r.json()); + const families: any[] = listed.token_families ?? []; + if (families.some((f) => f.name === doomed)) { + throw Error( + `'${doomed}' disappeared from the list but the backend still has it`, + ); + } + logger.info(`Successfully deleted token family '${doomed}'.`); + }); + }); + } finally { + if (walletProc) { + walletProc.kill("SIGTERM"); + } + if (fs.existsSync(walletDbPath)) { + try { + fs.unlinkSync(walletDbPath); + } catch {} + } + if (fs.existsSync(walletSocketPath)) { + try { + fs.unlinkSync(walletSocketPath); + } catch {} + } + if (localServer) { + await localServer.close(); + } + } + + logger.info( + options.existingAccount + ? `successfully resumed '${instanceId}' against ${baseUrl} and completed the post-login scenario` + : `successfully onboarded, signed out, re-authenticated, added payout account, completed KYC auth, created simple order, and managed templates for '${instanceId}' against ${baseUrl}`, + ); + + return { + baseUrl, + webuiUrl: webuiUrl!, + instanceId, + password, + email, + phone, + screenshotDir: stage.screenshotDir, + }; +} + +/** + * Submit the form on screen and insist that it got as far as leaving the form. + * + * These screens validate in the submit handler and report a refusal by putting a + * message on the page and staying put, without sending anything. A step that + * just clicks and navigates on cannot tell that apart from success โ it fails + * later, somewhere else, on an empty list. So read the message while it is + * still on screen. + */ +async function submitForm( + page: Page, + fieldOfForm: string, + timeoutMs: number, +): Promise<void> { + const stillOnForm = page.locator(fieldOfForm); + if ((await stillOnForm.count()) === 0) { + // Without this the helper would see "no form" straight away and report a + // submission it never observed โ the very thing it exists to prevent. + throw Error( + `'${fieldOfForm}' is not on screen, so this is not the form to submit`, + ); + } + await page.click('button[type="submit"]'); + // Only containers that carry a message, not anything merely styled red: a + // "Remove" button in red text is not a refusal. + const refused = page.locator("[role=alert], .bg-red-50"); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if ((await stillOnForm.count()) === 0) { + return; + } + if ((await refused.count()) > 0) { + const why = (await refused.first().innerText()).trim(); + if (why) { + throw Error(`the form refused to be submitted: ${why}`); + } + } + await page.waitForTimeout(200); + } + throw Error( + `the form with '${fieldOfForm}' stayed on screen after being submitted, ` + + `without saying what it did not like`, + ); +} + +/** + * Work through the portal's MFA dialog. + * + * With more than one mandatory TAN channel it first asks which one to use, then + * for the code, and it may ask again for the next channel. Returns how many + * codes were entered, so a caller can tell "no challenge" from "solved one". + */ +async function solveMfaChallenges( + page: Page, + mock2fa: Mock2faReader, + addresses: string[], + login: string, + timeoutMs: number, +): Promise<number> { + let solved = 0; + for (let round = 0; round < 4; round++) { + const code = page.locator("#signin-2fa-code"); + const channel = page.locator('input[type=radio][name="2fa_channel"]'); + if ((await code.count()) > 0) { + const msg = await mock2fa.waitForNewCode(addresses, { login, timeoutMs }); + await code.fill(msg.code); + await page.click('button[type="submit"]'); + solved++; + await page.waitForTimeout(1500); + continue; + } + if ((await channel.count()) > 0) { + await channel.first().check(); + await page.click('button[type="submit"]'); + await page.waitForTimeout(1000); + continue; + } + break; + } + return solved; +} + +/** + * Take the browser to a route of the portal. + * + * page.goto() to a URL that differs only in the fragment is a same-document + * navigation, and late in this scenario the browser does not act on it at all: + * the location keeps pointing at the previous route, so the app goes on showing + * it. Setting the hash from inside the page always changes the location, and + * reloading then makes the app start up on the requested route. + */ +async function gotoRoute( + page: Page, + webuiUrl: string, + route: string, +): Promise<void> { + const hash = route.startsWith("#") ? route : `#${route}`; + await page.goto(new URL(hash, webuiUrl).href); + await page.evaluate((h) => { + (globalThis as any).location.hash = h; + }, hash); + await page.reload(); +} + +/** + * List the KYC requirements the exchange currently has under @a accessToken. + */ +async function readKycRequirements( + exchangeBaseUrl: string, + accessToken: string, +): Promise<any[]> { + const infoUrl = new URL(`kyc-info/${accessToken}`, exchangeBaseUrl).href; + const info: any = await fetch(infoUrl).then((r: any) => r.json()); + return info.requirements ?? []; +} + +/** + * Versions to offer as the accepted terms of service, best guess first. + * + * The exchange compares ACCEPTED_TERMS_OF_SERVICE against the TERMS_ETAG it is + * configured with (taler-exchange.env's + * EXCHANGE_AML_PROGRAM_TOPS_ENABLE_DEPOSITS_TOS_NAME, see + * taler-exchange-helper-measure-validate-accepted-tos). A value that does not + * match is *not* an error: the helper answers 204, leaves the account's rules + * as they are and the requirement reappears, so the only way to tell a wrong + * value from a right one is to look at whether the requirement cleared. + * + * A client is meant to read that value from 'tos_required' of GET /kyc-check, + * which the exchange fills in only when kyc_swap_tos_acceptance is enabled, or + * from tos_version in the measure's context, which the taler-ops deployments do + * not set (their kyc-rules.conf accept-tos CONTEXT has tos_url, provider_name, + * successor_measure and validity_years only). Where neither is available this + * falls back to the ETag of the terms and then to the name those deployments + * configure, so that the scenario works without being told. + */ +function tosVersionCandidates( + requirement: any, + tosEtag: string, + explicit?: string, +): string[] { + const candidates = [ + explicit, + requirement.context?.tos_version, + requirement.context?.tosVersion, + tosEtag, + DEFAULT_TOS_VERSION, + ]; + return candidates.filter( + (c, i): c is string => !!c && candidates.indexOf(c) === i, + ); +} + +/** + * Accept the exchange's terms of service for the 'accept-tos' requirements it + * currently lists under @a accessToken. Returns the requirement ids that were + * answered in a way the exchange accepted. + * + * The payload is the one TALER_EXCHANGE_post_kyc_upload_accept_tos_create() + * sends: ACCEPTED_TERMS_OF_SERVICE names the version of the terms that were + * accepted, and DOWNLOADED_TERMS_OF_SERVICE affirms they were read. + * + * Requirements that need a form a machine cannot fill in are left alone and + * reported, so that the KYC wait fails with the name of the form it is stuck + * on rather than on a timeout. + */ +async function acceptKycTermsOfService( + exchangeBaseUrl: string, + accessToken: string, + tosVersion?: string, +): Promise<string[]> { + const satisfied: string[] = []; + let requirements: any[] = []; + try { + requirements = await readKycRequirements(exchangeBaseUrl, accessToken); + } catch (e: any) { + logger.warn(`could not read the KYC requirements: ${e?.message || e}`); + return satisfied; + } + + let tosEtag = ""; + for (const req of requirements) { + if (req.form !== TOS_ACCEPTANCE_FORM || !req.id) { + logger.warn( + `KYC requirement '${req.form}' (${req.id}) needs a form this scenario cannot fill in`, + ); + continue; + } + if (!tosEtag) { + const tosUrl = + req.context?.tos_url ?? new URL("terms", exchangeBaseUrl).href; + // taler-util installs a portable fetch whose Response type is narrower + // than the platform one, hence the cast, as elsewhere in this file. + const tosResp: any = await fetch(tosUrl); + tosEtag = (tosResp.headers.get("etag") ?? "").replace(/^"|"$/g, ""); + } + // Every attempt is answered by a new requirement for the same form, so + // re-read the list to find the one to submit the next candidate against. + let pending = req; + for (const version of tosVersionCandidates(req, tosEtag, tosVersion)) { + const uploadUrl = new URL(`kyc-upload/${pending.id}`, exchangeBaseUrl) + .href; + const resp: any = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + FORM_ID: TOS_ACCEPTANCE_FORM, + ACCEPTED_TERMS_OF_SERVICE: version, + DOWNLOADED_TERMS_OF_SERVICE: true, + }), + }); + if (!resp.ok) { + logger.warn( + `accepting the terms of service for ${pending.id} failed with HTTP ${resp.status}`, + ); + break; + } + const left = await readKycRequirements(exchangeBaseUrl, accessToken); + const stillAsked = left.find((r: any) => r.form === TOS_ACCEPTANCE_FORM); + if (!stillAsked) { + logger.info( + `accepted the terms of service as '${version}' for ${pending.id}`, + ); + satisfied.push(pending.id); + break; + } + logger.info( + `the exchange did not take '${version}' as its terms of service version, ` + + `it asks again as ${stillAsked.id}`, + ); + pending = stillAsked; + } + } + return satisfied; +} + +function generateSwissIban(logger: Logger): string { + try { + const out = execFileSync( + "libeufin-nexus", + ["testing", "iban", "gen", "--country", "CH"], + { encoding: "utf8" }, + ); + const iban = out.trim(); + if (iban.startsWith("CH")) { + logger.info(`Generated Swiss IBAN via libeufin-nexus: ${iban}`); + return iban; + } + } catch (e) { + throw new Error( + `Failed to generate Swiss IBAN via libeufin-nexus CLI: ${e}`, + ); + } + throw new Error( + "Failed to generate valid Swiss IBAN starting with CH via libeufin-nexus", + ); +} diff --git a/packages/taler-harness/src/stagefright/mock2fa.ts b/packages/taler-harness/src/stagefright/mock2fa.ts @@ -19,7 +19,7 @@ * * Deployments used for testing do not really send messages to the * test-NN@taler.net addresses and +417000000NN phone numbers; they write the - * message to $BASE_URL/mock-2fa/$ADDRESS instead. + * message to $BASE_URL/mock-mfa/$ADDRESS.txt instead. */ /** @@ -73,7 +73,7 @@ export class Mock2faReader { constructor(private baseUrl: string) {} private mailboxUrl(address: string): string { - return new URL(`mock-2fa/${encodeURIComponent(address)}`, this.baseUrl) + return new URL(`mock-mfa/${encodeURIComponent(address)}.txt`, this.baseUrl) .href; } diff --git a/packages/taler-harness/src/stagefright/stage.ts b/packages/taler-harness/src/stagefright/stage.ts @@ -31,6 +31,7 @@ import os from "node:os"; import path from "node:path"; import * as nodeUrl from "node:url"; import type { Browser, BrowserContext, Page } from "playwright-core"; +import { assertNoUnexpectedErrorBanner } from "../harness/browser-assertions.js"; const logger = new Logger("stagefright/stage.ts"); @@ -40,6 +41,7 @@ const logger = new Logger("stagefright/stage.ts"); * without downloading playwright's own browser bundle. */ export const DEFAULT_TIMEOUT_MS = 60000; +export const DEFAULT_OVERALL_TIMEOUT_MS = 300000; // 5 minutes overall scenario timeout const CHROMIUM_CANDIDATES = [ "/usr/bin/chromium", @@ -88,9 +90,24 @@ export interface StageOptions { */ timeoutMs?: number; + /** + * How long to wait for the whole scenario to complete. Defaults to 5 minutes (300,000ms). + */ + overallTimeoutMs?: number; + + /** + * Primary currency for amounts in the deployment (e.g. "CHF", "EUR", "KUDOS"). + */ + currency?: string; + viewport?: { width: number; height: number }; } +export interface StageStepOptions { + /** Only for a step whose purpose is to exercise an error response. */ + allowErrorBanner?: boolean; +} + export function findBrowserBinary(explicit?: string): string | undefined { const configured = explicit ?? process.env.BROWSER_BINARY; if (configured) { @@ -110,9 +127,11 @@ function timestampSlug(): string { } export function defaultScreenshotDir(scenarioName: string): string { + const user = + process.env.USER || (process.getuid ? String(process.getuid()) : "user"); return path.join( os.tmpdir(), - "taler-stagefright", + `taler-stagefright-${user}`, `${scenarioName}-${timestampSlug()}`, ); } @@ -135,7 +154,7 @@ function slugify(title: string): string { * * Returns a function that undoes the change. */ -function installNativeUrl(): () => void { +export function installNativeUrl(): () => void { const saved = { URL: globalThis.URL, URLSearchParams: globalThis.URLSearchParams, @@ -175,6 +194,7 @@ export class Stage { public readonly page: Page, public readonly screenshotDir: string, public readonly scenarioName: string, + public readonly options: StageOptions = {}, ) {} static async create( @@ -221,6 +241,7 @@ export class Stage { page, screenshotDir, scenarioName, + options, ); } catch (e) { restoreUrl(); @@ -263,10 +284,20 @@ export class Stage { /** * Run one step of the scenario and screenshot the result. */ - async step<T>(title: string, fn: (page: Page) => Promise<T>): Promise<T> { + async step<T>( + title: string, + fn: (page: Page) => Promise<T>, + options: StageStepOptions = {}, + ): Promise<T> { logger.info(`step: ${title}`); try { const result = await fn(this.page); + if (!options.allowErrorBanner) { + await assertNoUnexpectedErrorBanner( + this.page, + `Stagefright step โ${title}โ`, + ); + } await this.screenshot(title); return result; } catch (e) { @@ -280,14 +311,30 @@ export class Stage { * well, and close the browser afterwards. */ async run<T>(fn: () => Promise<T>): Promise<T> { + const overallTimeout = + this.options.overallTimeoutMs ?? DEFAULT_OVERALL_TIMEOUT_MS; + let timeoutTimer: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise<never>((_, reject) => { + timeoutTimer = setTimeout(() => { + reject( + new Error( + `Stagefright scenario '${this.scenarioName}' timed out after ${overallTimeout}ms`, + ), + ); + }, overallTimeout); + }); + try { - return await fn(); + return await Promise.race([fn(), timeoutPromise]); } catch (e) { if (!this.failureDumped) { await this.dumpFailure("failed"); } throw e; } finally { + if (timeoutTimer) { + clearTimeout(timeoutTimer); + } await this.close(); } } diff --git a/packages/taler-merchant-webui/package.json b/packages/taler-merchant-webui/package.json @@ -12,6 +12,7 @@ "compile": "tsc && ./build.mjs", "dev": "./dev.mjs", "test": "./test.mjs", + "test:stagefright": "node ../taler-harness/bin/taler-harness.mjs stagefright merchant-webui", "lint": "../qa-tooling/bin/eslint.mjs .", "i18n:source2po": "pogen extract && pogen merge", "i18n:po2strings": "pogen emit", diff --git a/packages/taler-merchant-webui/visual/baselines/accounts-kyc-swapped-information.aria.yml b/packages/taler-merchant-webui/visual/baselines/accounts-kyc-swapped-information.aria.yml @@ -34,7 +34,10 @@ - role: "StaticText" name: "CHF" - role: "generic" - - role: "generic" + - role: "button" + name: "Continue verification โ" + state: "disabled=true" + - role: "alert" - role: "generic" name: "Payment service onboarding progress" - role: "button" @@ -67,7 +70,7 @@ - role: "StaticText" name: "Not usable yet โ action is needed" - role: "button" - name: "Actions for Alpenblick Bakery" + name: "Actions for bank account Alpenblick Bakery" state: "expanded=false" - role: "StaticText" name: "Payment services for this account" @@ -84,8 +87,10 @@ - role: "InlineTextBox" name: "CHF" - role: "generic" - - role: "button" - name: "Continue verification โ" + - role: "StaticText" + name: "Continue verification โ" + - role: "StaticText" + name: "Verification cannot continue because the payment service response is incomplete." - role: "StaticText" name: "Accept terms" - role: "StaticText" @@ -131,8 +136,20 @@ name: "exchange.demo.taler.net" - role: "StaticText" name: "Action needed" - - role: "StaticText" - name: "Continue verification โ" + - role: "InlineTextBox" + name: "Continue " + - role: "InlineTextBox" + name: "verification โ" + - role: "InlineTextBox" + name: "Verification cannot " + - role: "InlineTextBox" + name: "continue because the " + - role: "InlineTextBox" + name: "payment service " + - role: "InlineTextBox" + name: "response is " + - role: "InlineTextBox" + name: "incomplete." - role: "InlineTextBox" name: "Accept terms" - role: "InlineTextBox" @@ -164,8 +181,4 @@ - role: "InlineTextBox" name: "Action needed" - role: "InlineTextBox" - name: "Continue " - - role: "InlineTextBox" - name: "verification โ" - - role: "InlineTextBox" name: "More information" diff --git a/packages/taler-merchant-webui/visual/baselines/accounts-kyc-swapped-information.webp b/packages/taler-merchant-webui/visual/baselines/accounts-kyc-swapped-information.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/accounts-kyc-swapped-ready.aria.yml b/packages/taler-merchant-webui/visual/baselines/accounts-kyc-swapped-ready.aria.yml @@ -72,7 +72,7 @@ - role: "StaticText" name: "Usable with 1 of 1 payment services" - role: "button" - name: "Actions for Alpenblick Bakery" + name: "Actions for bank account Alpenblick Bakery" state: "expanded=false" - role: "StaticText" name: "Payment services for this account" diff --git a/packages/taler-merchant-webui/visual/baselines/accounts-kyc-swapped-terms.aria.yml b/packages/taler-merchant-webui/visual/baselines/accounts-kyc-swapped-terms.aria.yml @@ -67,7 +67,7 @@ - role: "StaticText" name: "Not usable yet โ action is needed" - role: "button" - name: "Actions for Alpenblick Bakery" + name: "Actions for bank account Alpenblick Bakery" state: "expanded=false" - role: "StaticText" name: "Payment services for this account" diff --git a/packages/taler-merchant-webui/visual/baselines/accounts-kyc-swapped-validation.aria.yml b/packages/taler-merchant-webui/visual/baselines/accounts-kyc-swapped-validation.aria.yml @@ -67,7 +67,7 @@ - role: "StaticText" name: "Not usable yet โ action is needed" - role: "button" - name: "Actions for Alpenblick Bakery" + name: "Actions for bank account Alpenblick Bakery" state: "expanded=false" - role: "StaticText" name: "Payment services for this account" diff --git a/packages/taler-merchant-webui/visual/baselines/accounts-populated-desktop.aria.yml b/packages/taler-merchant-webui/visual/baselines/accounts-populated-desktop.aria.yml @@ -67,12 +67,12 @@ - role: "InlineTextBox" name: "IBAN" - role: "button" - name: "DE89 3704 0044 0532 0130 00 ยท Musterbank" + name: "DE89 3704 0044 0532 0130 00" state: "expanded=false" - role: "StaticText" name: "Usable with 1 of 1 payment services" - role: "button" - name: "Actions for Taler Merchant GmbH" + name: "Actions for bank account Taler Merchant GmbH" state: "expanded=false" - role: "StaticText" name: "Payment services for this account" @@ -130,7 +130,7 @@ - role: "InlineTextBox" name: "Bank account" - role: "StaticText" - name: "DE89 3704 0044 0532 0130 00 ยท Musterbank" + name: "DE89 3704 0044 0532 0130 00" - role: "InlineTextBox" name: "Usable with 1 of 1 payment services" - role: "InlineTextBox" @@ -168,6 +168,6 @@ - role: "InlineTextBox" name: "Check onboarding status and take your first payment" - role: "InlineTextBox" - name: "DE89 3704 0044 0532 0130 00 ยท Musterbank" + name: "DE89 3704 0044 0532 0130 00" - role: "InlineTextBox" name: "Ready" diff --git a/packages/taler-merchant-webui/visual/baselines/accounts-populated-desktop.webp b/packages/taler-merchant-webui/visual/baselines/accounts-populated-desktop.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/accounts-populated-mobile-de.aria.yml b/packages/taler-merchant-webui/visual/baselines/accounts-populated-mobile-de.aria.yml @@ -5,7 +5,7 @@ - role: "generic" - role: "main" - role: "heading" - name: "Money In โบ Payout Accounts โ Healthy State" + name: "Geld rein โบ Auszahlungskonten โ Gesunder Zustand" - role: "sectionheader" - role: "navigation" - role: "generic" @@ -30,10 +30,10 @@ - role: "generic" name: "Fortschritt der Einrichtung des Zahlungsdienstes" - role: "button" - name: "Money In" + name: "Geld rein" - role: "generic" - role: "StaticText" - name: "Payout Accounts โ Healthy State" + name: "Auszahlungskonten โ Gesunder Zustand" - role: "heading" name: "Bankkonten & Auszahlungen" - role: "paragraph" @@ -45,7 +45,7 @@ - role: "StaticText" name: " " - role: "link" - name: "รberprรผfen Sie den Onboarding-Status und nehmen Sie Ihre erste Zahlung entgegen" + name: "รberprรผfen Sie den Einrichtungsstatus und nehmen Sie Ihre erste Zahlung entgegen" - role: "StaticText" name: "Ihre Bankkonten" - role: "StaticText" @@ -59,12 +59,12 @@ - role: "InlineTextBox" name: "IBAN" - role: "button" - name: "DE89 3704 0044 0532 0130 00 ยท Musterbank" + name: "DE89 3704 0044 0532 0130 00" state: "expanded=false" - role: "StaticText" name: "Mit 1 von 1 Zahlungsdiensten verwendbar" - role: "button" - name: "Aktionen fรผr Taler Merchant GmbH" + name: "Aktionen fรผr Bankkonto Taler Merchant GmbH" state: "expanded=false" - role: "StaticText" name: "Zahlungsdienste fรผr dieses Konto" @@ -74,19 +74,19 @@ name: "EUR" - role: "generic" - role: "StaticText" - name: "Kontovalidierung" + name: "Kontoprรผfung" - role: "StaticText" - name: "Weitere Angaben" + name: "Weitere Informationen" - role: "StaticText" name: "Bereit zum Einsatz" - role: "StaticText" - name: "Money In" + name: "Geld rein" - role: "StaticText" name: "โบ" - role: "InlineTextBox" - name: "Payout Accounts โ " + name: "Auszahlungskonten โ " - role: "InlineTextBox" - name: "Healthy State" + name: "Gesunder Zustand" - role: "StaticText" name: "Bankkonten & Auszahlungen" - role: "StaticText" @@ -104,7 +104,7 @@ - role: "StaticText" name: "Bankkonto hinzugefรผgt." - role: "StaticText" - name: "รberprรผfen Sie den Onboarding-Status und nehmen Sie Ihre erste Zahlung entgegen" + name: "รberprรผfen Sie den Einrichtungsstatus und nehmen Sie Ihre erste Zahlung entgegen" - role: "InlineTextBox" name: "Ihre Bankkonten" - role: "InlineTextBox" @@ -118,7 +118,7 @@ - role: "InlineTextBox" name: "Bankkonto" - role: "StaticText" - name: "DE89 3704 0044 0532 0130 00 ยท Musterbank" + name: "DE89 3704 0044 0532 0130 00" - role: "InlineTextBox" name: "Mit 1 von 1 " - role: "InlineTextBox" @@ -132,13 +132,13 @@ - role: "StaticText" name: "Bereit zum Einsatz" - role: "InlineTextBox" - name: "Kontovalidierung" + name: "Kontoprรผfung" - role: "InlineTextBox" - name: "Weitere Angaben" + name: "Weitere Informationen" - role: "InlineTextBox" name: "Bereit zum Einsatz" - role: "InlineTextBox" - name: "Money In" + name: "Geld rein" - role: "InlineTextBox" name: "โบ" - role: "InlineTextBox" @@ -166,14 +166,14 @@ - role: "InlineTextBox" name: "Bankkonto hinzugefรผgt." - role: "InlineTextBox" - name: "รberprรผfen Sie den Onboarding-" + name: "รberprรผfen Sie den " - role: "InlineTextBox" - name: "Status und nehmen Sie Ihre erste " + name: "Einrichtungsstatus und nehmen " - role: "InlineTextBox" - name: "Zahlung entgegen" + name: "Sie Ihre erste Zahlung entgegen" - role: "InlineTextBox" name: "DE89 3704 0044 0532 0130 " - role: "InlineTextBox" - name: "00 ยท Musterbank" + name: "00" - role: "InlineTextBox" name: "Bereit zum Einsatz" diff --git a/packages/taler-merchant-webui/visual/baselines/accounts-populated-mobile-de.webp b/packages/taler-merchant-webui/visual/baselines/accounts-populated-mobile-de.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/create-order-basic-desktop.aria.yml b/packages/taler-merchant-webui/visual/baselines/create-order-basic-desktop.aria.yml @@ -18,6 +18,7 @@ - role: "heading" name: "Create New Order" - role: "paragraph" + - role: "LabelText" - role: "tablist" name: "Order authoring mode" - role: "LabelText" @@ -34,8 +35,6 @@ state: "required=true" - role: "StaticText" name: "What the customer sees on their receipt." - - role: "button" - name: "Order settings Deadlines, fulfillment, fees, age limits, and metadata. โผ Show" - role: "generic" - role: "StaticText" name: "Orders" @@ -51,6 +50,11 @@ name: "Create New Order" - role: "StaticText" name: "Choose an amount or build an itemized order." + - role: "StaticText" + name: "Advanced editing" + - role: "checkbox" + name: "Advanced editing" + state: "checked=false" - role: "tab" name: "Quick amount" state: "selected=true" @@ -74,15 +78,12 @@ - role: "generic" - role: "InlineTextBox" name: "What the customer sees on their receipt." - - role: "heading" - name: "Order settings" - - role: "paragraph" - - role: "StaticText" - name: "โผ Show" + - role: "note" - role: "button" name: "Cancel" - role: "button" name: "Create Order" + state: "disabled=true" - role: "InlineTextBox" name: "Orders" - role: "InlineTextBox" @@ -95,6 +96,8 @@ name: "Create New Order" - role: "InlineTextBox" name: "Choose an amount or build an itemized order." + - role: "InlineTextBox" + name: "Advanced editing" - role: "StaticText" name: "Quick amount" - role: "StaticText" @@ -108,12 +111,6 @@ - role: "option" name: "EUR" state: "selected=true" - - role: "option" - name: "USD" - state: "selected=false" - - role: "option" - name: "CHF" - state: "selected=false" - role: "StaticText" name: "10.00" - role: "InlineTextBox" @@ -122,12 +119,8 @@ name: "Summary" - role: "InlineTextBox" name: " *" - - role: "StaticText" - name: "Order settings" - - role: "StaticText" - name: "Deadlines, fulfillment, fees, age limits, and metadata." - - role: "InlineTextBox" - name: "โผ Show" + - role: "StaticText" + name: "Editable preview: connect a merchant backend to enable order creation." - role: "StaticText" name: "Cancel" - role: "StaticText" @@ -138,10 +131,8 @@ name: "Itemized order" - role: "InlineTextBox" name: "10.00" - - role: "InlineTextBox" - name: "Order settings" - - role: "InlineTextBox" - name: "Deadlines, fulfillment, fees, age limits, and metadata." + - role: "InlineTextBox" + name: "Editable preview: connect a merchant backend to enable order creation." - role: "InlineTextBox" name: "Cancel" - role: "InlineTextBox" diff --git a/packages/taler-merchant-webui/visual/baselines/create-order-basic-desktop.webp b/packages/taler-merchant-webui/visual/baselines/create-order-basic-desktop.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/create-order-category-rules-empty-desktop.aria.yml b/packages/taler-merchant-webui/visual/baselines/create-order-category-rules-empty-desktop.aria.yml @@ -89,6 +89,7 @@ - role: "heading" name: "Add from Inventory" - role: "combobox" + name: "Product to add from inventory" state: "expanded=false" - role: "spinbutton" state: "required=false" @@ -96,10 +97,12 @@ name: "Add to Order" state: "disabled=true" - role: "generic" + - role: "note" - role: "button" name: "Cancel" - role: "button" name: "Create Order" + state: "disabled=true" - role: "InlineTextBox" name: "Orders" - role: "InlineTextBox" @@ -145,6 +148,8 @@ - role: "button" name: "Add custom item" - role: "StaticText" + name: "Editable preview: connect a merchant backend to enable order creation." + - role: "StaticText" name: "Cancel" - role: "StaticText" name: "Create Order" @@ -171,6 +176,8 @@ - role: "StaticText" name: "Add custom item" - role: "InlineTextBox" + name: "Editable preview: connect a merchant backend to enable order creation." + - role: "InlineTextBox" name: "Cancel" - role: "InlineTextBox" name: "Create Order" diff --git a/packages/taler-merchant-webui/visual/baselines/create-order-category-rules-empty-desktop.webp b/packages/taler-merchant-webui/visual/baselines/create-order-category-rules-empty-desktop.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/create-order-custom-item-desktop.aria.yml b/packages/taler-merchant-webui/visual/baselines/create-order-custom-item-desktop.aria.yml @@ -100,10 +100,12 @@ name: "Add One-off" state: "disabled=true" - role: "generic" + - role: "note" - role: "button" name: "Cancel" - role: "button" name: "Create Order" + state: "disabled=true" - role: "InlineTextBox" name: "Orders" - role: "InlineTextBox" @@ -150,6 +152,8 @@ - role: "button" name: "Add from Inventory" - role: "StaticText" + name: "Editable preview: connect a merchant backend to enable order creation." + - role: "StaticText" name: "Cancel" - role: "StaticText" name: "Create Order" @@ -172,6 +176,8 @@ - role: "StaticText" name: "Add from Inventory" - role: "InlineTextBox" + name: "Editable preview: connect a merchant backend to enable order creation." + - role: "InlineTextBox" name: "Cancel" - role: "InlineTextBox" name: "Create Order" diff --git a/packages/taler-merchant-webui/visual/baselines/create-order-custom-item-desktop.webp b/packages/taler-merchant-webui/visual/baselines/create-order-custom-item-desktop.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/create-order-token-effects-desktop.aria.yml b/packages/taler-merchant-webui/visual/baselines/create-order-token-effects-desktop.aria.yml @@ -90,10 +90,12 @@ - role: "paragraph" - role: "generic" - role: "generic" + - role: "note" - role: "button" name: "Cancel" - role: "button" name: "Create Order" + state: "disabled=true" - role: "InlineTextBox" name: "Orders" - role: "InlineTextBox" @@ -127,12 +129,6 @@ - role: "option" name: "EUR" state: "selected=true" - - role: "option" - name: "USD" - state: "selected=false" - - role: "option" - name: "CHF" - state: "selected=false" - role: "StaticText" name: "10.00" - role: "InlineTextBox" @@ -188,6 +184,8 @@ name: "All purchases qualify; this order totals EUR 10.00." - role: "group" - role: "StaticText" + name: "Editable preview: connect a merchant backend to enable order creation." + - role: "StaticText" name: "Cancel" - role: "StaticText" name: "Create Order" @@ -241,6 +239,8 @@ name: "Calculation details" state: "expanded=false" - role: "InlineTextBox" + name: "Editable preview: connect a merchant backend to enable order creation." + - role: "InlineTextBox" name: "Cancel" - role: "InlineTextBox" name: "Create Order" diff --git a/packages/taler-merchant-webui/visual/baselines/create-order-token-effects-desktop.webp b/packages/taler-merchant-webui/visual/baselines/create-order-token-effects-desktop.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/create-order-token-review-desktop.aria.yml b/packages/taler-merchant-webui/visual/baselines/create-order-token-review-desktop.aria.yml @@ -109,10 +109,12 @@ - role: "paragraph" - role: "StaticText" name: "โผ Show" + - role: "note" - role: "button" name: "Cancel" - role: "button" name: "Create Order" + state: "disabled=true" - role: "InlineTextBox" name: "Orders" - role: "InlineTextBox" @@ -146,12 +148,6 @@ - role: "option" name: "EUR" state: "selected=true" - - role: "option" - name: "USD" - state: "selected=false" - - role: "option" - name: "CHF" - state: "selected=false" - role: "StaticText" name: "10.00" - role: "InlineTextBox" @@ -263,6 +259,8 @@ - role: "InlineTextBox" name: "โผ Show" - role: "StaticText" + name: "Editable preview: connect a merchant backend to enable order creation." + - role: "StaticText" name: "Cancel" - role: "StaticText" name: "Create Order" @@ -398,6 +396,8 @@ - role: "InlineTextBox" name: "Deadlines, fulfillment, fees, age limits, and metadata." - role: "InlineTextBox" + name: "Editable preview: connect a merchant backend to enable order creation." + - role: "InlineTextBox" name: "Cancel" - role: "InlineTextBox" name: "Create Order" diff --git a/packages/taler-merchant-webui/visual/baselines/create-order-token-review-desktop.webp b/packages/taler-merchant-webui/visual/baselines/create-order-token-review-desktop.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/create-order-token-review-mobile.aria.yml b/packages/taler-merchant-webui/visual/baselines/create-order-token-review-mobile.aria.yml @@ -111,10 +111,12 @@ - role: "paragraph" - role: "StaticText" name: "โผ Show" + - role: "note" - role: "button" name: "Cancel" - role: "button" name: "Create Order" + state: "disabled=true" - role: "InlineTextBox" name: "Orders" - role: "InlineTextBox" @@ -150,12 +152,6 @@ - role: "option" name: "EUR" state: "selected=true" - - role: "option" - name: "USD" - state: "selected=false" - - role: "option" - name: "CHF" - state: "selected=false" - role: "StaticText" name: "10.00" - role: "InlineTextBox" @@ -269,6 +265,8 @@ - role: "InlineTextBox" name: "Show" - role: "StaticText" + name: "Editable preview: connect a merchant backend to enable order creation." + - role: "StaticText" name: "Cancel" - role: "StaticText" name: "Create Order" @@ -418,9 +416,25 @@ - role: "InlineTextBox" name: "limits, and metadata." - role: "InlineTextBox" + name: "Editable " + - role: "InlineTextBox" + name: "preview: " + - role: "InlineTextBox" + name: "connect a " + - role: "InlineTextBox" + name: "merchant " + - role: "InlineTextBox" + name: "backend to " + - role: "InlineTextBox" + name: "enable order " + - role: "InlineTextBox" + name: "creation." + - role: "InlineTextBox" name: "Cancel" - role: "InlineTextBox" - name: "Create Order" + name: "Create " + - role: "InlineTextBox" + name: "Order" - role: "InlineTextBox" name: "CUSTOMER EARNS" - role: "StaticText" diff --git a/packages/taler-merchant-webui/visual/baselines/create-order-token-review-mobile.webp b/packages/taler-merchant-webui/visual/baselines/create-order-token-review-mobile.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/create-order-token-warning-desktop.aria.yml b/packages/taler-merchant-webui/visual/baselines/create-order-token-warning-desktop.aria.yml @@ -83,10 +83,12 @@ name: "Discount and pass rules could not be evaluated. This sale can still be created, but automatic effects will not be included." - role: "button" name: "Retry token rules" + - role: "note" - role: "button" name: "Cancel" - role: "button" name: "Create Order" + state: "disabled=true" - role: "InlineTextBox" name: "Orders" - role: "InlineTextBox" @@ -114,12 +116,6 @@ - role: "option" name: "EUR" state: "selected=true" - - role: "option" - name: "USD" - state: "selected=false" - - role: "option" - name: "CHF" - state: "selected=false" - role: "StaticText" name: "10.00" - role: "InlineTextBox" @@ -133,6 +129,8 @@ - role: "StaticText" name: "Retry token rules" - role: "StaticText" + name: "Editable preview: connect a merchant backend to enable order creation." + - role: "StaticText" name: "Cancel" - role: "StaticText" name: "Create Order" @@ -145,6 +143,8 @@ - role: "InlineTextBox" name: "Retry token rules" - role: "InlineTextBox" + name: "Editable preview: connect a merchant backend to enable order creation." + - role: "InlineTextBox" name: "Cancel" - role: "InlineTextBox" name: "Create Order" diff --git a/packages/taler-merchant-webui/visual/baselines/create-order-token-warning-desktop.webp b/packages/taler-merchant-webui/visual/baselines/create-order-token-warning-desktop.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/kyc-instructions-desktop.aria.yml b/packages/taler-merchant-webui/visual/baselines/kyc-instructions-desktop.aria.yml @@ -166,14 +166,10 @@ - role: "InlineTextBox" name: "*" - role: "StaticText" - name: "Copy this exactly into the" - - role: "StaticText" - name: " " + name: "Copy this exactly into the " - role: "strong" - role: "StaticText" - name: " " - - role: "StaticText" - name: "field at your bank:" + name: " field at your bank:" - role: "StaticText" name: "KYC:ALPENBLICK:PRIMARY" - role: "StaticText" @@ -220,15 +216,11 @@ - role: "InlineTextBox" name: "CHF 0.01" - role: "InlineTextBox" - name: "Copy this exactly into the" - - role: "InlineTextBox" - name: " " + name: "Copy this exactly into the " - role: "StaticText" name: "subject or payment reference" - role: "InlineTextBox" - name: " " - - role: "InlineTextBox" - name: "field at your bank:" + name: " field at your bank:" - role: "InlineTextBox" name: "KYC:ALPENBLICK:PRIMARY" - role: "InlineTextBox" diff --git a/packages/taler-merchant-webui/visual/baselines/kyc-instructions-desktop.webp b/packages/taler-merchant-webui/visual/baselines/kyc-instructions-desktop.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/kyc-instructions-mobile.aria.yml b/packages/taler-merchant-webui/visual/baselines/kyc-instructions-mobile.aria.yml @@ -182,14 +182,10 @@ - role: "InlineTextBox" name: "*" - role: "StaticText" - name: "Copy this exactly into the" - - role: "StaticText" - name: " " + name: "Copy this exactly into the " - role: "strong" - role: "StaticText" - name: " " - - role: "StaticText" - name: "field at your bank:" + name: " field at your bank:" - role: "StaticText" name: "KYC:ALPENBLICK:PRIMARY" - role: "StaticText" @@ -258,15 +254,11 @@ - role: "InlineTextBox" name: "CHF 0.01" - role: "InlineTextBox" - name: "Copy this exactly into the" - - role: "InlineTextBox" - name: " " + name: "Copy this exactly into the " - role: "StaticText" name: "subject or payment reference" - role: "InlineTextBox" - name: " " - - role: "InlineTextBox" - name: "field at your " + name: " field at your " - role: "InlineTextBox" name: "bank:" - role: "InlineTextBox" diff --git a/packages/taler-merchant-webui/visual/baselines/kyc-swapped-terms-desktop.aria.yml b/packages/taler-merchant-webui/visual/baselines/kyc-swapped-terms-desktop.aria.yml @@ -32,20 +32,14 @@ - role: "InlineTextBox" name: "Before the transfer: accept your payment serviceโs terms" - role: "StaticText" - name: "The payment service (" - - role: "strong" - - role: "StaticText" - name: ") needs you to read and accept its terms before you send the transfer." + name: "The payment service (exchange.demo.taler.net) needs you to read and accept its terms before you send the transfer." - role: "StaticText" name: "Read the terms โ" - role: "checkbox" name: "I have read and agree to the Terms of Service for exchange.demo.taler.net" - state: "checked=false" - - role: "StaticText" - name: "I have read and agree to the Terms of Service for" + state: "disabled=true, checked=false" - role: "StaticText" - name: " " - - role: "strong" + name: "I have read and agree to the Terms of Service for exchange.demo.taler.net" - role: "StaticText" name: "Accept the terms" - role: "StaticText" @@ -61,19 +55,11 @@ - role: "StaticText" name: "Send one small transfer from this account, so that exchange.demo.taler.net can see that it is yours." - role: "InlineTextBox" - name: "The payment service (" - - role: "StaticText" - name: "exchange.demo.taler.net" - - role: "InlineTextBox" - name: ") needs you to read and accept its terms before you send the transfer." + name: "The payment service (exchange.demo.taler.net) needs you to read and accept its terms before you send the transfer." - role: "InlineTextBox" name: "Read the terms โ" - role: "InlineTextBox" - name: "I have read and agree to the Terms of Service for" - - role: "InlineTextBox" - name: " " - - role: "StaticText" - name: "exchange.demo.taler.net" + name: "I have read and agree to the Terms of Service for exchange.demo.taler.net" - role: "InlineTextBox" name: "Accept the terms" - role: "InlineTextBox" @@ -86,7 +72,3 @@ name: "Verify this bank account" - role: "InlineTextBox" name: "Send one small transfer from this account, so that exchange.demo.taler.net can see that it is yours." - - role: "InlineTextBox" - name: "exchange.demo.taler.net" - - role: "InlineTextBox" - name: "exchange.demo.taler.net" diff --git a/packages/taler-merchant-webui/visual/baselines/kyc-swapped-terms-desktop.webp b/packages/taler-merchant-webui/visual/baselines/kyc-swapped-terms-desktop.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/kyc-swapped-terms-mobile.aria.yml b/packages/taler-merchant-webui/visual/baselines/kyc-swapped-terms-mobile.aria.yml @@ -36,20 +36,14 @@ - role: "InlineTextBox" name: "terms" - role: "StaticText" - name: "The payment service (" - - role: "strong" - - role: "StaticText" - name: ") needs you to read and accept its terms before you send the transfer." + name: "The payment service (exchange.demo.taler.net) needs you to read and accept its terms before you send the transfer." - role: "StaticText" name: "Read the terms โ" - role: "checkbox" name: "I have read and agree to the Terms of Service for exchange.demo.taler.net" - state: "checked=false" - - role: "StaticText" - name: "I have read and agree to the Terms of Service for" + state: "disabled=true, checked=false" - role: "StaticText" - name: " " - - role: "strong" + name: "I have read and agree to the Terms of Service for exchange.demo.taler.net" - role: "StaticText" name: "Accept the terms" - role: "StaticText" @@ -69,23 +63,17 @@ - role: "InlineTextBox" name: "The payment service " - role: "InlineTextBox" - name: "(" - - role: "StaticText" - name: "exchange.demo.taler.net" + name: "(exchange.demo.taler.net) needs you to " - role: "InlineTextBox" - name: ") needs you " + name: "read and accept its terms before you send " - role: "InlineTextBox" - name: "to read and accept its terms before you " - - role: "InlineTextBox" - name: "send the transfer." + name: "the transfer." - role: "InlineTextBox" name: "Read the terms โ" - role: "InlineTextBox" name: "I have read and agree to the Terms of " - role: "InlineTextBox" - name: "Service for" - - role: "StaticText" - name: "exchange.demo.taler.net" + name: "Service for exchange.demo.taler.net" - role: "InlineTextBox" name: "Accept the terms" - role: "InlineTextBox" @@ -106,7 +94,3 @@ name: "so that exchange.demo.taler.net can see " - role: "InlineTextBox" name: "that it is yours." - - role: "InlineTextBox" - name: "exchange.demo.taler.net" - - role: "InlineTextBox" - name: "exchange.demo.taler.net" diff --git a/packages/taler-merchant-webui/visual/baselines/kyc-swapped-terms-mobile.webp b/packages/taler-merchant-webui/visual/baselines/kyc-swapped-terms-mobile.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/merchant-account-desktop.aria.yml b/packages/taler-merchant-webui/visual/baselines/merchant-account-desktop.aria.yml @@ -52,7 +52,7 @@ name: "Transaction fees Business covers transaction fees Edit" state: "expanded=false" - role: "button" - name: "Payment, refund, and payout timing Payment window: 1d ยท Refund window: 30d ยท Payout delay: 7d Edit" + name: "Payment, refund, and payout timing Payment window: 1 day ยท Refund window: 30 days ยท Payout delay: 7 days Edit" state: "expanded=false" - role: "StaticText" name: "Account security" @@ -164,21 +164,21 @@ - role: "StaticText" name: " " - role: "StaticText" - name: "1d" + name: "1 day" - role: "StaticText" name: " ยท " - role: "strong" - role: "StaticText" name: " " - role: "StaticText" - name: "30d" + name: "30 days" - role: "StaticText" name: " ยท " - role: "strong" - role: "StaticText" name: " " - role: "StaticText" - name: "7d" + name: "7 days" - role: "InlineTextBox" name: "Edit" - role: "StaticText" @@ -222,7 +222,7 @@ - role: "InlineTextBox" name: " " - role: "InlineTextBox" - name: "1d" + name: "1 day" - role: "InlineTextBox" name: " ยท " - role: "StaticText" @@ -232,7 +232,7 @@ - role: "InlineTextBox" name: " " - role: "InlineTextBox" - name: "30d" + name: "30 days" - role: "InlineTextBox" name: " ยท " - role: "StaticText" @@ -242,7 +242,7 @@ - role: "InlineTextBox" name: " " - role: "InlineTextBox" - name: "7d" + name: "7 days" - role: "InlineTextBox" name: "Verification phone" - role: "InlineTextBox" diff --git a/packages/taler-merchant-webui/visual/baselines/merchant-account-desktop.webp b/packages/taler-merchant-webui/visual/baselines/merchant-account-desktop.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/merchant-account-mobile.aria.yml b/packages/taler-merchant-webui/visual/baselines/merchant-account-mobile.aria.yml @@ -55,7 +55,7 @@ name: "Transaction fees Business covers transaction fees Edit" state: "expanded=false" - role: "button" - name: "Payment, refund, and payout timing Payment window: 1d ยท Refund window: 30d ยท Payout delay: 7d Edit" + name: "Payment, refund, and payout timing Payment window: 1 day ยท Refund window: 30 days ยท Payout delay: 7 days Edit" state: "expanded=false" - role: "StaticText" name: "Account security" @@ -215,21 +215,21 @@ - role: "StaticText" name: " " - role: "StaticText" - name: "1d" + name: "1 day" - role: "StaticText" name: " ยท " - role: "strong" - role: "StaticText" name: " " - role: "StaticText" - name: "30d" + name: "30 days" - role: "StaticText" name: " ยท " - role: "strong" - role: "StaticText" name: " " - role: "StaticText" - name: "7d" + name: "7 days" - role: "InlineTextBox" name: "Edit" - role: "StaticText" @@ -301,7 +301,7 @@ - role: "InlineTextBox" name: " " - role: "InlineTextBox" - name: "1d" + name: "1 day" - role: "InlineTextBox" name: " ยท " - role: "StaticText" @@ -311,7 +311,7 @@ - role: "InlineTextBox" name: " " - role: "InlineTextBox" - name: "30d" + name: "30 days" - role: "InlineTextBox" name: " ยท " - role: "StaticText" @@ -319,7 +319,9 @@ - role: "StaticText" name: ":" - role: "InlineTextBox" - name: "7d" + name: " " + - role: "InlineTextBox" + name: "7 days" - role: "InlineTextBox" name: "Verification phone" - role: "InlineTextBox" @@ -337,9 +339,7 @@ - role: "InlineTextBox" name: ":" - role: "InlineTextBox" - name: "Refund " - - role: "InlineTextBox" - name: "window" + name: "Refund window" - role: "InlineTextBox" name: ":" - role: "InlineTextBox" diff --git a/packages/taler-merchant-webui/visual/baselines/merchant-account-password-desktop.aria.yml b/packages/taler-merchant-webui/visual/baselines/merchant-account-password-desktop.aria.yml @@ -52,7 +52,7 @@ name: "Transaction fees Business covers transaction fees Edit" state: "expanded=false" - role: "button" - name: "Payment, refund, and payout timing Payment window: 1d ยท Refund window: 30d ยท Payout delay: 7d Edit" + name: "Payment, refund, and payout timing Payment window: 1 day ยท Refund window: 30 days ยท Payout delay: 7 days Edit" state: "expanded=false" - role: "StaticText" name: "Account security" @@ -194,21 +194,21 @@ - role: "StaticText" name: " " - role: "StaticText" - name: "1d" + name: "1 day" - role: "StaticText" name: " ยท " - role: "strong" - role: "StaticText" name: " " - role: "StaticText" - name: "30d" + name: "30 days" - role: "StaticText" name: " ยท " - role: "strong" - role: "StaticText" name: " " - role: "StaticText" - name: "7d" + name: "7 days" - role: "InlineTextBox" name: "Edit" - role: "StaticText" @@ -265,7 +265,7 @@ - role: "InlineTextBox" name: " " - role: "InlineTextBox" - name: "1d" + name: "1 day" - role: "InlineTextBox" name: " ยท " - role: "StaticText" @@ -275,7 +275,7 @@ - role: "InlineTextBox" name: " " - role: "InlineTextBox" - name: "30d" + name: "30 days" - role: "InlineTextBox" name: " ยท " - role: "StaticText" @@ -285,7 +285,7 @@ - role: "InlineTextBox" name: " " - role: "InlineTextBox" - name: "7d" + name: "7 days" - role: "InlineTextBox" name: "Verification phone" - role: "InlineTextBox" diff --git a/packages/taler-merchant-webui/visual/baselines/merchant-account-password-desktop.webp b/packages/taler-merchant-webui/visual/baselines/merchant-account-password-desktop.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/order-timeline-desktop.aria.yml b/packages/taler-merchant-webui/visual/baselines/order-timeline-desktop.aria.yml @@ -78,7 +78,7 @@ name: "Reference on your bank statement" - role: "definition" - role: "DisclosureTriangle" - name: "Contract details 1 line items and technical terms Show details" + name: "Contract details 1 line item and technical terms Show details" state: "expanded=false" - role: "StaticText" name: "Orders" @@ -185,7 +185,7 @@ - role: "StaticText" name: "Contract details" - role: "StaticText" - name: "1 line items and technical terms" + name: "1 line item and technical terms" - role: "InlineTextBox" name: "Show details" - role: "InlineTextBox" @@ -219,7 +219,7 @@ - role: "InlineTextBox" name: "Contract details" - role: "InlineTextBox" - name: "1 line items and technical terms" + name: "1 line item and technical terms" - role: "InlineTextBox" name: "๐" - role: "InlineTextBox" diff --git a/packages/taler-merchant-webui/visual/baselines/order-timeline-mobile.aria.yml b/packages/taler-merchant-webui/visual/baselines/order-timeline-mobile.aria.yml @@ -78,7 +78,7 @@ name: "Reference on your bank statement" - role: "definition" - role: "DisclosureTriangle" - name: "Contract details 1 line items and technical terms Show details" + name: "Contract details 1 line item and technical terms Show details" state: "expanded=false" - role: "StaticText" name: "Orders" @@ -195,7 +195,7 @@ - role: "StaticText" name: "Contract details" - role: "StaticText" - name: "1 line items and technical terms" + name: "1 line item and technical terms" - role: "InlineTextBox" name: "Show " - role: "InlineTextBox" @@ -233,7 +233,7 @@ - role: "InlineTextBox" name: "Contract details" - role: "InlineTextBox" - name: "1 line items and technical " + name: "1 line item and technical " - role: "InlineTextBox" name: "terms" - role: "InlineTextBox" diff --git a/packages/taler-merchant-webui/visual/baselines/setup-desktop-de.aria.yml b/packages/taler-merchant-webui/visual/baselines/setup-desktop-de.aria.yml @@ -5,13 +5,13 @@ - role: "generic" - role: "main" - role: "heading" - name: "Onboarding โบ Setup Guide" + name: "Ersteinrichtung โบ Setup-Anleitung" - role: "sectionheader" - role: "heading" name: "Bereit, Zahlungen zu akzeptieren" - role: "paragraph" - role: "StaticText" - name: "3 von 3 komplett" + name: "3 von 3 abgeschlossen" - role: "progressbar" name: "Einrichtungsfortschritt" - role: "paragraph" @@ -20,19 +20,19 @@ - role: "generic" - role: "generic" - role: "button" - name: "Onboarding" + name: "Ersteinrichtung" - role: "generic" - role: "StaticText" - name: "Setup Guide" + name: "Setup-Anleitung" - role: "heading" name: "Einrichtungsstand" - role: "paragraph" - role: "StaticText" name: "Bereit, Zahlungen zu akzeptieren" - role: "StaticText" - name: "Ihr Hรคndlerskonto ist bereit fรผr Kundenzahlungen." + name: "Ihr Hรคndlerkonto ist bereit fรผr Kundenzahlungen." - role: "InlineTextBox" - name: "3 von 3 komplett" + name: "3 von 3 abgeschlossen" - role: "StaticText" name: "Neu im Portal?" - role: "StaticText" @@ -44,7 +44,7 @@ - role: "paragraph" - role: "paragraph" - role: "StaticText" - name: "Abschlieรen" + name: "Abgeschlossen" - role: "link" name: "Information bearbeiten" - role: "heading" @@ -66,18 +66,18 @@ - role: "StaticText" name: "Optional" - role: "heading" - name: "Nehmen Sie Ihre erste Zahlung" + name: "Nehmen Sie Ihre erste Zahlung entgegen" - role: "paragraph" - role: "link" name: "Erstellen Sie eine druckbare Zahlungsvorlage Drucken Sie einen wiederverwendbaren QR-Code fรผr Schilder, Aufkleber oder die Theke." - role: "link" name: "Erstellen Sie eine einmalige Bestellung Geben Sie jetzt die Positionen und den Betrag dieses Kunden ein." - role: "StaticText" - name: "Onboarding" + name: "Ersteinrichtung" - role: "StaticText" name: "โบ" - role: "InlineTextBox" - name: "Setup Guide" + name: "Setup-Anleitung" - role: "StaticText" name: "Einrichtungsstand" - role: "StaticText" @@ -85,7 +85,7 @@ - role: "InlineTextBox" name: "Bereit, Zahlungen zu akzeptieren" - role: "InlineTextBox" - name: "Ihr Hรคndlerskonto ist bereit fรผr Kundenzahlungen." + name: "Ihr Hรคndlerkonto ist bereit fรผr Kundenzahlungen." - role: "InlineTextBox" name: "Neu im Portal?" - role: "InlineTextBox" @@ -106,7 +106,7 @@ - role: "StaticText" name: "Logo hinzugefรผgt" - role: "InlineTextBox" - name: "Abschlieรen" + name: "Abgeschlossen" - role: "StaticText" name: "Information bearbeiten" - role: "StaticText" @@ -121,7 +121,7 @@ - role: "StaticText" name: " " - role: "StaticText" - name: "DE89 3704 0044 0532 0130 00ยทM USTE RBAN K" + name: "DE89 3704 0044 0532 0130 00" - role: "InlineTextBox" name: "Konto hinzugefรผgt" - role: "StaticText" @@ -139,7 +139,7 @@ - role: "InlineTextBox" name: "Optional" - role: "StaticText" - name: "Nehmen Sie Ihre erste Zahlung" + name: "Nehmen Sie Ihre erste Zahlung entgegen" - role: "StaticText" name: "Ihre Einrichtung ist abgeschlossen. Wรคhlen Sie aus, wie Sie die erste Kundenzahlung entgegennehmen mรถchten." - role: "strong" @@ -149,7 +149,7 @@ - role: "StaticText" name: "Geben Sie jetzt die Positionen und den Betrag dieses Kunden ein." - role: "InlineTextBox" - name: "Onboarding" + name: "Ersteinrichtung" - role: "InlineTextBox" name: "โบ" - role: "InlineTextBox" @@ -187,7 +187,7 @@ - role: "InlineTextBox" name: " " - role: "InlineTextBox" - name: "DE89 3704 0044 0532 0130 00ยทM USTE RBAN K" + name: "DE89 3704 0044 0532 0130 00" - role: "InlineTextBox" name: "Konten verwalten" - role: "InlineTextBox" @@ -199,7 +199,7 @@ - role: "InlineTextBox" name: "Status anzeigen" - role: "InlineTextBox" - name: "Nehmen Sie Ihre erste Zahlung" + name: "Nehmen Sie Ihre erste Zahlung entgegen" - role: "InlineTextBox" name: "Ihre Einrichtung ist abgeschlossen. Wรคhlen Sie aus, wie Sie die erste Kundenzahlung entgegennehmen mรถchten." - role: "StaticText" diff --git a/packages/taler-merchant-webui/visual/baselines/setup-desktop-de.webp b/packages/taler-merchant-webui/visual/baselines/setup-desktop-de.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/signup-desktop.aria.yml b/packages/taler-merchant-webui/visual/baselines/signup-desktop.aria.yml @@ -6,12 +6,21 @@ - role: "main" - role: "heading" name: "Authentication โบ Self-Provisioning Sign-Up" + - role: "LabelText" + - role: "paragraph" - role: "generic" - role: "button" name: "Authentication" - role: "generic" - role: "StaticText" name: "Self-Provisioning Sign-Up" + - role: "StaticText" + name: "Dataset" + - role: "combobox" + name: "Story dataset" + state: "expanded=false" + - role: "StaticText" + name: "Email and mobile number are optional under the server policy." - role: "generic" - role: "image" name: "Taler Logo" @@ -20,7 +29,7 @@ - role: "StaticText" name: "Creating a new merchant account on" - role: "StaticText" - name: "my.taler-ops.ch" + name: "backend.demo.taler.net" - role: "link" name: "Change merchant backend server URL" - role: "list" @@ -32,6 +41,11 @@ name: "โบ" - role: "InlineTextBox" name: "Self-Provisioning Sign-Up" + - role: "InlineTextBox" + name: "Dataset" + - role: "MenuListPopup" + - role: "InlineTextBox" + name: "Email and mobile number are optional under the server policy." - role: "StaticText" name: "๐" - role: "combobox" @@ -45,7 +59,7 @@ - role: "InlineTextBox" name: "Creating a new merchant account on" - role: "InlineTextBox" - name: "my.taler-ops.ch" + name: "backend.demo.taler.net" - role: "image" - role: "listitem" - role: "listitem" @@ -62,13 +76,13 @@ - role: "paragraph" - role: "LabelText" - role: "textbox" - name: "Email Address *" - state: "required=true" + name: "Email Address" + state: "required=false" - role: "paragraph" - role: "LabelText" - role: "textbox" - name: "Mobile Phone *" - state: "required=true" + name: "Mobile Phone" + state: "required=false" - role: "paragraph" - role: "LabelText" - role: "textbox" @@ -91,6 +105,12 @@ name: "Authentication" - role: "InlineTextBox" name: "โบ" + - role: "option" + name: "Optional contact fields" + state: "selected=true" + - role: "option" + name: "Required contact fields" + state: "selected=false" - role: "InlineTextBox" name: "๐" - role: "MenuListPopup" @@ -107,11 +127,11 @@ - role: "StaticText" name: "2" - role: "StaticText" - name: "Email" + name: "Verification method" - role: "StaticText" name: "3" - role: "StaticText" - name: "Phone" + name: "Verification code" - role: "StaticText" name: "Business Name" - role: "StaticText" @@ -132,19 +152,11 @@ name: "This is the short identifier you will use to sign in. Uppercase letters are accepted and saved in lowercase." - role: "StaticText" name: "Email Address" - - role: "StaticText" - name: " " - - role: "StaticText" - name: "*" - role: "generic" - role: "StaticText" name: "For verification codes." - role: "StaticText" name: "Mobile Phone" - - role: "StaticText" - name: " " - - role: "StaticText" - name: "*" - role: "generic" - role: "StaticText" name: "For SMS codes." @@ -190,11 +202,13 @@ - role: "InlineTextBox" name: "2" - role: "InlineTextBox" - name: "Email" + name: "Verification " + - role: "InlineTextBox" + name: "method" - role: "InlineTextBox" name: "3" - role: "InlineTextBox" - name: "Phone" + name: "Verification code" - role: "InlineTextBox" name: "Business Name" - role: "InlineTextBox" @@ -216,18 +230,10 @@ - role: "InlineTextBox" name: "Email Address" - role: "InlineTextBox" - name: " " - - role: "InlineTextBox" - name: "*" - - role: "InlineTextBox" name: "For verification codes." - role: "InlineTextBox" name: "Mobile Phone" - role: "InlineTextBox" - name: " " - - role: "InlineTextBox" - name: "*" - - role: "InlineTextBox" name: "For SMS codes." - role: "InlineTextBox" name: "New Password" diff --git a/packages/taler-merchant-webui/visual/baselines/signup-desktop.webp b/packages/taler-merchant-webui/visual/baselines/signup-desktop.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/signup-required-desktop.aria.yml b/packages/taler-merchant-webui/visual/baselines/signup-required-desktop.aria.yml @@ -0,0 +1,275 @@ +# Chrome: Google Chrome 151.0.7922.108 +# Locale/timezone/time: pinned per scenario / Europe/Berlin / 2026-08-12T10:00:00.000Z +- role: "RootWebArea" + name: "Taler Merchant Portal" + - role: "generic" + - role: "main" + - role: "heading" + name: "Authentication โบ Self-Provisioning Sign-Up" + - role: "LabelText" + - role: "paragraph" + - role: "generic" + - role: "button" + name: "Authentication" + - role: "generic" + - role: "StaticText" + name: "Self-Provisioning Sign-Up" + - role: "StaticText" + name: "Dataset" + - role: "combobox" + name: "Story dataset" + state: "expanded=false" + - role: "StaticText" + name: "The server policy requires both email and SMS verification channels." + - role: "generic" + - role: "image" + name: "Taler Logo" + - role: "heading" + name: "Create your merchant account" + - role: "StaticText" + name: "Creating a new merchant account on" + - role: "StaticText" + name: "backend.demo.taler.net" + - role: "link" + name: "Change merchant backend server URL" + - role: "list" + name: "Account creation progress" + - role: "form" + - role: "StaticText" + name: "Authentication" + - role: "StaticText" + name: "โบ" + - role: "InlineTextBox" + name: "Self-Provisioning Sign-Up" + - role: "InlineTextBox" + name: "Dataset" + - role: "MenuListPopup" + - role: "InlineTextBox" + name: "The server policy requires both email and SMS verification channels." + - role: "StaticText" + name: "๐" + - role: "combobox" + name: "Select Language" + state: "expanded=false" + - role: "StaticText" + name: "|" + - role: "generic" + - role: "StaticText" + name: "Create your merchant account" + - role: "InlineTextBox" + name: "Creating a new merchant account on" + - role: "InlineTextBox" + name: "backend.demo.taler.net" + - role: "image" + - role: "listitem" + - role: "listitem" + - role: "listitem" + - role: "LabelText" + - role: "textbox" + name: "Business Name *" + state: "required=true" + - role: "paragraph" + - role: "LabelText" + - role: "textbox" + name: "Merchant account *" + state: "required=true" + - role: "paragraph" + - role: "LabelText" + - role: "textbox" + name: "Email Address *" + state: "required=true" + - role: "paragraph" + - role: "LabelText" + - role: "textbox" + name: "Mobile Phone *" + state: "required=true" + - role: "paragraph" + - role: "LabelText" + - role: "textbox" + name: "New Password *" + state: "required=true" + - role: "button" + name: "Show password" + state: "pressed=false" + - role: "LabelText" + - role: "textbox" + name: "Repeat Password *" + state: "required=true" + - role: "button" + name: "Show password" + state: "pressed=false" + - role: "LabelText" + - role: "generic" + - role: "generic" + - role: "InlineTextBox" + name: "Authentication" + - role: "InlineTextBox" + name: "โบ" + - role: "option" + name: "Optional contact fields" + state: "selected=false" + - role: "option" + name: "Required contact fields" + state: "selected=true" + - role: "InlineTextBox" + name: "๐" + - role: "MenuListPopup" + - role: "InlineTextBox" + name: "|" + - role: "StaticText" + name: "vX.Y.Z-build" + - role: "InlineTextBox" + name: "Create your merchant account" + - role: "StaticText" + name: "1" + - role: "StaticText" + name: "Account details" + - role: "StaticText" + name: "2" + - role: "StaticText" + name: "Verification method" + - role: "StaticText" + name: "3" + - role: "StaticText" + name: "Verification code" + - role: "StaticText" + name: "Business Name" + - role: "StaticText" + name: " " + - role: "StaticText" + name: "*" + - role: "generic" + - role: "StaticText" + name: "The business name customers see on their receipts." + - role: "StaticText" + name: "Merchant account" + - role: "StaticText" + name: " " + - role: "StaticText" + name: "*" + - role: "generic" + - role: "StaticText" + name: "This is the short identifier you will use to sign in. Uppercase letters are accepted and saved in lowercase." + - role: "StaticText" + name: "Email Address" + - role: "StaticText" + name: " " + - role: "StaticText" + name: "*" + - role: "generic" + - role: "StaticText" + name: "For verification codes." + - role: "StaticText" + name: "Mobile Phone" + - role: "StaticText" + name: " " + - role: "StaticText" + name: "*" + - role: "generic" + - role: "StaticText" + name: "For SMS codes." + - role: "StaticText" + name: "New Password" + - role: "generic" + - role: "StaticText" + name: "Repeat Password" + - role: "generic" + - role: "checkbox" + name: "I accept the Terms of Service." + state: "checked=false" + - role: "StaticText" + name: "I accept the" + - role: "StaticText" + name: " " + - role: "link" + name: "Terms of Service" + - role: "StaticText" + name: "." + - role: "button" + name: "Create merchant account" + - role: "link" + name: "Already have an account? Sign in" + - role: "option" + name: "๐ฌ๐ง English" + state: "selected=true" + - role: "option" + name: "๐ฉ๐ช Deutsch" + state: "selected=false" + - role: "option" + name: "๐ซ๐ท Franรงais" + state: "selected=false" + - role: "option" + name: "๐ฎ๐น Italiano" + state: "selected=false" + - role: "InlineTextBox" + name: "vX.Y.Z-build" + - role: "InlineTextBox" + name: "1" + - role: "InlineTextBox" + name: "Account details" + - role: "InlineTextBox" + name: "2" + - role: "InlineTextBox" + name: "Verification " + - role: "InlineTextBox" + name: "method" + - role: "InlineTextBox" + name: "3" + - role: "InlineTextBox" + name: "Verification code" + - role: "InlineTextBox" + name: "Business Name" + - role: "InlineTextBox" + name: " " + - role: "InlineTextBox" + name: "*" + - role: "InlineTextBox" + name: "The business name customers see on their receipts." + - role: "InlineTextBox" + name: "Merchant account" + - role: "InlineTextBox" + name: " " + - role: "InlineTextBox" + name: "*" + - role: "InlineTextBox" + name: "This is the short identifier you will use to sign in. Uppercase " + - role: "InlineTextBox" + name: "letters are accepted and saved in lowercase." + - role: "InlineTextBox" + name: "Email Address" + - role: "InlineTextBox" + name: " " + - role: "InlineTextBox" + name: "*" + - role: "InlineTextBox" + name: "For verification codes." + - role: "InlineTextBox" + name: "Mobile Phone" + - role: "InlineTextBox" + name: " " + - role: "InlineTextBox" + name: "*" + - role: "InlineTextBox" + name: "For SMS codes." + - role: "InlineTextBox" + name: "New Password" + - role: "InlineTextBox" + name: "Repeat Password" + - role: "InlineTextBox" + name: "I accept the" + - role: "InlineTextBox" + name: " " + - role: "StaticText" + name: "Terms of Service" + - role: "InlineTextBox" + name: "." + - role: "StaticText" + name: "Create merchant account" + - role: "StaticText" + name: "Already have an account? Sign in" + - role: "InlineTextBox" + name: "Terms of Service" + - role: "InlineTextBox" + name: "Create merchant account" + - role: "InlineTextBox" + name: "Already have an account? Sign in" diff --git a/packages/taler-merchant-webui/visual/baselines/signup-required-desktop.webp b/packages/taler-merchant-webui/visual/baselines/signup-required-desktop.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/template-print-preview-desktop.aria.yml b/packages/taler-merchant-webui/visual/baselines/template-print-preview-desktop.aria.yml @@ -2,256 +2,47 @@ # Locale/timezone/time: pinned per scenario / Europe/Berlin / 2026-08-12T10:00:00.000Z - role: "RootWebArea" name: "Taler Merchant Portal" - - role: "generic" - - role: "main" - - role: "heading" - name: "Selling Tools โบ Template Details & Print" - - role: "sectionheader" - - role: "heading" - name: "Template details" - - role: "paragraph" - - role: "button" - name: "Create order from this template" - - role: "button" - name: "Print QR code" - - role: "button" - name: "Template actions" - state: "expanded=false" - - role: "heading" - name: "1. What it Sells" - - role: "StaticText" - name: "๐ท๏ธ" - - role: "generic" - - role: "generic" - - role: "StaticText" - name: "๐" - - role: "generic" - - role: "generic" - - role: "StaticText" - name: "๐๏ธ" - - role: "generic" - - role: "generic" - - role: "heading" - name: "2. Template Details" - - role: "LabelText" - - role: "textbox" - name: "Template Name" - state: "disabled=true, required=false" - - role: "LabelText" - - role: "textbox" - name: "Template ID" - state: "disabled=true, required=false" - - role: "LabelText" - - role: "textbox" - name: "Order Summary Text" - state: "disabled=true, required=false" - - role: "LabelText" - - role: "textbox" - name: "Configured Amount / Price" - state: "disabled=true, required=false" - - role: "heading" - name: "3. Contract Deadlines & Rules" - - role: "LabelText" - - role: "textbox" - name: "Payment Deadline" - state: "disabled=true, required=false" - - role: "dialog" - - role: "button" - name: "Selling Tools" - - role: "generic" - - role: "StaticText" - name: "Template Details & Print" - - role: "button" - name: "โ Templates" - - role: "heading" - name: "Espresso counter card" - - role: "paragraph" - - role: "StaticText" - name: "Template details" - - role: "StaticText" - name: "Review configured payment shape, summary text, and contract parameters." - - role: "StaticText" - name: "Create order from this template" - - role: "StaticText" - name: "Print QR code" - - role: "StaticText" - name: "1. What it Sells" - - role: "InlineTextBox" - name: "๐ท๏ธ" - - role: "StaticText" - name: "A fixed amount" - - role: "StaticText" - name: " " - - role: "StaticText" - name: "โ" - - role: "StaticText" - name: "Every customer pays the same fixed price." - - role: "InlineTextBox" - name: "๐" - - role: "StaticText" - name: "Customer enters amount" - - role: "StaticText" - name: "For voluntary donations, tips, and open amounts." - - role: "InlineTextBox" - name: "๐๏ธ" - - role: "StaticText" - name: "Inventory products" - - role: "StaticText" - name: "Customer selects products from your inventory." - - role: "StaticText" - name: "2. Template Details" - - role: "StaticText" - name: "Template Name" - - role: "generic" - - role: "StaticText" - name: "Template ID" - - role: "generic" - - role: "StaticText" - name: "Order Summary Text" - - role: "generic" - - role: "StaticText" - name: "Configured Amount / Price" - - role: "generic" - - role: "StaticText" - name: "3. Contract Deadlines & Rules" - - role: "StaticText" - name: "Payment Deadline" - - role: "generic" - - role: "generic" - - role: "StaticText" - name: "Selling Tools" - - role: "StaticText" - name: "โบ" - - role: "InlineTextBox" - name: "Template Details & Print" - - role: "StaticText" - name: "โ " - - role: "StaticText" - name: "Templates" - - role: "StaticText" - name: "Espresso counter card" - - role: "StaticText" - name: "Template ID: espresso-counter" - - role: "InlineTextBox" - name: "Template details" - - role: "InlineTextBox" - name: "Review configured payment shape, summary text, and contract " - - role: "InlineTextBox" - name: "parameters." - - role: "InlineTextBox" - name: "Create order from this template" - - role: "InlineTextBox" - name: "Print QR code" - - role: "InlineTextBox" - name: "1. What it Sells" - - role: "InlineTextBox" - name: "A fixed amount" - - role: "InlineTextBox" - name: " " - - role: "InlineTextBox" - name: "โ" - - role: "InlineTextBox" - name: "Every customer pays the " - - role: "InlineTextBox" - name: "same fixed price." - - role: "InlineTextBox" - name: "Customer enters " - - role: "InlineTextBox" - name: "amount" - - role: "InlineTextBox" - name: "For voluntary donations, tips, " - - role: "InlineTextBox" - name: "and open amounts." - - role: "InlineTextBox" - name: "Inventory products" - - role: "InlineTextBox" - name: "Customer selects products " - - role: "InlineTextBox" - name: "from your inventory." - - role: "InlineTextBox" - name: "2. Template Details" - - role: "InlineTextBox" - name: "Template Name" - - role: "StaticText" - name: "Espresso counter card" - - role: "InlineTextBox" - name: "Template ID" - - role: "StaticText" - name: "espresso-counter" - - role: "InlineTextBox" - name: "Order Summary Text" - - role: "StaticText" - name: "Espresso" - - role: "InlineTextBox" - name: "Configured Amount / Price" - - role: "StaticText" - name: "EUR 2.50" - - role: "InlineTextBox" - name: "3. Contract Deadlines & Rules" - - role: "InlineTextBox" - name: "Payment Deadline" - - role: "StaticText" - name: "Customers must pay within 2 hours after the order is created (merchant account default)." - - role: "heading" - name: "Espresso counter card" - - role: "button" - name: "Close" - - role: "paragraph" - - role: "generic" - - role: "generic" - - role: "button" - name: "Print QR code" - - role: "button" - name: "Close" - - role: "InlineTextBox" - name: "Selling Tools" - - role: "InlineTextBox" - name: "โบ" - - role: "InlineTextBox" - name: "โ " - - role: "InlineTextBox" - name: "Templates" - - role: "InlineTextBox" - name: "Espresso counter card" - - role: "InlineTextBox" - name: "Template ID: espresso-counter" - - role: "InlineTextBox" - name: "Espresso counter card" - - role: "InlineTextBox" - name: "espresso-counter" - - role: "InlineTextBox" - name: "Espresso" - - role: "InlineTextBox" - name: "EUR 2.50" - - role: "InlineTextBox" - name: "Customers must pay within 2 hours after the order is created (merchant account default)." - - role: "StaticText" - name: "Espresso counter card" - - role: "StaticText" - name: "โ" - - role: "StaticText" - name: "One espresso for EUR:2.50" - - role: "generic" - - role: "generic" - - role: "StaticText" - name: "taler://pay-template/demo.taler.net/espresso-counter" - - role: "StaticText" - name: "Print QR code" - - role: "StaticText" - name: "Close" - - role: "InlineTextBox" - name: "Espresso counter card" - - role: "InlineTextBox" - name: "โ" - - role: "InlineTextBox" - name: "One espresso for EUR:2.50" - - role: "image" - name: "Espresso counter card" - - role: "image" - name: "Taler Logo" - - role: "InlineTextBox" - name: "taler://pay-template/demo.taler.net/espresso-counter" - - role: "InlineTextBox" - name: "Print QR code" - - role: "InlineTextBox" - name: "Close" + - role: "dialog" + name: "Espresso counter card" + - role: "generic" + - role: "heading" + name: "Espresso counter card" + - role: "button" + name: "Close" + - role: "paragraph" + - role: "generic" + - role: "generic" + - role: "button" + name: "Print QR code" + - role: "button" + name: "Close" + - role: "StaticText" + name: "Espresso counter card" + - role: "StaticText" + name: "โ" + - role: "StaticText" + name: "One espresso for EUR:2.50" + - role: "generic" + - role: "generic" + - role: "StaticText" + name: "taler://pay-template/demo.taler.net/espresso-counter" + - role: "StaticText" + name: "Print QR code" + - role: "StaticText" + name: "Close" + - role: "InlineTextBox" + name: "Espresso counter card" + - role: "InlineTextBox" + name: "โ" + - role: "InlineTextBox" + name: "One espresso for EUR:2.50" + - role: "image" + name: "Espresso counter card" + - role: "image" + name: "Taler Logo" + - role: "InlineTextBox" + name: "taler://pay-template/demo.taler.net/espresso-counter" + - role: "InlineTextBox" + name: "Print QR code" + - role: "InlineTextBox" + name: "Close" diff --git a/packages/taler-merchant-webui/visual/baselines/template-print-preview-desktop.webp b/packages/taler-merchant-webui/visual/baselines/template-print-preview-desktop.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/run.mjs b/packages/taler-merchant-webui/visual/run.mjs @@ -120,10 +120,13 @@ async function ariaSnapshot(page) { return `${lines.join("\n")}\n`; } -let failed = false; -for (const scenario of scenarios) { - console.log(`capture ${scenario.id}`); - const page = await browser.newPage(); +async function captureScenario(scenario, page) { + const runtimeErrors = []; + page.on("pageerror", (error) => runtimeErrors.push(`page error: ${error.message}`)); + page.on("requestfailed", (request) => runtimeErrors.push(`request failed: ${request.method()} ${request.url()}`)); + page.on("response", (response) => { + if (response.status() >= 400) runtimeErrors.push(`HTTP ${response.status()}: ${response.url()}`); + }); await page.setViewport({ width: scenario.width, height: scenario.height, deviceScaleFactor: 1 }); await page.emulateTimezone("Europe/Berlin"); await page.evaluateOnNewDocument((locale, frozenTime) => { @@ -156,12 +159,16 @@ for (const scenario of scenarios) { if (scenario.click) { const clickLabels = Array.isArray(scenario.click) ? scenario.click : [scenario.click]; for (const label of clickLabels) { - const clicked = await page.evaluate((currentLabel) => { - const control = [...document.querySelectorAll("button, summary, label")].find((item) => item.textContent?.trim().includes(currentLabel)); - control?.click(); - return Boolean(control); + const matches = await page.evaluate((currentLabel) => { + const normalize = (value) => value?.replace(/\s+/g, " ").trim() ?? ""; + const expected = normalize(currentLabel); + const controls = [...document.querySelectorAll("button, summary, label")].filter((item) => + normalize(item.textContent).includes(expected), + ); + if (controls.length === 1) controls[0].click(); + return controls.length; }, label); - if (!clicked) throw new Error(`${scenario.id}: button not found: ${label}`); + if (matches !== 1) throw new Error(`${scenario.id}: expected one control containing ${label}, found ${matches}`); if (label === "Print QR code") { await page.waitForSelector('[role="dialog"]', { timeout: 5000 }); } @@ -177,13 +184,13 @@ for (const scenario of scenarios) { }).map((item) => item.textContent?.trim()), ); if (banners.length) throw new Error(`${scenario.id}: unexpected error banner: ${banners.join(" | ")}`); + if (runtimeErrors.length) throw new Error(`${scenario.id}: ${runtimeErrors.join(" | ")}`); const png = path.join(actualDir, `${scenario.id}.png`); const webp = path.join(actualDir, `${scenario.id}.webp`); await page.screenshot({ path: png, type: "png", fullPage: false }); execFileSync("magick", [png, "-define", "webp:lossless=true", webp]); fs.rmSync(png); fs.writeFileSync(path.join(actualDir, `${scenario.id}.aria.yml`), await ariaSnapshot(page)); - await page.close(); if (command === "compare") { const expectedImage = path.join(expectedDir, `${scenario.id}.webp`); @@ -191,7 +198,7 @@ for (const scenario of scenarios) { if (!fs.existsSync(expectedImage) || !fs.existsSync(expectedAria)) { console.error(`${scenario.id}: baseline missing`); failed = true; - continue; + return; } const imageResult = spawnSync("compare", ["-metric", "AE", expectedImage, webp, path.join(diffDir, `${scenario.id}.webp`)], { encoding: "utf8" }); const pixels = (imageResult.stderr || imageResult.stdout || "0").trim(); @@ -206,16 +213,57 @@ for (const scenario of scenarios) { } } +let failed = false; +const captureErrors = new Map(); +for (const scenario of scenarios) { + console.log(`capture ${scenario.id}`); + const page = await browser.newPage(); + try { + await captureScenario(scenario, page); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.error(`${scenario.id}: capture failed: ${detail}`); + captureErrors.set(scenario.id, detail); + failed = true; + } finally { + await page.close().catch(() => undefined); + } +} + await browser.close(); await new Promise((resolve) => server.close(resolve)); if (command === "update") { - for (const file of fs.readdirSync(actualDir)) fs.copyFileSync(path.join(actualDir, file), path.join(expectedDir, file)); - console.log(`Updated ${scenarios.length} reviewed lossless WebP and ARIA baseline pairs.`); + if (captureErrors.size === 0) { + for (const file of fs.readdirSync(actualDir)) fs.copyFileSync(path.join(actualDir, file), path.join(expectedDir, file)); + console.log(`Updated ${scenarios.length} reviewed lossless WebP and ARIA baseline pairs.`); + } else { + console.error("Baselines were not updated because one or more scenarios failed to capture."); + } +} + +function htmlEscape(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function galleryImage(label, source, file) { + return fs.existsSync(file) + ? `<figure><figcaption>${label}</figcaption><img src="${source}"></figure>` + : `<figure><figcaption>${label}</figcaption><p>Not captured</p></figure>`; } -const cards = scenarios.map(({ id }) => `<section><h2>${id}</h2><div><figure><figcaption>Expected</figcaption><img src="../visual/baselines/${id}.webp"></figure><figure><figcaption>Actual</figcaption><img src="actual/${id}.webp"></figure><figure><figcaption>Diff</figcaption><img src="diff/${id}.webp"></figure></div></section>`).join("\n"); -fs.writeFileSync(path.join(resultDir, "index.html"), `<!doctype html><meta charset="utf-8"><title>Merchant WebUI visual results</title><style>body{font:14px sans-serif;margin:24px}section{margin:32px 0}section>div{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}img{width:100%;border:1px solid #bbb}figcaption{font-weight:bold;margin-bottom:6px}</style><h1>Merchant WebUI visual results</h1>${cards}`); +const cards = scenarios.map(({ id }) => { + const error = captureErrors.get(id); + const expected = galleryImage("Expected", `../visual/baselines/${id}.webp`, path.join(expectedDir, `${id}.webp`)); + const actual = galleryImage("Actual", `actual/${id}.webp`, path.join(actualDir, `${id}.webp`)); + const diff = galleryImage("Diff", `diff/${id}.webp`, path.join(diffDir, `${id}.webp`)); + return `<section><h2>${id}</h2>${error ? `<p class="error">Capture failed: ${htmlEscape(error)}</p>` : ""}<div>${expected}${actual}${diff}</div></section>`; +}).join("\n"); +fs.writeFileSync(path.join(resultDir, "index.html"), `<!doctype html><meta charset="utf-8"><title>Merchant WebUI visual results</title><style>body{font:14px sans-serif;margin:24px}section{margin:32px 0}section>div{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}img{width:100%;border:1px solid #bbb}figcaption{font-weight:bold;margin-bottom:6px}.error{color:#b91c1c;font-weight:bold}</style><h1>Merchant WebUI visual results</h1>${cards}`); if (failed) { console.error(`Review ${path.join(resultDir, "index.html")}`); process.exit(1); diff --git a/packages/taler-merchant-webui/visual/scenarios.mjs b/packages/taler-merchant-webui/visual/scenarios.mjs @@ -4,6 +4,7 @@ export const fontFamily = "DejaVu Sans"; export const scenarios = [ { id: "signup-desktop", story: "auth-signup", locale: "en-US", width: 1440, height: 900 }, + { id: "signup-required-desktop", story: "auth-signup?dataset=required", locale: "en-US", width: 1440, height: 900 }, { id: "setup-desktop-de", story: "onboarding-guide", locale: "de-DE", width: 1440, height: 900 }, { id: "merchant-account-desktop", story: "setup-business-settings", locale: "en-US", width: 1440, height: 1100 }, { id: "merchant-account-mobile", story: "setup-business-settings", locale: "en-US", width: 390, height: 844, click: "Identity and logo" },