Skip to content

Commit 3878c55

Browse files
authored
Merge pull request #85 from seleniumboot/spike/wait-for-network-idle
feat: waitForNetworkIdle / waitForResponse (CDP + BiDi)
2 parents 625517a + 5c60bf9 commit 3878c55

4 files changed

Lines changed: 327 additions & 0 deletions

File tree

‎src/main/java/com/seleniumboot/driver/LocalFirefoxDriverProvider.java‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ public WebDriver createDriver() {
6363
// Keep alerts open until the test explicitly handles them.
6464
options.setCapability("unhandledPromptBehavior", "ignore");
6565

66+
// Enable BiDi so WaitEngine.waitForNetworkIdle()/waitForResponse() work out of the box —
67+
// without this, HasBiDi.getBiDi() throws BiDiException instead of the clean
68+
// UnsupportedOperationException NetworkActivityTracker reserves for truly unsupported
69+
// drivers. An explicit "webSocketUrl" in the user's own capabilities still wins.
70+
if (capabilities == null || !capabilities.containsKey("webSocketUrl")) {
71+
options.setCapability("webSocketUrl", true);
72+
}
73+
6674
WebDriver driver = new FirefoxDriver(options);
6775
driver.manage().timeouts().implicitlyWait(Duration.ZERO);
6876

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package com.seleniumboot.wait;
2+
3+
import com.seleniumboot.network.NetworkMock;
4+
import org.openqa.selenium.TimeoutException;
5+
import org.openqa.selenium.WebDriver;
6+
import org.openqa.selenium.bidi.HasBiDi;
7+
import org.openqa.selenium.bidi.module.Network;
8+
import org.openqa.selenium.chromium.ChromiumDriver;
9+
import org.openqa.selenium.devtools.DevTools;
10+
import org.openqa.selenium.devtools.v153.network.model.LoadingFailed;
11+
import org.openqa.selenium.devtools.v153.network.model.LoadingFinished;
12+
import org.openqa.selenium.devtools.v153.network.model.RequestWillBeSent;
13+
import org.openqa.selenium.devtools.v153.network.model.ResponseReceived;
14+
15+
import java.time.Duration;
16+
import java.time.Instant;
17+
import java.util.Optional;
18+
import java.util.concurrent.CompletableFuture;
19+
import java.util.concurrent.ExecutionException;
20+
import java.util.concurrent.TimeUnit;
21+
import java.util.concurrent.atomic.AtomicInteger;
22+
23+
/**
24+
* Backs {@link WaitEngine#waitForNetworkIdle} and {@link WaitEngine#waitForResponse}.
25+
*
26+
* <p>CDP (Chrome/Edge) and BiDi (Firefox, or any other {@code HasBiDi} driver) wiring is
27+
* covered by manual verification against real browsers, not unit tests — same convention
28+
* as {@link NetworkMock}. {@link IdleState}, the poll condition both paths feed, is pure
29+
* and unit-tested directly.
30+
*/
31+
final class NetworkActivityTracker {
32+
33+
private NetworkActivityTracker() {
34+
}
35+
36+
static void awaitIdle(WebDriver driver, Duration quietFor, Duration timeout) {
37+
IdleState state = new IdleState();
38+
if (driver instanceof ChromiumDriver chromium) {
39+
try (DevTools devTools = chromium.getDevTools()) {
40+
devTools.createSession();
41+
devTools.send(org.openqa.selenium.devtools.v153.network.Network.enable(
42+
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()));
43+
devTools.addListener(org.openqa.selenium.devtools.v153.network.Network.requestWillBeSent(),
44+
(RequestWillBeSent event) -> state.requestStarted());
45+
devTools.addListener(org.openqa.selenium.devtools.v153.network.Network.loadingFinished(),
46+
(LoadingFinished event) -> state.requestEnded());
47+
devTools.addListener(org.openqa.selenium.devtools.v153.network.Network.loadingFailed(),
48+
(LoadingFailed event) -> state.requestEnded());
49+
pollUntilIdle(state, quietFor, timeout);
50+
}
51+
} else if (driver instanceof HasBiDi) {
52+
try (Network network = new Network(driver)) {
53+
network.onBeforeRequestSent(event -> state.requestStarted());
54+
network.onResponseCompleted(event -> state.requestEnded());
55+
network.onFetchError(event -> state.requestEnded());
56+
pollUntilIdle(state, quietFor, timeout);
57+
}
58+
} else {
59+
throw unsupported(driver);
60+
}
61+
}
62+
63+
static int awaitResponse(WebDriver driver, String urlPattern, Duration timeout) {
64+
ResponseLatch latch = new ResponseLatch();
65+
if (driver instanceof ChromiumDriver chromium) {
66+
try (DevTools devTools = chromium.getDevTools()) {
67+
devTools.createSession();
68+
devTools.send(org.openqa.selenium.devtools.v153.network.Network.enable(
69+
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()));
70+
devTools.addListener(org.openqa.selenium.devtools.v153.network.Network.responseReceived(),
71+
(ResponseReceived event) -> {
72+
String url = event.getResponse().getUrl();
73+
if (NetworkMock.matches(urlPattern, url)) {
74+
latch.complete(event.getResponse().getStatus());
75+
}
76+
});
77+
return latch.await(timeout);
78+
}
79+
} else if (driver instanceof HasBiDi) {
80+
try (Network network = new Network(driver)) {
81+
network.onResponseCompleted(event -> {
82+
String url = event.getResponseData().getUrl();
83+
if (NetworkMock.matches(urlPattern, url)) {
84+
latch.complete(event.getResponseData().getStatus());
85+
}
86+
});
87+
return latch.await(timeout);
88+
}
89+
} else {
90+
throw unsupported(driver);
91+
}
92+
}
93+
94+
private static void pollUntilIdle(IdleState state, Duration quietFor, Duration timeout) {
95+
Instant deadline = Instant.now().plus(timeout);
96+
while (Instant.now().isBefore(deadline)) {
97+
if (state.isIdle(quietFor)) return;
98+
sleep(100);
99+
}
100+
throw new TimeoutException("waitForNetworkIdle: timed out after " + timeout
101+
+ " with " + state.inFlightCount() + " request(s) still in flight");
102+
}
103+
104+
private static void sleep(long millis) {
105+
try {
106+
Thread.sleep(millis);
107+
} catch (InterruptedException e) {
108+
Thread.currentThread().interrupt();
109+
}
110+
}
111+
112+
private static UnsupportedOperationException unsupported(WebDriver driver) {
113+
return new UnsupportedOperationException(
114+
"waitForNetworkIdle/waitForResponse need CDP (Chrome/Edge) or BiDi support; got "
115+
+ driver.getClass().getSimpleName());
116+
}
117+
118+
// ------------------------------------------------------------------
119+
// Pure, unit-tested pieces
120+
// ------------------------------------------------------------------
121+
122+
/** In-flight request counter + last-activity timestamp. Idle = none in flight, quiet long enough. */
123+
static final class IdleState {
124+
private final AtomicInteger inFlight = new AtomicInteger();
125+
private volatile long lastActivityNanos = System.nanoTime();
126+
127+
void requestStarted() {
128+
inFlight.incrementAndGet();
129+
lastActivityNanos = System.nanoTime();
130+
}
131+
132+
void requestEnded() {
133+
inFlight.decrementAndGet();
134+
lastActivityNanos = System.nanoTime();
135+
}
136+
137+
boolean isIdle(Duration quietFor) {
138+
return inFlight.get() <= 0 && (System.nanoTime() - lastActivityNanos) >= quietFor.toNanos();
139+
}
140+
141+
int inFlightCount() {
142+
return inFlight.get();
143+
}
144+
}
145+
146+
/** Resolves once a matching response is observed, or times out. */
147+
static final class ResponseLatch {
148+
private final CompletableFuture<Integer> future = new CompletableFuture<>();
149+
150+
void complete(int status) {
151+
future.complete(status);
152+
}
153+
154+
int await(Duration timeout) {
155+
try {
156+
return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
157+
} catch (java.util.concurrent.TimeoutException e) {
158+
throw new TimeoutException("waitForResponse: no matching response within " + timeout);
159+
} catch (InterruptedException e) {
160+
Thread.currentThread().interrupt();
161+
throw new TimeoutException("waitForResponse: interrupted while waiting", e);
162+
} catch (ExecutionException e) {
163+
throw new RuntimeException("waitForResponse: listener failed", e.getCause());
164+
}
165+
}
166+
}
167+
}

‎src/main/java/com/seleniumboot/wait/WaitEngine.java‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
@SeleniumBootApi(since = "0.4.0")
3131
public final class WaitEngine {
3232

33+
private static final Duration DEFAULT_NETWORK_QUIET_FOR = Duration.ofMillis(500);
34+
3335
private WaitEngine() {
3436
}
3537

@@ -248,6 +250,58 @@ public static void waitForReactHydration() {
248250
});
249251
}
250252

