commit 5e20fe15f931203ff5e31f6fc64e582a615b5579
parent 8999243646f1ec4dab6c0d84d47564ae982748da
Author: Florian Dold <dold@taler.net>
Date: Thu, 13 Aug 2026 12:47:15 +0200
taler-harness: support Firefox WebDriver scenarios
Diffstat:
3 files changed, 670 insertions(+), 7 deletions(-)
diff --git a/packages/taler-harness/build.mjs b/packages/taler-harness/build.mjs
@@ -52,10 +52,9 @@ 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"],
+ // Browser automation libraries locate browsers and driver helpers relative
+ // to their own package directories, which does not survive bundling.
+ external: ["playwright-core", "selenium-webdriver", "selenium-webdriver/*"],
inject: ["src/import-meta-url.js"],
define: {
__VERSION__: `"${PACKAGE_VERSION}"`,
diff --git a/packages/taler-harness/src/stagefright/stage.ts b/packages/taler-harness/src/stagefright/stage.ts
@@ -50,6 +50,16 @@ const CHROMIUM_CANDIDATES = [
];
export interface StageOptions {
+ /** Browser engine used for the scenario. Defaults to Chromium. */
+ browserType?: "chromium" | "firefox";
+
+ /**
+ * Run in an off-the-record browser context. Chromium contexts are already
+ * incognito; Firefox additionally needs permanent private browsing enabled
+ * in its temporary automation profile.
+ */
+ privateBrowsing?: boolean;
+
/**
* Directory for the screenshots. Created if it doesn't exist.
* Defaults to a fresh timestamped directory below the temp dir.
@@ -175,14 +185,25 @@ export class Stage {
const screenshotDir =
options.screenshotDir ?? defaultScreenshotDir(scenarioName);
fs.mkdirSync(screenshotDir, { recursive: true });
- const executablePath = findBrowserBinary(options.browserBinary);
- logger.info(`starting chromium (${executablePath ?? "bundled"})`);
+ const browserType = options.browserType ?? "chromium";
+ const executablePath =
+ browserType === "chromium"
+ ? findBrowserBinary(options.browserBinary)
+ : (options.browserBinary ?? process.env.BROWSER_BINARY);
+ logger.info(`starting ${browserType} (${executablePath ?? "bundled"})`);
const restoreUrl = installNativeUrl();
try {
- const browser = await pw.chromium.launch({
+ const browser = await pw[browserType].launch({
headless: options.headless ?? true,
executablePath,
slowMo: options.slowMoMs,
+ ...(browserType === "firefox" && options.privateBrowsing
+ ? {
+ firefoxUserPrefs: {
+ "browser.privatebrowsing.autostart": true,
+ },
+ }
+ : {}),
});
const context = await browser.newContext({
viewport: options.viewport ?? { width: 1280, height: 1024 },
diff --git a/packages/taler-harness/src/stagefright/webdriver-stage.ts b/packages/taler-harness/src/stagefright/webdriver-stage.ts
@@ -0,0 +1,643 @@
+/*
+ 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.
+*/
+
+/**
+ * The stock Firefox counterpart to Stage.
+ *
+ * Playwright's Firefox transport only works with its patched Firefox build.
+ * This adapter intentionally exposes the small Playwright Page/Locator subset
+ * used by the wallet-webui scenarios while driving an unmodified Firefox ESR
+ * through WebDriver BiDi.
+ */
+
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import type { Page } from "playwright-core";
+import {
+ Builder,
+ Key,
+ until,
+ type WebDriver,
+ type WebElement,
+} from "selenium-webdriver";
+import ScriptManager from "selenium-webdriver/bidi/scriptManager.js";
+import * as Firefox from "selenium-webdriver/firefox.js";
+import {
+ DEFAULT_TIMEOUT_MS,
+ defaultScreenshotDir,
+ type StageOptions,
+} from "./stage.js";
+
+type TextMatcher = string | RegExp;
+
+interface LocatorOptions {
+ hasText?: TextMatcher;
+}
+
+interface RoleOptions {
+ name?: TextMatcher;
+ exact?: boolean;
+ level?: number;
+}
+
+interface WaitOptions {
+ timeout?: number;
+ state?: "attached" | "visible";
+}
+
+function slugify(title: string): string {
+ return (
+ title
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "")
+ .slice(0, 60) || "step"
+ );
+}
+
+function matcherData(matcher: TextMatcher | undefined, exact = false) {
+ if (matcher instanceof RegExp) {
+ return {
+ source: matcher.source,
+ flags: matcher.flags,
+ exact: false,
+ };
+ }
+ return { text: matcher, exact };
+}
+
+const queryScript = `
+const roots = arguments[0].length ? arguments[0] : [document];
+const query = arguments[1];
+const normalize = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim();
+const matches = (value, matcher) => {
+ const normalized = normalize(value);
+ if (matcher.source !== undefined) {
+ return new RegExp(matcher.source, matcher.flags).test(normalized);
+ }
+ if (matcher.text === undefined) return true;
+ return matcher.exact
+ ? normalized === matcher.text
+ : normalized.includes(matcher.text);
+};
+const implicitRole = (element) => {
+ const tag = element.localName;
+ if (/^h[1-6]$/.test(tag)) return 'heading';
+ if (tag === 'button') return 'button';
+ if (tag === 'a' && element.hasAttribute('href')) return 'link';
+ if (tag === 'select') return 'combobox';
+ if (tag === 'textarea') return 'textbox';
+ if (tag === 'input') {
+ const type = (element.getAttribute('type') || 'text').toLowerCase();
+ if (type === 'button' || type === 'submit' || type === 'reset') return 'button';
+ if (type === 'checkbox') return 'checkbox';
+ if (type === 'radio') return 'radio';
+ if (type !== 'hidden') return 'textbox';
+ }
+ return undefined;
+};
+const accessibleName = (element) => {
+ const aria = element.getAttribute('aria-label');
+ if (aria) return normalize(aria);
+ const labelledBy = element.getAttribute('aria-labelledby');
+ if (labelledBy) {
+ return normalize(labelledBy.split(/\\s+/).map((id) => document.getElementById(id)?.textContent || '').join(' '));
+ }
+ if (element.id) {
+ const label = Array.from(document.querySelectorAll('label')).find((candidate) => candidate.htmlFor === element.id);
+ if (label) return normalize(label.textContent);
+ }
+ const wrappingLabel = element.closest('label');
+ if (wrappingLabel) return normalize(wrappingLabel.textContent);
+ return normalize(element.getAttribute('alt') || element.getAttribute('title') || element.value || element.textContent);
+};
+const descendants = (root) => Array.from(root.querySelectorAll('*'));
+let result = [];
+for (const root of roots) {
+ let candidates;
+ if (query.kind === 'css') {
+ candidates = Array.from(root.querySelectorAll(query.selector));
+ } else if (query.kind === 'xpath') {
+ const snapshot = document.evaluate(query.selector, root, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
+ candidates = Array.from({ length: snapshot.snapshotLength }, (_, index) => snapshot.snapshotItem(index));
+ } else if (query.kind === 'role') {
+ candidates = descendants(root).filter((element) => {
+ const role = element.getAttribute('role') || implicitRole(element);
+ if (role !== query.role) return false;
+ if (query.level !== undefined) {
+ const level = Number(element.getAttribute('aria-level') || (/^h[1-6]$/.test(element.localName) ? element.localName.slice(1) : 0));
+ if (level !== query.level) return false;
+ }
+ return matches(accessibleName(element), query.matcher);
+ });
+ } else if (query.kind === 'label') {
+ candidates = descendants(root).filter((element) =>
+ ['button', 'input', 'meter', 'output', 'progress', 'select', 'textarea'].includes(element.localName) &&
+ matches(accessibleName(element), query.matcher)
+ );
+ } else {
+ const all = descendants(root);
+ candidates = all.filter((element) => matches(element.textContent, query.matcher));
+ candidates = candidates.filter((element) => !Array.from(element.children).some((child) => matches(child.textContent, query.matcher)));
+ }
+ result.push(...candidates);
+}
+if (query.hasText) result = result.filter((element) => matches(element.textContent, query.hasText));
+return result;
+`;
+
+type Query =
+ | { kind: "css"; selector: string; hasText?: ReturnType<typeof matcherData> }
+ | {
+ kind: "xpath";
+ selector: string;
+ hasText?: ReturnType<typeof matcherData>;
+ }
+ | {
+ kind: "role";
+ role: string;
+ matcher: ReturnType<typeof matcherData>;
+ level?: number;
+ hasText?: ReturnType<typeof matcherData>;
+ }
+ | {
+ kind: "label" | "text";
+ matcher: ReturnType<typeof matcherData>;
+ hasText?: ReturnType<typeof matcherData>;
+ };
+
+class WebDriverLocator {
+ constructor(
+ private readonly page: WebDriverPage,
+ private readonly query: Query,
+ private readonly parent?: WebDriverLocator,
+ private readonly index?: number | "last",
+ ) {}
+
+ private async all(): Promise<WebElement[]> {
+ const roots = this.parent ? await this.parent.all() : [];
+ const elements = (await this.page.driver.executeScript(
+ queryScript,
+ roots,
+ this.query,
+ )) as WebElement[];
+ if (this.index === "last") return elements.slice(-1);
+ if (this.index !== undefined)
+ return elements.slice(this.index, this.index + 1);
+ return elements;
+ }
+
+ private async one(timeout = this.page.timeoutMs): Promise<WebElement> {
+ try {
+ return await this.page.driver.wait(
+ async () => (await this.all())[0],
+ timeout,
+ );
+ } catch (error) {
+ throw Error(
+ `WebDriver locator timed out: ${JSON.stringify(this.query)}`,
+ { cause: error },
+ );
+ }
+ }
+
+ first(): WebDriverLocator {
+ return new WebDriverLocator(this.page, this.query, this.parent, 0);
+ }
+
+ last(): WebDriverLocator {
+ return new WebDriverLocator(this.page, this.query, this.parent, "last");
+ }
+
+ nth(index: number): WebDriverLocator {
+ return new WebDriverLocator(this.page, this.query, this.parent, index);
+ }
+
+ filter(options: LocatorOptions): WebDriverLocator {
+ return new WebDriverLocator(
+ this.page,
+ { ...this.query, hasText: matcherData(options.hasText) },
+ this.parent,
+ this.index,
+ );
+ }
+
+ locator(selector: string, options: LocatorOptions = {}): WebDriverLocator {
+ return new WebDriverLocator(
+ this.page,
+ selector.startsWith("xpath=")
+ ? {
+ kind: "xpath",
+ selector: selector.slice("xpath=".length),
+ hasText: matcherData(options.hasText),
+ }
+ : {
+ kind: "css",
+ selector,
+ hasText: matcherData(options.hasText),
+ },
+ this,
+ );
+ }
+
+ async waitFor(options: WaitOptions = {}): Promise<void> {
+ const element = await this.one(options.timeout);
+ if (options.state !== "attached") {
+ await this.page.driver.wait(
+ until.elementIsVisible(element),
+ options.timeout ?? this.page.timeoutMs,
+ );
+ }
+ }
+
+ async click(): Promise<void> {
+ const element = await this.one();
+ await this.page.driver.executeScript(
+ "arguments[0].scrollIntoView({block:'center',inline:'nearest'})",
+ element,
+ );
+ await this.page.driver.wait(
+ until.elementIsVisible(element),
+ this.page.timeoutMs,
+ );
+ await element.click();
+ }
+
+ async fill(value: string): Promise<void> {
+ const element = await this.one();
+ await this.page.driver.executeScript(
+ `const element = arguments[0];
+ const value = arguments[1];
+ const descriptor = Object.getOwnPropertyDescriptor(
+ element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype,
+ 'value',
+ );
+ descriptor.set.call(element, value);
+ element.dispatchEvent(new Event('input', { bubbles: true }));
+ element.dispatchEvent(new Event('change', { bubbles: true }));`,
+ element,
+ value,
+ );
+ }
+
+ async press(key: string): Promise<void> {
+ const element = await this.one();
+ await element.sendKeys(key === "Enter" ? Key.ENTER : key);
+ }
+
+ async selectOption(value: string): Promise<void> {
+ await this.page.driver.executeScript(
+ `const element = arguments[0];
+ element.value = arguments[1];
+ element.dispatchEvent(new Event('input', { bubbles: true }));
+ element.dispatchEvent(new Event('change', { bubbles: true }));`,
+ await this.one(),
+ value,
+ );
+ }
+
+ async setInputFiles(file: string): Promise<void> {
+ await (await this.one()).sendKeys(path.resolve(file));
+ }
+
+ async count(): Promise<number> {
+ return (await this.all()).length;
+ }
+
+ async textContent(): Promise<string | null> {
+ return this.page.driver.executeScript(
+ "return arguments[0].textContent",
+ await this.one(),
+ ) as Promise<string | null>;
+ }
+
+ async innerText(): Promise<string> {
+ return this.page.driver.executeScript(
+ "return arguments[0].innerText",
+ await this.one(),
+ ) as Promise<string>;
+ }
+
+ async inputValue(): Promise<string> {
+ return this.page.driver.executeScript(
+ "return arguments[0].value",
+ await this.one(),
+ ) as Promise<string>;
+ }
+
+ async getAttribute(name: string): Promise<string | null> {
+ return (await this.one()).getAttribute(name);
+ }
+
+ async isDisabled(): Promise<boolean> {
+ return !(await (await this.one()).isEnabled());
+ }
+
+ async isVisible(): Promise<boolean> {
+ const element = (await this.all())[0];
+ return element ? element.isDisplayed() : false;
+ }
+
+ async elementHandle(): Promise<WebDriverElementHandle | null> {
+ const element = (await this.all())[0];
+ return element ? new WebDriverElementHandle(this.page, element) : null;
+ }
+}
+
+class WebDriverElementHandle {
+ constructor(
+ private readonly page: WebDriverPage,
+ readonly element: WebElement,
+ ) {}
+
+ async evaluate<T>(fn: (element: any) => T): Promise<T> {
+ return this.page.driver.executeScript(
+ `return (${fn.toString()})(arguments[0])`,
+ this.element,
+ ) as Promise<T>;
+ }
+}
+
+class WebDriverDownload {
+ constructor(private readonly source: string) {}
+
+ async saveAs(destination: string): Promise<void> {
+ fs.copyFileSync(this.source, destination);
+ }
+}
+
+class WebDriverPage {
+ private scriptManager?: Awaited<ReturnType<typeof ScriptManager>>;
+
+ constructor(
+ public readonly driver: WebDriver,
+ public readonly timeoutMs: number,
+ private readonly downloadDir: string,
+ ) {}
+
+ locator(selector: string, options: LocatorOptions = {}): WebDriverLocator {
+ return new WebDriverLocator(
+ this,
+ selector.startsWith("xpath=")
+ ? {
+ kind: "xpath",
+ selector: selector.slice("xpath=".length),
+ hasText: matcherData(options.hasText),
+ }
+ : {
+ kind: "css",
+ selector,
+ hasText: matcherData(options.hasText),
+ },
+ );
+ }
+
+ getByRole(role: string, options: RoleOptions = {}): WebDriverLocator {
+ return new WebDriverLocator(this, {
+ kind: "role",
+ role,
+ matcher: matcherData(options.name, options.exact),
+ level: options.level,
+ });
+ }
+
+ getByLabel(
+ label: TextMatcher,
+ options: { exact?: boolean } = {},
+ ): WebDriverLocator {
+ return new WebDriverLocator(this, {
+ kind: "label",
+ matcher: matcherData(label, options.exact),
+ });
+ }
+
+ getByText(
+ text: TextMatcher,
+ options: { exact?: boolean } = {},
+ ): WebDriverLocator {
+ return new WebDriverLocator(this, {
+ kind: "text",
+ matcher: matcherData(text, options.exact),
+ });
+ }
+
+ async goto(url: string): Promise<void> {
+ await this.driver.get(url);
+ }
+
+ async reload(): Promise<void> {
+ await this.driver.navigate().refresh();
+ }
+
+ async title(): Promise<string> {
+ return this.driver.getTitle();
+ }
+
+ async content(): Promise<string> {
+ return this.driver.getPageSource();
+ }
+
+ async evaluate<T, A = undefined>(
+ fn: (arg: A) => T | Promise<T>,
+ arg?: A,
+ ): Promise<T> {
+ const envelope = (await this.driver.executeAsyncScript(
+ `const argument = arguments[0];
+ const done = arguments[arguments.length - 1];
+ const fn = (${fn.toString()});
+ Promise.resolve()
+ .then(() => fn(argument))
+ .then(
+ (value) => done({ ok: true, value }),
+ (error) => done({
+ ok: false,
+ error: String(error && (error.stack || error.message) || error),
+ }),
+ );`,
+ arg ?? null,
+ )) as { ok: boolean; value?: T; error?: string };
+ if (!envelope.ok)
+ throw Error(envelope.error ?? "browser evaluation failed");
+ return envelope.value as T;
+ }
+
+ async addInitScript(fn: () => unknown): Promise<void> {
+ if (!this.scriptManager) {
+ this.scriptManager = await ScriptManager(
+ await this.driver.getWindowHandle(),
+ this.driver as never,
+ );
+ }
+ // The Selenium type declaration says Function, while WebDriver BiDi's
+ // wire format (and Selenium's implementation) requires its source text.
+ await this.scriptManager.addPreloadScript(fn.toString() as never);
+ }
+
+ async waitForTimeout(milliseconds: number): Promise<void> {
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
+ }
+
+ async waitForFunction(
+ fn: (arg: unknown) => boolean,
+ arg: unknown,
+ ): Promise<void> {
+ const scriptArgument =
+ arg instanceof WebDriverElementHandle ? arg.element : arg;
+ await this.driver.wait(
+ async () =>
+ Boolean(
+ await this.driver.executeScript(
+ `return (${fn.toString()})(arguments[0])`,
+ scriptArgument,
+ ),
+ ),
+ this.timeoutMs,
+ );
+ }
+
+ waitForEvent(event: "download"): Promise<WebDriverDownload> {
+ if (event !== "download")
+ throw Error(`unsupported WebDriver event ${event}`);
+ const before = new Set(fs.readdirSync(this.downloadDir));
+ return this.driver.wait(async () => {
+ for (const name of fs.readdirSync(this.downloadDir)) {
+ if (before.has(name) || name.endsWith(".part")) continue;
+ const candidate = path.join(this.downloadDir, name);
+ if (fs.statSync(candidate).isFile())
+ return new WebDriverDownload(candidate);
+ }
+ return false;
+ }, this.timeoutMs) as Promise<WebDriverDownload>;
+ }
+}
+
+export class WebDriverStage {
+ private stepCounter = 0;
+ private failureDumped = false;
+
+ private constructor(
+ private readonly driver: WebDriver,
+ private readonly webdriverPage: WebDriverPage,
+ private readonly downloadDir: string,
+ public readonly screenshotDir: string,
+ public readonly scenarioName: string,
+ ) {}
+
+ get page(): Page {
+ return this.webdriverPage as unknown as Page;
+ }
+
+ static async create(
+ scenarioName: string,
+ options: StageOptions = {},
+ ): Promise<WebDriverStage> {
+ if (options.browserType && options.browserType !== "firefox") {
+ throw Error("the WebDriver stage currently supports Firefox only");
+ }
+ const screenshotDir =
+ options.screenshotDir ?? defaultScreenshotDir(scenarioName);
+ const downloadDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), "taler-webdriver-downloads-"),
+ );
+ fs.mkdirSync(screenshotDir, { recursive: true });
+ const firefoxOptions = new Firefox.Options();
+ if (options.headless ?? true) firefoxOptions.addArguments("-headless");
+ firefoxOptions.enableBidi();
+ firefoxOptions.setPreference("browser.download.folderList", 2);
+ firefoxOptions.setPreference("browser.download.dir", downloadDir);
+ firefoxOptions.setPreference("browser.download.useDownloadDir", true);
+ firefoxOptions.setPreference(
+ "browser.helperApps.neverAsk.saveToDisk",
+ "application/json,application/octet-stream",
+ );
+ firefoxOptions.setPreference("pdfjs.disabled", true);
+ if (options.privateBrowsing) {
+ firefoxOptions.setPreference("browser.privatebrowsing.autostart", true);
+ }
+ const binary = options.browserBinary ?? process.env.BROWSER_BINARY;
+ if (binary) firefoxOptions.setBinary(binary);
+ const service = new Firefox.ServiceBuilder().addArguments(
+ "--allow-system-access",
+ );
+ const driver = await new Builder()
+ .forBrowser("firefox")
+ .setFirefoxOptions(firefoxOptions)
+ .setFirefoxService(service)
+ .build();
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
+ await driver.manage().setTimeouts({
+ implicit: 0,
+ pageLoad: timeoutMs,
+ script: Math.max(timeoutMs, 240_000),
+ });
+ const viewport = options.viewport ?? { width: 1280, height: 1024 };
+ await driver.manage().window().setRect(viewport);
+ return new WebDriverStage(
+ driver,
+ new WebDriverPage(driver, timeoutMs, downloadDir),
+ downloadDir,
+ screenshotDir,
+ scenarioName,
+ );
+ }
+
+ async screenshot(label: string): Promise<string> {
+ const index = String(++this.stepCounter).padStart(3, "0");
+ const file = path.join(
+ this.screenshotDir,
+ `${index}-${slugify(label)}.png`,
+ );
+ fs.writeFileSync(file, await this.driver.takeScreenshot(), "base64");
+ return file;
+ }
+
+ 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.driver.getPageSource(),
+ );
+ } catch {
+ // Preserve the original scenario failure.
+ }
+ }
+
+ async step<T>(title: string, fn: (page: Page) => Promise<T>): Promise<T> {
+ try {
+ const result = await fn(this.page);
+ await this.screenshot(title);
+ return result;
+ } catch (error) {
+ await this.dumpFailure(`${title}-failed`);
+ throw error;
+ }
+ }
+
+ async run<T>(fn: () => Promise<T>): Promise<T> {
+ try {
+ return await fn();
+ } catch (error) {
+ if (!this.failureDumped) await this.dumpFailure("failed");
+ throw error;
+ } finally {
+ await this.close();
+ }
+ }
+
+ async close(): Promise<void> {
+ try {
+ await this.driver.quit();
+ } finally {
+ fs.rmSync(this.downloadDir, { recursive: true, force: true });
+ }
+ }
+}