Chapter 18Lesson 02~245 minutes

WebDriver BiDi: Events, Network, Logs, Script, and Bidirectional Control: Guided Hands-On Workflow

The mental model becomes useful only when each event can be tied to an intentional browser action. This guided workflow uses a loopback AUT, current Selenium Python high-level APIs, bounded waits, and explicit cleanup. A simulation path keeps the lesson reproducible when a browser/binding combination does not expose the same event feature.

Hands-onConsole eventsNetwork eventsFallbackLoopback AUT

Learning objectives

  • Create a disposable local AUT that emits one console event and one network request.
  • Enable BiDi in Selenium Python and inspect the negotiated session before subscribing.
  • Subscribe to a console message and a network request with documented high-level APIs.
  • Correlate events to test ID and context without using fixed sleeps.
  • Remove handlers and select a deterministic simulation fallback when runtime parity is unavailable.

1. Create the disposable fixture

The following example makes the Create the disposable fixture behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

from pathlib import Path

ROOT = Path("chapter18_fixture")
SITE = ROOT / "site"
SITE.mkdir(parents=True, exist_ok=True)

(SITE / "index.html").write_text(r'''<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>BiDi Local Lab</title><style>body{font-family:system-ui;margin:30px;max-width:850px}button{margin:6px;padding:10px 14px}.panel{border:1px solid #999;padding:16px;border-radius:12px;margin:16px 0}</style></head>
<body><h1 data-testid="heading">BiDi Local Lab</h1><p data-testid="context">ready</p>
<div class="panel"><button data-testid="console" type="button">Emit console</button><button data-testid="request" type="button">Make request</button><button data-testid="fail" type="button">Fail request</button></div>
<div class="panel"><p data-testid="status" data-state="idle">idle</p><code data-testid="result"></code></div>
<script>
const q=id=>document.querySelector(`[data-testid="${id}"]`);
const testId=new URLSearchParams(location.search).get('test_id')||'manual';
q('context').textContent=`test_id=${testId}`;
q('console').onclick=()=>{console.log(`bidi-console test_id=${testId}`);q('status').dataset.state='console';q('status').textContent='console emitted'};
q('request').onclick=async()=>{q('status').dataset.state='pending';const r=await fetch(`/api/ping?test_id=${encodeURIComponent(testId)}`,{headers:{'X-Test-Id':testId}});const data=await r.json();q('result').textContent=JSON.stringify(data);q('status').dataset.state='ok';q('status').textContent='request complete'};
q('fail').onclick=async()=>{q('status').dataset.state='pending';try{const r=await fetch(`/api/fail?test_id=${encodeURIComponent(testId)}`);q('result').textContent=String(r.status);q('status').dataset.state='http-error';q('status').textContent='expected HTTP error'}catch(e){q('status').dataset.state='transport-error';q('status').textContent='transport error'}};
</script></body></html>''', encoding="utf-8")

(ROOT / "serve.py").write_text(r'''from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from urllib.parse import urlsplit, parse_qs
import json
ROOT=Path(__file__).resolve().parent; SITE=ROOT/"site"; HOST="127.0.0.1"; PORT=8782
class H(SimpleHTTPRequestHandler):
    def translate_path(self,path):
        raw=super().translate_path(path); rel=Path(raw).relative_to(Path.cwd()); return str(SITE/rel)
    def log_message(self,fmt,*args): pass
    def do_GET(self):
        parts=urlsplit(self.path)
        if parts.path=="/api/ping":
            body=json.dumps({"ok":True,"test_id":parse_qs(parts.query).get("test_id",[""])[0]}).encode()
            self.send_response(200); self.send_header("Content-Type","application/json"); self.send_header("Content-Length",str(len(body))); self.end_headers(); self.wfile.write(body); return
        if parts.path=="/api/fail":
            body=b'{"ok":false,"reason":"synthetic"}'
            self.send_response(503); self.send_header("Content-Type","application/json"); self.send_header("Content-Length",str(len(body))); self.end_headers(); self.wfile.write(body); return
        return super().do_GET()
server=ThreadingHTTPServer((HOST,PORT),H); print(f"BiDi fixture: http://{HOST}:{PORT}/")
try: server.serve_forever()
except KeyboardInterrupt: pass
finally: server.server_close()
''', encoding="utf-8")
print(ROOT.resolve())

The following example makes the Create the disposable fixture behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

python -m venv .venv
# Windows: .venv\Scripts\activate
# POSIX:   source .venv/bin/activate
python -m pip install "selenium==4.47.0"
python build_fixture.py
python chapter18_fixture/serve.py