253+
// ----------------------------------------------------------
254+
// Network
255+
// ----------------------------------------------------------
256+
257+
/**
258+
* Waits until the browser has no in-flight network request for {@link #DEFAULT_NETWORK_QUIET_FOR}
259+
* (500ms), bounded by the configured explicit timeout. Supported on Chrome/Edge (via CDP) and
260+
* any BiDi-capable browser such as Firefox; throws {@link UnsupportedOperationException} on others.
261+
*
262+
* <pre>
263+
* page.clickLoadMore();
264+
* WaitEngine.waitForNetworkIdle();
265+
* </pre>
266+
*/
267+
public static void waitForNetworkIdle() {
268+
waitForNetworkIdle(DEFAULT_NETWORK_QUIET_FOR);
269+
}
270+
271+
/**
272+
* Like {@link #waitForNetworkIdle()}, but with a custom quiet period instead of the 500ms default.
273+
*/
274+
public static void waitForNetworkIdle(Duration quietFor) {
275+
NetworkActivityTracker.awaitIdle(DriverManager.getDriver(), quietFor, explicitTimeout());
276+
}
277+
278+
/**
279+
* Waits until a response whose URL matches {@code urlPattern} is observed, and returns its
280+
* HTTP status code. Bounded by the configured explicit timeout. Same glob syntax as
281+
* {@link com.seleniumboot.network.NetworkMock#stub(String)} ({@code *} within a path segment,
282+
* {@code **} across segments). Supported on Chrome/Edge (via CDP) and any BiDi-capable browser
283+
* such as Firefox; throws {@link UnsupportedOperationException} on others.
284+
*
285+
* <pre>
286+
* page.clickCheckout();
287+
* int status = WaitEngine.waitForResponse("&#42;&#42;/api/checkout");
288+
* </pre>
289+
*/
290+
public static int waitForResponse(String urlPattern) {
291+
return waitForResponse(urlPattern, explicitTimeout());
292+
}
293+
294+
/**
295+
* Like {@link #waitForResponse(String)}, but with a custom timeout instead of the configured one.
296+
*/
297+
public static int waitForResponse(String urlPattern, Duration timeout) {
298+
return NetworkActivityTracker.awaitResponse(DriverManager.getDriver(), urlPattern, timeout);
299+
}
300+
301+
private static Duration explicitTimeout() {
302+
return Duration.ofSeconds(SeleniumBootContext.getConfig().getTimeouts().getExplicit());
303+
}
304+
251305
// ----------------------------------------------------------
252306
// Alert
253307
// ----------------------------------------------------------
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package com.seleniumboot.wait;
2+
3+
import org.openqa.selenium.TimeoutException;
4+
import org.testng.annotations.Test;
5+
6+
import java.time.Duration;
7+
8+
import static org.testng.Assert.*;
9+
10+
/**
11+
* Unit tests for the pure request-tracking logic behind {@link WaitEngine#waitForNetworkIdle}
12+
* and {@link WaitEngine#waitForResponse}. CDP/BiDi wiring itself needs a real browser and is
13+
* verified manually — same convention as {@code NetworkMockTest} for CDP request interception.
14+
*/
15+
public class NetworkActivityTrackerTest {
16+
17+
// ------------------------------------------------------------------
18+
// IdleState
19+
// ------------------------------------------------------------------
20+
21+
@Test
22+
public void idleState_startsIdle() {
23+
NetworkActivityTracker.IdleState state = new NetworkActivityTracker.IdleState();
24+
assertTrue(state.isIdle(Duration.ZERO));
25+
}
26+
27+
@Test
28+
public void idleState_notIdleWhileRequestInFlight() {
29+
NetworkActivityTracker.IdleState state = new NetworkActivityTracker.IdleState();
30+
state.requestStarted();
31+
assertFalse(state.isIdle(Duration.ZERO));
32+
assertEquals(state.inFlightCount(), 1);
33+
}
34+
35+
@Test
36+
public void idleState_idleAgainOnceRequestEnds() {
37+
NetworkActivityTracker.IdleState state = new NetworkActivityTracker.IdleState();
38+
state.requestStarted();
39+
state.requestEnded();
40+
assertTrue(state.isIdle(Duration.ZERO));
41+
assertEquals(state.inFlightCount(), 0);
42+
}
43+
44+
@Test
45+
public void idleState_notIdleUntilQuietPeriodElapses() throws InterruptedException {
46+
NetworkActivityTracker.IdleState state = new NetworkActivityTracker.IdleState();
47+
state.requestStarted();
48+
state.requestEnded();
49+
// just ended — a long quiet period hasn't elapsed yet
50+
assertFalse(state.isIdle(Duration.ofMinutes(1)));
51+
Thread.sleep(20);
52+
assertTrue(state.isIdle(Duration.ofMillis(5)));
53+
}
54+
55+
@Test
56+
public void idleState_multipleOverlappingRequests() {
57+
NetworkActivityTracker.IdleState state = new NetworkActivityTracker.IdleState();
58+
state.requestStarted();
59+
state.requestStarted();
60+
assertEquals(state.inFlightCount(), 2);
61+
state.requestEnded();
62+
assertFalse(state.isIdle(Duration.ZERO), "one request still in flight");
63+
state.requestEnded();
64+
assertTrue(state.isIdle(Duration.ZERO));
65+
}
66+
67+
// ------------------------------------------------------------------
68+
// ResponseLatch
69+
// ------------------------------------------------------------------
70+
71+
@Test
72+
public void responseLatch_returnsStatusOnceCompleted() {
73+
NetworkActivityTracker.ResponseLatch latch = new NetworkActivityTracker.ResponseLatch();
74+
latch.complete(204);
75+
assertEquals(latch.await(Duration.ofSeconds(1)), 204);
76+
}
77+
78+
@Test
79+
public void responseLatch_returnsStatusFromAnotherThread() throws InterruptedException {
80+
NetworkActivityTracker.ResponseLatch latch = new NetworkActivityTracker.ResponseLatch();
81+
Thread producer = new Thread(() -> {
82+
try {
83+
Thread.sleep(20);
84+
} catch (InterruptedException ignored) {
85+
}
86+
latch.complete(200);
87+
});
88+
producer.start();
89+
assertEquals(latch.await(Duration.ofSeconds(1)), 200);
90+
producer.join();
91+
}
92+
93+
@Test(expectedExceptions = TimeoutException.class)
94+
public void responseLatch_timesOutWithNoMatch() {
95+
NetworkActivityTracker.ResponseLatch latch = new NetworkActivityTracker.ResponseLatch();
96+
latch.await(Duration.ofMillis(50));
97+
}
98+
}

0 commit comments

Comments
 (0)