From 8886fe8f1280ae70f0a6b9e5712282b07a73f3e1 Mon Sep 17 00:00:00 2001
From: Pushpak Jaiswal <149317014+PUSHPAK-JAISWAL@users.noreply.github.com>
Date: Sat, 24 Oct 2026 12:40:39 +0530
Subject: [PATCH 1/3] feat(network): support network mocking on Firefox via
WebDriver BiDi (#196)
---
CHANGELOG.md | 2 +
docs-site/docs/changelog.md | 2 +
.../com/seleniumboot/network/NetworkMock.java | 277 +++++++++---
.../com/seleniumboot/network/StubBuilder.java | 20 +
.../seleniumboot/unit/NetworkMockTest.java | 406 +++++++++++++++++-
5 files changed, 638 insertions(+), 69 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e72c6d2..372332e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
### v3.9.0 — 2026-10-21
+- **Network mocking on Firefox via WebDriver BiDi** — `networkMock().stub(...)` now uses WebDriver BiDi network interception under the hood when supported by the driver (including Firefox and BiDi-enabled drivers), falling back to Chrome DevTools Protocol (CDP) for Chromium-based drivers. The existing stubbing API (`returnJson`, `returnBody`, `returnStatus`, `delay`, `clear`) works identically across Chrome and Firefox, removing the hard dependency on a single pinned CDP version. (#196)
+
- **GitHub Actions job summary: link to the run's artifacts** — the failed-tests summary now adds a line linking to the workflow run page (built from `GITHUB_SERVER_URL`, `GITHUB_REPOSITORY` and `GITHUB_RUN_ID`, no config) where uploaded `target/traces/` artifacts live, so a reviewer can get from a red PR to the trace in one click. Omitted when those variables are missing. (#130)
- **`Locator` keyboard and pointer actions: `press(Keys...)`, `focus()`, `doubleClick()`, `rightClick()`** — send keys without clearing the value, focus without clicking, or perform native double/context clicks directly on a locator. Each action re-resolves on every poll and retries stale or not-yet-present elements until `timeouts.explicit` elapses. (#134, #188)
diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md
index 140b4d2..d96fa7f 100644
--- a/docs-site/docs/changelog.md
+++ b/docs-site/docs/changelog.md
@@ -11,6 +11,8 @@ All notable changes to Selenium Boot are documented here.
## [3.9.0] — 2026-10-21
+- **Network mocking on Firefox via WebDriver BiDi** — `networkMock().stub(...)` now uses WebDriver BiDi network interception under the hood when supported by the driver (including Firefox and BiDi-enabled drivers), falling back to Chrome DevTools Protocol (CDP) for Chromium-based drivers. The existing stubbing API (`returnJson`, `returnBody`, `returnStatus`, `delay`, `clear`) works identically across Chrome and Firefox, removing the hard dependency on a single pinned CDP version. (#196)
+
- **GitHub Actions job summary: link to the run's artifacts** — the failed-tests summary now adds a line linking to the workflow run page (built from `GITHUB_SERVER_URL`, `GITHUB_REPOSITORY` and `GITHUB_RUN_ID`, no config) where uploaded `target/traces/` artifacts live, so a reviewer can get from a red PR to the trace in one click. Omitted when those variables are missing. (#130)
- **`Locator` keyboard and pointer actions: `press(Keys...)`, `focus()`, `doubleClick()`, `rightClick()`** — send keys without clearing the value, focus without clicking, or perform native double/context clicks directly on a locator. Each action re-resolves on every poll and retries stale or not-yet-present elements until `timeouts.explicit` elapses. (#134, #188)
diff --git a/src/main/java/com/seleniumboot/network/NetworkMock.java b/src/main/java/com/seleniumboot/network/NetworkMock.java
index b414619..d26cca1 100644
--- a/src/main/java/com/seleniumboot/network/NetworkMock.java
+++ b/src/main/java/com/seleniumboot/network/NetworkMock.java
@@ -3,40 +3,53 @@
import com.seleniumboot.api.SeleniumBootApi;
import com.seleniumboot.driver.DriverManager;
import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.bidi.HasBiDi;
+import org.openqa.selenium.bidi.module.Network;
+import org.openqa.selenium.bidi.network.AddInterceptParameters;
+import org.openqa.selenium.bidi.network.BeforeRequestSent;
+import org.openqa.selenium.bidi.network.BytesValue;
+import org.openqa.selenium.bidi.network.ContinueRequestParameters;
+import org.openqa.selenium.bidi.network.Header;
+import org.openqa.selenium.bidi.network.InterceptPhase;
+import org.openqa.selenium.bidi.network.ProvideResponseParameters;
import org.openqa.selenium.chromium.ChromiumDriver;
import org.openqa.selenium.devtools.DevTools;
import org.openqa.selenium.devtools.v153.fetch.Fetch;
import org.openqa.selenium.devtools.v153.fetch.model.HeaderEntry;
import org.openqa.selenium.devtools.v153.fetch.model.RequestId;
import org.openqa.selenium.devtools.v153.fetch.model.RequestPattern;
+import org.openqa.selenium.devtools.v153.fetch.model.RequestPaused;
import org.openqa.selenium.devtools.v153.fetch.model.RequestStage;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
+import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.logging.Logger;
/**
- * CDP-backed network interception and request mocking.
+ * Network interception and request mocking backed by WebDriver BiDi and CDP.
*
- *
Supported on Chrome and Edge (Chromium-based). On Firefox or other browsers
- * a warning is logged and all stub calls are silently ignored — tests continue
- * without interception.
+ *
Supported on Chrome, Edge, and Firefox. On drivers supporting {@link HasBiDi}
+ * (including Firefox and BiDi-enabled Chromium), WebDriver BiDi is used under the hood;
+ * otherwise, it falls back to Chrome DevTools Protocol (CDP) for {@link ChromiumDriver}.
+ * On unsupported browsers without BiDi or CDP, a warning is logged and all stub calls
+ * are silently ignored — tests continue without interception.
*
*
Obtain via {@code networkMock()} in {@link com.seleniumboot.test.BasePage}
* or {@link com.seleniumboot.test.BaseTest}.
*
*
* // Return mock JSON for an API call
- * networkMock().stub("** /api/users").returnJson("{\"users\":[{\"id\":1}]}");
+ * networkMock().stub("**/api/users").returnJson("{\"users\":[{\"id\":1}]}");
*
* // Simulate a server error
- * networkMock().stub("** /api/checkout").returnStatus(500);
+ * networkMock().stub("**/api/checkout").returnStatus(500);
*
* // Simulate slow network (2-second delay)
- * networkMock().stub("** /api/products").delay(2000).returnJson("[]");
+ * networkMock().stub("**/api/products").delay(2000).returnJson("[]");
*
* // Clear all stubs (auto-called after each test by the framework)
* networkMock().clear();
@@ -50,11 +63,21 @@ public final class NetworkMock {
private static final ThreadLocal INSTANCE =
ThreadLocal.withInitial(NetworkMock::new);
+ @FunctionalInterface
+ public interface BiDiNetworkFactory {
+ Network create(WebDriver driver);
+ }
+
+ // Factory hook for testing BiDi without an active browser WebSocket session
+ static BiDiNetworkFactory bidiFactory = Network::new;
+
private final List stubs = new CopyOnWriteArrayList<>();
- private boolean cdpAttached = false;
+ private boolean attached = false;
private DevTools devTools;
+ private Network bidiNetwork;
+ private String bidiInterceptId;
- private NetworkMock() {
+ NetworkMock() {
}
/** Returns the per-thread {@link NetworkMock} instance. */
@@ -91,11 +114,29 @@ public StubBuilder stub(String urlPattern) {
}
/**
- * Removes all registered stubs and disables CDP interception for this thread.
+ * Removes all registered stubs and disables interception for this thread.
* Called automatically by the framework after each test.
*/
public void clear() {
stubs.clear();
+
+ if (bidiNetwork != null) {
+ try {
+ if (bidiInterceptId != null) {
+ bidiNetwork.removeIntercept(bidiInterceptId);
+ }
+ } catch (Exception ignored) {
+ // BiDi session may already be closed
+ }
+ try {
+ bidiNetwork.close();
+ } catch (Exception ignored) {
+ // BiDi session may already be closed
+ }
+ bidiNetwork = null;
+ bidiInterceptId = null;
+ }
+
if (devTools != null) {
try {
devTools.send(Fetch.disable());
@@ -104,7 +145,8 @@ public void clear() {
}
devTools = null;
}
- cdpAttached = false;
+
+ attached = false;
}
// ------------------------------------------------------------------
@@ -113,81 +155,198 @@ public void clear() {
void register(StubBuilder stub) {
stubs.add(stub);
- ensureCdpAttached();
+ ensureAttached();
}
// ------------------------------------------------------------------
- // CDP wiring
+ // Interception wiring (BiDi with CDP fallback)
// ------------------------------------------------------------------
- private void ensureCdpAttached() {
- if (cdpAttached) return;
+ private void ensureAttached() {
+ if (attached) return;
- WebDriver driver = DriverManager.getDriver();
- if (!(driver instanceof ChromiumDriver)) {
- LOG.warning("[Selenium Boot] NetworkMock requires Chrome/Edge. " +
- "Stubs will be ignored on " + driver.getClass().getSimpleName() + ".");
- cdpAttached = true;
+ WebDriver driver;
+ try {
+ driver = DriverManager.getDriver();
+ } catch (IllegalStateException e) {
+ LOG.warning("[Selenium Boot] No active WebDriver session found for NetworkMock. " +
+ "Stubs will be ignored.");
+ attached = true;
return;
}
- devTools = ((ChromiumDriver) driver).getDevTools();
- devTools.createSession();
+ if (driver == null) {
+ LOG.warning("[Selenium Boot] No active WebDriver session found for NetworkMock. " +
+ "Stubs will be ignored.");
+ attached = true;
+ return;
+ }
- // Enable Fetch domain — intercept all requests before they are sent
- List patterns = List.of(
- new RequestPattern(
- Optional.of("*"),
- Optional.empty(),
- Optional.of(RequestStage.REQUEST)
- )
- );
- devTools.send(Fetch.enable(Optional.of(patterns), Optional.empty()));
+ // 1. Try BiDi first if driver supports HasBiDi
+ if (driver instanceof HasBiDi) {
+ try {
+ bidiNetwork = bidiFactory.create(driver);
+ bidiInterceptId = bidiNetwork.addIntercept(
+ new AddInterceptParameters(InterceptPhase.BEFORE_REQUEST_SENT)
+ );
+ bidiNetwork.onBeforeRequestSent(this::handleBiDiRequest);
+ attached = true;
+ return;
+ } catch (Exception e) {
+ LOG.fine("[Selenium Boot] BiDi network interception unavailable (" + e.getMessage() +
+ "); attempting CDP fallback.");
+ if (bidiNetwork != null) {
+ try { bidiNetwork.close(); } catch (Exception ignored) {}
+ bidiNetwork = null;
+ }
+ bidiInterceptId = null;
+ }
+ }
- // Handle each intercepted request
- devTools.addListener(Fetch.requestPaused(), event -> {
- String url = event.getRequest().getUrl();
- RequestId requestId = event.getRequestId();
+ // 2. Fall back to CDP for Chromium-based drivers
+ if (driver instanceof ChromiumDriver chromium) {
+ try {
+ devTools = chromium.getDevTools();
+ devTools.createSession();
- StubBuilder match = findMatch(url);
+ List patterns = List.of(
+ new RequestPattern(
+ Optional.of("*"),
+ Optional.empty(),
+ Optional.of(RequestStage.REQUEST)
+ )
+ );
+ devTools.send(Fetch.enable(Optional.of(patterns), Optional.empty()));
+ devTools.addListener(Fetch.requestPaused(), this::handleCdpRequest);
+ attached = true;
+ return;
+ } catch (Exception e) {
+ LOG.fine("[Selenium Boot] CDP interception initialization failed: " + e.getMessage());
+ devTools = null;
+ }
+ }
+
+ // 3. Neither BiDi nor CDP supported
+ LOG.warning("[Selenium Boot] NetworkMock requires BiDi (Firefox/Chrome) or CDP (Chrome/Edge). " +
+ "Stubs will be ignored on " + driver.getClass().getSimpleName() + ".");
+ attached = true;
+ }
+ // ------------------------------------------------------------------
+ // Request handlers
+ // ------------------------------------------------------------------
+
+ void handleBiDiRequest(BeforeRequestSent event) {
+ if (!event.isBlocked()) {
+ return;
+ }
+
+ String url = event.getRequest().getUrl();
+ String requestId = event.getRequest().getRequestId();
+ StubBuilder match = findMatch(url);
+
+ try {
if (match == null) {
- // No stub — let the request continue normally
- devTools.send(Fetch.continueRequest(
- requestId,
- Optional.empty(),
- Optional.empty(),
- Optional.empty(),
- Optional.empty(),
- Optional.empty()
- ));
+ bidiNetwork.continueRequest(new ContinueRequestParameters(requestId));
return;
}
- // Apply delay if configured
if (match.delayMs > 0) {
- try { Thread.sleep(match.delayMs); } catch (InterruptedException ignored) {}
+ try {
+ Thread.sleep(match.delayMs);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
}
- // Build response body as base64
- String body64 = Base64.getEncoder().encodeToString(
- match.responseBody.getBytes(StandardCharsets.UTF_8));
-
- List headers = List.of(
- new HeaderEntry("content-type", match.contentType)
+ String contentType = match.contentType != null ? match.contentType : "application/json";
+ List headers = List.of(
+ new Header("content-type", new BytesValue(BytesValue.Type.STRING, contentType))
);
- devTools.send(Fetch.fulfillRequest(
+ String body = match.responseBody != null ? match.responseBody : "";
+ ProvideResponseParameters params = new ProvideResponseParameters(requestId)
+ .statusCode(match.statusCode)
+ .headers(headers)
+ .body(new BytesValue(BytesValue.Type.STRING, body));
+
+ bidiNetwork.provideResponse(params);
+ } catch (Exception e) {
+ LOG.fine("[Selenium Boot] Failed to process BiDi intercepted request: " + e.getMessage());
+ }
+ }
+
+ void handleCdpRequest(RequestPaused event) {
+ String url = event.getRequest().getUrl();
+ RequestId requestId = event.getRequestId();
+
+ StubBuilder match = findMatch(url);
+
+ if (match == null) {
+ devTools.send(Fetch.continueRequest(
requestId,
- match.statusCode,
- Optional.of(headers),
Optional.empty(),
- Optional.of(body64),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
Optional.empty()
));
- });
+ return;
+ }
+
+ if (match.delayMs > 0) {
+ try {
+ Thread.sleep(match.delayMs);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ String body = match.responseBody != null ? match.responseBody : "";
+ String body64 = Base64.getEncoder().encodeToString(
+ body.getBytes(StandardCharsets.UTF_8));
+
+ String contentType = match.contentType != null ? match.contentType : "application/json";
+ List headers = List.of(
+ new HeaderEntry("content-type", contentType)
+ );
+
+ devTools.send(Fetch.fulfillRequest(
+ requestId,
+ match.statusCode,
+ Optional.of(headers),
+ Optional.empty(),
+ Optional.of(body64),
+ Optional.empty()
+ ));
+ }
+
+ // ------------------------------------------------------------------
+ // Testing / Inspection helpers
+ // ------------------------------------------------------------------
+
+ public static void setBiDiFactory(BiDiNetworkFactory factory) {
+ bidiFactory = factory;
+ }
+
+ public static void resetBiDiFactory() {
+ bidiFactory = Network::new;
+ }
+
+ public boolean isAttached() {
+ return attached;
+ }
+
+ public boolean isBiDiActive() {
+ return bidiNetwork != null;
+ }
+
+ public boolean isCdpActive() {
+ return devTools != null;
+ }
- cdpAttached = true;
+ public List getStubs() {
+ return Collections.unmodifiableList(stubs);
}
// ------------------------------------------------------------------
diff --git a/src/main/java/com/seleniumboot/network/StubBuilder.java b/src/main/java/com/seleniumboot/network/StubBuilder.java
index 3a83b11..109cd12 100644
--- a/src/main/java/com/seleniumboot/network/StubBuilder.java
+++ b/src/main/java/com/seleniumboot/network/StubBuilder.java
@@ -76,4 +76,24 @@ public StubBuilder delay(long ms) {
this.delayMs = ms;
return this;
}
+
+ public String getPattern() {
+ return pattern;
+ }
+
+ public String getResponseBody() {
+ return responseBody;
+ }
+
+ public String getContentType() {
+ return contentType;
+ }
+
+ public int getStatusCode() {
+ return statusCode;
+ }
+
+ public long getDelayMs() {
+ return delayMs;
+ }
}
diff --git a/src/test/java/com/seleniumboot/unit/NetworkMockTest.java b/src/test/java/com/seleniumboot/unit/NetworkMockTest.java
index 7719706..32179ac 100644
--- a/src/test/java/com/seleniumboot/unit/NetworkMockTest.java
+++ b/src/test/java/com/seleniumboot/unit/NetworkMockTest.java
@@ -1,16 +1,60 @@
package com.seleniumboot.unit;
+import com.seleniumboot.driver.DriverManager;
import com.seleniumboot.network.NetworkMock;
+import com.seleniumboot.network.StubBuilder;
+import org.mockito.ArgumentCaptor;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.bidi.BiDiException;
+import org.openqa.selenium.bidi.HasBiDi;
+import org.openqa.selenium.bidi.module.Network;
+import org.openqa.selenium.bidi.network.AddInterceptParameters;
+import org.openqa.selenium.bidi.network.BeforeRequestSent;
+import org.openqa.selenium.bidi.network.ContinueRequestParameters;
+import org.openqa.selenium.bidi.network.Header;
+import org.openqa.selenium.bidi.network.ProvideResponseParameters;
+import org.openqa.selenium.bidi.network.RequestData;
+import org.openqa.selenium.chromium.ChromiumDriver;
+import org.openqa.selenium.devtools.DevTools;
+import org.openqa.selenium.devtools.Command;
+import org.openqa.selenium.devtools.v153.fetch.Fetch;
+import org.openqa.selenium.devtools.v153.fetch.model.HeaderEntry;
+import org.openqa.selenium.devtools.v153.fetch.model.RequestId;
+import org.openqa.selenium.devtools.v153.fetch.model.RequestPaused;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
import static org.testng.Assert.*;
/**
- * Unit tests for {@link NetworkMock} — pattern matching and stub registration.
- * CDP wiring requires a real browser and is covered by integration tests.
+ * Unit tests for {@link NetworkMock} — URL matching, StubBuilder configurations,
+ * WebDriver BiDi interception, CDP fallback, and unsupported browser handling.
*/
public class NetworkMockTest {
+ @BeforeMethod
+ public void setUp() {
+ NetworkMock.cleanup();
+ NetworkMock.resetBiDiFactory();
+ }
+
+ @AfterMethod
+ public void tearDown() {
+ NetworkMock.cleanup();
+ NetworkMock.resetBiDiFactory();
+ try {
+ DriverManager.popSessionOverride();
+ } catch (Exception ignored) {
+ }
+ }
+
// ------------------------------------------------------------------
// Pattern matching — globToRegex + matches()
// ------------------------------------------------------------------
@@ -82,21 +126,363 @@ public void globToRegex_singleStarConverted() {
}
// ------------------------------------------------------------------
- // StubBuilder
+ // StubBuilder Configuration
// ------------------------------------------------------------------
@Test
public void stub_returnJson_setsDefaults() {
- // StubBuilder is constructed via NetworkMock.stub() but we can test
- // the pattern is stored correctly via toString-level assertions
- // Full wiring requires CDP — tested in integration.
- // Here we verify the pattern matching that drives stub lookup.
- assertTrue(NetworkMock.matches("**/users", "https://example.com/api/users"),
- "Pattern **/users should match any URL ending in /users");
+ StubBuilder builder = NetworkMock.get().stub("**/users");
+ builder.returnJson("{\"users\":[]}");
+
+ assertEquals(builder.getPattern(), "**/users");
+ assertEquals(builder.getResponseBody(), "{\"users\":[]}");
+ assertEquals(builder.getContentType(), "application/json");
+ assertEquals(builder.getStatusCode(), 200);
+ assertEquals(builder.getDelayMs(), 0);
}
@Test
public void stub_returnStatus_500() {
- assertTrue(NetworkMock.matches("**/checkout", "https://shop.example.com/checkout"));
+ StubBuilder builder = NetworkMock.get().stub("**/checkout");
+ builder.returnStatus(500);
+
+ assertEquals(builder.getStatusCode(), 500);
+ assertEquals(builder.getResponseBody(), "");
+ }
+
+ @Test
+ public void stub_returnStatus_withBody() {
+ StubBuilder builder = NetworkMock.get().stub("**/error");
+ builder.returnStatus(503, "Service Unavailable");
+
+ assertEquals(builder.getStatusCode(), 503);
+ assertEquals(builder.getResponseBody(), "Service Unavailable");
+ }
+
+ @Test
+ public void stub_returnBody_customContentType() {
+ StubBuilder builder = NetworkMock.get().stub("**/document.xml");
+ builder.returnBody("", "application/xml");
+
+ assertEquals(builder.getResponseBody(), "");
+ assertEquals(builder.getContentType(), "application/xml");
+ assertEquals(builder.getStatusCode(), 200);
+ }
+
+ @Test
+ public void stub_delay_setsDelayMillis() {
+ StubBuilder builder = NetworkMock.get().stub("**/slow").delay(1500);
+ assertEquals(builder.getDelayMs(), 1500);
+ }
+
+ // ------------------------------------------------------------------
+ // BiDi Interception Path
+ // ------------------------------------------------------------------
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void bidi_attachesAndInterceptsMatchingRequest() {
+ WebDriver mockDriver = mock(WebDriver.class, withSettings().extraInterfaces(HasBiDi.class));
+ DriverManager.pushSessionOverride(mockDriver);
+
+ Network mockNetwork = mock(Network.class);
+ when(mockNetwork.addIntercept(any(AddInterceptParameters.class))).thenReturn("intercept-bidi-1");
+
+ NetworkMock.setBiDiFactory(d -> mockNetwork);
+
+ NetworkMock mock = NetworkMock.get();
+ mock.stub("**/api/users").returnJson("{\"users\":[{\"id\":1}]}");
+
+ assertTrue(mock.isBiDiActive(), "BiDi should be active when driver implements HasBiDi");
+ assertTrue(mock.isAttached(), "NetworkMock should be attached");
+ verify(mockNetwork).addIntercept(any(AddInterceptParameters.class));
+
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(Consumer.class);
+ verify(mockNetwork).onBeforeRequestSent(captor.capture());
+ Consumer listener = captor.getValue();
+
+ // Simulate intercepted matching request
+ BeforeRequestSent event = mock(BeforeRequestSent.class);
+ RequestData req = mock(RequestData.class);
+ when(event.isBlocked()).thenReturn(true);
+ when(event.getRequest()).thenReturn(req);
+ when(req.getUrl()).thenReturn("https://api.example.com/api/users");
+ when(req.getRequestId()).thenReturn("req-101");
+
+ listener.accept(event);
+
+ ArgumentCaptor respCaptor =
+ ArgumentCaptor.forClass(ProvideResponseParameters.class);
+ verify(mockNetwork).provideResponse(respCaptor.capture());
+
+ Map paramsMap = respCaptor.getValue().toMap();
+ assertEquals(paramsMap.get("request"), "req-101");
+ assertEquals(paramsMap.get("statusCode"), 200);
+
+ List