Trace Mode
Headless capture path โ no DevTools UI window opens. At session end the adapter writes trace artifacts into a test-results/ folder: under WebdriverIO's outputDir (or rootDir), and for Selenium and Nightwatch next to the test file, falling back to the config directory, then the current directory. For session / spec granularity that's a trace-<sessionId>.zip (or a trace-<sessionId>/ directory); for test granularity each test gets its own subfolder (see Trace granularity). The artifact is portable and ships everything needed for offline replay, AI-agent diffing, or any consumer that prefers a file over a live UI.
Trace mode is mutually exclusive with live mode. Pick one per session: humans debugging interactively want live; agents diffing runs or CI bots collecting artifacts want trace.
Enableโ
// wdio.conf.ts
services: [
[
'devtools',
{
mode: 'trace',
traceFormat: 'zip' // optional; 'zip' (default) | 'ndjson-directory'
}
]
]
Trace mode needs WebDriver BiDi (Chrome 114+, Firefox 130+), which WebdriverIO enables automatically.
To try it, run the mocha example in trace mode with DEVTOOLS_MODE=trace pnpm demo:wdio:mocha; add DEVTOOLS_TRACE_GRANULARITY=test DEVTOOLS_TRACE_POLICY=retain-on-failure for per-test retention. The config is examples/wdio/mocha/wdio.conf.ts.
Selenium and Nightwatch ship the same trace pipeline โ see their adapter pages for framework-specific enable syntax: Selenium ยท Nightwatch.
What's inside the artifactโ
| File | Contents |
|---|---|
trace.trace | NDJSON context-options + before / after action events; one line per record |
trace.network | HAR-style network entries, one per line; an entry read from the page's Resource Timing buffer (no BiDi, e.g. Safari or Appium) carries _source: "resource-timing" and _resourceType |
trace.mutations | NDJSON DOM mutation stream behind DOM time-travel; omitted when nothing was captured |
transcript.md | Human/LLM-readable Markdown summary with timing, selectors, value annotations |
resources/page@<id>-<ts>.jpeg | Screenshot taken at each user-facing action |
resources/page@<id>-<ts>-elements.json | Flat list of interactable elements at that action |
resources/page@<id>-<ts>-snapshot.txt | Depth-indented accessibility-tree snapshot (AI-friendly) |
resources/<sha1>.jpeg | Dense filmstrip frames, content-addressed |
resources/src@<sha1>.txt | Captured test source for the Source tab |
resources/<sha1> | Network response bodies, content-addressed |
What counts as an "action"โ
Commands are filtered through an allow-list before they produce trace entries. Examples that land in the trace:
url/getโPage.navigateclickโElement.clicksetValue/sendKeysโElement.fillsubmit,clear,selectByVisibleText, โฆ
Locator resolution ($, $$, findElement(s)), Selenium's executeScript polling and the clearValue that setValue fires internally are deliberately excluded: they don't represent user-facing intent and would noise up the timeline. Explicit waits (waitFor*, waitUntil, Nightwatch waitForElement*, Selenium driver.wait, Python WebDriverWait; see Waits), element reads (getText, isDisplayed, โฆ), page reads such as getTitle, and execute do appear. The allow-list is ACTION_MAP in packages/shared/src/trace-actions.ts.
Waitsโ
A wait the test wrote is one row, named after what it waited for: Element.waitForExist on its selector for a wait on an element locator, Element.waitForDisplayed for a wait on an element's visibility, and Browser.waitForFunction for a wait on any other condition. The row appears as soon as the wait starts and is updated when it ends, so the live dashboard shows a long wait as running rather than listing it only once it is over. A wait that times out is a failed row carrying the timeout, in the Actions list, the Errors tab and the trace.
The commands the wait's condition issued nest under its row, one row per distinct call rather than one per poll, in the live dashboard and the trace player alike. They are collapsed by default, and open by themselves when the wait failed or holds the selected row. The chevron beside a wait is a button of its own, so the keyboard reaches it (Enter or Space expands or collapses it) while Enter on the row still selects the wait. A wait started inside another wait's condition nests under that wait.
A command under a wait that failed before the wait passed, such as a getText on an element that appears a moment later, is a poll the wait retried past, not a failure: it shows neutral with its error in a tooltip, does not open the wait, and is left out of the Errors tab, the copied failures and the trace's errors. Under a wait that timed out, the same commands keep their errors, since the last poll is what explains the timeout.
What each adapter shows:
| Adapter | Waits | Nested under the wait |
|---|---|---|
| WebdriverIO | waitUntil and the waitFor* family | The commands a waitUntil condition issued, and the polls of an element wait (such as isExisting). |
| Nightwatch | waitUntil and the waitForElement* family | The commands a waitUntil condition issued. A waitForElement* row has no children: Nightwatch polls for the element through its own transport, which the plugin does not see. |
| Selenium (Node.js) | driver.wait(...). until.elementLocated is waitForElementLocated (Element.waitForExist) on its locator, until.elementIsVisible is waitForElementVisible (Element.waitForDisplayed), and any other condition is wait (Browser.waitForFunction), labelled with the condition's description. | The commands the condition issued. An element wait's findElement(s) polls nest in the live dashboard but are left out of the trace, like every locator lookup. |
| Selenium (Python) | WebDriverWait(...).until(...) and until_not(...). EC.presence_of_element_located is waitForElementLocated (Element.waitForExist), EC.visibility_of_element_located is waitForElementVisible (Element.waitForDisplayed), and any other condition, every until_not included, is wait (Browser.waitForFunction), labelled with the condition (an expected_conditions condition by its name and arguments). | As for Node.js. |
Output format โ traceFormatโ
{
mode: 'trace',
traceFormat: 'zip' | 'ndjson-directory' // default: 'zip'
}
zip(default) โ single archive attest-results/trace-<sessionId>.zip.ndjson-directoryโ same files unpacked intotest-results/trace-<sessionId>/. One less unzip step for scripted or agentic consumers that want to grep / stream the NDJSON directly.
The zip opens in the first-party show-trace player; an ndjson-directory trace opens in other compatible trace viewers, or zip it first to open it in show-trace.
Trace granularity โ traceGranularityโ
How many trace artifacts a run produces:
{
mode: 'trace',
traceGranularity: 'session' | 'spec' | 'test' // default: 'session'
}
| Value | Output |
|---|---|
session (default) | One trace per worker/session โ test-results/trace-<sessionId>.zip. |
spec | One trace per spec file, at test-results/trace-<spec>-<hash>-<sessionId8>.zip. Smaller, easier to navigate. |
test | One trace per test, each in its own folder: test-results/<spec>-<title>-<browser>[-retry<N>]/trace.zip. |
For test granularity the folder name is built from the spec basename, a slug of the test title, the browser, and a -retry<N> suffix on retried attempts โ e.g. test-results/login_e2e-logs-in-chrome/trace.zip, with a first retry at test-results/login_e2e-logs-in-chrome-retry1/trace.zip. Per-test traces are the most navigable and pair best with a retention policy so only the traces you care about are written.
Retention โ tracePolicyโ
By default every trace is kept ('on'). To keep only the interesting ones โ ideal with traceGranularity: 'test':
{
mode: 'trace',
traceGranularity: 'test',
tracePolicy: 'retain-on-failure' // default: 'on'
}
| Policy | Keeps the trace whenโฆ |
|---|---|
'on' (default) | Always โ every trace is written. |
'retain-on-failure' | The test's final attempt failed. A fail-then-pass retry sequence ends passed, so it is not kept โ you don't over-retain a flake that eventually went green. |
'retain-on-first-failure' | Attempt 0 failed, regardless of whether a later retry passed. |
'on-first-retry' | The test was retried at least once (an attempt 1 exists). |
'on-all-retries' | Any retried attempt (attempt โฅ 1) exists. |
'retain-on-failure-and-retries' | The final attempt failed or the test was retried. |
A non-retained slice is decided against and never written to disk. The retry-aware policies key on a per-attempt outcome ledger the adapter keeps per retry-stable test id, so retain-on-failure and retain-on-first-failure evaluate the right attempt. Where a runner doesn't expose per-attempt retry information, every policy except retain-on-failure degrades to retain-on-failure; a run with no observed outcomes (e.g. a plain standalone script) fails open and keeps the trace rather than risk dropping one you need.
Retry-aware retention is verified end-to-end for WebdriverIO (mocha / cucumber) and Selenium (mocha). For Nightwatch,
retain-on-failureworks, but the other retry-aware policies degrade to it because Nightwatch's--retriesre-runs a testcase internally without re-firing the per-test hooks. WDIO's cross-processspecFileRetriesalso falls outside the (per-worker) ledger. See the Nightwatch adapter page for the specifics.
Dense filmstrip โ filmstripโ
By default the trace records a dense, continuous screencast so the player scrubs smooth playback rather than jumping frame-to-frame. The dense frames sit alongside the per-action frames (which carry the DOM snapshots). Set filmstrip: false to record only one frame per action โ a smaller trace with no continuous recorder:
{
mode: 'trace',
filmstrip: false // opt out โ one frame per action (default is true)
}
- Dense frames are added alongside the per-action frames (which carry the DOM snapshots), so no DOM data is lost โ when dense frames are present they supersede the sparse per-action filmstrip for scrubbing.
- Frames are thinned at export (โฅ100 ms apart) and content-addressed, so identical frames (a static wait) collapse to one resource. The live session buffer is bounded by
screencast.maxBufferFrames(default 2000). - Recording uses the screencast recorder โ CDP push on Chrome/Chromium, screenshot polling elsewhere. On non-Chrome browsers the polling issues many
takeScreenshotcommands; pair with your reporter's step-silencing option (see Allure Integration).
filmstrip is available on all three adapters (WebdriverIO / Selenium / Nightwatch).
Per-test screenshot & video โ screenshot / videoโ
At traceGranularity: 'test' each test can also produce a standalone screenshot and/or a per-test video slice, mirroring the familiar screenshot/video-on-failure ergonomics:
{
mode: 'trace',
traceGranularity: 'test',
screenshot: 'only-on-failure', // 'off' (default) | 'on' | 'only-on-failure'
video: 'retain-on-failure' // 'off' (default) | any tracePolicy value
}
| Option | Values | Behavior |
|---|---|---|
screenshot | 'off' (default) ยท 'on' ยท 'only-on-failure' | 'on' captures after every test; 'only-on-failure' only after a failing test. PNG. |
video | 'off' (default) ยท any tracePolicy value | Records the screencast continuously and keeps each test's slice per the same retention semantics as tracePolicy. WebM; encoding needs ffmpeg on PATH. Setting a non-off value starts the recorder on its own โ you don't also need filmstrip or screencast.enabled. |
Both are gated to trace mode + traceGranularity: 'test' (the per-test scope these attach to). At coarser granularities they no-op.
- WebdriverIO โ
screenshot/videoare service options; attached inline to Allure when@wdio/allure-reporteris present. - Selenium โ same options on its
DevToolsOptions; attached inline to Allure viaallure-js-commonswhen an Allure runner adapter is active. - Nightwatch โ produce-only: the files are written to the trace output dir (and listed in the manifest), but not attached inline to Allure โ Nightwatch has no live Allure attach API. See Trace Mode Limitations.
screencast.enabledis the separate live-mode continuous.webmrecording and is ignored in trace mode. In trace mode usefilmstrip(dense frames into the trace) or per-testvideo; the screencast tuning fields (quality,maxWidth,pollIntervalMs, โฆ) still apply to whichever recorder runs.
Artifacts manifest โ emitArtifactsManifestโ
Writes a devtools-artifacts-<sessionId>.json next to the trace โ a generic index that reporters and CI consume to discover the produced artifacts (every trace / screenshot / video, plus each test's state):
{
mode: 'trace',
emitArtifactsManifest: true // default: off; auto-on when Allure is detected
}
- Off by default. It auto-enables when an Allure reporter is detected โ WebdriverIO's
@wdio/allure-reporterin the config, or an active Seleniumallure-js-commonsruntime. - Nightwatch is opt-in: it has no live Allure signal to auto-detect against (
nightwatch-allureis post-hoc), so it never auto-enables โ set it explicitly if you want the manifest.
Assertions โ captureAssertionsโ
Assertions surface as first-class action rows in the trace (on by default; set captureAssertions: false to opt out):
node:assertโ captured across all three JavaScript adapters asassert.<method>rows. The Python adapter captures Python's ownassertstatement the same way; see the Python tab of Selenium โ Assertions.- WebdriverIO
expectโ passing and failingexpect(...)matchers (expect($el).toHaveText(...),toBeExisting(), โฆ) appear asexpect.<matcher>rows carrying the expected value, the element's source location, and a snapshot; the matcher's internal polling commands are suppressed so only the assertion shows. - Nightwatch
browser.assert.*/browser.verify.*โ native assertions surface asassert.<m>/verify.<m>rows.
Passing assertions render green; failing ones render red with the error message.
An assertion row names the element it is about, so selecting it boxes that element in the snapshot. A node:assert call (or a Python assert) takes values rather than elements, so its element comes from where the asserted value was read: assert.equal(await heading.getText(), 'Secure Area') names heading's selector, and so does a value from any other element read, getHTML and getComputedRole included. Only a read made earlier in the same test (or Cucumber scenario) counts, and a value read from the page itself rather than from an element, such as the title or the URL, names no element, even when some element's text reads the same: assert.notEqual(await browser.getTitle(), 'Secure Area') boxes nothing. WebdriverIO's browser.getTitle() is a Page.getTitle row of its own, and expect(browser).toHaveTitle(...) folds into that read as one expect.toHaveTitle row.
Mobile testingโ
Appium sessions are captured in live and trace mode. The discriminator is the browser name in the capabilities, not the device:
- Mobile web (Chrome on Android, Safari on iOS): same DOM-based snapshot pipeline as desktop.
- Native app (no
browserName): the page-injected DOM scripts are guarded off; the Appium XML tree feeds the snapshot serializer instead. - Hybrid app: only WebdriverIO follows the session into its webview; the other adapters decide from the startup capabilities.
The trace's context-options records the platform, model and OS version so the viewer labels frames correctly. Run the examples with pnpm demo:wdio:mobile (also :selenium:mobile, :nightwatch:mobile, :python:mobile). See Mobile Testing for the full reference.
Viewing the artifactโ
Open a trace in the first-party Trace Player โ the WebdriverIO DevTools UI in a dedicated read-only player mode:
show-trace trace-<sessionId>.zip # bin on PATH after install
npx show-trace trace-<sessionId>.zip # or via npx
The player gives you DOM time-travel, the A11y tab and pick-locator overlay, the Transcript tab with Copy-for-LLM, the Errors / Console / Network / Source dock tabs, and a scrubbable timeline. The same portable .zip also opens in other standalone trace viewers and inside an Allure report's embedded viewer. See the Trace Player page for the full walkthrough, features, and keyboard shortcuts.
Locators in the A11y tabโ
The show-trace bin shipped by each adapter opens the same archive in the DevTools player, which additionally exposes an A11y tab: the accessibility tree captured per action, where clicking a row copies that element's locator.
Those locators are written in the recording runner's own dialect, so they paste straight into the framework that produced the trace. An element identified only by its text is a*=Logout under WebdriverIO and //a[contains(., "Logout")] under Selenium โ captioned with the call that resolves it, By.xpath(). Nightwatch prefers a native CSS locator such as button[type="submit"], because it is the only runner that reads a bare selector string under a default CSS strategy, and falls back to XPath (captioned useXpath() / locateStrategy: 'xpath') only when no unique CSS locator exists. Every other locator is portable CSS.
For LLM / agent consumption, read transcript.md directly โ it's a tight Markdown rendering of the actions with selectors and values.
Learn moreโ
- Trace Player โ the full
show-traceplayer walkthrough, features, and keyboard shortcuts. - Allure Integration โ how trace / screenshot / video artifacts attach to an Allure report.
- Cross-Framework Support โ the per-adapter capability matrix (WebdriverIO / Selenium / Nightwatch).
- Trace Mode Limitations โ what trace mode skips and the known per-adapter gaps.
- Configuration Reference โ every option at a glance.