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:
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(