commit 9d4391204590bb1e3e523e5f684b99c4347a1759
parent ab69051900cd338f6c5943bb8c5927f934512292
Author: Florian Dold <dold@taler.net>
Date: Thu, 10 Sep 2026 23:58:08 +0200
taler-harness: unify Stagefright merchant scenarios
Replace merchant-webui and merchant-mytops with stagefright merchant.
Select repo, deployed, or custom UI independently of onboarding or full
coverage, defaulting to the repo UI and full coverage.
Share registration and MFA handling, require checkout assets in repo
mode, and defer wallet setup until full coverage is selected. Update
scenario selectors and clean up resources when setup fails.
Diffstat:
14 files changed, 3434 insertions(+), 2992 deletions(-)
diff --git a/packages/taler-harness/README.md b/packages/taler-harness/README.md
@@ -4,6 +4,46 @@ This package implements the `taler-harness` CLI tool. It contains integration
tests for GNU Taler and GNU anastasis, as well as various helpers for managing
deployments of GNU Taler.
+## Stagefright merchant browser scenarios
+
+Run the full merchant scenario using this checkout's Web UI against staging:
+
+```sh
+pnpm --filter @gnu-taler/taler-harness build
+pnpm --filter @gnu-taler/taler-merchant-webui test:stagefright
+# Equivalent after building the harness:
+taler-harness stagefright merchant
+```
+
+UI source and coverage are independent:
+
+```sh
+# Short registration/MFA check using the deployed portal:
+taler-harness stagefright merchant --ui deployed --coverage onboarding
+# Check registration with repo assets:
+taler-harness stagefright merchant --coverage onboarding
+# Use an already-served custom UI against a selected backend:
+taler-harness stagefright merchant --webui-url http://localhost:8080/ --base-url https://stage.my.taler-ops.ch/
+```
+
+Defaults are `--ui repo --coverage full` and the staging merchant backend.
+Repo mode requires a checkout and builds missing or stale `dist/prod` assets;
+it never uses an installed Web UI. Deployed mode loads `webui/` below
+`--base-url` and retains the deployment's configuration. `--webui-url` selects
+custom mode and cannot be combined with `--ui`; that UI must support backend
+selection or already be configured for the selected backend.
+
+Onboarding coverage stops after registration and MFA. Full coverage also tests
+sign-in, payout accounts, KYC, payments, and merchant management. It needs a
+wallet CLI and access to the staging KYC/transfer helpers over SSH. The default
+overall timeouts are five minutes and thirty minutes respectively; override
+with `--overall-timeout` (milliseconds). `--existing-account` is available only
+with full coverage and requires credentials and MFA addresses.
+
+Each run logs its UI source, UI URL, backend, and coverage, and saves step
+screenshots plus HTML diagnostics on failure under `--screenshot-dir` (a
+temporary directory by default).
+
## Challenger tester
See [Testing a local Challenger service](README-challenger-tester.md) for how
diff --git a/packages/taler-harness/src/harness/webui-server.test.ts b/packages/taler-harness/src/harness/webui-server.test.ts
@@ -0,0 +1,57 @@
+/*
+ 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 assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { test } from "node:test";
+import { findWebuiBuild } from "./webui-server.js";
+
+test("workspace UI selection never falls back to an installed build", (t) => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "webui-build-source-"));
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
+ const packageName = "stagefright-fixture-webui";
+ const installed = path.join(root, "installed", "share", packageName);
+ const workspace = path.join(root, "packages", packageName);
+ fs.mkdirSync(installed, { recursive: true });
+ fs.mkdirSync(workspace, { recursive: true });
+ fs.writeFileSync(path.join(installed, "index.html"), "installed");
+ t.mock.method(process, "cwd", () => root);
+ const globals = globalThis as any;
+ const previous = Object.getOwnPropertyDescriptor(
+ globals,
+ "__INSTALL_PREFIX__",
+ );
+ Object.defineProperty(globals, "__INSTALL_PREFIX__", {
+ configurable: true,
+ value: path.join(root, "installed"),
+ });
+ t.after(() => {
+ if (previous)
+ Object.defineProperty(globals, "__INSTALL_PREFIX__", previous);
+ else delete globals.__INSTALL_PREFIX__;
+ });
+ assert.equal(findWebuiBuild(packageName).distDir, installed);
+ // A missing dist must select the checkout so the caller rebuilds it.
+ assert.equal(
+ findWebuiBuild(packageName, "workspace").distDir,
+ path.join(workspace, "dist/prod"),
+ );
+ fs.rmSync(workspace, { recursive: true });
+ assert.throws(() => findWebuiBuild(packageName, "workspace"), /checkout/);
+ assert.equal(findWebuiBuild(packageName).distDir, installed);
+});
diff --git a/packages/taler-harness/src/harness/webui-server.ts b/packages/taler-harness/src/harness/webui-server.ts
@@ -31,21 +31,23 @@ export interface WebuiServer {
export interface WebuiServerOptions {
experimental?: boolean;
+ /** Require a checkout build instead of preferring installed assets. */
+ buildSource?: "auto" | "workspace";
}
function findWorkspacePackage(
packageName: string,
+ preferCwd: boolean,
): { packageDir: string; rootDir: string } | undefined {
- let rootDir = path.resolve(
+ const rootDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../..",
);
- if (fs.existsSync(path.join(rootDir, `packages/${packageName}`))) {
- return {
- rootDir,
- packageDir: path.join(rootDir, `packages/${packageName}`),
- };
- }
+ const packageDir = path.join(rootDir, "packages", packageName);
+ const bundledWorkspace = fs.existsSync(packageDir)
+ ? { rootDir, packageDir }
+ : undefined;
+ if (!preferCwd && bundledWorkspace) return bundledWorkspace;
let curr = process.cwd();
while (curr !== path.parse(curr).root) {
if (fs.existsSync(path.join(curr, `packages/${packageName}`))) {
@@ -56,27 +58,37 @@ function findWorkspacePackage(
}
curr = path.dirname(curr);
}
- return undefined;
+ return bundledWorkspace;
}
-function findWebuiBuild(packageName: string): {
+export function findWebuiBuild(
+ packageName: string,
+ source: "auto" | "workspace" = "auto",
+): {
distDir: string;
packageDir?: string;
rootDir?: string;
} {
- const installed = path.join(__INSTALL_PREFIX__, "share", packageName);
- if (fs.existsSync(path.join(installed, "index.html"))) {
+ const installPrefix =
+ typeof __INSTALL_PREFIX__ === "string" ? __INSTALL_PREFIX__ : "/usr/local";
+ const installed = path.join(installPrefix, "share", packageName);
+ if (source === "auto" && fs.existsSync(path.join(installed, "index.html"))) {
return { distDir: installed };
}
- const workspace = findWorkspacePackage(packageName);
+ const workspace = findWorkspacePackage(packageName, source === "workspace");
if (workspace) {
return {
...workspace,
distDir: path.join(workspace.packageDir, "dist/prod"),
};
}
+ if (source === "workspace") {
+ throw new Error(
+ `Could not find ${packageName} in a checkout; run from the repository or select --ui deployed / --webui-url`,
+ );
+ }
throw new Error(
- `could not find installed ${packageName} WebUI below ${__INSTALL_PREFIX__}/share or in a workspace`,
+ `could not find installed ${packageName} WebUI below ${installPrefix}/share or in a workspace`,
);
}
@@ -114,8 +126,12 @@ async function startStaticWebuiServer(args: {
packageFilter: string;
dynamicFiles: Record<string, unknown | ((serverBaseUrl: string) => unknown)>;
proxyBaseUrl?: string;
+ buildSource?: "auto" | "workspace";
}): Promise<WebuiServer> {
- const { rootDir, packageDir, distDir } = findWebuiBuild(args.packageName);
+ const { rootDir, packageDir, distDir } = findWebuiBuild(
+ args.packageName,
+ args.buildSource,
+ );
if (rootDir && packageDir && webuiBuildIsStale(packageDir, distDir)) {
logger.info(`building ${args.packageName} package...`);
execFileSync("pnpm", ["--filter", args.packageFilter, "build"], {
@@ -234,6 +250,7 @@ export async function startStaticServerMerchantWebui(
): Promise<WebuiServer> {
return startStaticWebuiServer({
packageName: "taler-merchant-webui",
+ buildSource: options.buildSource,
packageFilter: "@gnu-taler/taler-merchant-webui",
dynamicFiles: {
"/webui-config.json": {
diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts
@@ -126,9 +126,9 @@ import {
} from "./playground.js";
import {
MYTOPS_STAGE_BASE_URL,
- runStagefrightMerchantMytops,
-} from "./stagefright/merchant-mytops.js";
-import { runStagefrightMerchantWebui } from "./stagefright/merchant-webui.js";
+ runStagefrightMerchant,
+} from "./stagefright/merchant.js";
+import type { MerchantOptions } from "./stagefright/merchant-options.js";
const logger = new Logger("taler-harness:index.ts");
@@ -2468,72 +2468,20 @@ export const stagefrightCli = talerHarnessCli.subcommand(
);
stagefrightCli
- .subcommand("merchantMytops", "merchant-mytops", {
- help: "Onboard an account in the my.taler-ops.ch merchant backend, as described in mytops-merchant-devtesting.rst.",
+ .subcommand("merchant", "merchant", {
+ help: "Test the merchant portal (defaults: repo UI, full coverage).",
})
- .maybeOption("baseUrl", ["--base-url"], clk.STRING, {
- help: `base URL of the merchant deployment (default: ${MYTOPS_STAGE_BASE_URL})`,
- })
- .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("ui", ["--ui"], clk.STRING, {
+ help: "UI source: repo or deployed (default: repo); mutually exclusive with --webui-url",
})
- .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("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",
- })
- .flag("headed", ["--headed"], {
- help: "run with a visible browser window instead of headless",
- })
- .action(async (args) => {
- const res = await runStagefrightMerchantMytops({
- baseUrl: args.merchantMytops.baseUrl,
- instanceId: args.merchantMytops.instanceId,
- businessName: args.merchantMytops.businessName,
- password: args.merchantMytops.password,
- addressIndex: args.merchantMytops.addressIndex,
- email: args.merchantMytops.email,
- phone: args.merchantMytops.phone,
- screenshotDir: args.merchantMytops.screenshotDir,
- browserBinary: args.merchantMytops.browserBinary,
- slowMoMs: args.merchantMytops.slowMo,
- timeoutMs: args.merchantMytops.timeout,
- headless: !args.merchantMytops.headed,
- });
- 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("coverage", ["--coverage"], clk.STRING, {
+ help: "Scenario coverage: onboarding or full (default: full)",
})
.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)",
+ help: "Custom merchant UI URL; mutually exclusive with --ui",
})
.maybeOption("exchangeUrl", ["--exchange-url"], clk.STRING, {
help: "base URL of the exchange to withdraw from (default: the staging exchange)",
@@ -2592,26 +2540,28 @@ stagefrightCli
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,
+ const res = await runStagefrightMerchant({
+ ui: args.merchant.ui as MerchantOptions["ui"],
+ coverage: args.merchant.coverage as MerchantOptions["coverage"],
+ baseUrl: args.merchant.baseUrl,
+ webuiUrl: args.merchant.webuiUrl,
+ exchangeUrl: args.merchant.exchangeUrl,
+ currency: args.merchant.currency,
+ tosVersion: args.merchant.tosVersion,
+ instanceId: args.merchant.instanceId,
+ businessName: args.merchant.businessName,
+ password: args.merchant.password,
+ addressIndex: args.merchant.addressIndex,
+ email: args.merchant.email,
+ phone: args.merchant.phone,
+ existingAccount: args.merchant.existingAccount,
+ screenshotDir: args.merchant.screenshotDir,
+ browserBinary: args.merchant.browserBinary,
+ walletCliBinary: args.merchant.walletCliBinary,
+ slowMoMs: args.merchant.slowMo,
+ timeoutMs: args.merchant.timeout,
+ overallTimeoutMs: args.merchant.overallTimeout,
+ headless: !args.merchant.headed,
});
console.log(j2s(res));
});
diff --git a/packages/taler-harness/src/stagefright/merchant-auth.ts b/packages/taler-harness/src/stagefright/merchant-auth.ts
@@ -0,0 +1,280 @@
+/*
+ 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 type { Page } from "playwright-core";
+import type { Stage } from "./stage.js";
+import type { Mock2faReader } from "./mock2fa.js";
+
+export interface MerchantAccount {
+ instanceId: string;
+ businessName: string;
+ password: string;
+ email: string;
+ phone: string;
+ timeoutMs: number;
+}
+
+type RegistrationState = "email" | "sms" | "choose" | "logged-in";
+
+/**
+ * Wait for whatever comes after submitting the registration form: a code
+ * field, a choice of MFA channel, or the portal once onboarding is complete.
+ */
+async function waitForRegistrationState(
+ page: Page,
+ timeoutMs: number,
+): Promise<RegistrationState> {
+ const deadline = Date.now() + timeoutMs;
+ let lastComplaint: string | undefined = undefined;
+ for (;;) {
+ if (await page.locator("#signup-email-code").isVisible()) {
+ return "email";
+ }
+ if (await page.locator("#signup-sms-code").isVisible()) {
+ return "sms";
+ }
+ if (
+ await page.locator('input[name="signup_2fa_channel"]').first().isVisible()
+ ) {
+ return "choose";
+ }
+ // The navigation sidebar only exists for a logged-in instance.
+ if (await page.locator("aside").isVisible()) {
+ return "logged-in";
+ }
+ // Error notifications dismiss themselves after a while, so remember what
+ // the last one said instead of only reporting a timeout.
+ const complaint = page.locator("[data-error-banner]");
+ if ((await complaint.count()) > 0) {
+ lastComplaint = (await complaint.first().innerText())
+ .replace(/\s+/g, " ")
+ .trim();
+ if (lastComplaint)
+ throw Error(`the deployment refused the request: ${lastComplaint}`);
+ }
+ if (Date.now() >= deadline) {
+ if (lastComplaint) {
+ throw Error(`the deployment refused the request: ${lastComplaint}`);
+ }
+ throw Error(
+ `neither a registration challenge nor the merchant portal showed up within ${timeoutMs}ms`,
+ );
+ }
+ await page.waitForTimeout(250);
+ }
+}
+
+/**
+ * Submit one registration challenge and wait until the UI advances. Waiting
+ * for the input to be cleared as well as removed covers consecutive
+ * challenges that use the same channel and therefore reuse the same element.
+ */
+async function submitChallengeCode(
+ page: Page,
+ selector: string,
+ code: string,
+ timeoutMs: number,
+): Promise<void> {
+ const input = page.locator(selector);
+ const oldInput = await input.elementHandle();
+ await input.fill(code);
+ await page.click('button[type="submit"]');
+ if (!oldInput) {
+ return;
+ }
+ try {
+ await page.waitForFunction(
+ (el) => {
+ if (!el.isConnected) return true;
+ const input = el as any;
+ const submit = input
+ .closest("form")
+ ?.querySelector('button[type="submit"]');
+ // Sign-in clears its code while the final authentication request is
+ // still running. Only an idle form can represent the next challenge.
+ return (
+ input.value === "" && !input.disabled && submit && !submit.disabled
+ );
+ },
+ oldInput,
+ { timeout: timeoutMs },
+ );
+ } finally {
+ await oldInput.dispose();
+ }
+}
+
+export async function registerMerchant(
+ stage: Stage,
+ account: MerchantAccount,
+ mock2fa: Mock2faReader,
+): Promise<void> {
+ const { instanceId, businessName, password, email, phone, timeoutMs } =
+ account;
+ await stage.step("start the onboarding", async (page) => {
+ await page.click('a[href="#/signup"]');
+ await page.waitForSelector("#signup-business");
+ });
+
+ await stage.step("fill in the account details", async (page) => {
+ await page.fill("#signup-business", businessName);
+ await page.fill("#signup-username", instanceId);
+ await page.fill("#signup-password", password);
+ await page.fill("#signup-confirm-password", password);
+ // Which of the two the deployment asks for depends on its configured
+ // mandatory TAN channels.
+ 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.locator('input[type="checkbox"]').check();
+ });
+
+ await stage.step("request the account", async (page) => {
+ await page.click('button[type="submit"]');
+ });
+
+ // A deployment can require more than one channel, in which case the next
+ // challenge is sent as soon as the previous one is solved.
+ for (let round = 1; ; round++) {
+ const state = await waitForRegistrationState(stage.page, timeoutMs);
+ if (state === "logged-in") {
+ break;
+ }
+ if (state === "choose") {
+ await stage.step(
+ `pick the channel for challenge ${round}`,
+ async (page) => {
+ const channel = page.locator('input[name="signup_2fa_channel"]');
+ if (!(await channel.first().isChecked())) {
+ await channel.first().check();
+ }
+ await page.click('button[type="submit"]');
+ await page.waitForSelector("#signup-email-code, #signup-sms-code");
+ },
+ );
+ }
+ const code = await stage.step(
+ `receive the code for challenge ${round}`,
+ async () => {
+ const message = await mock2fa.waitForNewCode([email, phone], {
+ login: instanceId,
+ timeoutMs,
+ });
+ return message.code;
+ },
+ );
+ await stage.step(`enter the code for challenge ${round}`, async (page) => {
+ const selector =
+ (await page.locator("#signup-email-code").count()) > 0
+ ? "#signup-email-code"
+ : "#signup-sms-code";
+ await submitChallengeCode(page, selector, code, timeoutMs);
+ });
+ }
+
+ await stage.step("check that the new account is logged in", async (page) => {
+ await page.waitForSelector("aside");
+ const shown = await page.locator("aside").innerText();
+ // The UI renders the instance ID folded to lower case, since that is
+ // the only spelling the backend stores.
+ if (!shown.toLowerCase().includes(instanceId.toLowerCase())) {
+ throw Error(`the sidebar does not mention '${instanceId}'`);
+ }
+ });
+}
+
+export async function signInMerchant(
+ stage: Stage,
+ account: MerchantAccount,
+ mock2fa: Mock2faReader,
+): Promise<void> {
+ const { instanceId, password, email, phone, timeoutMs } = account;
+ await stage.step("sign in to merchant account", async (page) => {
+ await page.waitForSelector("#signin-account");
+ await page.fill("#signin-account", instanceId);
+ await page.fill("#signin-password", password);
+ await page.click('button[type="submit"]');
+ });
+ for (let round = 1; ; round++) {
+ await stage.page.waitForSelector(
+ 'aside, #signin-2fa-code, input[name="2fa_channel"], [data-error-banner]',
+ { timeout: timeoutMs },
+ );
+ if (await stage.page.locator("aside").isVisible()) break;
+ const error = stage.page.locator("[data-error-banner]");
+ if (await error.isVisible()) throw Error(await error.innerText());
+ const channel = stage.page.locator('input[name="2fa_channel"]').first();
+ if (await channel.isVisible()) {
+ await stage.step(`pick sign-in channel ${round}`, async (page) => {
+ await channel.check();
+ await page.click('button[type="submit"]');
+ await page.waitForSelector("#signin-2fa-code");
+ });
+ }
+ const message = await stage.step(`receive sign-in code ${round}`, () =>
+ mock2fa.waitForNewCode([email, phone], { login: instanceId, timeoutMs }),
+ );
+ await stage.step(`enter sign-in code ${round}`, (page) =>
+ submitChallengeCode(page, "#signin-2fa-code", message.code, timeoutMs),
+ );
+ }
+ await stage.step("verify signed-in account", async (page) => {
+ const shown = await page.locator("aside").innerText();
+ if (!shown.toLowerCase().includes(instanceId.toLowerCase())) {
+ throw Error(`the sidebar does not mention '${instanceId}'`);
+ }
+ });
+}
+
+/**
+ * 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".
+ */
+export 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;
+}
diff --git a/packages/taler-harness/src/stagefright/merchant-mytops.ts b/packages/taler-harness/src/stagefright/merchant-mytops.ts
@@ -1,298 +0,0 @@
-/*
- 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/>
- */
-
-/**
- * Automation of the manual testing instructions for the my.taler-ops.ch
- * merchant backend, see mytops-merchant-devtesting.rst in taler-docs.
- */
-
-/**
- * Imports.
- */
-import { Logger, getRandomBytes, encodeCrock } from "@gnu-taler/taler-util";
-import type { Page } from "playwright-core";
-import { Mock2faReader } from "./mock2fa.js";
-import { DEFAULT_TIMEOUT_MS, Stage, StageOptions } from "./stage.js";
-
-const logger = new Logger("stagefright/merchant-mytops.ts");
-
-export const MYTOPS_STAGE_BASE_URL = "https://stage.my.taler-ops.ch/";
-
-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-mfa/$ADDRESS.txt instead.
- */
-function mockEmailAddress(index: string): string {
- return `test-${index}@taler.net`;
-}
-
-function mockPhoneNumber(index: string): string {
- return `+417000000${index}`;
-}
-
-export interface MerchantMytopsOptions extends StageOptions {
- /**
- * Base URL of the deployment, defaults to the staging environment.
- */
- baseUrl?: string;
-
- /**
- * Username of the account to create. Random by default, since an account
- * can only be onboarded once.
- */
- instanceId?: string;
-
- businessName?: string;
-
- password?: string;
-
- /**
- * The two digits that select the mock email address and phone number.
- * Random by default.
- */
- addressIndex?: string;
-
- email?: string;
-
- phone?: string;
-}
-
-export interface MerchantMytopsResult {
- baseUrl: 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 `sf-${encodeCrock(getRandomBytes(5)).toLowerCase()}`;
-}
-
-function randomPassword(): string {
- return encodeCrock(getRandomBytes(12));
-}
-
-/**
- * Ensure that relative URLs (webui/, mock-mfa/...) resolve below the
- * deployment and not next to it.
- */
-function normalizeBaseUrl(url: string): string {
- return url.endsWith("/") ? url : `${url}/`;
-}
-
-type RegistrationState = "email" | "sms" | "choose" | "logged-in";
-
-/**
- * Wait for whatever comes after submitting the registration form: a code
- * field, a choice of MFA channel, or the portal once onboarding is complete.
- */
-async function waitForRegistrationState(
- page: Page,
- timeoutMs: number,
-): Promise<RegistrationState> {
- const deadline = Date.now() + timeoutMs;
- let lastComplaint: string | undefined = undefined;
- for (;;) {
- if ((await page.locator("#signup-email-code").count()) > 0) {
- return "email";
- }
- if ((await page.locator("#signup-sms-code").count()) > 0) {
- return "sms";
- }
- if ((await page.locator('input[name="signup_2fa_channel"]').count()) > 0) {
- return "choose";
- }
- // The navigation sidebar only exists for a logged-in instance.
- if ((await page.locator("aside").count()) > 0) {
- return "logged-in";
- }
- // Error notifications dismiss themselves after a while, so remember what
- // the last one said instead of only reporting a timeout.
- const complaint = page.locator("[data-error-banner]");
- if ((await complaint.count()) > 0) {
- lastComplaint = (await complaint.first().innerText())
- .replace(/\s+/g, " ")
- .trim();
- }
- if (Date.now() >= deadline) {
- if (lastComplaint) {
- throw Error(`the deployment refused the request: ${lastComplaint}`);
- }
- throw Error(
- `neither a registration challenge nor the merchant portal showed up within ${timeoutMs}ms`,
- );
- }
- await page.waitForTimeout(250);
- }
-}
-
-/**
- * Submit one registration challenge and wait until the UI advances. Waiting
- * for the input to be cleared as well as removed covers consecutive
- * challenges that use the same channel and therefore reuse the same element.
- */
-async function submitChallengeCode(
- page: Page,
- selector: string,
- code: string,
- timeoutMs: number,
-): Promise<void> {
- const input = page.locator(selector);
- const oldInput = await input.elementHandle();
- await input.fill(code);
- await page.click('button[type="submit"]');
- if (!oldInput) {
- return;
- }
- try {
- await page.waitForFunction(
- (el) =>
- !el.isConnected ||
- ((el as { value: string; disabled: boolean }).value === "" &&
- !(el as { value: string; disabled: boolean }).disabled),
- oldInput,
- { timeout: timeoutMs },
- );
- } finally {
- await oldInput.dispose();
- }
-}
-
-export async function runStagefrightMerchantMytops(
- options: MerchantMytopsOptions = {},
-): Promise<MerchantMytopsResult> {
- const baseUrl = normalizeBaseUrl(options.baseUrl ?? MYTOPS_STAGE_BASE_URL);
- const addressIndex = options.addressIndex ?? randomAddressIndex();
- const instanceId = options.instanceId ?? randomInstanceId();
- const businessName = options.businessName ?? `Stagefright ${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;
-
- logger.info(`onboarding '${instanceId}' at ${baseUrl}`);
- logger.info(`using ${email} and ${phone} for multi-factor authentication`);
-
- const mock2fa = new Mock2faReader(baseUrl);
- // Codes that are already in the mailboxes are not ours.
- await mock2fa.markCurrent([email, phone]);
-
- const stage = await Stage.create(SCENARIO_NAME, options);
- await stage.run(async () => {
- await stage.step("open the merchant portal", async (page) => {
- await page.goto(new URL("webui/", baseUrl).href);
- await page.waitForSelector("#signin-account");
- });
-
- await stage.step("start the onboarding", async (page) => {
- await page.click('a[href="#/signup"]');
- await page.waitForSelector("#signup-business");
- });
-
- await stage.step("fill in the account details", async (page) => {
- await page.fill("#signup-business", businessName);
- await page.fill("#signup-username", instanceId);
- await page.fill("#signup-password", password);
- await page.fill("#signup-confirm-password", password);
- // Which of the two the deployment asks for depends on its configured
- // mandatory TAN channels.
- 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.locator('input[type="checkbox"]').check();
- });
-
- await stage.step("request the account", async (page) => {
- await page.click('button[type="submit"]');
- });
-
- // A deployment can require more than one channel, in which case the next
- // challenge is sent as soon as the previous one is solved.
- for (let round = 1; ; round++) {
- const state = await waitForRegistrationState(stage.page, timeoutMs);
- if (state === "logged-in") {
- break;
- }
- if (state === "choose") {
- await stage.step(
- `pick the channel for challenge ${round}`,
- async (page) => {
- const channel = page.locator('input[name="signup_2fa_channel"]');
- if (!(await channel.first().isChecked())) {
- await channel.first().check();
- }
- await page.click('button[type="submit"]');
- await page.waitForSelector("#signup-email-code, #signup-sms-code");
- },
- );
- }
- const code = await stage.step(
- `receive the code for challenge ${round}`,
- async () => {
- const message = await mock2fa.waitForNewCode([email, phone], {
- login: instanceId,
- timeoutMs,
- });
- return message.code;
- },
- );
- await stage.step(
- `enter the code for challenge ${round}`,
- async (page) => {
- const selector =
- (await page.locator("#signup-email-code").count()) > 0
- ? "#signup-email-code"
- : "#signup-sms-code";
- await submitChallengeCode(page, selector, code, timeoutMs);
- },
- );
- }
-
- await stage.step(
- "check that the new account is logged in",
- async (page) => {
- await page.waitForSelector("aside");
- const shown = await page.locator("aside").innerText();
- // The UI renders the instance ID folded to lower case, since that is
- // the only spelling the backend stores.
- if (!shown.toLowerCase().includes(instanceId.toLowerCase())) {
- logger.warn(`the sidebar does not mention '${instanceId}'`);
- }
- },
- );
- });
-
- logger.info(`onboarded '${instanceId}' with password '${password}'`);
- return {
- baseUrl,
- instanceId,
- password,
- email,
- phone,
- screenshotDir: stage.screenshotDir,
- };
-}
diff --git a/packages/taler-harness/src/stagefright/merchant-options.test.ts b/packages/taler-harness/src/stagefright/merchant-options.test.ts
@@ -0,0 +1,66 @@
+/*
+ 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 assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ resolveMerchantOptions,
+ type MerchantOptions,
+} from "./merchant-options.js";
+
+test("merchant defaults and UI selection are independent of coverage", () => {
+ assert.equal(resolveMerchantOptions().uiSource, "repo");
+ assert.equal(resolveMerchantOptions().coverage, "full");
+ assert.equal(
+ resolveMerchantOptions({ coverage: "onboarding" }).uiSource,
+ "repo",
+ );
+ const deployed = resolveMerchantOptions({
+ ui: "deployed",
+ baseUrl: "https://example.com/tenant",
+ });
+ assert.equal(deployed.coverage, "full");
+ assert.equal(deployed.webuiUrl, "https://example.com/tenant/webui/");
+ const custom = resolveMerchantOptions({
+ webuiUrl: "http://localhost:8080/ui",
+ coverage: "onboarding",
+ });
+ assert.equal(custom.uiSource, "custom");
+ assert.equal(custom.webuiUrl, "http://localhost:8080/ui/");
+});
+
+test("invalid merchant options fail before starting a scenario", () => {
+ for (const options of [
+ { ui: "repo", webuiUrl: "http://localhost/" },
+ { ui: "deployed", webuiUrl: "http://localhost/" },
+ { ui: "installed" },
+ { coverage: "smoke" },
+ { coverage: "onboarding", existingAccount: true },
+ { existingAccount: true },
+ { existingAccount: true, instanceId: "a", password: "b" },
+ { baseUrl: "file:///tmp/" },
+ ])
+ assert.throws(() => resolveMerchantOptions(options as MerchantOptions));
+ assert.equal(
+ resolveMerchantOptions({
+ existingAccount: true,
+ instanceId: "a",
+ password: "b",
+ addressIndex: "01",
+ }).coverage,
+ "full",
+ );
+});
diff --git a/packages/taler-harness/src/stagefright/merchant-options.ts b/packages/taler-harness/src/stagefright/merchant-options.ts
@@ -0,0 +1,148 @@
+/*
+ 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 type { StageOptions } from "./stage.js";
+
+export const MYTOPS_STAGE_BASE_URL = "https://stage.my.taler-ops.ch/";
+
+export interface MerchantOptions extends StageOptions {
+ /**
+ * Base URL of the merchant backend deployment, defaults to staging.
+ */
+ baseUrl?: string;
+
+ ui?: "repo" | "deployed";
+
+ coverage?: "onboarding" | "full";
+
+ /**
+ * Custom UI URL. Mutually exclusive with ui.
+ */
+ 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 MerchantResult {
+ uiSource: "repo" | "deployed" | "custom";
+ coverage: "onboarding" | "full";
+ baseUrl: string;
+ webuiUrl: string;
+ instanceId: string;
+ password: string;
+ email: string;
+ phone: string;
+ screenshotDir: string;
+}
+
+export function resolveMerchantOptions(options: MerchantOptions = {}) {
+ if (options.ui !== undefined && !["repo", "deployed"].includes(options.ui)) {
+ throw new Error("--ui must be repo or deployed");
+ }
+ if (
+ options.coverage !== undefined &&
+ !["onboarding", "full"].includes(options.coverage)
+ ) {
+ throw new Error("--coverage must be onboarding or full");
+ }
+ if (options.webuiUrl !== undefined && options.ui !== undefined) {
+ throw new Error("--webui-url cannot be combined with --ui");
+ }
+ const coverage = options.coverage ?? "full";
+ if (coverage === "onboarding" && options.existingAccount) {
+ throw new Error("--existing-account requires --coverage full");
+ }
+ 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 = normalizeMerchantUrl(
+ options.baseUrl ?? MYTOPS_STAGE_BASE_URL,
+ );
+ const uiSource: MerchantResult["uiSource"] =
+ options.webuiUrl !== undefined ? "custom" : (options.ui ?? "repo");
+ const webuiUrl =
+ uiSource === "custom"
+ ? normalizeMerchantUrl(options.webuiUrl!)
+ : uiSource === "deployed"
+ ? new URL("webui/", baseUrl).href
+ : undefined;
+ return { ...options, coverage, baseUrl, uiSource, webuiUrl };
+}
+
+function normalizeMerchantUrl(value: string): string {
+ const url = new URL(value);
+ if (
+ !["http:", "https:"].includes(url.protocol) ||
+ url.username ||
+ url.password ||
+ url.search ||
+ url.hash
+ ) {
+ throw new Error(
+ `Expected an HTTP(S) base URL without credentials, query, or fragment: ${value}`,
+ );
+ }
+ if (!url.pathname.endsWith("/")) url.pathname += "/";
+ return url.href;
+}
diff --git a/packages/taler-harness/src/stagefright/merchant-ui.ts b/packages/taler-harness/src/stagefright/merchant-ui.ts
@@ -0,0 +1,54 @@
+/*
+ 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 type { Page } from "playwright-core";
+
+/** Select a custom UI's backend through the same screen a user uses. */
+export async function selectCustomBackend(
+ page: Page,
+ baseUrl: string,
+ timeoutMs: number,
+): Promise<void> {
+ const changeServer = page.getByTitle("Change merchant backend server URL");
+ // Wait for configuration loading before deciding whether selection is allowed.
+ await page.waitForSelector(
+ '#signin-account, a[title="Change merchant backend server URL"]',
+ { timeout: timeoutMs },
+ );
+ if (await changeServer.isVisible()) {
+ await changeServer.click();
+ await page.getByRole("textbox", { name: "Server address" }).fill(baseUrl);
+ await page.getByRole("button", { name: "Save & Apply Server URL" }).click();
+ await page.waitForSelector("#signin-account", { timeout: timeoutMs });
+ return;
+ }
+ // A fixed deployment may already target the requested backend.
+ const config = await page.evaluate(async () => {
+ const response = await (globalThis as any).fetch(
+ new URL("webui-config.json", (globalThis as any).document.baseURI),
+ );
+ if (!response.ok) return undefined;
+ return response.json();
+ });
+ if (
+ config?.merchant_base_url &&
+ new URL(config.merchant_base_url, page.url()).href === baseUrl
+ )
+ return;
+ throw Error(
+ `Custom UI cannot select backend ${baseUrl}; enable merchant_base_url_configurable or serve it with matching merchant_base_url`,
+ );
+}
diff --git a/packages/taler-harness/src/stagefright/merchant-webui.ts b/packages/taler-harness/src/stagefright/merchant-webui.ts
@@ -1,2595 +0,0 @@
-/*
- 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 configResponse: any = undefined;
- try {
- configResponse = 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 =
- configResponse?.currency ||
- configResponse?.currency_specification?.name ||
- "CHF";
- }
- logger.info(`Deployment currency resolved to: '${deploymentCurrency}'`);
-
- if (
- configResponse?.exchanges &&
- Array.isArray(configResponse.exchanges)
- ) {
- const supportedUrls = configResponse.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 {
- 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 preselected 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, {
- uri: 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 {
- 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}'...`,
- );
- 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/merchant.test.ts b/packages/taler-harness/src/stagefright/merchant.test.ts
@@ -0,0 +1,375 @@
+/*
+ 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 assert from "node:assert/strict";
+import fs from "node:fs";
+import http from "node:http";
+import os from "node:os";
+import path from "node:path";
+import { test, type TestContext } from "node:test";
+import { URL } from "node:url";
+import {
+ Stage,
+ findBrowserBinary,
+ installNativeUrl,
+ type StageOptions,
+} from "./stage.js";
+import { runStagefrightMerchant } from "./merchant.js";
+import { loadPlaywright } from "../harness/browser-dependencies.js";
+import { signInMerchant } from "./merchant-auth.js";
+import { Mock2faReader } from "./mock2fa.js";
+import { selectCustomBackend } from "./merchant-ui.js";
+
+const restoreUrl = installNativeUrl();
+let browserAvailable: boolean;
+try {
+ const playwright = await loadPlaywright();
+ browserAvailable = fs.existsSync(
+ findBrowserBinary() ?? playwright.chromium.executablePath(),
+ );
+} finally {
+ restoreUrl();
+}
+const browserTest = (name: string, fn: (t: TestContext) => Promise<void>) =>
+ test(
+ name,
+ {
+ skip: browserAvailable
+ ? false
+ : "Chromium unavailable; set BROWSER_BINARY to run browser fixtures",
+ },
+ fn,
+ );
+
+/** A real browser fixture; MFA state and mailboxes advance through HTTP. */
+async function fixture(t: TestContext, rounds: string[]) {
+ const requests: string[] = [];
+ let round = -1;
+ const server = http.createServer((req, res) => {
+ const pathname = new URL(req.url!, "http://localhost").pathname;
+ requests.push(pathname);
+ if (pathname.endsWith("/advance")) {
+ round++;
+ res.setHeader("Content-Type", "application/json");
+ res.end(
+ JSON.stringify({
+ kind: rounds[round] ?? "done",
+ code: String(12340000 + round),
+ }),
+ );
+ } else if (pathname.includes("/mock-mfa/")) {
+ if (round < 0) {
+ res.writeHead(404);
+ res.end();
+ } else res.end(`Login: fixture-account\nCode: ${12340000 + round}`);
+ } else if (pathname.endsWith("/webui-config.json")) {
+ res.setHeader("Content-Type", "application/json");
+ res.end(
+ JSON.stringify({
+ merchant_base_url: baseUrl,
+ merchant_base_url_configurable: false,
+ }),
+ );
+ } else if (pathname.endsWith("/webui/")) {
+ res.setHeader("Content-Type", "text/html");
+ res.end(`<!doctype html><html><head><style>body { min-height: 1024px; }</style></head><body><input id="signin-account" style="display:none"><a href="#/signup">Sign up</a>
+ <script>
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ document.getElementById('signin-account').style.display = '';
+ }));
+ const signup = () => {
+ document.body.innerHTML = '<form><input id="signup-business"><input id="signup-username"><input id="signup-password"><input id="signup-confirm-password"><input id="signup-email"><input id="signup-phone"><input type="checkbox"><button type="submit">Submit</button></form>';
+ document.querySelector('form').onsubmit = advance;
+ };
+ document.querySelector('a').onclick = signup;
+ let expectedCode;
+ async function advance(event) {
+ event.preventDefault();
+ const code = document.querySelector('#signup-email-code, #signup-sms-code');
+ if (code && code.value !== expectedCode) { show('error'); return; }
+ const state = await fetch('../advance', { method: 'POST' }).then(r => r.json());
+ expectedCode = state.code;
+ show(state.kind);
+ }
+ function show(kind) {
+ if (kind === 'done') { document.body.innerHTML = '<aside>FIXTURE-ACCOUNT</aside>'; return; }
+ if (kind === 'error') { document.body.innerHTML = '<div data-error-banner>Registration refused by fixture</div>'; return; }
+ if (kind === 'stall') { document.body.innerHTML = '<p>Waiting</p>'; return; }
+ if (kind === 'choose') {
+ document.body.innerHTML = '<form><input type="radio" name="signup_2fa_channel" value="email"><button type="submit">Choose</button></form>';
+ document.querySelector('form').onsubmit = advance; return;
+ }
+ const id = kind === 'email' ? 'signup-email-code' : 'signup-sms-code';
+ const old = document.getElementById(id);
+ if (old) { old.value = ''; old.disabled = false; return; }
+ document.body.innerHTML = '<form><input id="' + id + '"><button type="submit">Verify</button></form>';
+ document.querySelector('form').onsubmit = advance;
+ }
+ </script></body></html>`);
+ } else if (pathname.endsWith("/config")) {
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify({ currency: "CHF" }));
+ } else {
+ res.writeHead(404);
+ res.end();
+ }
+ });
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ assert(address && typeof address !== "string");
+ const baseUrl = `http://127.0.0.1:${address.port}/tenant/`;
+ t.after(async () => {
+ server.closeAllConnections();
+ await new Promise<void>((resolve, reject) =>
+ server.close((error) => (error ? reject(error) : resolve())),
+ );
+ });
+ const screenshotDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "merchant-scenario-test-"),
+ );
+ t.after(() => fs.rmSync(screenshotDir, { recursive: true, force: true }));
+ const options = {
+ baseUrl,
+ instanceId: "fixture-account",
+ password: "fixture-password",
+ addressIndex: "01",
+ screenshotDir,
+ timeoutMs: 2000,
+ overallTimeoutMs: 15000,
+ walletCliBinary: "/nonexistent-stagefright-wallet",
+ exchangeUrl: "http://127.0.0.1:1/",
+ };
+ return { requests, options };
+}
+
+for (const rounds of [
+ [],
+ ["email"],
+ ["sms"],
+ ["email", "sms"],
+ ["choose", "email", "sms"],
+ ["email", "email"],
+]) {
+ browserTest(
+ `deployed onboarding completes MFA ${JSON.stringify(rounds)} without full dependencies`,
+ async (t) => {
+ const { requests, options } = await fixture(t, rounds);
+ let openedStage: Stage | undefined;
+ const create = Stage.create.bind(Stage);
+ t.mock.method(
+ Stage,
+ "create",
+ async (name: string, opts: StageOptions) => {
+ openedStage = await create(name, opts);
+ return openedStage;
+ },
+ );
+ const result = await runStagefrightMerchant({
+ ...options,
+ ui: "deployed",
+ coverage: "onboarding",
+ });
+ assert.equal(result.uiSource, "deployed");
+ assert.equal(result.coverage, "onboarding");
+ assert.equal(result.webuiUrl, `${options.baseUrl}webui/`);
+ assert(openedStage?.page.isClosed());
+ assert(
+ !requests.some((url) => url.endsWith("/config")),
+ "onboarding must not run full-scenario backend/exchange checks",
+ );
+ assert(requests.includes("/tenant/webui/"));
+ },
+ );
+}
+
+for (const [rounds, error] of [
+ [["error"], /Registration refused/],
+ [["stall"], /neither a registration challenge/],
+] as const) {
+ browserTest(`onboarding captures diagnostics on ${rounds[0]}`, async (t) => {
+ const { options } = await fixture(t, [...rounds]);
+ await assert.rejects(
+ runStagefrightMerchant({
+ ...options,
+ ui: "deployed",
+ coverage: "onboarding",
+ }),
+ error,
+ );
+ const files = fs.readdirSync(options.screenshotDir);
+ assert(files.some((file) => file.endsWith(".html")));
+ assert(
+ files.some(
+ (file) => file.endsWith("-failed.png") || file.endsWith("failed.png"),
+ ),
+ );
+ });
+}
+
+browserTest(
+ "custom UI supports a fixed matching backend and rejects a mismatch",
+ async (t) => {
+ const { options } = await fixture(t, []);
+ const result = await runStagefrightMerchant({
+ ...options,
+ webuiUrl: `${options.baseUrl}webui/`,
+ coverage: "onboarding",
+ });
+ assert.equal(result.uiSource, "custom");
+ const stage = await Stage.create("custom-backend", options);
+ await stage.run(async () => {
+ await stage.page.goto(`${options.baseUrl}webui/`);
+ await assert.rejects(
+ selectCustomBackend(stage.page, "https://wrong.example/", 2000),
+ /cannot select backend/,
+ );
+ });
+ },
+);
+
+browserTest(
+ "full scenario cleans up the browser when wallet startup fails",
+ async (t) => {
+ const { requests, options } = await fixture(t, []);
+ let openedStage: Stage | undefined;
+ const create = Stage.create.bind(Stage);
+ t.mock.method(Stage, "create", async (name: string, opts: StageOptions) => {
+ openedStage = await create(name, opts);
+ return openedStage;
+ });
+ // Custom UI selection retains full coverage by default.
+ await assert.rejects(
+ runStagefrightMerchant({
+ ...options,
+ webuiUrl: `${options.baseUrl}webui/`,
+ }),
+ /ENOENT/,
+ );
+ assert(openedStage?.page.isClosed());
+ assert(requests.some((url) => url.endsWith("/config")));
+ },
+);
+
+test("repo server is closed when browser setup fails", async (t) => {
+ const { options } = await fixture(t, []);
+ const servers: http.Server[] = [];
+ const createServer = http.createServer;
+ t.mock.method(
+ http,
+ "createServer",
+ (...args: Parameters<typeof http.createServer>) => {
+ const server = createServer(...args);
+ servers.push(server);
+ return server;
+ },
+ );
+ t.mock.method(Stage, "create", async () => {
+ throw Error("fixture browser launch failure");
+ });
+ await assert.rejects(
+ runStagefrightMerchant({ ...options, coverage: "onboarding" }),
+ /fixture browser launch failure/,
+ );
+ assert.equal(servers.length, 1);
+ assert.equal(servers[0].listening, false);
+});
+
+browserTest(
+ "sign-in waits for the final authentication request after the code clears",
+ async (t) => {
+ const { options } = await fixture(t, []);
+ const stage = await Stage.create("sign-in-transition", options);
+ const reader = new Mock2faReader(options.baseUrl);
+ const codes = t.mock.method(reader, "waitForNewCode", async () => ({
+ code: "12345678",
+ message: "fixture",
+ address: "email",
+ }));
+ await stage.run(async () => {
+ await stage.page
+ .setContent(`<!doctype html><style>body { min-height: 1024px; }</style><form><input id="signin-account"><input id="signin-password"><button type="submit">Sign in</button></form>
+ <script>
+ document.querySelector('form').onsubmit = event => {
+ event.preventDefault();
+ document.body.innerHTML = '<form><input id="signin-2fa-code"><button type="submit">Confirm</button></form>';
+ document.querySelector('form').onsubmit = event => {
+ event.preventDefault();
+ document.querySelector('input').value = '';
+ document.querySelector('button').disabled = true;
+ setTimeout(() => { document.body.innerHTML = '<aside>fixture-account</aside>'; }, 300);
+ };
+ };
+ </script>`);
+ await signInMerchant(
+ stage,
+ { ...options, businessName: "fixture", email: "email", phone: "phone" },
+ reader,
+ );
+ assert.equal(codes.mock.callCount(), 1);
+ });
+ },
+);
+
+test("Stage closes a launched browser when context creation fails", async (t) => {
+ const restore = installNativeUrl();
+ t.after(restore);
+ const playwright = await loadPlaywright();
+ let closed = false;
+ t.mock.method(playwright.chromium, "launch", async () => ({
+ newContext: async () => {
+ throw Error("fixture context failure");
+ },
+ close: async () => {
+ closed = true;
+ },
+ }));
+ const screenshotDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "stage-launch-test-"),
+ );
+ t.after(() => fs.rmSync(screenshotDir, { recursive: true, force: true }));
+ await assert.rejects(
+ Stage.create("context-failure", { screenshotDir }),
+ /fixture context failure/,
+ );
+ assert(closed);
+});
+
+browserTest(
+ "custom UI selects a configurable backend through its form",
+ async (t) => {
+ const { options } = await fixture(t, []);
+ const stage = await Stage.create("custom-configurable", options);
+ await stage.run(async () => {
+ await stage.page
+ .setContent(`<!doctype html><style>body { min-height: 1024px; }</style><input id="signin-account"><a title="Change merchant backend server URL">Change server</a>
+ <script>
+ document.querySelector('a').onclick = () => {
+ document.body.innerHTML = '<form><label>Server address<input></label><button type="submit">Save & Apply Server URL</button></form>';
+ document.querySelector('form').onsubmit = event => {
+ event.preventDefault();
+ window.selectedBackend = document.querySelector('input').value;
+ document.body.innerHTML = '<input id="signin-account">';
+ };
+ };
+ </script>`);
+ await selectCustomBackend(stage.page, options.baseUrl, 2000);
+ assert.equal(
+ await stage.page.evaluate(() => (globalThis as any).selectedBackend),
+ options.baseUrl,
+ );
+ });
+ },
+);
diff --git a/packages/taler-harness/src/stagefright/merchant.ts b/packages/taler-harness/src/stagefright/merchant.ts
@@ -0,0 +1,2343 @@
+/*
+ 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/>
+ */
+
+/**
+ * Merchant browser scenarios using a repo, deployed, or custom Web UI.
+ * Onboarding coverage shares its registration steps with the full scenario.
+ */
+
+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 os from "node:os";
+import path from "node:path";
+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 } from "./stage.js";
+
+import { selectCustomBackend } from "./merchant-ui.js";
+import {
+ registerMerchant,
+ signInMerchant,
+ solveMfaChallenges,
+} from "./merchant-auth.js";
+import {
+ resolveMerchantOptions,
+ type MerchantOptions,
+ type MerchantResult,
+} from "./merchant-options.js";
+export { MYTOPS_STAGE_BASE_URL } from "./merchant-options.js";
+export type { MerchantOptions, MerchantResult } from "./merchant-options.js";
+
+const logger = new Logger("stagefright/merchant.ts");
+
+const SCENARIO_NAME = "merchant";
+
+/**
+ * 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}`;
+}
+
+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 runStagefrightMerchant(
+ input: MerchantOptions = {},
+): Promise<MerchantResult> {
+ const options = resolveMerchantOptions(input);
+ const { baseUrl, coverage, uiSource } = options;
+ 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;
+ let resolvedWebuiUrl = options.webuiUrl;
+ let walletProc: ChildProcess | undefined;
+ let walletClient: WalletClient | undefined;
+ let walletDir: string | undefined;
+
+ try {
+ if (uiSource === "repo") {
+ localServer = await startStaticServerMerchantWebui(baseUrl, {
+ experimental: true,
+ buildSource: "workspace",
+ });
+ resolvedWebuiUrl = localServer.url;
+ }
+ if (!resolvedWebuiUrl) throw Error("No merchant UI URL resolved");
+ const webuiUrl = resolvedWebuiUrl;
+ logger.info(
+ `merchant scenario: UI=${uiSource} (${webuiUrl}), coverage=${coverage}, backend=${baseUrl}`,
+ );
+ const mock2fa = new Mock2faReader(baseUrl);
+ await mock2fa.markCurrent([email, phone]);
+ const browserStage = await Stage.create(SCENARIO_NAME, {
+ ...options,
+ overallTimeoutMs:
+ options.overallTimeoutMs ??
+ (coverage === "full" ? DEFAULT_SCENARIO_TIMEOUT_MS : undefined),
+ });
+ const result: MerchantResult = {
+ baseUrl,
+ webuiUrl,
+ uiSource,
+ coverage,
+ instanceId,
+ password,
+ email,
+ phone,
+ screenshotDir: browserStage.screenshotDir,
+ };
+ const account = {
+ instanceId,
+ businessName,
+ password,
+ email,
+ phone,
+ timeoutMs,
+ };
+ await browserStage.run(async () => {
+ const stage = browserStage;
+ await stage.step("open the merchant portal", async (page) => {
+ await page.goto(webuiUrl);
+ if (uiSource === "custom")
+ await selectCustomBackend(page, baseUrl, timeoutMs);
+ await page.waitForSelector("#signin-account");
+ });
+ if (!options.existingAccount) {
+ await registerMerchant(stage, account, mock2fa);
+ }
+ if (coverage === "onboarding") return;
+ 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}`;
+ let deploymentCurrency = options.currency || "";
+ let configResponse: any = undefined;
+ try {
+ configResponse = 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 =
+ configResponse?.currency ||
+ configResponse?.currency_specification?.name ||
+ "CHF";
+ }
+ logger.info(`Deployment currency resolved to: '${deploymentCurrency}'`);
+
+ if (
+ configResponse?.exchanges &&
+ Array.isArray(configResponse.exchanges)
+ ) {
+ const supportedUrls = configResponse.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(", ")}`,
+ );
+ }
+ }
+
+ walletDir = fs.mkdtempSync(path.join(os.tmpdir(), "sf-wallet-"));
+ const walletSocketPath = path.join(walletDir, "wallet.sock");
+ const walletDbPath = path.join(walletDir, "wallet.db");
+ // 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" });
+ let walletSpawnError: Error | undefined;
+ walletProc.on("error", (error) => {
+ walletSpawnError = error;
+ });
+
+ // Connect to wallet daemon socket via IPC
+ let walletConnected = false;
+ for (let i = 0; i < 40; i++) {
+ if (walletSpawnError) throw walletSpawnError;
+ try {
+ await tryUnixConnect(walletSocketPath);
+ walletConnected = true;
+ break;
+ } catch {
+ await delayMs(200);
+ }
+ }
+
+ if (!walletConnected) {
+ if (walletSpawnError) throw walletSpawnError;
+ 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}`,
+ );
+
+ if (!options.existingAccount) {
+ 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 signInMerchant(stage, account, mock2fa);
+
+ // --- 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-webui:custom_merchant_backend_url",
+ );
+ const customUrl = customUrlRaw
+ ? JSON.parse(customUrlRaw)
+ : JSON.parse(
+ win.localStorage?.getItem("taler-merchant-webui:session") ??
+ "{}",
+ ).backendBaseUrl;
+ const sessionRaw = win.localStorage?.getItem(
+ "taler-merchant-webui: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) {
+ throw new Error(
+ "Could not obtain KYC auth instructions from the merchant session",
+ );
+ }
+ 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-webui:custom_merchant_backend_url",
+ );
+ const customUrl = customUrlRaw
+ ? JSON.parse(customUrlRaw)
+ : JSON.parse(
+ win.localStorage?.getItem(
+ "taler-merchant-webui:session",
+ ) ?? "{}",
+ ).backendBaseUrl;
+ const sessionRaw = win.localStorage?.getItem(
+ "taler-merchant-webui: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 preselected 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(
+ () => {
+ const match = String((globalThis as any).location.hash).match(
+ /\/orders\/([^/?#]+)/,
+ );
+ return Boolean(match && match[1] !== "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-webui:custom_merchant_backend_url",
+ );
+ const customUrl = customUrlRaw
+ ? JSON.parse(customUrlRaw)
+ : JSON.parse(
+ win.localStorage?.getItem("taler-merchant-webui:session") ??
+ "{}",
+ ).backendBaseUrl;
+ const sessionRaw = win.localStorage?.getItem(
+ "taler-merchant-webui: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
+ .getByRole("main")
+ .getByRole("img", { name: "Payment QR Code", exact: true })
+ .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, {
+ uri: 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 {
+ 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-webui:custom_merchant_backend_url",
+ );
+ const customUrl = customUrlRaw
+ ? JSON.parse(customUrlRaw)
+ : JSON.parse(
+ win.localStorage?.getItem("taler-merchant-webui:session") ??
+ "{}",
+ ).backendBaseUrl;
+ const sessionRaw = win.localStorage?.getItem(
+ "taler-merchant-webui: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-webui:custom_merchant_backend_url",
+ );
+ const customUrl = customUrlRaw
+ ? JSON.parse(customUrlRaw)
+ : JSON.parse(
+ win.localStorage?.getItem("taler-merchant-webui:session") ??
+ "{}",
+ ).backendBaseUrl;
+ const sessionRaw = win.localStorage?.getItem(
+ "taler-merchant-webui: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
+ .getByRole("main")
+ .getByRole("img", { name: "Payment QR Code", exact: true });
+ 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}'...`,
+ );
+ 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 page
+ .getByRole("menu", {
+ name: `Actions for ${templateName}`,
+ exact: true,
+ })
+ .getByRole("menuitem", { name: "Edit template", exact: true })
+ .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-advanced-tools", {
+ timeout: timeoutMs,
+ });
+ logger.info("Successfully opened Personalization screen.");
+ });
+
+ await stage.step(
+ "change advanced tools and date format without saving",
+ async (page) => {
+ await page.selectOption("#pref-dateformat", "dmy");
+ await page.locator("#pref-advanced-tools").check();
+
+ // 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,
+ );
+ const reloadedAdvanced = await page
+ .locator("#pref-advanced-tools")
+ .isChecked();
+ if (reloadedFmt !== "dmy" && !reloadedAdvanced) {
+ 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.locator("#pref-advanced-tools").check();
+
+ 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 savedAdvanced = await page
+ .locator("#pref-advanced-tools")
+ .isChecked();
+
+ if (savedFmt === "dmy" && savedAdvanced) {
+ logger.info(
+ "Successfully saved and verified updated date format ('dmy') and advanced tools.",
+ );
+ } else {
+ throw new Error(
+ `Failed to persist preferences! Got dateFormat=${savedFmt}, advancedTools=${savedAdvanced}`,
+ );
+ }
+ });
+
+ // --- 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 page
+ .getByRole("menu", {
+ name: `Actions for ${otpDeviceName}`,
+ exact: true,
+ })
+ .getByRole("menuitem", { name: "Edit", exact: true })
+ .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 page
+ .getByRole("menu", { name: `Actions for ${doomed}`, exact: true })
+ .getByRole("menuitem", { name: "Delete", exact: true })
+ .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}'.`);
+ });
+ });
+ logger.info(
+ `completed ${coverage} scenario for '${instanceId}' against ${baseUrl}`,
+ );
+ return result;
+ } finally {
+ try {
+ walletClient?.remoteWallet?.close();
+ if (
+ walletProc?.pid &&
+ walletProc.exitCode === null &&
+ walletProc.signalCode === null
+ ) {
+ await new Promise<void>((resolve) => {
+ const timer = setTimeout(() => walletProc!.kill("SIGKILL"), 5000);
+ walletProc!.once("close", () => {
+ clearTimeout(timer);
+ resolve();
+ });
+ walletProc!.kill("SIGTERM");
+ });
+ }
+ if (walletDir) fs.rmSync(walletDir, { recursive: true, force: true });
+ } finally {
+ await localServer?.close();
+ }
+ }
+}
+
+/**
+ * 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`,
+ );
+}
+
+/**
+ * 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.
+ *
+ * Read the version from the requirement context or the Taler-Terms-Version
+ * header of its terms URL, as the exchange KYC UI does. The ETag identifies
+ * the document representation and can differ from the accepted terms version.
+ * Retain the older guesses only for deployments that omit the version header.
+ */
+function tosVersionCandidates(
+ requirement: any,
+ termsVersion: string,
+ tosEtag: string,
+ explicit?: string,
+): string[] {
+ const candidates = [
+ explicit,
+ requirement.context?.tos_version,
+ requirement.context?.tosVersion,
+ termsVersion,
+ 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 = "";
+ let termsVersion = "";
+ 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);
+ termsVersion = tosResp.headers.get("Taler-Terms-Version") ?? "";
+ 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,
+ termsVersion,
+ 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/stage.ts b/packages/taler-harness/src/stagefright/stage.ts
@@ -198,8 +198,9 @@ export class Stage {
: (options.browserBinary ?? process.env.BROWSER_BINARY);
logger.info(`starting ${browserType} (${executablePath ?? "bundled"})`);
const restoreUrl = installNativeUrl();
+ let browser: Browser | undefined;
try {
- const browser = await pw[browserType].launch({
+ browser = await pw[browserType].launch({
headless: options.headless ?? true,
executablePath,
slowMo: options.slowMoMs,
@@ -230,7 +231,11 @@ export class Stage {
options,
);
} catch (e) {
- restoreUrl();
+ try {
+ await browser?.close();
+ } finally {
+ restoreUrl();
+ }
throw e;
}
}
diff --git a/packages/taler-merchant-webui/package.json b/packages/taler-merchant-webui/package.json
@@ -13,7 +13,7 @@
"dev": "./dev.mjs",
"test": "./test.mjs",
"test:exchange-status-browser": "node contrib/qa/exchange-status-check.mjs",
- "test:stagefright": "node ../taler-harness/bin/taler-harness.mjs stagefright merchant-webui",
+ "test:stagefright": "node ../taler-harness/bin/taler-harness.mjs stagefright merchant",
"lint": "../qa-tooling/bin/eslint.mjs .",
"i18n:source2po": "pogen extract && pogen merge",
"i18n:po2strings": "pogen emit",