taler-typescript-core

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

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:
Mpackages/taler-harness/Makefile | 7+++++++
Mpackages/taler-harness/build.mjs | 4++++
Mpackages/taler-harness/package.json | 1+
Mpackages/taler-harness/src/index.ts | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/stagefright/merchant-mytops.ts | 305++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/stagefright/mock2fa.ts | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/stagefright/stage.ts | 282+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpnpm-lock.yaml | 10++++++++++
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