Skip to main content
Selenium advanced Lesson 10 of 10

CDP, BiDi, and Modern Selenium

Intercept network traffic, emulate devices and capture console errors — with Chrome DevTools Protocol today and WebDriver BiDi as the cross-browser replacement.

Classic WebDriver drives the page. It cannot mock an API response, throttle the network, emulate a phone, or tell you the page threw an exception. CDP and BiDi fill that gap — one Chromium-only and mature, the other standard and arriving.

Console errors, the cheapest win

with webdriver.Chrome(options=opts) as driver:
    driver.get("https://demo.playwright.dev/todomvc")
    driver.execute_script("console.error('something broke');")
    driver.execute_script("setTimeout(() => { null.foo; }, 0);")
    time.sleep(0.5)

    for entry in driver.get_log("browser"):
        print(f"{entry['level']}: {entry['message'][:90]}")
SEVERE: console-api 1:1 "something broke"
SEVERE: https://demo.playwright.dev/todomvc/ 1:1 Uncaught TypeError: Cannot read
properties of null (reading 'foo')

Turn it into an assertion every test gets for free:

@pytest.fixture(autouse=True)
def no_console_errors(driver):
    yield
    severe = [e for e in driver.get_log("browser")
              if e["level"] == "SEVERE" and "favicon" not in e["message"]]
    assert not severe, "console errors:\n" + "\n".join(e["message"][:120] for e in severe)
test_todos.py::test_adds_a_todo FAILED

E   AssertionError: console errors:
E     https://app.example.com/main.js 412:19 Uncaught TypeError: Cannot read properties
E     of undefined (reading 'id')

The test’s own assertions passed; the fixture failed it for an exception nobody would have noticed. Adding this to an existing suite typically finds real bugs on the first run — expect to allowlist a few known-noisy sources before it is green.

get_log("browser") is Chromium-only. The BiDi section below is the portable version.

Network interception with CDP

with webdriver.Chrome(options=opts) as driver:
    driver.execute_cdp_cmd("Network.enable", {})
    driver.execute_cdp_cmd("Network.setBlockedURLs", {
        "urls": ["*google-analytics.com*", "*doubleclick*", "*.woff2"]
    })

    driver.get("https://demo.playwright.dev/todomvc")
    print("loaded with third-party requests blocked:", driver.title)
loaded with third-party requests blocked: React • TodoMVC

Blocking analytics and fonts suite-wide removes a real source of slowness and flakiness — nothing you assert on depends on them.

Mocking a response needs Fetch:

import base64, json, threading

def mock_orders(driver):
    driver.execute_cdp_cmd("Fetch.enable", {
        "patterns": [{"urlPattern": "*/api/orders*", "requestStage": "Request"}]
    })

    body = json.dumps([{"ref": "A-1001", "total": 25.5}])
    def handle(message):
        params = message["params"]
        driver.execute_cdp_cmd("Fetch.fulfillRequest", {
            "requestId": params["requestId"],
            "responseCode": 200,
            "responseHeaders": [{"name": "content-type", "value": "application/json"}],
            "body": base64.b64encode(body.encode()).decode(),
        })

    driver.add_cdp_listener("Fetch.requestPaused", handle)
GET /api/orders  →  fulfilled from mock (200, 1 order)
page rendered: A-1001 — £25.5

That is how you test an empty state, a 500, or a slow response without touching the backend — the same states lesson 7 of the Playwright track produces with page.route. Selenium’s version is more code and Chromium-only, which is the honest trade.

Failure states are the valuable case:

        driver.execute_cdp_cmd("Fetch.failRequest", {
            "requestId": params["requestId"], "errorReason": "Failed"
        })
page rendered: Could not load orders: Failed to fetch

Emulation

