Selenium DevTools
Selenium WebDriver adapter for WebdriverIO DevTools - brings the same visual debugging UI to any Selenium test, in Node.js or Python, regardless of the test runner.
Node.js works with Mocha, Jest, Cucumber, or a plain script - the plugin auto-detects the runner and wires test boundaries accordingly. Python works with pytest or a plain script, and under pytest needs no changes to your test files at all.
Pick your language in the tabs below; the choice follows you down the page.
Installation
- Node.js
- Python
npm install @wdio/selenium-devtools
pip install selenium-devtools-py
Requires Python 3.10+ and selenium>=4.44. Both are declared in the package metadata, so pip enforces them rather than leaving you to find an empty Network tab at runtime. Network capture subscribes through the public BiDi event API that selenium regenerated in 4.44; the private connection it replaced was removed in the same release, and 4.44 is what sets the Python floor.
Setup
- Node.js
- Python
Each block below is a complete, copy-paste-ready example including the DevTools.configure(...) call. Pick the runner you use, drop the snippet into your project, and run it.
Mocha
// tests/example.test.js
import { strict as assert } from 'node:assert'
import { Builder, By, until } from 'selenium-webdriver'
import { DevTools } from '@wdio/selenium-devtools'
DevTools.configure({
screencast: { enabled: true, quality: 70, maxWidth: 1280, maxHeight: 720 }
})
describe('smoke test', function () {
let driver
before(async function () {
driver = await new Builder().forBrowser('chrome').build()
})
after(async function () {
if (driver) {
await driver.quit()
}
})
it('loads example.com and reads the heading', async function () {
await driver.get('https://example.com')
const heading = await driver.wait(until.elementLocated(By.css('h1')), 10000)
assert.equal(await heading.getText(), 'Example Domain')
})
})
Run it:
mocha --timeout 60000 tests/example.test.js
Alternative: skip the per-file import and use
mocha --require @wdio/selenium-devtoolsto load the plugin once for the whole run.
Jest
// test/example.js
import { DevTools } from '@wdio/selenium-devtools'
import { Builder, By, until } from 'selenium-webdriver'
DevTools.configure({
screencast: { enabled: true, quality: 70, maxWidth: 1280, maxHeight: 720 }
})
describe('login flow', () => {
let driver
beforeEach(async () => {
driver = await new Builder().forBrowser('chrome').build()
}, 60000)
afterEach(async () => {
if (driver) {
await driver.quit()
}
})
test('logs in with valid credentials', async () => {
await driver.get('https://the-internet.herokuapp.com/login')
await driver.findElement(By.id('username')).sendKeys('tomsmith')
await driver.findElement(By.id('password')).sendKeys('SuperSecretPassword!')
await driver.findElement(By.css('button[type="submit"]')).click()
await driver.wait(until.urlContains('/secure'), 10000)
const flash = await driver.findElement(By.id('flash'))
expect(await flash.getText()).toMatch(/You logged into a secure area/i)
}, 60000)
})
jest.config.json:
{
"testEnvironment": "node",
"testMatch": ["<rootDir>/test/example.js"],
"testTimeout": 60000,
"transform": {}
}
Run it (ESM needs the experimental flag):
NODE_OPTIONS=--experimental-vm-modules jest --config jest.config.json
Cucumber
Cucumber's split layout means three small files - one to load the plugin, one for World/hooks, and one for step definitions.
features/support/setup.js - load the plugin and configure once:
import { DevTools } from '@wdio/selenium-devtools'
DevTools.configure({
screencast: { enabled: true, quality: 70, maxWidth: 1280, maxHeight: 720 }
})
features/support/world.js - driver lifecycle:
import {
setWorldConstructor,
World,
Before,
After,
setDefaultTimeout
} from '@cucumber/cucumber'
import { Builder } from 'selenium-webdriver'
setDefaultTimeout(60000)
class CustomWorld extends World {
constructor (options) {
super(options)
this.driver = null
}
}
setWorldConstructor(CustomWorld)
Before(async function () {
this.driver = await new Builder().forBrowser('chrome').build()
})
After(async function () {
if (this.driver) {
await this.driver.quit()
this.driver = null
}
})
cucumber.json - wire the setup file in first so the plugin patches Selenium before any step runs:
{
"default": {
"import": [
"features/support/setup.js",
"features/support/world.js",
"features/support/steps.js"
],
"paths": ["features/*.feature"],
"format": ["progress"]
}
}
Run it:
cucumber-js --config cucumber.json
Plain Node script (no test runner)
If you run node tests/google.test.js directly there's no runner for the plugin to auto-hook. By default you get a single "Selenium Session" row in the dashboard. To get a named test boundary, call DevTools.startTest / endTest around your work:
// tests/google.test.js
import { DevTools } from '@wdio/selenium-devtools'
import { Builder, By, until, Key } from 'selenium-webdriver'
DevTools.configure({
screencast: { enabled: true, quality: 70, maxWidth: 1280, maxHeight: 720 },
headless: false
})
async function run () {
DevTools.startTest('search Google for Selenium') // optional - names the test row
const driver = await new Builder().forBrowser('chrome').build()
try {
await driver.get('https://www.google.com')
const searchBox = await driver.findElement(By.name('q'))
await searchBox.sendKeys('Selenium WebDriver JavaScript', Key.ENTER)
await driver.wait(until.titleContains('Selenium'), 10000)
DevTools.endTest('passed')
} catch (err) {
DevTools.endTest('failed')
throw err
} finally {
await driver.quit()
}
}
run()
node tests/google.test.js
Only use
startTest/endTestfor plain Node scripts. Under Mocha / Jest / Cucumber the plugin already knows when each test starts and ends - calling these manually would create duplicate rows.
pytest
Nothing goes in your test files - the plugin is auto-discovered, and a flag turns it on for the run:
pytest --devtools tests/ # live dashboard
pytest --devtools-trace tests/ # write a trace archive instead (implies --devtools)
Or commit the choice, so nobody has to remember the flag:
[tool.pytest.ini_options]
devtools = true
# devtools_trace = true # trace archive instead of a dashboard
# devtools_trace_granularity = "test" # ... one archive per test
# devtools_trace_policy = "retain-on-failure" # ... keeping only what failed
A pytest.ini with a [pytest] section takes the same keys. The two trace settings are covered under How many archives, and which ones to keep.
Capture is always opt-in - installing the package must never change how an existing suite behaves. All that differs is how you say yes:
| How you opt in | Scope |
|---|---|
--devtools / --devtools-trace | this run |
devtools / devtools_trace in [tool.pytest.ini_options] | this project |
DEVTOOLS_ENABLE=1 (or DEVTOOLS_PORT=<n>, which also attaches to a dashboard already running) | this shell - for CI |
Highest wins: CLI, then ini, then environment. pytest -o devtools=false turns a project default off for a single run, which is why there is no --no-devtools. DEVTOOLS_TRACE=1 picks trace mode but does not switch capture on by itself, so exporting it for your own scripts never captures a pytest run you did not ask for.
In live mode the dashboard opens in a dedicated browser window and stays open after the run so you can inspect what happened; close it (or Ctrl-C) to finish. 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 park your terminal on an empty dashboard.
Plain Python script (no test runner)
Two lines around your existing Selenium code:
import selenium_devtools as devtools
from selenium import webdriver
devtools.enable() # open the dashboard, capture every command
# devtools.enable(trace=True) # or: write a trace.zip and open no window
driver = webdriver.Chrome()
driver.get('https://the-internet.herokuapp.com/login')
driver.find_element('id', 'username').send_keys('tomsmith')
driver.quit()
devtools.wait_for_dashboard_close() # keep the UI up to inspect (no-op when no window is open)
devtools.disable()
If the backend cannot be launched or reached, enable() logs a warning and returns None. Capture is skipped and your tests still run - a missing dashboard never fails a suite.
Parallel runs (pytest -n)
pytest-xdist works with no extra configuration. Every process reporting into one run has to agree on a run id, or the backend treats each connect as a new run and wipes what the previous one captured. With xdist they do agree: the plugin loads in the controller as well, and enabling capture there resolves the id before xdist spawns any worker - workers are child processes, so they inherit it.
What genuinely reads as separate runs: two independent pytest invocations, or a worker started without the environment. Export DEVTOOLS_RUN_ID yourself to join such processes into one run.
Configuration Options
- Node.js
- Python
| Option | Type | Default | Description |
|---|---|---|---|
port | number | 3000 | Port for the DevTools backend server. Auto-incremented if already in use. |
hostname | string | 'localhost' | Hostname the backend server binds to. |
openUi | boolean | true | Auto-open the DevTools UI in a new Chrome window. Set false for CI. |
captureScreenshots | boolean | true | Capture a screenshot after every WebDriver command. |
headless | boolean | false | Run the test browser headless (injects --headless=old). The DevTools UI window is unaffected. |
screencast | ScreencastOptions | { enabled: false } | Per-session .webm video recording. Options match the WebdriverIO Screencast page. |
rerunCommand | string | auto | Command template for per-test rerun. {{testName}} is substituted. Auto-derived from runner argv if omitted. |
mode | 'live' | 'trace' | 'live' | live opens the DevTools UI; trace skips it and writes a portable artifact instead. See Trace Mode. Overrides openUi. |
traceFormat | 'zip' | 'ndjson-directory' | 'zip' | Trace artifact layout. Only applies when mode: 'trace'. |
traceGranularity | 'session' | 'spec' | 'test' | 'session' | One trace per session / spec file / test. 'test' writes each to test-results/<spec>-<title>-<browser>[-retryN]/trace.zip. Only applies when mode: 'trace'. See Trace Mode. |
tracePolicy | 'on' | 'retain-on-failure' | 'retain-on-first-failure' | 'on-first-retry' | 'on-all-retries' | 'retain-on-failure-and-retries' | 'on' | Which traces to keep. Pairs with traceGranularity: 'test'. Only applies when mode: 'trace'. |
filmstrip | boolean | true | Record a dense, continuous screencast into the trace for frame-by-frame scrubbing in the player. Only applies when mode: 'trace'. |
screenshot | 'off' | 'on' | 'only-on-failure' | 'off' | Trace mode + traceGranularity: 'test'. Per-test screenshot, attached inline to Allure (image/png) via allure-js-commons when an Allure runner adapter is active. |
video | 'off' | TraceRetentionPolicy | 'off' | Trace mode + traceGranularity: 'test'. Per-test screencast video, retained per the given policy, attached inline to Allure (video/webm) via allure-js-commons when an Allure runner adapter is active. |
emitArtifactsManifest | boolean | auto | Write the devtools-artifacts-<sessionId>.json manifest — the generic index reporters/CI consume to discover produced artifacts — next to the trace. Off by default; auto-enables when an allure-js-commons runtime is active. Trace mode only. |
captureAssertions | boolean | true | Capture node:assert assertions (both passing and failing) as trace action rows. Set false to opt out. |
DevTools.configure({
port: 3000,
hostname: 'localhost',
headless: false,
openUi: true
})
For CI, set both
headless: true(hide the test browser) andopenUi: false(don't try to open the dashboard window - CI environments have no display). The backend keeps running on the configured port so you can still open the UI later if needed.
There is no options object - nothing devtools-specific has to appear in your test code. Under pytest you configure the adapter the way you configure pytest; a script passes keyword arguments to enable(); anything without a flag is an environment variable.
| pytest flag | [tool.pytest.ini_options] | Effect |
|---|---|---|
--devtools | devtools = true | Capture this run and open the dashboard. |
--devtools-trace | devtools_trace = true | Capture this run and write a trace archive instead of opening a dashboard. Implies --devtools. |
--devtools-trace-granularity <session|test> | devtools_trace_granularity = test | One archive for the whole run (session, the default) or one per test. Implies --devtools-trace. |
--devtools-trace-policy <policy> | devtools_trace_policy = "retain-on-failure" | Which archives are worth keeping. Implies --devtools-trace. See How many archives, and which ones to keep. |
Highest wins: CLI, then ini, then the environment below. pytest -o devtools=false turns a project default off for one run, and pytest -o devtools_trace_policy=on does the same for any of the others.
| Variable | Effect |
|---|---|
DEVTOOLS_ENABLE=1 | Turn capture on, when no flag or ini option already did. |
DEVTOOLS_PORT=<n> | Attach to a dashboard already listening on this port; also opts in. |
DEVTOOLS_HOST=<host> | Host the dashboard is reached on (default localhost). |
DEVTOOLS_TRACE=1 | Write a trace archive instead of opening a dashboard. Selects the mode for a plain script; under pytest it does not opt the run in by itself. |
DEVTOOLS_TRACE_GRANULARITY=<session|test> | Trace mode: one archive for the whole run, or one per test. Ambient, so it never selects trace mode by itself - pair it with DEVTOOLS_TRACE=1. |
DEVTOOLS_TRACE_POLICY=<policy> | Trace mode: which archives are worth keeping. Ambient, so it never selects trace mode by itself - pair it with DEVTOOLS_TRACE=1. |
DEVTOOLS_FILMSTRIP=0 | Trace mode: leave the dense filmstrip out of the archive. |
DEVTOOLS_A11Y=0 | Trace mode: skip the per-action A11y tree and element rects. |
DEVTOOLS_OPEN=0 | Do not open the dashboard window (CI). |
DEVTOOLS_BIDI=0 | Disable BiDi, and with it console and network capture. |
DEVTOOLS_RUN_ID=<id> | Join several processes into one run. |
DEVTOOLS_BACKEND_CMD=<cmd> | Start the backend with an explicit command instead of the resolved one. |
The backend is a Node application, so Node 18+ must be available in every mode - even in trace mode, where no dashboard window ever opens. It is not only the UI: the page collector is served by the backend, the whole event stream travels over its WebSocket, and in trace mode it is also what builds the archive. enable() checks for Node up front and names what is missing rather than failing later as a spawn timeout. The adapter finds or launches the backend for you - see running the backend on its own if you would rather manage it yourself, or point DEVTOOLS_PORT at one you are already running, in which case no local Node is needed.