Skip to main content

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​

npm install @wdio/selenium-devtools

Requires Node.js 22.23.4+, 24.22.1+ or 26.12.1+ and selenium-webdriver 4.8 or later. Encoding a screencast .webm needs ffmpeg on PATH.

Setup​

The minimum is one import, import '@wdio/selenium-devtools', in your test file. Under Mocha, Jest and Cucumber nothing else is needed; DevTools.configure(...) is optional and only changes defaults.

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 the login page and reads the heading', async function () {
await driver.get('https://the-internet.herokuapp.com/login')
const heading = await driver.wait(until.elementLocated(By.css('h2')), 10000)
assert.equal(await heading.getText(), 'Login Page')
})
})

Run it:

mocha --timeout 60000 tests/example.test.js

Alternative: skip the per-file import and use mocha --require @wdio/selenium-devtools to load the plugin once for the whole run. You still need a one-time DevTools.configure(...) call somewhere if you want non-default options.

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" test in the dashboard, standing for the whole script: when the script ends it is closed as passed (or keeps the state it already had) and its suite is finalized, so the dashboard shows the run as finished. A script that quits its driver and builds another is still that one test. To get a named test boundary, call DevTools.startTest / endTest around your work; a test opened with startTest is closed by your own endTest, not at script end:

// 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 / endTest for plain Node scripts. Under Mocha / Jest / Cucumber the plugin already knows when each test starts and ends - calling these manually would create duplicate rows.

Configuration Options​

OptionTypeDefaultDescription
portnumber3000Port for the DevTools backend server. Auto-incremented if already in use.
hostnamestring'localhost'Hostname the backend server binds to.
openUibooleantrueAuto-open the DevTools UI in a new Chrome window. Set false for CI.
captureScreenshotsbooleantrueCapture a screenshot after every successful WebDriver command.
headlessbooleanfalseRun the test browser headless (injects --headless=old). The DevTools UI window is unaffected.
screencastScreencastOptions{ enabled: false }Per-session .webm video recording. Options match the WebdriverIO Screencast page.
rerunCommandstringautoCommand 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'.
filmstripbooleantrueRecord 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.
emitArtifactsManifestbooleanautoWrite 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.
captureAssertionsbooleantrueCapture 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
})

port and hostname only take effect before the backend starts, which happens when the first driver is built, so call DevTools.configure(...) before that; a later call ignores them with a warning. A rerun keeps its parent's backend whatever they say.

For CI, set both headless: true (hide the test browser) and openUi: 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.

Trace mode​

Headless capture path, in both languages - no DevTools UI window opens, and the run writes a portable trace archive into a test-results/ folder, with the same shape as the WebdriverIO trace artifact. The two differ only in how much of the artifact you can tune, and in who builds it.

At session end the adapter writes trace-<sessionId>.zip (or a directory) itself, into test-results/ next to the resolved test / config directory.

DevTools.configure({
mode: 'trace',
traceFormat: 'ndjson-directory' // optional; default 'zip'
})

The backend port-bind, UI window, and screencast option are all skipped in trace mode. For the full feature reference (artifact contents, viewer, mobile testing, when to pick zip vs ndjson-directory), see the Trace Mode page.

Per-test artifacts and retention​

At traceGranularity: 'test' each test gets its own artifact folder, and tracePolicy decides which are kept (e.g. retain-on-failure). In that mode you can also capture a per-test screenshot (PNG) and video (.webm), and enable a dense filmstrip recorded into the trace for frame-by-frame scrubbing. When an allure-js-commons runner adapter is active, per-test traces / screenshots / videos are attached inline to the Allure report (and emitArtifactsManifest auto-enables); otherwise they're written to test-results/ and recorded in the manifest.

DevTools.configure({
mode: 'trace',
traceGranularity: 'test',
tracePolicy: 'retain-on-failure',
filmstrip: true,
screenshot: 'only-on-failure',
video: 'retain-on-failure'
})

Viewing the trace​

