# selenium-devtools-py (Python) Python Selenium adapter for the WebdriverIO DevTools dashboard — the fourth adapter alongside the JS WebdriverIO / Nightwatch / Selenium-JS ones. It feeds the **same backend and UI**, unchanged, over the language-neutral `{scope, data}` WebSocket contract. **Both modes work.** **Live** streams to a dashboard window that auto-opens and tears down with the run; **trace** writes the same portable `trace.zip` the JavaScript adapters do and opens no window. Command capture and the test tree, browser console & network via BiDi, assertion rows, per-command screenshots and selectors, DOM replay, and screencast video are common to both. Verified against real headless Chrome. See [Trace mode](#trace-mode) and [Roadmap](#roadmap). ## Install (dev) ```bash pip install -e packages/selenium-devtools-py # or: pip install selenium-devtools-py (when published) ``` The transport is **dependency-free** (stdlib WebSocket client). `selenium>=4.44` is installed with the package; `pytest` is optional. Then install the backend, once: ```bash selenium-devtools install-backend ``` pip cannot do this for you — a wheel has no install hook, and the backend is a Node package. Skip it and runs still work: they fall back to fetching it with `npx` on first use. That fallback costs a registry round trip on *every* run, pays the whole download inside the first run, and fails in the middle of a test session when a proxy declines the package, so the one-time install is worth the one line. `selenium-devtools backend-path` says whether it is there. **Requires Node.js 18+ on your PATH — in every mode.** The backend is a Node app and pip cannot resolve it, so it is obtained separately. It is not only the dashboard window: the page collector is served by the backend, the whole event stream goes through its WebSocket, and in trace mode it is also what builds the archive (see [Trace mode](#trace-mode)) — so **without Node there is no capture at all**, and trace mode is no exception. `enable()` checks for it up front and names what is missing rather than failing later as a spawn timeout. To use a backend you are already running — in CI, or one started by hand — set `DEVTOOLS_PORT` and no local Node is needed. **Requires Python 3.10+ and selenium 4.44+.** Network capture subscribes through the public BiDi event API that selenium regenerated in 4.44 — before that the only way to observe requests without pausing them was a private connection, which the same release removed. 4.44 requires Python 3.10, which sets the Python floor too. Both floors are declared in `pyproject.toml` (`requires-python` and `dependencies`), so pip enforces them at install time rather than leaving you to discover an empty Network tab at runtime. If pip resolves an older selenium anyway — a pin elsewhere in your project, or an existing environment — the adapter says so on the first command instead of degrading quietly. ## Use **With pytest (recommended) — no code changes to your tests:** ```bash pytest --devtools tests/ # dashboard pytest --devtools-trace tests/ # trace archive instead (implies --devtools) ``` Or commit it, so everyone on the project gets it without remembering a flag: ```toml [tool.pytest.ini_options] devtools = true # devtools_trace = true # trace archive instead of a dashboard ``` Capture is always opt-in — the plugin auto-loads when the package is installed, so installing it must never change how an existing suite behaves. What you choose is only *how* you say yes: | | | |---|---| | `--devtools` / `--devtools-trace` | this run | | `devtools` / `devtools_trace` in `[tool.pytest.ini_options]` | this project | | `DEVTOOLS_ENABLE=1` (or `DEVTOOLS_PORT=`, which also attaches) | this shell — for CI | Highest wins: CLI, then ini, then environment. `pytest -o devtools=false` turns a project default off for one run, which is why there is no `--no-devtools`. `DEVTOOLS_TRACE=1` selects trace mode but does **not** switch capture on by itself — it is a mode fallback you may have exported for your own scripts, and reading it as an opt-in would capture pytest runs you never asked for. Two kinds of run stay uncaptured even when you opt in: `--collect-only`, where nothing executes, and a run that collected no tests — a mistyped path would otherwise leave the terminal parked on a dashboard for a run that never happened. Only the first is knowable up front; the second closes the window again at the end of collection. In live mode the plugin opens the dashboard in a dedicated window and — after the run — **keeps it open so you can inspect it**; close the window (or Ctrl-C) to finish. Nothing devtools-specific goes in your test files. ### How many archives, and which ones to keep Two settings shape the output. `traceGranularity` decides how many archives a run writes; `tracePolicy` decides which of them survive. | `traceGranularity` | | |---|---| | `session` | one archive for the whole run (the default) | | `test` | one per test, holding only that test's own commands, console, network, DOM mutations, a11y trees and frames | `spec` is not offered: this adapter's spec **is** its test file, so it could only silently mean one of the two above. | `tracePolicy` | | |---|---| | `on` | keep everything (the default) | | `retain-on-failure` | keep only what failed | Together: | | | |---|---| | `test` + `retain-on-failure` | only the tests that failed | | `session` + `retain-on-failure` | the whole run, if anything in it failed | | either + `on` | everything | ```bash pytest --devtools-trace-granularity test --devtools-trace-policy retain-on-failure tests/ ``` ```toml [tool.pytest.ini_options] devtools_trace_granularity = "test" devtools_trace_policy = "retain-on-failure" ``` A plain script passes `devtools.enable(trace_granularity="test", trace_policy="retain-on-failure")`. A committed example of all of this is in [`examples/selenium-py/pytest/`](../../examples/selenium-py/pytest/), whose `pytest.ini` documents every setting the adapter has. Naming a policy or a granularity **explicitly** selects trace mode — the CLI flag, the ini option and the `enable()` argument all imply it, since a policy means nothing in live mode. `DEVTOOLS_TRACE_POLICY` and `DEVTOOLS_TRACE_GRANULARITY` deliberately do **not**: an exported variable is ambient, and may have been set for a different script in the same shell, so flipping a live run to trace mode on that basis would take away the dashboard nobody asked to lose. Pair it with `DEVTOOLS_TRACE=1`. A run that ignores it says so rather than leaving you to notice a missing archive. The values are shared's `TraceRetentionPolicy`: `on` (the default — keep everything), `retain-on-failure`, `retain-on-first-failure`, `on-first-retry`, `on-all-retries`, `retain-on-failure-and-retries`. A value outside that set warns and keeps everything, rather than being discovered as a missing file. Two limits: - At `session` granularity the decision covers **the whole run** — one failing test keeps everything, because there is only one archive to keep. Use `test` granularity if you want only the failure. - The retry-aware policies — `retain-on-first-failure`, `on-first-retry`, `on-all-retries`, `retain-on-failure-and-retries` — **behave exactly like `retain-on-failure`**. Nothing on the wire carries an attempt number, so a retried test overwrites its own earlier outcome and the retry-aware question cannot be asked; the backend logs the degradation rather than pretending otherwise. Per-test `screenshot`, `video` and inline Allure attachment are still Node.js-only — it is the trace **archive** that is now per-test, not the other artifacts. A declined run is reported as the policy working, not as a failed export. **Without pytest** (any script / unittest) — add two lines to a normal Selenium script (`devtools.enable()` + `devtools.wait_for_dashboard_close()`): ```python import selenium_devtools as devtools devtools.enable() # open dashboard + capture every command # devtools.enable(trace=True) # write a trace.zip instead — no window # ... your normal selenium code, ending with driver.quit() ... devtools.wait_for_dashboard_close() # keep the UI open to inspect (no-op when no window is open) devtools.disable() ``` Runnable example: [`web_form.py`](../../examples/selenium-py/scripts/web_form.py), the three-line version above. From the repo root, after `pip install -e` above and a `pnpm build` so the backend exists: ```bash pnpm demo:python ``` Unlike `demo:wdio` and friends, this runs bare `python3` — the same command you would run yourself, deliberately, so the example stays a working example rather than something only this repo can launch. That means it uses whichever `python3` your shell resolves, and it needs the adapter installed into *that* interpreter. From an unactivated shell you get `No module named 'selenium_devtools'` (or `No module named pytest`), which names neither the venv nor the fix — so set one up once and activate it before running the demos: ```bash python3 -m venv .venv source .venv/bin/activate pip install -e packages/selenium-devtools-py ``` If the backend can't be launched or reached, `enable()` warns and returns `None` — capture is skipped, your tests still run. **ChromeDriver:** you need one matching your Chrome (a mismatch breaks all Selenium, not just this). Selenium 4.6+ auto-manages it when no `chromedriver` is on `PATH`; otherwise keep it current (`brew upgrade chromedriver`). ## What it captures | Data | How | Scope | Mode | |---|---|---|---| | Commands (driver + element) | wrap `WebDriver.execute()` — the single chokepoint all commands flow through | `commands` | both | | Command screenshot + selector | one screenshot per command, and the locator the element handle was found by | `commands` | both | | Session metadata | read `session_id` + `caps` on the first ready command | `metadata` | both | | Test / suite tree | pytest plugin (`pytest_runtest_logreport` / `sessionfinish`) | `suites` | both | | Browser console + JS errors | Selenium **BiDi** (`driver.script` handlers) | `consoleLogs` | both | | Network requests | Selenium **BiDi** (`Network.add_event_handler`, observe-only — never an intercept, which would pause every request) | `networkRequests` | both | | Assertions | pytest hooks under pytest; line tracing for a plain script | `commands` | both | | DOM snapshot / time-travel | register `packages/script` as a BiDi document-start preload, drain mutations with a forced document anchor (per-document `