From 14a6ee240c109b1b8f557c3297a09a4e7fd6bd9a Mon Sep 17 00:00:00 2001 From: Mohammad Saddam Hossain Date: Sun, 27 Sep 2026 12:16:38 +0530 Subject: [PATCH 1/3] spike: waitForNetworkIdle prototype (CDP + BiDi) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prototype per the 09-26 design note: CDP path on Chromium, BiDi path on browsers implementing HasBiDi (Firefox), throws on neither. Both paths track an in-flight request counter + last-activity timestamp; idle means the counter is 0 and quietFor has elapsed, bounded by an overall timeout. Verified manually against real headless Chrome (not part of the unit suite, no browser required there): - Static page: idle correctly at ~quietFor after load (545ms observed for quietFor=500ms). - Page with a request fired 200ms after load, taking 1.5s to respond: wait correctly extends past it (2361ms observed vs. a naive page-load-only wait), proving it isn't just measuring page load. Not verified against Firefox/BiDi locally — the firefox binary here is an unconfigured snap stub, no real browser available. Code path mirrors the CDP one exactly (in-flight counter + BeforeRequestSent/ ResponseCompleted/FetchError) but is unexercised. Spike only — not wired into WaitEngine's public API, no unit tests added (needs a real browser, which the existing suite deliberately avoids). Next: decide scope (wire into WaitEngine, add waitForResponse(urlPattern), pick BiDi-only vs BiDi+CDP everywhere) and verify the BiDi path against real Firefox before that. Co-Authored-By: Claude Sonnet 5 --- .../wait/NetworkIdleWaitSpike.java | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 src/main/java/com/seleniumboot/wait/NetworkIdleWaitSpike.java diff --git a/src/main/java/com/seleniumboot/wait/NetworkIdleWaitSpike.java b/src/main/java/com/seleniumboot/wait/NetworkIdleWaitSpike.java new file mode 100644 index 0000000..d38e11b --- /dev/null +++ b/src/main/java/com/seleniumboot/wait/NetworkIdleWaitSpike.java @@ -0,0 +1,170 @@ +package com.seleniumboot.wait; + +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.bidi.HasBiDi; +import org.openqa.selenium.bidi.module.Network; +import org.openqa.selenium.chromium.ChromiumDriver; +import org.openqa.selenium.devtools.DevTools; +import org.openqa.selenium.devtools.v153.network.model.LoadingFailed; +import org.openqa.selenium.devtools.v153.network.model.LoadingFinished; +import org.openqa.selenium.devtools.v153.network.model.RequestWillBeSent; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * SPIKE — not the public API. Prototype for the design note in ROADMAP.md + * (waitForNetworkIdle / waitForResponse): CDP on Chromium, BiDi on Firefox, throw on others. + * + *

Mechanism on both paths is the same shape: track an in-flight request counter and + * a "last activity" timestamp; idle means the counter is 0 and {@code quietFor} has + * elapsed since the last change, bounded by an overall timeout. + * + *