Open any trace .zip in the first-party player — the same DevTools UI in a dedicated player mode:

npx show-trace path/to/trace.zip # in a project that installs the adapter
pnpm show-trace path/to/trace.zip # from the devtools monorepo

The show-trace bin ships with @wdio/selenium-devtools, so it's available in any project that installs it — no extra dependency. A Python project installs no Node.js adapter, but the same player ships with the backend the adapter already fetches for you: npx -p @wdio/devtools-backend show-trace path/to/trace.zip.

Because the Selenium adapter captures the page's DOM mutation stream and a per-command element / accessibility snapshot alongside each screenshot, a Selenium trace drives the player's full feature set — DOM time-travel, the A11y tab and pick-locator overlay, the Transcript tab with Copy-for-LLM, Cucumber Feature → Scenario → Step nesting, and the scrubbable timeline. A Python trace carries the same mutation stream and per-action snapshot (the element / a11y read is trace mode only there, and on by default); the Gherkin nesting is the one entry that has no pytest equivalent.

The trace uses a portable NDJSON schema, so the same .zip (or directory) also opens in other compatible trace viewers. See the Trace Player page for the full walkthrough.

Public API​

import { DevTools } from '@wdio/selenium-devtools'

DevTools.configure(opts) // set runtime options (see above)
DevTools.startTest(name, meta?) // mark a named test boundary (plain Node scripts only)
DevTools.endTest('passed'|'failed'|'skipped'|'pending')

Under Mocha / Jest / Cucumber the plugin auto-hooks the runner's lifecycle, so you don't need startTest / endTest manually - calling them would create duplicate rows.

Examples​

Working examples live in the repo's top-level examples/ directory. Build the workspace once (pnpm install && pnpm build), then run from the repo root. pnpm demo:selenium runs the default (Cucumber) example; the per-runner variants are:

DirectoryRunnerCommand
examples/selenium-js/mocha/Mochapnpm --filter @wdio/selenium-devtools example:mocha
examples/selenium-js/mocha/Mocha + allure-mochapnpm --filter @wdio/selenium-devtools example:mocha:allure
examples/selenium-js/jest/Jestpnpm --filter @wdio/selenium-devtools example:jest
examples/selenium-js/cucumber/Cucumberpnpm demo:selenium
examples/selenium-js/mobile/Mocha on Appiumpnpm demo:selenium:mobile

Features​

The Selenium adapter provides the same DevTools UI experience as WebdriverIO, in both languages. Every feature below is captured automatically with no per-feature config — the base DevTools.configure({}) in Node.js, or pytest --devtools in Python. Console and network stream via Selenium's BiDi handlers, with an injected-collector fallback in Node.js. Links go to each feature's full reference.

  • Interactive Test Rerunning & Visualization - Live browser previews, per-command screenshots, and one-click test/suite rerunning
  • Preserve & Rerun (Compare) - Snapshot a failing test, rerun it, and diff the two runs side-by-side
  • Multi-Framework Support - Auto-detects Mocha, Jest, Cucumber, or a plain script in Node.js; pytest or a plain script in Python
  • Console Logs - Capture and inspect browser console output
  • Network Logs - Monitor API calls and network activity
  • Metadata - Session capabilities, environment, and timing per browser session
  • TestLens - Jump from any command to the source line that triggered it
  • Session Screencast - Automatic video recording of browser sessions
  • Navigation timings - After every navigation command, load / DOM-ready / response timings, resource counts, cookies and document info are attached to that command row. The row's summary shows the load time in milliseconds to two decimals, such as 246.40 ms; the full-precision timings stay in the row's performance data
  • Trace Mode - Headless capture producing a portable trace.zip (no UI window), in both languages, with per-test slicing and retention in both (traceGranularity / tracePolicy in Node.js; --devtools-trace-granularity / --devtools-trace-policy in Python). Per-test screenshot / video and inline Allure attachment remain Node.js only; see Trace mode

