commit e8f66122a4e471310ad6484796616e0401c3ce15
parent 374a4e47e9b110f6b364efe92340dce502f93ba1
Author: Florian Dold <dold@taler.net>
Date: Wed, 29 Jul 2026 16:34:39 +0200
harness: add a stagefright scenario for the mytops merchant onboarding
Drives the manual instructions from mytops-merchant-devtesting.rst in a
headless browser, screenshotting every step so a run against a remote
deployment can be reviewed afterwards.
Diffstat:
8 files changed, 844 insertions(+), 0 deletions(-)
diff --git a/packages/taler-harness/Makefile b/packages/taler-harness/Makefile
@@ -34,6 +34,13 @@ install-nodeps:
install ./dist/taler-harness-bundled.cjs $(DESTDIR)$(NODEDIR)/dist/
install ./dist/taler-harness-bundled.cjs.map $(DESTDIR)$(NODEDIR)/dist/
install ./bin/taler-harness.mjs $(DESTDIR)$(NODEDIR)/bin/
+# Playwright can't be bundled, the stagefright subcommands require it next to
+# the bundle instead.
+ if test -d node_modules/playwright-core; then \
+ install -d $(DESTDIR)$(NODEDIR)/node_modules; \
+ rm -rf $(DESTDIR)$(NODEDIR)/node_modules/playwright-core; \
+ cp -RL node_modules/playwright-core $(DESTDIR)$(NODEDIR)/node_modules/; \
+ fi
ln -sf ../lib/taler-harness/node_modules/taler-harness/bin/taler-harness.mjs $(DESTDIR)$(BINDIR)/taler-harness
deps:
pnpm install --frozen-lockfile --filter @gnu-taler/taler-harness...
diff --git a/packages/taler-harness/build.mjs b/packages/taler-harness/build.mjs
@@ -52,6 +52,10 @@ export const buildConfig = {
format: "cjs",
platform: "node",
sourcemap: true,
+ // Playwright locates its browsers and its driver relative to its own
+ // package directory, which doesn't survive bundling. Only the stagefright
+ // subcommands need it, and they import it lazily.
+ external: ["playwright-core"],
inject: ["src/import-meta-url.js"],
define: {
__VERSION__: `"${PACKAGE_VERSION}"`,
diff --git a/packages/taler-harness/package.json b/packages/taler-harness/package.json
@@ -41,6 +41,7 @@
"@gnu-taler/taler-util": "workspace:*",
"@gnu-taler/taler-wallet-core": "workspace:*",
"@types/selenium-webdriver": "4.35.5",
+ "playwright-core": "^1.62.0",
"postgres": "^3.4.5",
"selenium-webdriver": "4.40.0",
"tslib": "^2.6.2"
diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts
@@ -109,6 +109,10 @@ import {
runPlaygroundAdvancedTokens1,
runPlaygroundBlog,
} from "./playground.js";
+import {
+ MYTOPS_STAGE_BASE_URL,
+ runStagefrightMerchantMytops,
+} from "./stagefright/merchant-mytops.js";
const logger = new Logger("taler-harness:index.ts");
@@ -2209,6 +2213,72 @@ merchantCli
console.log(j2s(info));
});
+export const stagefrightCli = talerHarnessCli.subcommand(
+ "stagefright",
+ "stagefright",
+ {
+ help: "Walk through the manual testing instructions of a deployment in a browser.",
+ },
+);
+
+stagefrightCli
+ .subcommand("merchantMytops", "merchant-mytops", {
+ help: "Onboard an account in the my.taler-ops.ch merchant backend, as described in mytops-merchant-devtesting.rst.",
+ })
+ .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("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));
+ });
+
export function main() {
talerHarnessCli.run();
}
diff --git a/packages/taler-harness/src/stagefright/merchant-mytops.ts b/packages/taler-harness/src/stagefright/merchant-mytops.ts
@@ -0,0 +1,305 @@
+/*
+ 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-2fa/$ADDRESS 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-2fa/...) resolve below the
+ * deployment and not next to it.
+ */
+function normalizeBaseUrl(url: string): string {
+ return url.endsWith("/") ? url : `${url}/`;
+}
+
+type ChallengeDialog = "solve" | "choose" | "logged-in";
+
+/**
+ * Wait for whatever comes after submitting a form that needs MFA: a dialog
+ * asking for a code, a dialog asking which channel to use, or the portal
+ * itself once no challenge is left.
+ *
+ * The channel dialog is only expected right after submitting the form. Once
+ * a challenge has been solved it shows up again while the original request is
+ * retried, and answering it there would wait for a code that is never sent.
+ */
+async function waitForChallengeDialog(
+ page: Page,
+ timeoutMs: number,
+ allowChannelChoice: boolean,
+): Promise<ChallengeDialog> {
+ const deadline = Date.now() + timeoutMs;
+ let lastComplaint: string | undefined = undefined;
+ for (;;) {
+ if ((await page.locator('input[name="code"]').count()) > 0) {
+ return "solve";
+ }
+ if (
+ allowChannelChoice &&
+ (await page.locator('input[name="challenge_id"]').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(".message.is-danger");
+ 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 an MFA challenge nor the merchant portal showed up within ${timeoutMs}ms`,
+ );
+ }
+ await page.waitForTimeout(250);
+ }
+}
+
+/**
+ * Type the code into the dialog and wait for the dialog to disappear. The
+ * last digit submits the form, so there is no button to press.
+ */
+async function enterChallengeCode(
+ page: Page,
+ code: string,
+ timeoutMs: number,
+): Promise<void> {
+ const digits = page.locator('input[name="code"]');
+ const numDigits = await digits.count();
+ if (numDigits !== code.length) {
+ throw Error(
+ `the dialog asks for ${numDigits} digits, but the code has ${code.length}`,
+ );
+ }
+ // Hold on to the first input, so that we can tell this dialog apart from
+ // the one of the next challenge, which looks exactly the same.
+ const dialog = await digits.first().elementHandle();
+ for (let i = 0; i < numDigits; i++) {
+ await digits.nth(i).fill(code[i]);
+ }
+ if (!dialog) {
+ return;
+ }
+ try {
+ await page.waitForFunction((el) => !el.isConnected, dialog, {
+ timeout: timeoutMs,
+ });
+ } finally {
+ await dialog.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('input[name="username"]');
+ });
+
+ await stage.step("start the onboarding", async (page) => {
+ await page.click('a[href="#/account/new"]');
+ await page.waitForSelector('input[name="id"]');
+ });
+
+ await stage.step("fill in the account details", async (page) => {
+ await page.fill('input[name="id"]', instanceId);
+ await page.fill('input[name="name"]', businessName);
+ await page.fill('input[name="password"]', password);
+ await page.fill('input[name="repeat"]', password);
+ // Which of the two the deployment asks for depends on its configured
+ // mandatory TAN channels.
+ if ((await page.locator('input[name="email"]').count()) > 0) {
+ await page.fill('input[name="email"]', email);
+ }
+ if ((await page.locator('input[name="phone"]').count()) > 0) {
+ await page.fill('input[name="phone"]', phone);
+ }
+ // The toggle for the terms of service is styled away, so it can't be
+ // clicked the way a user would.
+ await page.locator('input[name="tos"]').dispatchEvent("click");
+ });
+
+ await stage.step("request the account", async (page) => {
+ await page.click("footer.modal-card-foot button.is-success");
+ });
+
+ // 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 dialog = await waitForChallengeDialog(
+ stage.page,
+ timeoutMs,
+ round === 1,
+ );
+ if (dialog === "logged-in") {
+ break;
+ }
+ if (dialog === "choose") {
+ await stage.step(
+ `pick the channel for challenge ${round}`,
+ async (page) => {
+ await page.locator('input[name="challenge_id"]').first().check();
+ await page.click(
+ ".modal.is-active .modal-card-foot button.is-success",
+ );
+ await page.waitForSelector('input[name="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) => {
+ await enterChallengeCode(page, 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();
+ if (!shown.includes(instanceId)) {
+ 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/mock2fa.ts b/packages/taler-harness/src/stagefright/mock2fa.ts
@@ -0,0 +1,165 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ 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/>
+ */
+
+/**
+ * Reader for the mock 2FA mailbox of a merchant deployment.
+ *
+ * Deployments used for testing do not really send messages to the
+ * test-NN@taler.net addresses and +417000000NN phone numbers; they write the
+ * message to $BASE_URL/mock-2fa/$ADDRESS instead.
+ */
+
+/**
+ * Imports.
+ */
+import { Logger } from "@gnu-taler/taler-util";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+
+const logger = new Logger("stagefright/mock2fa.ts");
+
+/**
+ * The TAN is transmitted as two groups of four digits.
+ */
+const CODE_REGEX = /([0-9]{4})-?([0-9]{4})/;
+
+/**
+ * The messages name the account the challenge belongs to. Matching on it
+ * keeps concurrent runs on the same test address from stealing each other's
+ * codes.
+ */
+const LOGIN_REGEX = /^Login:[ \t]*(\S+)[ \t]*$/m;
+
+export interface Mock2faMessage {
+ address: string;
+ /**
+ * The TAN, without the separating dash.
+ */
+ code: string;
+ message: string;
+}
+
+interface MailboxState {
+ lastModified: string | undefined;
+ code: string | undefined;
+}
+
+export interface WaitForCodeOptions {
+ /**
+ * Only accept messages that belong to that account. Messages without a
+ * login line are always accepted, since not every challenge names one.
+ */
+ login?: string;
+ timeoutMs?: number;
+ pollIntervalMs?: number;
+}
+
+export class Mock2faReader {
+ private http = createPlatformHttpLib();
+ private seen = new Map<string, MailboxState>();
+
+ constructor(private baseUrl: string) {}
+
+ private mailboxUrl(address: string): string {
+ return new URL(`mock-2fa/${encodeURIComponent(address)}`, this.baseUrl)
+ .href;
+ }
+
+ private async readMailbox(
+ address: string,
+ ): Promise<{ state: MailboxState; message: string } | undefined> {
+ const resp = await this.http.fetch(this.mailboxUrl(address), {
+ method: "GET",
+ });
+ if (resp.status !== 200) {
+ // The mailbox only exists once a message has been sent to it.
+ return undefined;
+ }
+ const message = await resp.text();
+ const codeMatch = CODE_REGEX.exec(message);
+ return {
+ state: {
+ lastModified: resp.headers.get("last-modified") ?? undefined,
+ code: codeMatch ? `${codeMatch[1]}${codeMatch[2]}` : undefined,
+ },
+ message,
+ };
+ }
+
+ /**
+ * Remember the current contents of the mailboxes. Everything recorded here
+ * is treated as stale by waitForNewCode, which is how codes left behind by
+ * an earlier run are ignored.
+ */
+ async markCurrent(addresses: string[]): Promise<void> {
+ for (const address of addresses) {
+ const res = await this.readMailbox(address);
+ this.seen.set(
+ address,
+ res?.state ?? { lastModified: undefined, code: undefined },
+ );
+ }
+ }
+
+ /**
+ * Wait until one of the mailboxes receives a message that wasn't there
+ * before, and return the code from it.
+ *
+ * The deployment and the harness do not necessarily agree on the current
+ * time, so freshness is decided by comparing against the previously seen
+ * state instead of against a local timestamp.
+ */
+ async waitForNewCode(
+ addresses: string[],
+ options: WaitForCodeOptions = {},
+ ): Promise<Mock2faMessage> {
+ const timeoutMs = options.timeoutMs ?? 60000;
+ const pollIntervalMs = options.pollIntervalMs ?? 1000;
+ const deadline = Date.now() + timeoutMs;
+ for (;;) {
+ for (const address of addresses) {
+ const res = await this.readMailbox(address);
+ if (!res || !res.state.code) {
+ continue;
+ }
+ const previous = this.seen.get(address);
+ const isNew =
+ !previous ||
+ previous.code !== res.state.code ||
+ previous.lastModified !== res.state.lastModified;
+ if (!isNew) {
+ continue;
+ }
+ // Whatever we saw here, we don't want to look at it again.
+ this.seen.set(address, res.state);
+ const loginMatch = LOGIN_REGEX.exec(res.message);
+ if (options.login && loginMatch && loginMatch[1] !== options.login) {
+ logger.warn(
+ `ignoring a 2FA message for '${loginMatch[1]}' in the mailbox of ${address}`,
+ );
+ continue;
+ }
+ logger.info(`got a 2FA code for ${address}`);
+ return { address, code: res.state.code, message: res.message };
+ }
+ if (Date.now() >= deadline) {
+ throw Error(
+ `no 2FA message showed up for ${addresses.join(", ")} within ${timeoutMs}ms`,
+ );
+ }
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
+ }
+ }
+}
diff --git a/packages/taler-harness/src/stagefright/stage.ts b/packages/taler-harness/src/stagefright/stage.ts
@@ -0,0 +1,282 @@
+/*
+ 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/>
+ */
+
+/**
+ * Browser session shared by the stagefright scenarios.
+ *
+ * A scenario is a sequence of named steps. Each step leaves a screenshot
+ * behind, so that a run against a remote deployment can be reviewed (or
+ * debugged) after the fact.
+ */
+
+/**
+ * Imports.
+ */
+import { Logger } from "@gnu-taler/taler-util";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import * as nodeUrl from "node:url";
+import type { Browser, BrowserContext, Page } from "playwright-core";
+
+const logger = new Logger("stagefright/stage.ts");
+
+/**
+ * Places where a system chromium is typically installed. Used when neither
+ * BROWSER_BINARY nor --browser-binary is given, so that the scenarios work
+ * without downloading playwright's own browser bundle.
+ */
+export const DEFAULT_TIMEOUT_MS = 60000;
+
+const CHROMIUM_CANDIDATES = [
+ "/usr/bin/chromium",
+ "/usr/bin/chromium-browser",
+ "/usr/bin/google-chrome",
+ "/usr/bin/google-chrome-stable",
+ "/snap/bin/chromium",
+];
+
+export interface StageOptions {
+ /**
+ * Directory for the screenshots. Created if it doesn't exist.
+ * Defaults to a fresh timestamped directory below the temp dir.
+ */
+ screenshotDir?: string;
+
+ /**
+ * Run without a visible browser window. Defaults to true.
+ */
+ headless?: boolean;
+
+ /**
+ * Chromium/Chrome executable. Defaults to $BROWSER_BINARY, then to a
+ * system chromium, then to playwright's own bundled browser.
+ */
+ browserBinary?: string;
+
+ /**
+ * Slow every browser interaction down by that many milliseconds, which
+ * makes a non-headless run watchable.
+ */
+ slowMoMs?: number;
+
+ /**
+ * How long to wait for the deployment at any single point.
+ */
+ timeoutMs?: number;
+
+ viewport?: { width: number; height: number };
+}
+
+export function findBrowserBinary(explicit?: string): string | undefined {
+ const configured = explicit ?? process.env.BROWSER_BINARY;
+ if (configured) {
+ return configured;
+ }
+ return CHROMIUM_CANDIDATES.find((c) => fs.existsSync(c));
+}
+
+/**
+ * Directory name that sorts chronologically and is safe on every platform.
+ */
+function timestampSlug(): string {
+ return new Date()
+ .toISOString()
+ .replace(/\.[0-9]+Z$/, "")
+ .replace(/[:]/g, "-");
+}
+
+export function defaultScreenshotDir(scenarioName: string): string {
+ return path.join(
+ os.tmpdir(),
+ "taler-stagefright",
+ `${scenarioName}-${timestampSlug()}`,
+ );
+}
+
+function slugify(title: string): string {
+ return (
+ title
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "")
+ .slice(0, 60) || "step"
+ );
+}
+
+/**
+ * taler-util installs its own portable URL implementation as the global one,
+ * and playwright chokes on it: URL.prototype.toString() returns
+ * "[object Object]", so every navigation ends up at an invalid address. Hand
+ * the native implementation back for as long as the browser is around.
+ *
+ * Returns a function that undoes the change.
+ */
+function installNativeUrl(): () => void {
+ const saved = {
+ URL: globalThis.URL,
+ URLSearchParams: globalThis.URLSearchParams,
+ };
+ Object.assign(globalThis, {
+ URL: nodeUrl.URL,
+ URLSearchParams: nodeUrl.URLSearchParams,
+ });
+ return () => {
+ Object.assign(globalThis, saved);
+ };
+}
+
+/**
+ * Load playwright lazily: the harness is bundled as a single file and
+ * playwright is kept external, so a missing installation must not break
+ * unrelated subcommands.
+ */
+async function loadPlaywright(): Promise<typeof import("playwright-core")> {
+ try {
+ return await import("playwright-core");
+ } catch (e) {
+ throw Error(
+ `unable to load playwright-core, please run 'pnpm install' in the taler-harness package (${e})`,
+ );
+ }
+}
+
+export class Stage {
+ private stepCounter = 0;
+ private failureDumped = false;
+
+ private constructor(
+ private browser: Browser,
+ private context: BrowserContext,
+ private restoreUrl: () => void,
+ public readonly page: Page,
+ public readonly screenshotDir: string,
+ public readonly scenarioName: string,
+ ) {}
+
+ static async create(
+ scenarioName: string,
+ options: StageOptions = {},
+ ): Promise<Stage> {
+ const pw = await loadPlaywright();
+ const screenshotDir =
+ options.screenshotDir ?? defaultScreenshotDir(scenarioName);
+ fs.mkdirSync(screenshotDir, { recursive: true });
+ const executablePath = findBrowserBinary(options.browserBinary);
+ logger.info(`starting chromium (${executablePath ?? "bundled"})`);
+ const restoreUrl = installNativeUrl();
+ try {
+ const browser = await pw.chromium.launch({
+ headless: options.headless ?? true,
+ executablePath,
+ slowMo: options.slowMoMs,
+ });
+ const context = await browser.newContext({
+ viewport: options.viewport ?? { width: 1280, height: 1024 },
+ });
+ const page = await context.newPage();
+ page.setDefaultTimeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
+ page.on("pageerror", (e) => {
+ logger.warn(`uncaught error in page: ${e.message}`);
+ });
+ logger.info(`writing screenshots to ${screenshotDir}`);
+ return new Stage(
+ browser,
+ context,
+ restoreUrl,
+ page,
+ screenshotDir,
+ scenarioName,
+ );
+ } catch (e) {
+ restoreUrl();
+ throw e;
+ }
+ }
+
+ /**
+ * Take a screenshot of the current page. Returns the file name.
+ */
+ async screenshot(label: string): Promise<string> {
+ const index = String(++this.stepCounter).padStart(3, "0");
+ const file = path.join(
+ this.screenshotDir,
+ `${index}-${slugify(label)}.png`,
+ );
+ await this.page.screenshot({ path: file, fullPage: true });
+ logger.info(`screenshot: ${file}`);
+ return file;
+ }
+
+ /**
+ * Keep a screenshot and the DOM of whatever the browser is showing right
+ * now. Usually that is the only evidence left of what went wrong, so this
+ * never fails on its own and hides the error it is called for.
+ */
+ private async dumpFailure(label: string): Promise<void> {
+ this.failureDumped = true;
+ try {
+ const shot = await this.screenshot(label);
+ fs.writeFileSync(
+ shot.replace(/\.png$/, ".html"),
+ await this.page.content(),
+ );
+ } catch (e) {
+ logger.warn(`could not capture the state of the page: ${e}`);
+ }
+ }
+
+ /**
+ * Run one step of the scenario and screenshot the result.
+ */
+ async step<T>(title: string, fn: (page: Page) => Promise<T>): Promise<T> {
+ logger.info(`step: ${title}`);
+ try {
+ const result = await fn(this.page);
+ await this.screenshot(title);
+ return result;
+ } catch (e) {
+ await this.dumpFailure(`${title}-failed`);
+ throw e;
+ }
+ }
+
+ /**
+ * Run a whole scenario, so that a failure between the steps is captured as
+ * well, and close the browser afterwards.
+ */
+ async run<T>(fn: () => Promise<T>): Promise<T> {
+ try {
+ return await fn();
+ } catch (e) {
+ if (!this.failureDumped) {
+ await this.dumpFailure("failed");
+ }
+ throw e;
+ } finally {
+ await this.close();
+ }
+ }
+
+ async close(): Promise<void> {
+ try {
+ await this.context.close();
+ await this.browser.close();
+ } finally {
+ this.restoreUrl();
+ }
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
@@ -541,6 +541,9 @@ importers:
'@types/selenium-webdriver':
specifier: 4.35.5
version: 4.35.5
+ playwright-core:
+ specifier: ^1.62.0
+ version: 1.62.0
postgres:
specifier: ^3.4.5
version: 3.4.5
@@ -2487,6 +2490,11 @@ packages:
resolution: {integrity: sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==}
engines: {node: '>= 6'}
+ playwright-core@1.62.0:
+ resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==}
+ engines: {node: '>=20'}
+ hasBin: true
+
polished@4.2.2:
resolution: {integrity: sha512-Sz2Lkdxz6F2Pgnpi9U5Ng/WdWAUZxmHrNPoVlm3aAemxoy2Qy7LGjQg4uf8qKelDAUW94F4np3iH2YPf2qefcQ==}
engines: {node: '>=10'}
@@ -4726,6 +4734,8 @@ snapshots:
pirates@4.0.5: {}
+ playwright-core@1.62.0: {}
+
polished@4.2.2:
dependencies:
'@babel/runtime': 7.29.2