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.
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")
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.scriptanddriver.networkhigh-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?
The callback list itself is observable state, so a bounded explicit wait can wait for the event rather than guessing a delay.
What should happen if webSocketUrl is
absent?
Record the runtime as unsupported for the live BiDi path and use the explicitly labeled simulation fallback if the lesson must remain executable.
Why does the network callback record only URL and method?
Data minimization: those fields answer the learning question without collecting sensitive headers, cookies, or bodies.
What does removing the console handler prove?
That subscription lifecycle is explicit and later browser events do not continue contaminating this test collector.
A network API raises after webSocketUrl was
negotiated. Should the helper silently return simulation
success?
No. Preserve the live failure as evidence; fallback is for known support gaps, not a blanket exception-swallowing retry.
Official references and version notes
- Selenium 4.47 release notes — current stable client/Grid baseline and BiDi changes.
- WebDriver BiDi overview — high-level Selenium direction and CDP relationship.
- W3C-compliant BiDirectional API — cross-browser WebSocket event model and domains.
- BiDi logging features — console and JavaScript error handlers.
- BiDi network features — request/response/auth handler concepts.
- BiDi script features — high-level script namespace.
-
Python Options API
—
enable_bidi. - Selenium Python API 4.47 — Python 3.10+ and current binding surface.
- Selenium Grid — remote sessions and the next chapter boundary.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.