In Node.js, screencast is the one feature with its own options (see Configuration Options):

DevTools.configure({ screencast: { enabled: true, quality: 70, maxWidth: 1280, maxHeight: 720 } })

In Python the run's own output also reaches the Console tab: Python logging records (selenium's and the adapter's) and the test's print() output, which is teed so your terminal still shows it.

In Python screencast needs no configuration: Chrome streams frames over CDP, other browsers fall back to one screenshot per command, and encoding the .webm needs ffmpeg on PATH. In trace mode the same frames become the archive's dense filmstrip instead of a .webm, so nothing is encoded and ffmpeg is not needed.

How It Works​

The plugin patches selenium-webdriver's Builder, WebDriver, and WebElement prototypes at import time:

  • Builder.build() - after construction, the driver is registered with the session capturer and the DevTools backend is started in a detached child process.
  • Every public WebDriver / WebElement method - wrapped with command capture (args + result + screenshot + call source).
  • WebDriver.quit() - an awaited cleanup hook flushes screencast encoding, WebSocket buffer, and final metadata before the original quit runs.

When BiDi is available (Chrome ≥114), console logs, JavaScript exceptions, and network events stream directly via the Selenium BiDi handlers. Otherwise the plugin falls back to an injected browser-side collector script, so a Safari or Appium session still lists every request, from two sources of the page's own. A fetch / XMLHttpRequest call goes through the collector's patch and carries its method, headers, status and body. Every other load (the document, scripts, styles, images, fonts) comes from the page's Resource Timing buffer with its timing and size, but no headers or body, and on Safari no status; the Network tab says so on each such row.

A driver.wait() is one row named after what it waited for, with the commands its condition issued nested under it rather than a row per poll; see Waits.

The page-side collector is registered at document start through BiDi (script.addPreloadScript), so every page instruments itself before any of its own script runs; without BiDi it falls back to injecting a <script> after each navigation. It records the page's DOM mutation stream and a per-command element / accessibility snapshot, so a trace carries enough to rebuild the live DOM at each step (per-navigation mapping) — this is what powers the player's DOM time-travel and A11y tab rather than a screenshot-only replay.

Mobile (Appium)​

Appium sessions are captured in live and trace mode, in both languages. A mobile-browser session is captured like a desktop one; a native app gets commands, screenshots and element data from the platform's XML tree, with no DOM. This adapter decides from the startup capabilities, so a hybrid app's webview half is captured as if it were still native (only the WebdriverIO service follows context switches).

Try it with pnpm demo:selenium:mobile (Node.js) or pnpm demo:python:mobile (Python), run from the root of a built checkout of the devtools repo (setup steps in Running an example). DEVTOOLS_MOBILE_PLATFORM=ios runs the iOS spec, and DEVTOOLS_MOBILE=web drives the device's own browser. Prerequisites are in examples/MOBILE.md; see the Mobile Testing page for the full mobile reference. A native app's network is captured when the session sets appium:intercept: true and Appium runs the appium-interceptor plugin; see Native app network.

Limitations​

LimitationDetail
Cucumber leaf-step rerunCucumber's --name filter targets scenarios, not individual Gherkin steps. The dashboard's per-step rerun is disabled under Cucumber.
Headless mode caveatheadless: true injects --headless=old; --headless=new produces all-black CDP frames in the screencast.
Initial viewportThe dashboard's snapshot iframe falls back to 1280×800 until the first navigation completes and the browser-side collector reports the real viewport.
Parallel workersJest/Vitest worker processes each load the plugin and report as a separate run, so the dashboard keeps only the latest worker's state. Run serially (--runInBand) for a single dashboard run.
Safari in live modeSafari delivers synthesized input only to its frontmost window and still reports success, so input issued while another window is in front reaches nothing. On Safari the plugin opens the dashboard behind the browser and brings the session's own window forward before each element click, key input, clear or submit. Trace mode opens no window and is not affected.

Welcome! How can I help?

WebdriverIO AI Copilot