taler-typescript-core

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

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:
Mpackages/taler-harness/build.mjs | 7+++----
Mpackages/taler-harness/src/stagefright/stage.ts | 27++++++++++++++++++++++++---
Apackages/taler-harness/src/stagefright/webdriver-stage.ts | 643+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 }); + } + } +}