with webdriver.Chrome(options=opts) as driver:
    driver.execute_cdp_cmd("Emulation.setDeviceMetricsOverride", {
        "width": 390, "height": 844, "deviceScaleFactor": 3, "mobile": True,
    })
    driver.execute_cdp_cmd("Emulation.setUserAgentOverride", {
        "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
                     "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148",
    })
    driver.execute_cdp_cmd("Emulation.setTimezoneOverride", {"timezoneId": "Asia/Tokyo"})
    driver.execute_cdp_cmd("Emulation.setLocaleOverride", {"locale": "ja-JP"})
    driver.execute_cdp_cmd("Emulation.setGeolocationOverride", {
        "latitude": 35.6762, "longitude": 139.6503, "accuracy": 10,
    })

    driver.get("https://demo.playwright.dev/todomvc")
    print(driver.execute_script(
        "return [innerWidth, navigator.userAgent.slice(0,28), "
        "Intl.DateTimeFormat().resolvedOptions().timeZone]"))
[390, 'Mozilla/5.0 (iPhone; CPU iPh', 'Asia/Tokyo']

Timezone and locale overrides are the fix for the environment-dependent flakiness from lesson 7, applied at the browser rather than the machine.

Network conditions too:

    driver.execute_cdp_cmd("Network.emulateNetworkConditions", {
        "offline": False, "latency": 400,
        "downloadThroughput": 400 * 1024 / 8, "uploadThroughput": 400 * 1024 / 8,
    })
    t0 = time.perf_counter()
    driver.get("https://demo.playwright.dev/todomvc")
    print(f"slow 3G load: {time.perf_counter() - t0:.1f}s")

    driver.execute_cdp_cmd("Network.emulateNetworkConditions", {"offline": True,
        "latency": 0, "downloadThroughput": 0, "uploadThroughput": 0})
    driver.get("https://demo.playwright.dev/todomvc")
    print("offline title:", driver.title)
slow 3G load: 6.8s
offline title: demo.playwright.dev

Testing a loading spinner is otherwise nearly impossible — it exists for 40ms on a fast connection. Throttle, and it becomes assertable.

Basic auth without the dialog

    driver.execute_cdp_cmd("Network.enable", {})
    token = base64.b64encode(b"ada:correct-horse").decode()
    driver.execute_cdp_cmd("Network.setExtraHTTPHeaders", {
        "headers": {"Authorization": f"Basic {token}"}
    })
    driver.get("https://staging.example.com/orders")
    print("reached:", driver.title)
reached: Orders — Staging

Lesson 5 listed the basic-auth prompt as unreachable by WebDriver. This is the way around it, and it is the most common practical reason teams reach for CDP at all.

WebDriver BiDi

BiDi is the W3C standard doing what CDP does, across browsers, with the browser pushing events to you rather than only answering commands.

options = webdriver.ChromeOptions()
options.enable_bidi = True                    # also works with FirefoxOptions

with webdriver.Chrome(options=options) as driver:
    driver.get("https://demo.playwright.dev/todomvc")
    print("bidi session:", driver.capabilities.get("webSocketUrl") is not None)
bidi session: True

Collecting log entries as they happen:

from selenium.webdriver.common.bidi.script import LogEntryAdded

entries = []
with webdriver.Chrome(options=options) as driver:
    driver.script.add_console_message_handler(entries.append)
    driver.script.add_javascript_error_handler(entries.append)

    driver.get("https://demo.playwright.dev/todomvc")
    driver.execute_script("console.warn('low stock'); setTimeout(() => { null.foo; }, 0);")
    time.sleep(0.5)

    for e in entries:
        print(f"{getattr(e, 'level', 'error')}: {getattr(e, 'text', getattr(e, 'message', ''))[:70]}")
warning: low stock
error: Uncaught TypeError: Cannot read properties of null (reading 'foo')

Same information as get_log("browser"), pushed rather than polled, and — the point — working on Firefox too.

Running script before page load, which CDP does with Page.addScriptToEvaluateOnNewDocument:

    driver.script.add_preload_script("() => { window.__testMode = true; }")
    driver.get("https://demo.playwright.dev/todomvc")
    print("flag set before app code ran:", driver.execute_script("return window.__testMode"))
flag set before app code ran: True

Network events:

    seen = []
    driver.network.add_request_handler(lambda r: seen.append(r.url))
    driver.get("https://demo.playwright.dev/todomvc")
    print(f"{len(seen)} requests, first: {seen[0][:52]}")
