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