Only 127.0.0.1:8782 is used. The fixture has no real account, secret, proxy, certificate exception, cloud browser, or public Grid dependency.

2. Preflight browser and BiDi capability

The following example makes the Preflight browser and BiDi capability behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

import selenium
from selenium import webdriver

options = webdriver.ChromeOptions()
options.enable_bidi = True

driver = webdriver.Chrome(options=options)
try:
    caps = driver.capabilities
    print({
        "selenium": selenium.__version__,
        "session_id": driver.session_id,
        "browser": caps.get("browserName"),
        "browserVersion": caps.get("browserVersion"),
        "platformName": caps.get("platformName"),
        "webSocketUrl": caps.get("webSocketUrl"),
    })
finally:
    driver.quit()

If webSocketUrl is absent or high-level BiDi properties are unavailable, record that as a compatibility result. Do not replace the browser or silently switch to CDP simply to make the lesson “pass.”

3. Subscribe to a harmless console event

The following example makes the Subscribe to a harmless console event behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait

options = webdriver.ChromeOptions()
options.enable_bidi = True

driver = webdriver.Chrome(options=options)
messages = []
handler_id = None
try:
    driver.get("http://127.0.0.1:8782/?test_id=guided-console")
    handler_id = driver.script.add_console_message_handler(messages.append)
    driver.find_element(By.CSS_SELECTOR, '[data-testid="console"]').click()
    WebDriverWait(driver, 3).until(lambda _: messages)
    entry = messages[0]
    print("console_text", getattr(entry, "text", str(entry)))
    print("classic_handle", driver.current_window_handle)
finally:
    if handler_id is not None:
        driver.script.remove_console_message_handler(handler_id)
    driver.quit()

The handler exists before the click. WebDriverWait waits for the callback list to become non-empty; no fixed sleep guesses when the browser will deliver the event. The callback record is evidence; the click remains an ordinary WebDriver interaction.

4. Observe an outgoing request with the high-level network API

The following example makes the Observe an outgoing request with the high-level network API behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait

options = webdriver.ChromeOptions()
options.enable_bidi = True

driver = webdriver.Chrome(options=options)
seen = []
handler_id = None
try:
    driver.get("http://127.0.0.1:8782/?test_id=guided-network")

    def on_request(request):
        url = getattr(request, "url", "")
        method = getattr(request, "method", None)
        if "/api/ping" in url:
            seen.append({"url": url, "method": method})

    handler_id = driver.network.add_request_handler("before_request", on_request)
    driver.find_element(By.CSS_SELECTOR, '[data-testid="request"]').click()
    WebDriverWait(driver, 3).until(lambda _: seen)
    WebDriverWait(driver, 3).until(
        lambda d: d.find_element(By.CSS_SELECTOR, '[data-testid="status"]').get_attribute("data-state") == "ok"
    )
    print(seen[0])
finally:
    if handler_id is not None:
        driver.network.remove_request_handler("before_request", handler_id)
    driver.quit()

The callback is intentionally narrow: it records only URL and method for the synthetic /api/ping request. Production collectors should not persist headers, cookies, or bodies by default.

5. Correlate event and browser state

The following example makes the Correlate event and browser state behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

record = {
    "test_id": "guided-network",
    "session_id": driver.session_id,
    "classic_window_handle": driver.current_window_handle,
    "url": driver.current_url,
    "browser": driver.capabilities.get("browserName"),
    "browserVersion": driver.capabilities.get("browserVersion"),
    "network_event": seen[0],
}
print(record)

The record deliberately labels the classic handle as a classic handle. If you later add a BiDi context-tree identifier, store it under a different field such as bidi_context_id. Clear names prevent a debugging convenience from becoming a protocol assumption.

6. Prove unsubscribe behavior

The following example makes the Prove unsubscribe behavior behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

messages = []
handler_id = driver.script.add_console_message_handler(messages.append)
driver.script.remove_console_message_handler(handler_id)
driver.find_element(By.CSS_SELECTOR, '[data-testid="console"]').click()

# Do not sleep. Verify application state changed, while the removed callback stays empty.
WebDriverWait(driver, 3).until(
    lambda d: d.find_element(By.CSS_SELECTOR, '[data-testid="status"]').get_attribute("data-state") == "console"
)
assert messages == []