7 requests, first: https://demo.playwright.dev/todomvc/

BiDi coverage is still expanding — as of Selenium 4.28 logging, preload scripts, network events and basic interception are usable, with more landing each release. Check the current docs before designing a suite around a specific capability.

Which to reach for

NeedUse
clicks, typing, locators, waitsclassic WebDriver
console errors, one browserget_log("browser")
console errors, cross-browserBiDi
block third-party requestsCDP Network.setBlockedURLs
mock an API responseCDP Fetch, or BiDi interception
device / geolocation / timezone emulationCDP Emulation
basic auth headersCDP Network.setExtraHTTPHeaders
network throttlingCDP Network.emulateNetworkConditions

Two cautions on CDP. It is Chromium-only, so a suite depending on it cannot run the Firefox job from lesson 8. And it is version-coupled — Selenium ships bindings for a few protocol versions, and a Chrome upgrade can break them:

WARNING: Unable to find an exact match for CDP version 133, so returning the closest
version found: 132

Usually harmless, occasionally not. Keep CDP usage in one module behind a small wrapper so a protocol change is one file to fix, and so the BiDi migration is contained.

Where Selenium sits

Having read this track and, perhaps, the Playwright one: Playwright gives you auto-waiting, network interception and tracing as first-class features, with a smaller browser and language surface. Selenium gives you a W3C standard implemented by the browser vendors themselves, bindings in a dozen languages, Grid, and two decades of ecosystem — and asks you to handle synchronisation yourself.

Neither is the wrong answer. Choose Selenium when you need language breadth, real device and browser-version coverage, or you already have a suite; choose Playwright for a new JavaScript-or-Python project where speed of authoring matters most. BiDi is closing the capability gap, which makes the choice increasingly about ecosystem rather than features.

Practice

1. Fail a test on any console error.
E   AssertionError: console errors:
E     main.js 412:19 Uncaught TypeError: Cannot read properties of undefined

The test’s own assertions passed. Adding this fixture to an existing suite is the highest- value change in this lesson, and it usually finds something on the first run.

2. Block third-party requests and compare load time.
driver.execute_cdp_cmd("Network.setBlockedURLs",
                       {"urls": ["*analytics*", "*doubleclick*", "*.woff2"]})
without blocking: 2.41s
with blocking:    1.12s

Half the load time, and one fewer source of intermittent failure. Nothing you assert on depends on an analytics beacon.

3. Throttle to slow 3G and assert a loading state.
slow 3G load: 6.8s
spinner visible: True

At full speed the spinner exists for about 40 milliseconds and no assertion can catch it reliably. Throttling is what makes the loading state testable at all.

4. Enable BiDi and collect console messages on two browsers.
# chrome
warning: low stock
error: Uncaught TypeError: Cannot read properties of null

# firefox
warning: low stock
error: null has no properties

The same handler on both browsers — the message wording differs because the JavaScript engines do. That portability is what BiDi adds over CDP.

That closes the Selenium track. The thread through all ten lessons: WebDriver does exactly what you tell it and waits for nothing, so a reliable suite is one where every step states the condition it depends on.

Frequently Asked Questions

What is the Chrome DevTools Protocol in Selenium?
A Chromium-only channel that exposes browser internals WebDriver does not cover — network interception, device emulation, performance metrics, console streaming. `driver.execute_cdp_cmd` sends raw CDP commands, and it works only on Chromium browsers.
What is WebDriver BiDi and why does it matter?
A W3C standard for bidirectional browser communication — the browser can push events to your test rather than only answering commands. It gives CDP-like capabilities across Chrome, Firefox and Edge, and it is where Selenium is heading.
Can Selenium capture browser console errors?
Yes, three ways. `driver.get_log('browser')` works in Chromium as a simple poll, CDP streams them live, and BiDi does the same across browsers. Failing a test on an uncaught page exception catches bugs no assertion was written for.
Should I use CDP in production test suites?
Sparingly. It is Chromium-only and tied to protocol versions that change between releases, so it makes a suite fragile and non-portable. Use it for capabilities WebDriver genuinely lacks, and migrate to BiDi as coverage lands.