diff --git a/CHANGELOG.md b/CHANGELOG.md index e72c6d2..ba4d1ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index 140b4d2..596dda9 100644 --- a/docs-site/docs/changelog.md +++ b/docs-site/docs/changelog.md @@ -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) diff --git a/src/main/java/com/seleniumboot/network/NetworkMock.java b/src/main/java/com/seleniumboot/network/NetworkMock.java index b414619..fb83ffb 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
+    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,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 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 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
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(), @@ -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 headers = List.of( - new HeaderEntry("content-type", match.contentType) + new HeaderEntry("content-type", contentType) ); devTools.send(Fetch.fulfillRequest( @@ -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 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/network/FirefoxNetworkMockLiveTest.java b/src/test/java/com/seleniumboot/network/FirefoxNetworkMockLiveTest.java new file mode 100644 index 0000000..4c25a79 --- /dev/null +++ b/src/test/java/com/seleniumboot/network/FirefoxNetworkMockLiveTest.java @@ -0,0 +1,137 @@ +package com.seleniumboot.network; + +import com.seleniumboot.driver.DriverManager; +import com.sun.net.httpserver.HttpServer; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.firefox.FirefoxDriver; +import org.openqa.selenium.firefox.FirefoxOptions; +import org.testng.SkipException; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; + +/** + * Live integration test verifying WebDriver BiDi network mocking on real Firefox. + * Skips gracefully if Firefox or geckodriver is not installed in the environment. + */ +public class FirefoxNetworkMockLiveTest { + + private HttpServer server; + private int port; + private WebDriver driver; + + @BeforeClass + public void setUp() throws Exception { + // 1. Start lightweight local HTTP server + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + byte[] response = "

Firefox BiDi Test

" + .getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=UTF-8"); + exchange.sendResponseHeaders(200, response.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response); + } + }); + server.start(); + port = server.getAddress().getPort(); + + // 2. Configure Firefox with BiDi enabled and headless mode + FirefoxOptions options = new FirefoxOptions(); + options.addArguments("-headless"); + options.setCapability("webSocketUrl", true); + + // Check if Firefox is installed via Scoop or standard locations + String userHome = System.getProperty("user.home"); + File scoopFirefox = new File(userHome, "scoop/apps/firefox/current/firefox.exe"); + if (scoopFirefox.exists()) { + options.setBinary(scoopFirefox.getAbsolutePath()); + } + + try { + driver = new FirefoxDriver(options); + driver.manage().timeouts().scriptTimeout(Duration.ofSeconds(10)); + DriverManager.pushSessionOverride(driver); + } catch (Throwable t) { + if (server != null) { + server.stop(0); + } + throw new SkipException("Firefox or geckodriver not available: " + t.getMessage(), t); + } + } + + @AfterClass(alwaysRun = true) + public void tearDown() { + NetworkMock.cleanup(); + DriverManager.popSessionOverride(); + if (driver != null) { + try { + driver.quit(); + } catch (Exception ignored) { + } + } + if (server != null) { + server.stop(0); + } + } + + @Test + public void testFirefoxBiDiNetworkMockingReturnJson() { + NetworkMock mock = NetworkMock.get(); + mock.stub("**/api/user/profile") + .returnJson("{\"name\":\"Alice\",\"browser\":\"firefox\",\"mocked\":true}"); + + driver.get("http://127.0.0.1:" + port + "/"); + + JavascriptExecutor js = (JavascriptExecutor) driver; + String script = + "var callback = arguments[arguments.length - 1];\n" + + "fetch('/api/user/profile')\n" + + " .then(response => response.json())\n" + + " .then(data => callback(data))\n" + + " .catch(err => callback({error: err.toString()}));"; + + @SuppressWarnings("unchecked") + Map result = (Map) js.executeAsyncScript(script); + + assertNotNull(result, "Result from fetch should not be null"); + assertEquals(result.get("name"), "Alice"); + assertEquals(result.get("browser"), "firefox"); + assertEquals(result.get("mocked"), Boolean.TRUE); + + mock.clear(); + } + + @Test + public void testFirefoxBiDiNetworkMockingReturnStatus() { + NetworkMock mock = NetworkMock.get(); + mock.stub("**/api/error") + .returnStatus(503); + + driver.get("http://127.0.0.1:" + port + "/"); + + JavascriptExecutor js = (JavascriptExecutor) driver; + String script = + "var callback = arguments[arguments.length - 1];\n" + + "fetch('/api/error')\n" + + " .then(response => callback(response.status))\n" + + " .catch(err => callback(-1));"; + + Object status = js.executeAsyncScript(script); + assertEquals(status, 503L); + + mock.clear(); + } +} + diff --git a/src/test/java/com/seleniumboot/network/NetworkMockTestSupport.java b/src/test/java/com/seleniumboot/network/NetworkMockTestSupport.java new file mode 100644 index 0000000..c8c078c --- /dev/null +++ b/src/test/java/com/seleniumboot/network/NetworkMockTestSupport.java @@ -0,0 +1,46 @@ +package com.seleniumboot.network; + +import org.openqa.selenium.WebDriver; + +import java.util.List; + +/** + * Test-support helper for accessing package-private testing hooks in {@link NetworkMock}. + * Lives exclusively in test scope ({@code src/test/java}) so it is never exposed in the + * published framework artifact. + */ +public final class NetworkMockTestSupport { + + private NetworkMockTestSupport() { + } + + @FunctionalInterface + public interface BiDiNetworkFactory { + org.openqa.selenium.bidi.module.Network create(WebDriver driver); + } + + public static void setBiDiFactory(BiDiNetworkFactory factory) { + NetworkMock.setBiDiFactory(factory::create); + } + + public static void resetBiDiFactory() { + NetworkMock.resetBiDiFactory(); + } + + public static boolean isAttached(NetworkMock mock) { + return mock.isAttached(); + } + + public static boolean isBiDiActive(NetworkMock mock) { + return mock.isBiDiActive(); + } + + public static boolean isCdpActive(NetworkMock mock) { + return mock.isCdpActive(); + } + + public static List getStubs(NetworkMock mock) { + return mock.getStubs(); + } +} + diff --git a/src/test/java/com/seleniumboot/unit/NetworkMockTest.java b/src/test/java/com/seleniumboot/unit/NetworkMockTest.java index 7719706..d8eb40a 100644 --- a/src/test/java/com/seleniumboot/unit/NetworkMockTest.java +++ b/src/test/java/com/seleniumboot/unit/NetworkMockTest.java @@ -1,16 +1,61 @@ package com.seleniumboot.unit; +import com.seleniumboot.driver.DriverManager; import com.seleniumboot.network.NetworkMock; +import com.seleniumboot.network.NetworkMockTestSupport; +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(); + NetworkMockTestSupport.resetBiDiFactory(); + } + + @AfterMethod + public void tearDown() { + NetworkMock.cleanup(); + NetworkMockTestSupport.resetBiDiFactory(); + try { + DriverManager.popSessionOverride(); + } catch (Exception ignored) { + } + } + // ------------------------------------------------------------------ // Pattern matching — globToRegex + matches() // ------------------------------------------------------------------ @@ -82,21 +127,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"); + + NetworkMockTestSupport.setBiDiFactory(d -> mockNetwork); + + NetworkMock mock = NetworkMock.get(); + mock.stub("**/api/users").returnJson("{\"users\":[{\"id\":1}]}"); + + assertTrue(NetworkMockTestSupport.isBiDiActive(mock), "BiDi should be active when driver implements HasBiDi"); + assertTrue(NetworkMockTestSupport.isAttached(mock), "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> headers = (List>) paramsMap.get("headers"); + assertNotNull(headers); + assertEquals(headers.get(0).get("name"), "content-type"); + + Map bodyMap = (Map) paramsMap.get("body"); + assertEquals(bodyMap.get("value"), "{\"users\":[{\"id\":1}]}"); + } + + @Test + @SuppressWarnings("unchecked") + public void bidi_handlesNonMatchingRequest_callsContinueRequest() { + 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"); + NetworkMockTestSupport.setBiDiFactory(d -> mockNetwork); + + NetworkMock mock = NetworkMock.get(); + mock.stub("**/api/users").returnJson("[]"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Consumer.class); + verify(mockNetwork).onBeforeRequestSent(captor.capture()); + Consumer listener = captor.getValue(); + + // Non-matching URL + 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/orders"); + when(req.getRequestId()).thenReturn("req-202"); + + listener.accept(event); + + ArgumentCaptor contCaptor = + ArgumentCaptor.forClass(ContinueRequestParameters.class); + verify(mockNetwork).continueRequest(contCaptor.capture()); + assertEquals(contCaptor.getValue().toMap().get("request"), "req-202"); + verify(mockNetwork, never()).provideResponse(any()); + } + + @Test + @SuppressWarnings("unchecked") + public void bidi_ignoresUnblockedRequest() { + 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"); + NetworkMockTestSupport.setBiDiFactory(d -> mockNetwork); + + NetworkMock mock = NetworkMock.get(); + mock.stub("**/api/users").returnJson("[]"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Consumer.class); + verify(mockNetwork).onBeforeRequestSent(captor.capture()); + Consumer listener = captor.getValue(); + + BeforeRequestSent event = mock(BeforeRequestSent.class); + when(event.isBlocked()).thenReturn(false); + + listener.accept(event); + + verify(mockNetwork, never()).provideResponse(any()); + verify(mockNetwork, never()).continueRequest(any()); + } + + @Test + @SuppressWarnings("unchecked") + public void bidi_delayAppliesWaitTime() { + 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"); + NetworkMockTestSupport.setBiDiFactory(d -> mockNetwork); + + NetworkMock mock = NetworkMock.get(); + mock.stub("**/slow").delay(40).returnStatus(200); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Consumer.class); + verify(mockNetwork).onBeforeRequestSent(captor.capture()); + Consumer listener = captor.getValue(); + + 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/slow"); + when(req.getRequestId()).thenReturn("req-slow"); + + long start = System.currentTimeMillis(); + listener.accept(event); + long elapsed = System.currentTimeMillis() - start; + + assertTrue(elapsed >= 30, "Should have delayed response by at least 30ms, took " + elapsed + "ms"); + verify(mockNetwork).provideResponse(any()); + } + + @Test + public void bidi_clear_removesInterceptAndClosesNetwork() { + 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"); + NetworkMockTestSupport.setBiDiFactory(d -> mockNetwork); + + NetworkMock mock = NetworkMock.get(); + mock.stub("**/api/users").returnJson("[]"); + + mock.clear(); + + verify(mockNetwork).removeIntercept("intercept-bidi-1"); + verify(mockNetwork).close(); + assertFalse(NetworkMockTestSupport.isBiDiActive(mock)); + assertFalse(NetworkMockTestSupport.isAttached(mock)); + assertTrue(NetworkMockTestSupport.getStubs(mock).isEmpty()); + } + + // ------------------------------------------------------------------ + // CDP Fallback Paths + // ------------------------------------------------------------------ + + @Test + @SuppressWarnings("unchecked") + public void fallback_whenBiDiFailsOnChromium_fallsBackToCdp() { + // Driver supports both HasBiDi and ChromiumDriver, but BiDi fails + ChromiumDriver chromium = mock(ChromiumDriver.class, withSettings().extraInterfaces(HasBiDi.class)); + DevTools devTools = mock(DevTools.class); + when(chromium.getDevTools()).thenReturn(devTools); + DriverManager.pushSessionOverride(chromium); + + NetworkMockTestSupport.setBiDiFactory(d -> { + throw new BiDiException("WebSocket connection rejected"); + }); + + NetworkMock mock = NetworkMock.get(); + mock.stub("**/api/checkout").returnStatus(500); + + assertFalse(NetworkMockTestSupport.isBiDiActive(mock), "BiDi should be inactive due to failure"); + assertTrue(NetworkMockTestSupport.isCdpActive(mock), "CDP should be active after BiDi fallback"); + assertTrue(NetworkMockTestSupport.isAttached(mock), "NetworkMock should be attached"); + + verify(devTools).createSession(); + verify(devTools).send(any(Command.class)); + + ArgumentCaptor> cdpCaptor = ArgumentCaptor.forClass(Consumer.class); + verify(devTools).addListener(any(), cdpCaptor.capture()); + Consumer cdpListener = cdpCaptor.getValue(); + + RequestPaused event = mock(RequestPaused.class); + org.openqa.selenium.devtools.v153.network.model.Request cdpReq = + mock(org.openqa.selenium.devtools.v153.network.model.Request.class); + when(event.getRequest()).thenReturn(cdpReq); + when(cdpReq.getUrl()).thenReturn("https://api.example.com/api/checkout"); + when(event.getRequestId()).thenReturn(new RequestId("cdp-1")); + + cdpListener.accept(event); + + verify(devTools, atLeastOnce()).send(any(Command.class)); + + mock.clear(); + assertFalse(NetworkMockTestSupport.isCdpActive(mock)); + assertFalse(NetworkMockTestSupport.isAttached(mock)); + } + + @Test + public void fallback_whenDriverIsOnlyChromium_attachesCdpDirectly() { + ChromiumDriver chromium = mock(ChromiumDriver.class); + DevTools devTools = mock(DevTools.class); + when(chromium.getDevTools()).thenReturn(devTools); + DriverManager.pushSessionOverride(chromium); + + NetworkMock mock = NetworkMock.get(); + mock.stub("**/api/products").returnJson("[]"); + + assertFalse(NetworkMockTestSupport.isBiDiActive(mock)); + assertTrue(NetworkMockTestSupport.isCdpActive(mock)); + assertTrue(NetworkMockTestSupport.isAttached(mock)); + verify(devTools).createSession(); + } + + @Test + public void fallback_unsupportedBrowser_logsWarningAndContinuesSafely() { + WebDriver unsupportedDriver = mock(WebDriver.class); + DriverManager.pushSessionOverride(unsupportedDriver); + + NetworkMock mock = NetworkMock.get(); + mock.stub("**/api/users").returnJson("[]"); + + assertFalse(NetworkMockTestSupport.isBiDiActive(mock)); + assertFalse(NetworkMockTestSupport.isCdpActive(mock)); + assertTrue(NetworkMockTestSupport.isAttached(mock)); + + // Clearing should complete without exceptions + mock.clear(); + assertFalse(NetworkMockTestSupport.isAttached(mock)); + } + + @Test + public void noDriverActive_logsWarningAndDoesNotThrow() { + // No session override pushed, DriverManager.getDriver() throws IllegalStateException + NetworkMock mock = NetworkMock.get(); + mock.stub("**/api/users").returnJson("[]"); + + assertTrue(NetworkMockTestSupport.isAttached(mock)); + assertFalse(NetworkMockTestSupport.isBiDiActive(mock)); + assertFalse(NetworkMockTestSupport.isCdpActive(mock)); + + mock.clear(); + assertFalse(NetworkMockTestSupport.isAttached(mock)); + } + + @Test + public void sameStubTest_passesOnChromeAndFirefox() { + // 1. Run stub logic on Firefox (BiDi) + WebDriver firefoxDriver = mock(WebDriver.class, withSettings().extraInterfaces(HasBiDi.class)); + DriverManager.pushSessionOverride(firefoxDriver); + Network mockBidi = mock(Network.class); + when(mockBidi.addIntercept(any())).thenReturn("bidi-fx-1"); + NetworkMockTestSupport.setBiDiFactory(d -> mockBidi); + + NetworkMock fxMock = NetworkMock.get(); + fxMock.stub("**/api/profile").returnJson("{\"username\":\"tester\"}"); + + assertTrue(NetworkMockTestSupport.isBiDiActive(fxMock), "Firefox should activate BiDi interception"); + fxMock.clear(); + DriverManager.popSessionOverride(); + NetworkMock.cleanup(); + NetworkMockTestSupport.resetBiDiFactory(); + + // 2. Run the exact same stub logic on Chrome (CDP fallback when BiDi fails or is unconfigured) + ChromiumDriver chromeDriver = mock(ChromiumDriver.class); + DevTools chromeDevTools = mock(DevTools.class); + when(chromeDriver.getDevTools()).thenReturn(chromeDevTools); + DriverManager.pushSessionOverride(chromeDriver); + NetworkMockTestSupport.setBiDiFactory(d -> { + throw new BiDiException("WebSocket connection not enabled on Chrome session"); + }); + + NetworkMock chromeMock = NetworkMock.get(); + chromeMock.stub("**/api/profile").returnJson("{\"username\":\"tester\"}"); + + assertTrue(NetworkMockTestSupport.isCdpActive(chromeMock), "Chrome should activate CDP interception when BiDi is absent"); + chromeMock.clear(); + DriverManager.popSessionOverride(); + } + + @Test + public void cleanup_removesThreadLocalInstance() { + WebDriver mockDriver = mock(WebDriver.class, withSettings().extraInterfaces(HasBiDi.class)); + DriverManager.pushSessionOverride(mockDriver); + + Network mockNetwork = mock(Network.class); + NetworkMockTestSupport.setBiDiFactory(d -> mockNetwork); + + NetworkMock first = NetworkMock.get(); + first.stub("**/api/users").returnJson("[]"); + + NetworkMock.cleanup(); + + NetworkMock second = NetworkMock.get(); + assertNotSame(first, second, "cleanup() should remove per-thread instance"); } }