Manual run only (no browser in the unit-test suite) — see the {@code main} method. + */ +public final class NetworkIdleWaitSpike { + + private NetworkIdleWaitSpike() { + } + + /** + * Blocks until no network request has started or finished for {@code quietFor}, + * or throws once {@code timeout} elapses first. + */ + public static void waitForNetworkIdle(WebDriver driver, Duration quietFor, Duration timeout) { + if (driver instanceof ChromiumDriver chromium) { + waitViaCdp(chromium, quietFor, timeout); + } else if (driver instanceof HasBiDi) { + waitViaBidi(driver, quietFor, timeout); + } else { + throw new UnsupportedOperationException( + "waitForNetworkIdle needs CDP (Chrome/Edge) or BiDi support; got " + + driver.getClass().getSimpleName()); + } + } + + // ------------------------------------------------------------------ + // CDP path (Chrome / Edge) + // ------------------------------------------------------------------ + + private static void waitViaCdp(ChromiumDriver driver, Duration quietFor, Duration timeout) { + AtomicInteger inFlight = new AtomicInteger(0); + AtomicLongBox lastActivity = new AtomicLongBox(System.nanoTime()); + + try (DevTools devTools = driver.getDevTools()) { + devTools.createSession(); + devTools.send(org.openqa.selenium.devtools.v153.network.Network.enable( + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())); + + devTools.addListener(org.openqa.selenium.devtools.v153.network.Network.requestWillBeSent(), + (RequestWillBeSent event) -> { + inFlight.incrementAndGet(); + lastActivity.set(System.nanoTime()); + }); + devTools.addListener(org.openqa.selenium.devtools.v153.network.Network.loadingFinished(), + (LoadingFinished event) -> { + inFlight.decrementAndGet(); + lastActivity.set(System.nanoTime()); + }); + devTools.addListener(org.openqa.selenium.devtools.v153.network.Network.loadingFailed(), + (LoadingFailed event) -> { + inFlight.decrementAndGet(); + lastActivity.set(System.nanoTime()); + }); + + pollUntilIdle(inFlight, lastActivity, quietFor, timeout); + } + } + + // ------------------------------------------------------------------ + // BiDi path (Firefox, and any other BiDi-capable browser) + // ------------------------------------------------------------------ + + private static void waitViaBidi(WebDriver driver, Duration quietFor, Duration timeout) { + AtomicInteger inFlight = new AtomicInteger(0); + AtomicLongBox lastActivity = new AtomicLongBox(System.nanoTime()); + + try (Network network = new Network(driver)) { + network.onBeforeRequestSent(event -> { + inFlight.incrementAndGet(); + lastActivity.set(System.nanoTime()); + }); + network.onResponseCompleted(event -> { + inFlight.decrementAndGet(); + lastActivity.set(System.nanoTime()); + }); + network.onFetchError(event -> { + inFlight.decrementAndGet(); + lastActivity.set(System.nanoTime()); + }); + + pollUntilIdle(inFlight, lastActivity, quietFor, timeout); + } + } + + // ------------------------------------------------------------------ + // Shared poll loop + // ------------------------------------------------------------------ + + private static void pollUntilIdle(AtomicInteger inFlight, AtomicLongBox lastActivity, + Duration quietFor, Duration timeout) { + Instant deadline = Instant.now().plus(timeout); + while (Instant.now().isBefore(deadline)) { + long quietNanos = System.nanoTime() - lastActivity.get(); + if (inFlight.get() <= 0 && quietNanos >= quietFor.toNanos()) { + return; + } + sleep(100); + } + throw new RuntimeException("waitForNetworkIdle: timed out after " + timeout + + " with " + inFlight.get() + " request(s) still in flight"); + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** Tiny mutable long box — avoids AtomicLong just to store a nanoTime snapshot. */ + private static final class AtomicLongBox { + private volatile long value; + + AtomicLongBox(long value) { + this.value = value; + } + + void set(long value) { + this.value = value; + } + + long get() { + return value; + } + } + + // ------------------------------------------------------------------ + // Manual spike runner — not a unit test, needs a real browser + // ------------------------------------------------------------------ + + public static void main(String[] args) { + String target = args.length > 0 ? args[0] : "chrome"; + WebDriver driver = target.equals("firefox") + ? new org.openqa.selenium.firefox.FirefoxDriver() + : new org.openqa.selenium.chrome.ChromeDriver(); + try { + driver.get("https://example.com"); + long start = System.nanoTime(); + waitForNetworkIdle(driver, Duration.ofMillis(500), Duration.ofSeconds(10)); + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + System.out.println("[" + target + "] network idle reached after " + elapsedMs + "ms"); + } finally { + driver.quit(); + } + } +} From a0a176671c6110751b31ec23c45e672c713c916f Mon Sep 17 00:00:00 2001 From: Mohammad Saddam Hossain Date: Sun, 27 Sep 2026 13:08:41 +0530 Subject: [PATCH 2/3] spike: verify BiDi path against real Firefox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installed a real Firefox (156.0.1, official Mozilla tarball — the system 'firefox' was an unconfigured snap stub) + geckodriver v0.37.1 locally and ran the same delayed-slow-request scenario used to verify the CDP path. Result: firefox idle-reached ms=2300, matching the Chrome CDP result (2361ms) for the same scenario (200ms delay + 1.5s response + 500ms quietFor). BiDi path (onBeforeRequestSent/onResponseCompleted/ onFetchError via org.openqa.selenium.bidi.module.Network) confirmed working on a real browser, not just code-reviewed. Also verified end-to-end in selenium-boot-test (the consumer demo suite) via a throwaway test — real BaseTest lifecycle, real Chrome, real consumer classpath — same result, removed after (not part of that repo's public example). Both CDP and BiDi paths are now verified. Remaining before this can be considered for merge (unchanged from the PR description): wire into WaitEngine's public API, decide waitForResponse(urlPattern) scope, add test coverage once the API shape is settled. Co-Authored-By: Claude Sonnet 5 From 5c60bf99f19e6553befc14d6691acd06375fd9a7 Mon Sep 17 00:00:00 2001 From: Mohammad Saddam Hossain Date: Sun, 27 Sep 2026 13:25:24 +0530 Subject: [PATCH 3/3] feat: waitForNetworkIdle / waitForResponse (CDP + BiDi) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds out the spike into WaitEngine's real API: - WaitEngine.waitForNetworkIdle() / waitForNetworkIdle(Duration quietFor) — waits until no request has been in flight for the quiet period (default 500ms), bounded by the configured explicit timeout. - WaitEngine.waitForResponse(String urlPattern) / waitForResponse(String urlPattern, Duration timeout) — waits for a response whose URL matches (same glob syntax as NetworkMock.stub), returns its HTTP status code. - Both: CDP on Chrome/Edge, BiDi (org.openqa.selenium.bidi.module.Network) on any HasBiDi driver (Firefox), UnsupportedOperationException on neither. Implementation split for testability: NetworkActivityTracker.IdleState and ResponseLatch are pure, driver-independent logic (in-flight counter + last-activity timestamp; a CompletableFuture-backed latch) and are unit tested directly (8 new tests, 547/547 total green). CDP/ BiDi wiring itself needs a real browser and is verified manually — same convention NetworkMockTest documents for CDP request interception. Real gap found and fixed while verifying end-to-end: without this, Firefox raised a raw BiDiException ("Unable to create a BiDi connection") instead of working out of the box, because LocalFirefoxDriverProvider never requested a BiDi session. Fixed by setting the "webSocketUrl" capability by default (an explicit value in the user's own capabilities still wins) — otherwise every Firefox user would have needed to know to add that capability manually, breaking the framework's zero-config promise for this feature. Verified twice end-to-end via selenium-boot-test (real BaseTest lifecycle, real Chrome and real Firefox, zero manual config) with a throwaway test removed after — not part of that repo's permanent example. Chrome: waitForNetworkIdle correctly waited past a delayed 1.5s request; waitForResponse returned 200. Firefox: same, and reproduced+fixed the BiDi gap in the same pass. Co-Authored-By: Claude Sonnet 5 --- .../driver/LocalFirefoxDriverProvider.java | 8 + .../wait/NetworkActivityTracker.java | 167 +++++++++++++++++ .../wait/NetworkIdleWaitSpike.java | 170 ------------------ .../com/seleniumboot/wait/WaitEngine.java | 54 ++++++ .../wait/NetworkActivityTrackerTest.java | 98 ++++++++++ 5 files changed, 327 insertions(+), 170 deletions(-) create mode 100644 src/main/java/com/seleniumboot/wait/NetworkActivityTracker.java delete mode 100644 src/main/java/com/seleniumboot/wait/NetworkIdleWaitSpike.java create mode 100644 src/test/java/com/seleniumboot/wait/NetworkActivityTrackerTest.java diff --git a/src/main/java/com/seleniumboot/driver/LocalFirefoxDriverProvider.java b/src/main/java/com/seleniumboot/driver/LocalFirefoxDriverProvider.java index 0f06327..6306881 100644 --- a/src/main/java/com/seleniumboot/driver/LocalFirefoxDriverProvider.java +++ b/src/main/java/com/seleniumboot/driver/LocalFirefoxDriverProvider.java @@ -63,6 +63,14 @@ public WebDriver createDriver() { // Keep alerts open until the test explicitly handles them. options.setCapability("unhandledPromptBehavior", "ignore"); + // Enable BiDi so WaitEngine.waitForNetworkIdle()/waitForResponse() work out of the box — + // without this, HasBiDi.getBiDi() throws BiDiException instead of the clean + // UnsupportedOperationException NetworkActivityTracker reserves for truly unsupported + // drivers. An explicit "webSocketUrl" in the user's own capabilities still wins. + if (capabilities == null || !capabilities.containsKey("webSocketUrl")) { + options.setCapability("webSocketUrl", true); + } + WebDriver driver = new FirefoxDriver(options); driver.manage().timeouts().implicitlyWait(Duration.ZERO); diff --git a/src/main/java/com/seleniumboot/wait/NetworkActivityTracker.java b/src/main/java/com/seleniumboot/wait/NetworkActivityTracker.java new file mode 100644 index 0000000..39659f0 --- /dev/null +++ b/src/main/java/com/seleniumboot/wait/NetworkActivityTracker.java @@ -0,0 +1,167 @@ +package com.seleniumboot.wait; + +import com.seleniumboot.network.NetworkMock; +import org.openqa.selenium.TimeoutException; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.bidi.HasBiDi; +import org.openqa.selenium.bidi.module.Network; +import org.openqa.selenium.chromium.ChromiumDriver; +import org.openqa.selenium.devtools.DevTools; +import org.openqa.selenium.devtools.v153.network.model.LoadingFailed; +import org.openqa.selenium.devtools.v153.network.model.LoadingFinished; +import org.openqa.selenium.devtools.v153.network.model.RequestWillBeSent; +import org.openqa.selenium.devtools.v153.network.model.ResponseReceived; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Backs {@link WaitEngine#waitForNetworkIdle} and {@link WaitEngine#waitForResponse}. + * + *

CDP (Chrome/Edge) and BiDi (Firefox, or any other {@code HasBiDi} driver) wiring is + * covered by manual verification against real browsers, not unit tests — same convention + * as {@link NetworkMock}. {@link IdleState}, the poll condition both paths feed, is pure + * and unit-tested directly. + */ +final class NetworkActivityTracker { + + private NetworkActivityTracker() { + } + + static void awaitIdle(WebDriver driver, Duration quietFor, Duration timeout) { + IdleState state = new IdleState(); + if (driver instanceof ChromiumDriver chromium) { + try (DevTools devTools = chromium.getDevTools()) { + devTools.createSession(); + devTools.send(org.openqa.selenium.devtools.v153.network.Network.enable( + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())); + devTools.addListener(org.openqa.selenium.devtools.v153.network.Network.requestWillBeSent(), + (RequestWillBeSent event) -> state.requestStarted()); + devTools.addListener(org.openqa.selenium.devtools.v153.network.Network.loadingFinished(), + (LoadingFinished event) -> state.requestEnded()); + devTools.addListener(org.openqa.selenium.devtools.v153.network.Network.loadingFailed(), + (LoadingFailed event) -> state.requestEnded()); + pollUntilIdle(state, quietFor, timeout); + } + } else if (driver instanceof HasBiDi) { + try (Network network = new Network(driver)) { + network.onBeforeRequestSent(event -> state.requestStarted()); + network.onResponseCompleted(event -> state.requestEnded()); + network.onFetchError(event -> state.requestEnded()); + pollUntilIdle(state, quietFor, timeout); + } + } else { + throw unsupported(driver); + } + } + + static int awaitResponse(WebDriver driver, String urlPattern, Duration timeout) { + ResponseLatch latch = new ResponseLatch(); + if (driver instanceof ChromiumDriver chromium) { + try (DevTools devTools = chromium.getDevTools()) { + devTools.createSession(); + devTools.send(org.openqa.selenium.devtools.v153.network.Network.enable( + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())); + devTools.addListener(org.openqa.selenium.devtools.v153.network.Network.responseReceived(), + (ResponseReceived event) -> { + String url = event.getResponse().getUrl(); + if (NetworkMock.matches(urlPattern, url)) { + latch.complete(event.getResponse().getStatus()); + } + }); + return latch.await(timeout); + } + } else if (driver instanceof HasBiDi) { + try (Network network = new Network(driver)) { + network.onResponseCompleted(event -> { + String url = event.getResponseData().getUrl(); + if (NetworkMock.matches(urlPattern, url)) { + latch.complete(event.getResponseData().getStatus()); + } + }); + return latch.await(timeout); + } + } else { + throw unsupported(driver); + } + } + + private static void pollUntilIdle(IdleState state, Duration quietFor, Duration timeout) { + Instant deadline = Instant.now().plus(timeout); + while (Instant.now().isBefore(deadline)) { + if (state.isIdle(quietFor)) return; + sleep(100); + } + throw new TimeoutException("waitForNetworkIdle: timed out after " + timeout + + " with " + state.inFlightCount() + " request(s) still in flight"); + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static UnsupportedOperationException unsupported(WebDriver driver) { + return new UnsupportedOperationException( + "waitForNetworkIdle/waitForResponse need CDP (Chrome/Edge) or BiDi support; got " + + driver.getClass().getSimpleName()); + } + + // ------------------------------------------------------------------ + // Pure, unit-tested pieces + // ------------------------------------------------------------------ + + /** In-flight request counter + last-activity timestamp. Idle = none in flight, quiet long enough. */ + static final class IdleState { + private final AtomicInteger inFlight = new AtomicInteger(); + private volatile long lastActivityNanos = System.nanoTime(); + + void requestStarted() { + inFlight.incrementAndGet(); + lastActivityNanos = System.nanoTime(); + } + + void requestEnded() { + inFlight.decrementAndGet(); + lastActivityNanos = System.nanoTime(); + } + + boolean isIdle(Duration quietFor) { + return inFlight.get() <= 0 && (System.nanoTime() - lastActivityNanos) >= quietFor.toNanos(); + } + + int inFlightCount() { + return inFlight.get(); + } + } + + /** Resolves once a matching response is observed, or times out. */ + static final class ResponseLatch { + private final CompletableFuture future = new CompletableFuture<>(); + + void complete(int status) { + future.complete(status); + } + + int await(Duration timeout) { + try { + return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (java.util.concurrent.TimeoutException e) { + throw new TimeoutException("waitForResponse: no matching response within " + timeout); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new TimeoutException("waitForResponse: interrupted while waiting", e); + } catch (ExecutionException e) { + throw new RuntimeException("waitForResponse: listener failed", e.getCause()); + } + } + } +} diff --git a/src/main/java/com/seleniumboot/wait/NetworkIdleWaitSpike.java b/src/main/java/com/seleniumboot/wait/NetworkIdleWaitSpike.java deleted file mode 100644 index d38e11b..0000000 --- a/src/main/java/com/seleniumboot/wait/NetworkIdleWaitSpike.java +++ /dev/null @@ -1,170 +0,0 @@ -package com.seleniumboot.wait; - -import org.openqa.selenium.WebDriver; -import org.openqa.selenium.bidi.HasBiDi; -import org.openqa.selenium.bidi.module.Network; -import org.openqa.selenium.chromium.ChromiumDriver; -import org.openqa.selenium.devtools.DevTools; -import org.openqa.selenium.devtools.v153.network.model.LoadingFailed; -import org.openqa.selenium.devtools.v153.network.model.LoadingFinished; -import org.openqa.selenium.devtools.v153.network.model.RequestWillBeSent; - -import java.time.Duration; -import java.time.Instant; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * SPIKE — not the public API. Prototype for the design note in ROADMAP.md - * (waitForNetworkIdle / waitForResponse): CDP on Chromium, BiDi on Firefox, throw on others. - * - *

Mechanism on both paths is the same shape: track an in-flight request counter and - * a "last activity" timestamp; idle means the counter is 0 and {@code quietFor} has - * elapsed since the last change, bounded by an overall timeout. - * - *

Manual run only (no browser in the unit-test suite) — see the {@code main} method. - */ -public final class NetworkIdleWaitSpike { - - private NetworkIdleWaitSpike() { - } - - /** - * Blocks until no network request has started or finished for {@code quietFor}, - * or throws once {@code timeout} elapses first. - */ - public static void waitForNetworkIdle(WebDriver driver, Duration quietFor, Duration timeout) { - if (driver instanceof ChromiumDriver chromium) { - waitViaCdp(chromium, quietFor, timeout); - } else if (driver instanceof HasBiDi) { - waitViaBidi(driver, quietFor, timeout); - } else { - throw new UnsupportedOperationException( - "waitForNetworkIdle needs CDP (Chrome/Edge) or BiDi support; got " - + driver.getClass().getSimpleName()); - } - } - - // ------------------------------------------------------------------ - // CDP path (Chrome / Edge) - // ------------------------------------------------------------------ - - private static void waitViaCdp(ChromiumDriver driver, Duration quietFor, Duration timeout) { - AtomicInteger inFlight = new AtomicInteger(0); - AtomicLongBox lastActivity = new AtomicLongBox(System.nanoTime()); - - try (DevTools devTools = driver.getDevTools()) { - devTools.createSession(); - devTools.send(org.openqa.selenium.devtools.v153.network.Network.enable( - Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())); - - devTools.addListener(org.openqa.selenium.devtools.v153.network.Network.requestWillBeSent(), - (RequestWillBeSent event) -> { - inFlight.incrementAndGet(); - lastActivity.set(System.nanoTime()); - }); - devTools.addListener(org.openqa.selenium.devtools.v153.network.Network.loadingFinished(), - (LoadingFinished event) -> { - inFlight.decrementAndGet(); - lastActivity.set(System.nanoTime()); - }); - devTools.addListener(org.openqa.selenium.devtools.v153.network.Network.loadingFailed(), - (LoadingFailed event) -> { - inFlight.decrementAndGet(); - lastActivity.set(System.nanoTime()); - }); - - pollUntilIdle(inFlight, lastActivity, quietFor, timeout); - } - } - - // ------------------------------------------------------------------ - // BiDi path (Firefox, and any other BiDi-capable browser) - // ------------------------------------------------------------------ - - private static void waitViaBidi(WebDriver driver, Duration quietFor, Duration timeout) { - AtomicInteger inFlight = new AtomicInteger(0); - AtomicLongBox lastActivity = new AtomicLongBox(System.nanoTime()); - - try (Network network = new Network(driver)) { - network.onBeforeRequestSent(event -> { - inFlight.incrementAndGet(); - lastActivity.set(System.nanoTime()); - }); - network.onResponseCompleted(event -> { - inFlight.decrementAndGet(); - lastActivity.set(System.nanoTime()); - }); - network.onFetchError(event -> { - inFlight.decrementAndGet(); - lastActivity.set(System.nanoTime()); - }); - - pollUntilIdle(inFlight, lastActivity, quietFor, timeout); - } - } - - // ------------------------------------------------------------------ - // Shared poll loop - // ------------------------------------------------------------------ - - private static void pollUntilIdle(AtomicInteger inFlight, AtomicLongBox lastActivity, - Duration quietFor, Duration timeout) { - Instant deadline = Instant.now().plus(timeout); - while (Instant.now().isBefore(deadline)) { - long quietNanos = System.nanoTime() - lastActivity.get(); - if (inFlight.get() <= 0 && quietNanos >= quietFor.toNanos()) { - return; - } - sleep(100); - } - throw new RuntimeException("waitForNetworkIdle: timed out after " + timeout - + " with " + inFlight.get() + " request(s) still in flight"); - } - - private static void sleep(long millis) { - try { - Thread.sleep(millis); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - /** Tiny mutable long box — avoids AtomicLong just to store a nanoTime snapshot. */ - private static final class AtomicLongBox { - private volatile long value; - - AtomicLongBox(long value) { - this.value = value; - } - - void set(long value) { - this.value = value; - } - - long get() { - return value; - } - } - - // ------------------------------------------------------------------ - // Manual spike runner — not a unit test, needs a real browser - // ------------------------------------------------------------------ - - public static void main(String[] args) { - String target = args.length > 0 ? args[0] : "chrome"; - WebDriver driver = target.equals("firefox") - ? new org.openqa.selenium.firefox.FirefoxDriver() - : new org.openqa.selenium.chrome.ChromeDriver(); - try { - driver.get("https://example.com"); - long start = System.nanoTime(); - waitForNetworkIdle(driver, Duration.ofMillis(500), Duration.ofSeconds(10)); - long elapsedMs = (System.nanoTime() - start) / 1_000_000; - System.out.println("[" + target + "] network idle reached after " + elapsedMs + "ms"); - } finally { - driver.quit(); - } - } -} diff --git a/src/main/java/com/seleniumboot/wait/WaitEngine.java b/src/main/java/com/seleniumboot/wait/WaitEngine.java index 8e93ed0..23ebd50 100644 --- a/src/main/java/com/seleniumboot/wait/WaitEngine.java +++ b/src/main/java/com/seleniumboot/wait/WaitEngine.java @@ -30,6 +30,8 @@ @SeleniumBootApi(since = "0.4.0") public final class WaitEngine { + private static final Duration DEFAULT_NETWORK_QUIET_FOR = Duration.ofMillis(500); + private WaitEngine() { } @@ -248,6 +250,58 @@ public static void waitForReactHydration() { }); } + // ---------------------------------------------------------- + // Network + // ---------------------------------------------------------- + + /** + * Waits until the browser has no in-flight network request for {@link #DEFAULT_NETWORK_QUIET_FOR} + * (500ms), bounded by the configured explicit timeout. Supported on Chrome/Edge (via CDP) and + * any BiDi-capable browser such as Firefox; throws {@link UnsupportedOperationException} on others. + * + *

+     * page.clickLoadMore();
+     * WaitEngine.waitForNetworkIdle();
+     * 
+ */ + public static void waitForNetworkIdle() { + waitForNetworkIdle(DEFAULT_NETWORK_QUIET_FOR); + } + + /** + * Like {@link #waitForNetworkIdle()}, but with a custom quiet period instead of the 500ms default. + */ + public static void waitForNetworkIdle(Duration quietFor) { + NetworkActivityTracker.awaitIdle(DriverManager.getDriver(), quietFor, explicitTimeout()); + } + + /** + * Waits until a response whose URL matches {@code urlPattern} is observed, and returns its + * HTTP status code. Bounded by the configured explicit timeout. Same glob syntax as + * {@link com.seleniumboot.network.NetworkMock#stub(String)} ({@code *} within a path segment, + * {@code **} across segments). Supported on Chrome/Edge (via CDP) and any BiDi-capable browser + * such as Firefox; throws {@link UnsupportedOperationException} on others. + * + *
+     * page.clickCheckout();
+     * int status = WaitEngine.waitForResponse("**/api/checkout");
+     * 
+ */ + public static int waitForResponse(String urlPattern) { + return waitForResponse(urlPattern, explicitTimeout()); + } + + /** + * Like {@link #waitForResponse(String)}, but with a custom timeout instead of the configured one. + */ + public static int waitForResponse(String urlPattern, Duration timeout) { + return NetworkActivityTracker.awaitResponse(DriverManager.getDriver(), urlPattern, timeout); + } + + private static Duration explicitTimeout() { + return Duration.ofSeconds(SeleniumBootContext.getConfig().getTimeouts().getExplicit()); + } + // ---------------------------------------------------------- // Alert // ---------------------------------------------------------- diff --git a/src/test/java/com/seleniumboot/wait/NetworkActivityTrackerTest.java b/src/test/java/com/seleniumboot/wait/NetworkActivityTrackerTest.java new file mode 100644 index 0000000..03d31ca --- /dev/null +++ b/src/test/java/com/seleniumboot/wait/NetworkActivityTrackerTest.java @@ -0,0 +1,98 @@ +package com.seleniumboot.wait; + +import org.openqa.selenium.TimeoutException; +import org.testng.annotations.Test; + +import java.time.Duration; + +import static org.testng.Assert.*; + +/** + * Unit tests for the pure request-tracking logic behind {@link WaitEngine#waitForNetworkIdle} + * and {@link WaitEngine#waitForResponse}. CDP/BiDi wiring itself needs a real browser and is + * verified manually — same convention as {@code NetworkMockTest} for CDP request interception. + */ +public class NetworkActivityTrackerTest { + + // ------------------------------------------------------------------ + // IdleState + // ------------------------------------------------------------------ + + @Test + public void idleState_startsIdle() { + NetworkActivityTracker.IdleState state = new NetworkActivityTracker.IdleState(); + assertTrue(state.isIdle(Duration.ZERO)); + } + + @Test + public void idleState_notIdleWhileRequestInFlight() { + NetworkActivityTracker.IdleState state = new NetworkActivityTracker.IdleState(); + state.requestStarted(); + assertFalse(state.isIdle(Duration.ZERO)); + assertEquals(state.inFlightCount(), 1); + } + + @Test + public void idleState_idleAgainOnceRequestEnds() { + NetworkActivityTracker.IdleState state = new NetworkActivityTracker.IdleState(); + state.requestStarted(); + state.requestEnded(); + assertTrue(state.isIdle(Duration.ZERO)); + assertEquals(state.inFlightCount(), 0); + } + + @Test + public void idleState_notIdleUntilQuietPeriodElapses() throws InterruptedException { + NetworkActivityTracker.IdleState state = new NetworkActivityTracker.IdleState(); + state.requestStarted(); + state.requestEnded(); + // just ended — a long quiet period hasn't elapsed yet + assertFalse(state.isIdle(Duration.ofMinutes(1))); + Thread.sleep(20); + assertTrue(state.isIdle(Duration.ofMillis(5))); + } + + @Test + public void idleState_multipleOverlappingRequests() { + NetworkActivityTracker.IdleState state = new NetworkActivityTracker.IdleState(); + state.requestStarted(); + state.requestStarted(); + assertEquals(state.inFlightCount(), 2); + state.requestEnded(); + assertFalse(state.isIdle(Duration.ZERO), "one request still in flight"); + state.requestEnded(); + assertTrue(state.isIdle(Duration.ZERO)); + } + + // ------------------------------------------------------------------ + // ResponseLatch + // ------------------------------------------------------------------ + + @Test + public void responseLatch_returnsStatusOnceCompleted() { + NetworkActivityTracker.ResponseLatch latch = new NetworkActivityTracker.ResponseLatch(); + latch.complete(204); + assertEquals(latch.await(Duration.ofSeconds(1)), 204); + } + + @Test + public void responseLatch_returnsStatusFromAnotherThread() throws InterruptedException { + NetworkActivityTracker.ResponseLatch latch = new NetworkActivityTracker.ResponseLatch(); + Thread producer = new Thread(() -> { + try { + Thread.sleep(20); + } catch (InterruptedException ignored) { + } + latch.complete(200); + }); + producer.start(); + assertEquals(latch.await(Duration.ofSeconds(1)), 200); + producer.join(); + } + + @Test(expectedExceptions = TimeoutException.class) + public void responseLatch_timesOutWithNoMatch() { + NetworkActivityTracker.ResponseLatch latch = new NetworkActivityTracker.ResponseLatch(); + latch.await(Duration.ofMillis(50)); + } +}