This is a lifecycle test: the AUT action still occurred, but the removed observer did not collect the later event. If messages accumulate after supposed cleanup, suspect leaked handlers or a collector that reuses state across tests.

7. Deterministic fallback when parity is unavailable

BiDi is evolving. A browser/binding pair may lack a specific high-level domain even when another domain works. The learning objective is the event model, not forcing unsupported APIs. Use a clearly labeled simulation packet when the chosen runtime cannot expose the requested feature.

from pathlib import Path
import json
from datetime import datetime, timezone

OUT=Path("bidi-simulation")
OUT.mkdir(exist_ok=True)
context_id="simulated-context-01"
rows=[
    {"ts":datetime.now(timezone.utc).isoformat(),"domain":"log","event":"log.entryAdded","context":context_id,"text":"bidi-console test_id=checkpoint"},
    {"ts":datetime.now(timezone.utc).isoformat(),"domain":"network","event":"network.beforeRequestSent","context":context_id,"url":"http://127.0.0.1:8782/api/ping?test_id=checkpoint","method":"GET"},
]
(OUT/"events.jsonl").write_text("".join(json.dumps(x)+"\n" for x in rows),encoding="utf-8")
print(OUT/"events.jsonl")
Fallback contract

A simulated event must be labeled simulated, use the same evidence schema, and never be reported as proof that the browser emitted the event.

8. Capability-driven helper instead of exception swallowing

The following example makes the Capability-driven helper instead of exception swallowing behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

def choose_bidi_path(driver):
    caps = driver.capabilities
    if not caps.get("webSocketUrl"):
        return "simulation:no-websocket"
    if not hasattr(type(driver), "script"):
        return "simulation:no-script-api"
    if not hasattr(type(driver), "network"):
        return "partial:script-only"
    return "live:script+network"

print(choose_bidi_path(driver))

This helper describes support; it does not catch every exception and pretend success. If a live API exists but fails during subscription, preserve the exception and session evidence for diagnosis.

9. Challenge: choose the correct control

Your test needs to know whether a request to /api/ping was issued, but the application eventually reaches the same “ok” DOM state whether it came from cache or network. Choose among: polling the DOM, a broad console subscription, a targeted network request handler, or a Chromium CDP listener. For a currently supported BiDi session, the targeted network handler best matches the evidence question and minimizes unrelated data.

10. Cleanup

The following example makes the Cleanup behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

# Stop chapter18_fixture/serve.py with Ctrl+C.
python - <<'PY'
from pathlib import Path
import shutil
for p in [Path("chapter18_fixture"), Path("bidi-simulation")]:
    if p.exists(): shutil.rmtree(p)
print("Chapter 18 disposable files removed")
PY

Handler cleanup happens inside the Selenium examples before quit(). Filesystem cleanup is separate. This separation matters in CI because evidence may need retention even after the browser session closes.

11. Lesson summary

  • Enable BiDi explicitly and inspect negotiated capability state.
  • Subscribe before triggering the event and wait on observable callback state.
  • Use driver.script and driver.network high-level APIs where supported.
  • Record only the evidence fields needed for the hypothesis.
  • Unsubscribe deterministically and keep fallback results explicitly labeled.

Knowledge check

Why is time.sleep() unnecessary in the console example?

What should happen if webSocketUrl is absent?

Why does the network callback record only URL and method?

What does removing the console handler prove?

A network API raises after webSocketUrl was negotiated. Should the helper silently return simulation success?

Next lesson

WebDriver BiDi: Events, Network, Logs, Script, and Bidirectional Control: Configuration, Design Patterns, and Trade-Offs

Continue with WebDriver BiDi: Events, Network, Logs, Script, and Bidirectional Control: Configuration, Design Patterns, and Trade-Offs. It builds directly on the state, evidence, and operating assumptions established here, so carry those constraints forward rather than treating the next page as an isolated topic.

Official references and version notes

Version and compatibility note

Version-sensitive behavior was rechecked against Selenium primary documentation on 2026-08-28. Mandatory examples pin Selenium Python 4.47.0 and Python 3.10+, request BiDi through options.enable_bidi = True, prefer documented high-level driver.script/driver.network APIs, use Selenium Manager for normal local driver resolution, and target only the loopback synthetic AUT. BiDi domain parity is explicitly treated as evolving. Low-level/internal classes are discussed as an adapter-only escape hatch, not the default. CDP is labeled Chromium-specific/temporary and is not used as a cross-browser fallback. Paid clouds, enterprise identity/proxies, managed Kubernetes, and public Grid endpoints are not required.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.