taler-typescript-core

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

commit ca493c76f40e9d9e7c6b28a3bfb528a7ef8362a1
parent a04e88fc2021cd00e52e2f76c216764374d6a3cc
Author: Florian Dold <dold@taler.net>
Date:   Mon,  2 Nov 2026 20:45:18 +0100

taler-harness: implement negative waits for WebDriver

Diffstat:
Apackages/taler-harness/src/stagefright/webdriver-stage.test.ts | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/stagefright/webdriver-stage.ts | 53++++++++++++++++++++++++++++++++++++++++++++++++-----
2 files changed, 135 insertions(+), 5 deletions(-)

diff --git a/packages/taler-harness/src/stagefright/webdriver-stage.test.ts b/packages/taler-harness/src/stagefright/webdriver-stage.test.ts @@ -0,0 +1,87 @@ +/* + 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. +*/ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { WebDriver, error, type WebElement } from "selenium-webdriver"; +import { WebDriverPage } from "./webdriver-stage.js"; + +function fixture(results: Array<WebElement[] | Error>) { + let queries = 0; + const driver = { + executeScript: async () => { + const result = results[Math.min(queries++, results.length - 1)]; + if (result instanceof Error) throw result; + return result; + }, + // Exercise Selenium's real polling and error propagation. + wait: WebDriver.prototype.wait, + } as unknown as WebDriver; + return { + locator: new WebDriverPage(driver, 20, "").locator(".gate"), + queries: () => queries, + }; +} + +const visible = { isDisplayed: async () => true } as WebElement; +const hidden = { isDisplayed: async () => false } as WebElement; + +test("negative waits accept absence and distinguish hidden from detached", async () => { + await fixture([[]]).locator.waitFor({ state: "detached" }); + await fixture([[]]).locator.waitFor({ state: "hidden" }); + await fixture([[hidden]]).locator.waitFor({ state: "hidden" }); + await assert.rejects( + fixture([[visible]]).locator.waitFor({ state: "hidden" }), + /timed out/, + ); + await assert.rejects( + fixture([[hidden]]).locator.waitFor({ state: "detached" }), + /timed out/, + ); +}); + +test("detached waits re-query until removal", async () => { + const f = fixture([[visible], []]); + await f.locator.waitFor({ state: "detached", timeout: 1000 }); + assert.equal(f.queries(), 2); +}); + +test("hidden waits re-query stale elements instead of accepting a visible replacement", async () => { + const stale = { + isDisplayed: async () => { + throw new error.StaleElementReferenceError(); + }, + } as unknown as WebElement; + const f = fixture([[stale], [visible]]); + await assert.rejects(f.locator.waitFor({ state: "hidden" }), /timed out/); + assert.ok(f.queries() >= 2); +}); + +test("negative waits retry stale queries", async () => { + const f = fixture([new error.StaleElementReferenceError(), []]); + await f.locator.waitFor({ state: "detached", timeout: 1000 }); + assert.equal(f.queries(), 2); +}); + +test("negative waits propagate query and visibility failures", async () => { + const failure = new error.NoSuchSessionError("session lost"); + const broken = { + isDisplayed: async () => { + throw failure; + }, + } as unknown as WebElement; + for (const results of [[failure], [[broken]]] as Array< + Array<WebElement[] | Error> + >) { + await assert.rejects( + fixture(results).locator.waitFor({ state: "hidden" }), + (actual) => actual === failure, + ); + } +}); diff --git a/packages/taler-harness/src/stagefright/webdriver-stage.ts b/packages/taler-harness/src/stagefright/webdriver-stage.ts @@ -50,7 +50,7 @@ interface RoleOptions { interface WaitOptions { timeout?: number; - state?: "attached" | "visible"; + state?: "attached" | "detached" | "hidden" | "visible"; } function slugify(title: string): string { @@ -249,11 +249,54 @@ class WebDriverLocator { } async waitFor(options: WaitOptions = {}): Promise<void> { - const element = await this.one(options.timeout); - if (options.state !== "attached") { + const state = options.state ?? "visible"; + const timeout = options.timeout ?? this.page.timeoutMs; + if (state === "detached" || state === "hidden") { + selenium ??= await loadSelenium(); + // "detached" waits for the element to be gone and "hidden" also accepts + // an element that is still in the DOM but not displayed. Waiting for + // the element to exist first, as the attached states do, would make + // these two wait for the opposite of what was asked. + try { + await this.page.driver.wait(async () => { + let element: WebElement | undefined; + try { + element = (await this.all())[0]; + } catch (error) { + if (error instanceof selenium.error.StaleElementReferenceError) { + return false; + } + throw error; + } + if (!element) return true; + if (state === "detached") return false; + try { + return !(await element.isDisplayed()); + } catch (error) { + if (error instanceof selenium.error.StaleElementReferenceError) { + // Re-query: a visible replacement may already match the locator. + return false; + } + throw error; + } + }, timeout); + } catch (error) { + if (!(error instanceof selenium.error.TimeoutError)) throw error; + throw Error( + `WebDriver locator timed out: ${JSON.stringify({ + ...this.query, + state, + })}`, + { cause: error }, + ); + } + return; + } + const element = await this.one(timeout); + if (state !== "attached") { await this.page.driver.wait( selenium.until.elementIsVisible(element), - options.timeout ?? this.page.timeoutMs, + timeout, ); } } @@ -374,7 +417,7 @@ class WebDriverDownload { } } -class WebDriverPage { +export class WebDriverPage { private scriptManager?: Awaited<ReturnType<typeof ScriptManager>>; constructor(