Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

---

### Unreleased

- **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. (#196)

---

### v3.9.0 — 2026-10-21

- **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)
Expand Down
6 changes: 6 additions & 0 deletions docs-site/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ sidebar_position: 99

All notable changes to Selenium Boot are documented here.

## [Unreleased]

- **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. (#196)

---

## [3.9.0] — 2026-10-21

- **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)
Expand Down
249 changes: 206 additions & 43 deletions src/main/java/com/seleniumboot/network/NetworkMock.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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.
* <p>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.
*
* <p>Obtain via {@code networkMock()} in {@link com.seleniumboot.test.BasePage}
* or {@link com.seleniumboot.test.BaseTest}.
*
* <pre>
* // Return mock JSON for an API call
* networkMock().stub("** /api/users").returnJson("{\"users\":[{\"id\":1}]}");
* networkMock().stub("**&#47;api/users").returnJson("{\"users\":[{\"id\":1}]}");
*
* // Simulate a server error
* networkMock().stub("** /api/checkout").returnStatus(500);
* networkMock().stub("**&#47;api/checkout").returnStatus(500);
*
* // Simulate slow network (2-second delay)
* networkMock().stub("** /api/products").delay(2000).returnJson("[]");
* networkMock().stub("**&#47;api/products").delay(2000).returnJson("[]");
*
* // Clear all stubs (auto-called after each test by the framework)
* networkMock().clear();
Expand All @@ -50,11 +63,21 @@ public final class NetworkMock {
private static final ThreadLocal<NetworkMock> INSTANCE =
ThreadLocal.withInitial(NetworkMock::new);

@FunctionalInterface
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<StubBuilder> 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. */
Expand Down Expand Up @@ -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());
Expand All @@ -104,7 +145,8 @@ public void clear() {
}
devTools = null;
}
cdpAttached = false;

attached = false;
}

// ------------------------------------------------------------------
Expand All @@ -113,46 +155,135 @@ 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<RequestPattern> 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;
}
}

// 2. Fall back to CDP for Chromium-based drivers
if (driver instanceof ChromiumDriver chromium) {
try {
devTools = chromium.getDevTools();
devTools.createSession();

List<RequestPattern> 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;
}

// Handle each intercepted request
devTools.addListener(Fetch.requestPaused(), event -> {
String url = event.getRequest().getUrl();
RequestId requestId = event.getRequestId();
// ------------------------------------------------------------------
// 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) {
bidiNetwork.continueRequest(new ContinueRequestParameters(requestId));
return;
}

if (match.delayMs > 0) {
try {
Thread.sleep(match.delayMs);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}

String contentType = match.contentType != null ? match.contentType : "application/json";
List<Header> headers = List.of(
new Header("content-type", new BytesValue(BytesValue.Type.STRING, contentType))
);

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.warning("[Selenium Boot] Failed to process BiDi intercepted request for " + url + ": " + e.getMessage());
}
}

void handleCdpRequest(RequestPaused event) {
String url = event.getRequest().getUrl();
RequestId requestId = event.getRequestId();

try {
StubBuilder match = findMatch(url);

if (match == null) {
// No stub — let the request continue normally
devTools.send(Fetch.continueRequest(
requestId,
Optional.empty(),
Expand All @@ -164,17 +295,21 @@ private void ensureCdpAttached() {
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 body = match.responseBody != null ? match.responseBody : "";
String body64 = Base64.getEncoder().encodeToString(
match.responseBody.getBytes(StandardCharsets.UTF_8));
body.getBytes(StandardCharsets.UTF_8));

String contentType = match.contentType != null ? match.contentType : "application/json";
List<HeaderEntry> headers = List.of(
new HeaderEntry("content-type", match.contentType)
new HeaderEntry("content-type", contentType)
);

devTools.send(Fetch.fulfillRequest(
Expand All @@ -185,9 +320,37 @@ private void ensureCdpAttached() {
Optional.of(body64),
Optional.empty()
));
});
} catch (Exception e) {
LOG.warning("[Selenium Boot] Failed to process CDP intercepted request for " + url + ": " + e.getMessage());
}
}

// ------------------------------------------------------------------
// Testing / Inspection helpers (package-private for test support)
// ------------------------------------------------------------------

static void setBiDiFactory(BiDiNetworkFactory factory) {
bidiFactory = factory;
}

static void resetBiDiFactory() {
bidiFactory = Network::new;
}

boolean isAttached() {
return attached;
}

boolean isBiDiActive() {
return bidiNetwork != null;
}

boolean isCdpActive() {
return devTools != null;
}

cdpAttached = true;
List<StubBuilder> getStubs() {
return Collections.unmodifiableList(stubs);
}

// ------------------------------------------------------------------
Expand Down
Loading
Loading