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
+ * 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));
+ }
+}