This file describes the conventions in place across the devtools monorepo — how code is organized, how packages relate to each other, how tests are structured, and what the coding style looks like. It's the companion to ARCHITECTURE.md: that file says where the pieces are; this one says why they're shaped the way they are and what to look for when adding or changing code.
Anyone working in the repo, human or AI agent, can use this as the source of truth for "how do we do things here."
A devtools dashboard for end-to-end browser tests. Three test frameworks (WebdriverIO, Nightwatch, Selenium) push the same normalized event stream through a single backend into a single Lit-based browser UI. The adapters are deliberately thin — they translate framework hooks into calls on a shared core capture/reporting library and own only the framework-specific glue.
Package map and data flow are in ARCHITECTURE.md. The summary: shared for types and contracts, trace for the event→zip transforms, core for framework-agnostic capture, three adapters (service, nightwatch-devtools, selenium-devtools) for framework glue, backend for the server, app for the UI, script for the page-injected runtime.
Run from repo root unless noted.
| Command | What it does |
|---|---|
pnpm install |
Install workspace dependencies. |
pnpm build |
Build all packages (pnpm -r build). |
pnpm test |
Run vitest suite once. |
pnpm test:watch |
Run vitest in watch mode. |
pnpm test:coverage |
Run vitest with v8 coverage. The thresholds in vitest.config.ts are aspirational, not a gate: that file states CI does not run this and the suite is currently below all four. CI runs test/lint/test:ui. |
pnpm lint |
Lint all packages in parallel. Includes eslint-plugin-security for a subset of CodeQL findings; deeper taint-flow checks surface on the PR's CodeQL scan. |
pnpm demo:wdio / pnpm demo:nightwatch / pnpm demo:selenium / pnpm demo:python:pytest |
Run the per-framework example projects, live by default; DEVTOOLS_MODE=trace flips them to trace mode. The Python adapter does not read that variable, so the pytest example's conftest.py maps it onto the plugin's options (per-test archives, retain-on-failure), yielding to anything set on the command line, in its pytest.ini or in the environment. Useful for manual verification of UI or runtime changes. No package pins a browser driver, deliberately: a pinned chromedriver rots against whatever Chrome the developer has, and because pnpm puts the package's node_modules/.bin on PATH, Selenium Manager finds it there, prefers it over resolving, and on a major mismatch only warns before returning it anyway. The session then fails to start. With nothing on PATH both adapters resolve a matching driver (Nightwatch's Chrome service builder sets requiresDriverBinary: false and passes an unset server_path through to the same resolver), and nightwatch declares chromedriver an optional peer. |
pnpm demo:wdio:mobile / :selenium:mobile / :nightwatch:mobile / :python:mobile |
The same, against Appium. All four build the same capability bag and drive the Clock app that ships with every Android system image (keying a duration on the timer keypad, never starting a timer), so a native example needs no .apk. DEVTOOLS_MOBILE_PLATFORM=ios runs the iOS spec instead — all four adapters — which drives Settings because the simulator ships no Clock, from a separate spec per platform rather than a branch. The simulator is chosen by udid and defaults to whichever is already booted, and an unmatched IOS_DEVICE_NAME is refused: naming one that does not exist makes the XCUITest driver create and boot it, every run, rather than fail. That refusal and the booted-simulator preflight are local policy — xcrun simctl enumerates local simulators and nothing else — so IOS_UDID and a non-local APPIUM_HOST both bypass them, or a real device and a cloud grid would be refused a run they were correctly configured for. DEVTOOLS_MOBILE=web drives the device's own browser on both platforms — Chrome on Android, Safari on iOS, which the XCUITest driver serves without a chromedriver. examples/MOBILE.md holds the prerequisites and the DEVTOOLS_MOBILE / APPIUM_APP switches; DEVTOOLS_MODE=trace flips any demo to trace mode. |
pnpm dev |
Run all packages in parallel dev mode. |
pnpm smoke:fixtures [--port N] |
Serve the local fixture site the desktop examples drive (a copy of the-internet's login flow: /, /login, /secure, /logout; default http://127.0.0.1:4567). Every desktop login example reads DEVTOOLS_EXAMPLE_BASE_URL (trailing slash ignored); unset, they drive the public site. |
pnpm smoke:assert --expect <name> [--exit-code N] [--log file] <zip-or-dir>... |
Check trace zips against packages/conformance/smoke/expectations/<name>.json: the listed rows under each test's own tracing group (or the whole run for an entry with scope: "run" and a reason), an error row on each deliberately failing test, a retried test's attempt count with its other checks read from its final attempt alone (attempts), title fragments such as a failed wait's condition, a call site on every row of a per-test entry (at least one for a run-scoped entry) and every call site inside the example's own sourceFiles, the run's exit code, no wait timeout, in any adapter's wording (TIMEOUT_PATTERNS in smoke/constants.ts), in the trace's stdout or the run log other than the lines of a test marked allowTimeoutError, and the zips' combined size against the file's required traceBudget.maxBytes. |
pnpm player:hosted |
Build the hosted trace player (the app with --mode hosted, no backend) and serve it at http://localhost:4173 under the headers vercel.json sends from trace.webdriver.io. |
pnpm player:e2e [--headed] [--port N] |
Build the hosted trace player, serve it under the vercel.json headers and open trace zips through it in headless Chrome with WebdriverIO (packages/conformance/hosted-player/). pnpm exec tsx packages/conformance/hosted-player/run.ts --url <player> runs the same check against a deployed page without building. |
pnpm docs:capabilities |
Render the per-adapter capability tables from packages/conformance/src/features.ts into README.md; --website <webdriverio checkout> also renders the docs-site tables, --check fails instead of writing, and --parity lists every feature some adapter lacks plus the evidence counts. |
python3 packages/selenium-devtools-py/scripts/changes.py next-version |
The version a Python-adapter release would publish, from the fragments pending in changes/. check --base <ref> is the CI gate; apply is what the release runs. |
selenium-devtools exposes per-runner variants of its example via pnpm --filter @wdio/selenium-devtools example:mocha / :mocha:allure / :jest / :cucumber.
Defined in root tsconfig.json:
| Alias | Resolves to |
|---|---|
@/* |
packages/app/src/* |
@components/* |
packages/app/src/components/* |
@core/* |
packages/app/src/core/* (app-internal — not the framework-agnostic packages/core) |
@wdio/devtools-backend / * |
packages/backend/src/... |
@wdio/devtools-script / * |
packages/script/src/... |
@wdio/devtools-service / * |
packages/service/src/... |
@wdio/selenium-devtools / * |
packages/selenium-devtools/src/... |
@wdio/nightwatch-devtools/* |
packages/nightwatch-devtools/src/* (subpaths only, for type imports such as the option types) |
@wdio/devtools-shared / * |
packages/shared/src/... |
@wdio/devtools-core / * |
packages/core/src/... |
@wdio/devtools-trace / * |
packages/trace/src/... |
@wdio/elements / * |
packages/elements/src/... |
These exist so imports stay short and grep-able. Long relative paths (../../../components/…) aren't used.
The @core/* name is a historical alias for app-internal helpers and predates packages/core. They don't collide because they resolve to different roots, but the names are confusable.
Every shared type, constant, enum, schema, and HTTP/WS contract lives in packages/shared. Adapter packages and the app never re-declare a concept that already exists upstream — they re-export shared definitions when a local consumer name needs to stay stable (e.g. nightwatch's TEST_FILE_PATTERN is export { SPEC_FILE_RE as TEST_FILE_PATTERN } from '@wdio/devtools-shared').
When a duplicate is discovered, the next change that touches either copy consolidates them into shared.
Anything that captures, parses, normalizes, formats, or transports test-event data and doesn't depend on a specific framework's API lives in packages/core. Adapters call into core; they don't reimplement.
If the same logical change would land in two or more adapters, the logic belongs in core. This rule produced the current SessionCapturerBase, TestReporterBase, ScreencastRecorderBase, resolveAdapterOutputDir, and the pure helpers around console capture, error serialization, UID generation, stack-trace parsing, BiDi attachment, and screencast finalization.
Some helpers are framework-agnostic by nature but used in only one adapter today (e.g. nightwatch's parseNetworkFromPerfLogs for CDP perf-log parsing, selenium's detectRunner/captureLaunchCommand). They stay in their adapter until a second consumer appears; at that point they move to core.
packages/trace holds the pure transforms that turn captured events into trace-zip content — the zip writer, action events, group paths, frame snapshots, mutations, HAR, sources, transcript — and the reader that turns a zip back into a player payload (trace-reader.ts), so the format has one owner in both directions. core keeps the adapter-side orchestration and policy that calls them: trace-finalizer, spec-trace-helpers, trace-retention.
The split is not aesthetic. backend may not import core (it would pull framework-adapter logic into the server), but it does need to build a trace on behalf of an adapter that can't — the Python adapter ships no Node. trace is the layer both can reach, so it may import shared and nothing else; a single import of core from it re-creates the cycle the split exists to remove, and ESLint enforces that.
The test for a new helper: would the backend ever need this to build or read a zip? If yes, trace. If it needs a driver, a framework hook, or a capture session, core.
The per-action trace snapshot is captured in beforeCommand, stamped at the previous action's end, so an action's result IS the next action's "before" and every row resolves to a state the driver was idle for. afterCommand captures nothing in trace mode. An eager post-action capture — added and removed twice — lands while the screen is still moving, which is the only reason waitForActionResult and the __wdioSnapMark document tag existed; both are deleted, and both should stay that way. The last action has no successor, so #finalizePerScenario supplies it and names its capture after that action — lastRenderedScreenshot skips a capture named __final__, which is reserved for a session that ran no action at all. What that one capture waits on is settleAfterLastAction, and the wait is gated, not timed: the drain that runs immediately before it (captureTrace(browser, true)) anchors each document once, so SessionCapturer.replacedDocumentInLastDrain says whether the last action navigated to a document the session had not seen. No → return at once (the app has been at rest since the last action, so the test's own teardown is the gap that let the paint land). Yes → waitUntil(document.readyState === 'complete'), which is only meaningful once you know the document being described is the incoming one: ungated, the OUTGOING document already reports 'complete' right after a click, so a blind poll returns instantly and captures the page the test just left, and the old body.childElementCount > 0 clause that papered over that made a legitimately blank destination a guaranteed 8 s timeout per test. Native pauses 250 ms instead (no document to poll; a capture at a 0 s gap measures 359–476 KB against 1.87 MB settled on Appium). Residual: a navigation that has not committed when the anchor is read still reads as "no navigation" — the same blind spot the deleted document tag had. Don't reintroduce a poll without a gate, or a tag.
Adapter packages own only:
- Framework-specific hook registration and lifecycle binding.
- Framework-specific driver/browser patching.
- Framework-specific config and capabilities.
They import from shared and core, never from each other. They aren't imported by backend or app.
backend and app import from shared (for contracts) and from each other via the WS/HTTP boundary. Neither imports an adapter package. backend also imports trace, to write and read zips; app imports only trace's reader, which the hosted player (trace.webdriver.io, built with pnpm player:hosted, deployed by Vercel from vercel.json) runs in the browser to open a dropped zip with no backend at all. It runs the reader in a module Web Worker loaded from the player's own origin (app/src/controller/trace-parse.worker.ts, which script-src 'self' already admits), so the tab stays responsive and the landing screen names the phase the open is on; the live build never references the worker. That build is HOSTED_PLAYER in app/src/controller/constants.ts; it never writes the trace to browser storage, and vercel.json is also what vite preview serves, so its CSP is checked locally before it ships.
Framework-specific behavior in the backend is contained in two files: runner.ts and framework-filters.ts. Both branch on a typed TestRunnerId from shared, never on a magic string. The framework-filters dispatch is a switch over TestRunnerId (not a table lookup) so CodeQL's unvalidated-dynamic-method-call query trusts the call site.
Every fetch(...) and ws.send(...) has a typed request/response shape in shared. SocketMessage<T extends WsMessageScope> is the canonical WS wire format — receivers narrow on scope to get the exact payload type per branch.
No any crosses a package boundary. When a framework API forces a loosely-typed value (Nightwatch's currentTest, Selenium's BiDi events, raw HTTP payloads), the any is cast to a typed shape immediately at the boundary, with the cast site documenting why.
packages/shared, packages/trace and packages/core are "private": true and never published. Each consumer inlines their code into its own dist/ at build time.
-
All three are listed in
devDependencieswithworkspace:^, never independencies. Vite and tsup both externalize anything independenciesby default;devDependenciesis what gets inlined. -
None of them is added to a bundler's
externalconfig. Vite'sexternalcallback receives both the bare package name and the resolved absolute path (e.g./Users/.../packages/core/src/index.ts); a check for only one form silently externalizes the other. -
That callback enumerates the private packages, so adding a fourth one means editing it — a package missing from the list falls through to the default and is externalized silently, producing a dist that dies at install with
ERR_MODULE_NOT_FOUND. It is aPRIVATE_WORKSPACE_PACKAGESarray rather than a chain of||s for exactly that reason. Adding a workspace package also means adding it topnpm-workspace.yaml, whosepackages:list is explicit rather than a glob. -
The same callback receives bare relative imports (
./utils.js,../constants.js). A check that allows only./will externalize../-style imports from subfolders and the dist crashes withERR_MODULE_NOT_FOUNDat install time. -
packages/service/vite.config.tsis the canonical pattern for getting both right. -
After any change to a bundler config or build script,
grep -nE "(from|require\()\s*['\"](@wdio/devtools-(core|shared|trace)|.*/packages/(core|shared|trace)/)" packages/<pkg>/dist/*.jsshould return nothing. That's how you catch the absolute-path leak. Match on thefrom/require(prefix, not the bare package name:LIBRARY_NAME = "@wdio/devtools-core"(written into the trace'scontext-options) andSymbol.for("@wdio/devtools-core/assert-patched")are inlined string values that legitimately survive bundling, so a bare-name grep always reports a false leak. -
A CJS-only dependency must be externalized, not inlined, or esbuild rewrites its
requireinto a shim that throwsDynamic require of "fs" is not supportedthe moment the module loads. Declaring it independenciesis what externalizes it; that is why all three adapters — and nowbackend— listyazlthere rather than indevDependencies. This is the opposite of the workspace-internal rule above, and for the same underlying reason:dependenciesis externalized,devDependenciesis inlined. Neitherpnpm buildnorpnpm testnor the leak grep notices — every one of them passes on a dist that dies on first import — sopackages/backend/tests/dist-bundling.test.tsasserts the shim is absent.
Bundlers in use: vite for app, service, script; tsup for backend, nightwatch-devtools, selenium-devtools.
- A published package that ships a BUNDLE declares its build libraries as
devDependencies.appandscripteach publish a vite build with everything inlined — the app's dist carries no bare import of lit, preact or codemirror, the script's none of htm, parse5 or preact — so listing those underdependenciesinstalled a whole toolchain on every consumer that needed none of it.scriptalso listedvite-plugin-singlefile, which pulls vite, rolldown and lightningcss;applisted@wdio/devtools-service, which it never imports and which pulls webdriverio. Measured against the registry: installing@wdio/devtools-backendcost 338 packages / 264 MB, against ~85 / ~27 MB once both were moved. Every adapter paid that; the Python one pays it hardest, since it fetches the backend at runtime. The test is the same grep as above — a bare import surviving indist/means the dependency is real and belongs independencies; nothing surviving means it was build-time. Note this is the opposite default from the workspace-internal rule: theredevDependenciesis chosen so code is inlined, here it is chosen because the code already is.
Files own one concern:
- UI components render. They don't
fetch, manage WebSocket state, or run business logic. - Controllers and services own I/O and state. They don't render.
- Backend route handlers wire requests to services. They don't contain business logic inline.
- Reporters report. They don't also resolve sourcemaps, read files, and generate step UIDs in the same module.
Mixed-concern files are split as they're touched. The app-side helpers like contextUpdates.ts, runnerCapabilities.ts, renderDetailBlock.ts, compareUtils.ts, suite-merge.ts, mark-running.ts, run-detection.ts, and stepResolution.ts are all extractions from larger god-files.
strict: trueis on (roottsconfig.json).- No
any. If a framework or library forces it, theanyis isolated at the boundary and cast to a typed shape with a one-line comment explaining why. As of writing, there are nono-explicit-anywarnings repo-wide. - No
as unknown as Xdouble-casts unless the reason is documented inline. typefor unions,interfacefor object shapes that may be extended.- Names exported from
sharedandcoreare public API of those packages — renames are breaking changes for downstream consumers.
- One name per concept across the whole repo. The canonical test-status name is
TestStatusin shared; the sidebarTestStateis a value-only enum-style accessor over the same string union. - Constants are
SCREAMING_SNAKE_CASE. Types arePascalCase. Functions and variables arecamelCase. Files arekebab-case.tsunless they match a class name (SessionCapturer.ts).
Soft caps (warnings in pnpm lint, not errors):
- File: 500 logic lines (blank lines and comments excluded). Files growing toward this cap are split as their sections are edited.
- Function: 50 logic lines.
A few declarative blocks (#getInternals accessor bags in the adapter plugins) exceed the function cap intentionally — splitting them artificially hurts readability. Those are marked with an inline eslint-disable-next-line max-lines-per-function plus a one-line justification.
- Default to no comments. Names should explain what.
- A comment is written only when the why is non-obvious: a hidden constraint, a workaround for a specific bug, a subtle invariant, behavior that would surprise a reader.
// TODO,// added for X,// removed Y,// keep in syncaren't used — the first three belong in git history; the fourth means a single source of truth is missing.- One line max. Multi-paragraph docstrings aren't used.
- Validation happens at boundaries (HTTP input, WS messages, framework callbacks). Internal code is trusted.
- Errors aren't swallowed silently.
catchonly adds context, then rethrows or logs with enough detail to debug. Empty catches don't appear in production code.
Unused exports, unused imports, commented-out blocks, and _unused parameters get deleted when discovered. Git history is the safety net for "in case we need it later" code.
The repo uses vitest at the root. The current state: 1776 tests across 139 files; thresholds at vitest.config.ts enforce a floor of 85/77/86/85 (statements/branches/functions/lines). Coverage is ratcheted upward as gaps close, never downward.
sharedandcore: unit tests for every exported function and type guard. These are the foundation; regressions cascade.- Bug fixes (any package): a regression test that fails before the fix and passes after. When a real test is genuinely impossible (e.g. requires a live browser the infra doesn't have), the PR description says so.
- New HTTP/WS contracts: a test that exercises the contract end-to-end at least once.
Non-trivial parsing or transformation logic in adapters has unit tests. Hook wiring is verified manually via examples/<framework>/. backend and app test their non-UI logic (parsers, transforms, state reducers); UI verification is manual.
For UI or runtime changes, examples/<framework>/ is the verification harness. Type-checks and unit tests verify code correctness, not feature correctness — claiming a UI change works on the basis of tsc --noEmit alone misses the point.
When CI can't run an example (no real browser), the PR description says so explicitly.
ci.yml's examples-smoke job runs one desktop example per adapter (WDIO mocha, Selenium mocha, Nightwatch BDD, Python pytest) in trace mode in the runner's headless Chrome, against the fixture site (pnpm smoke:fixtures) via DEVTOOLS_EXAMPLE_BASE_URL, so no third-party page change can break it. .github/scripts/examples-smoke/run-example.sh collects the zips each run wrote and hands them to pnpm smoke:assert; each red step names its example and each FAIL line names the expectation, and the zips and run logs are uploaded as the examples-smoke artifact. It checks that capture still produces the rows the examples imply, and that an example's zips stay inside its byte budget, not how they render, so dashboard visuals, mobile and Safari still need a person. A change that alters an example's rows updates its expectation file in the same PR. A budget is the measured total plus about 25% and is lowered, with a new measuredBytes, when a change shrinks traces, so the win cannot be undone silently; the artifact is uploaded only on failure, so a passing job's measurement is its log's trace zips total line.
The venv's editable install of selenium_devtools points at the primary checkout, so in a worktree every Python example, and the backend and page collector it resolves through _find_monorepo_backend, come from main unless PYTHONPATH=packages/selenium-devtools-py/src is exported first. A Python run that reports the old behaviour from a worktree is usually this, not the change.
The Python adapter cannot import core, so some pure logic exists twice. packages/shared/mirror-vectors.json holds input/expected-output cases for it, one section per mirrored function; packages/selenium-devtools/tests/mirror-vectors.test.ts runs every section against the TypeScript side under pnpm test, and packages/selenium-devtools-py/tests/test_mirror_vectors.py runs the same file against the Python side in the Python CI job (whose path filter includes the file). Both runners refuse a section they do not handle. Covered today: assertion target resolution, native-session detection, run id, output directory, command row id range, wait poll keys, nested-wait identity, wait row naming (whose negated case the TypeScript runner skips, selenium-webdriver having no negated wait), performance payload shaping and the capture timing summary; notCovered in the file says why screencast thinning is not. Add a case whenever a change touches either side of a mirrored function, and a section when new logic is mirrored, provided its output is deterministic without a driver. A deliberate difference between the two goes in the section's notes, not around the case.
The hosted player runs the newest reader against zips any adapter version wrote. packages/trace/tests/fixtures/trace-formats/ holds real zips from older writers, one per adapter, reduced by reduce-fixture.ts beside them (images become 1x1 images of the same format; every other entry keeps its bytes), each with a manifest counted from its raw entries; trace-format-fixtures.test.ts checks the current reader still yields what each one held. A release that changes what the writer puts in a zip adds a fixture for it, as the README there describes, and older ones stay.
ci.yml's hosted-player job runs pnpm player:e2e: it serves the hosted build under the vercel.json headers and, in headless Chrome, opens each committed older-format fixture and a zip written by this checkout's writer (hosted-player/current-trace.ts) through the landing screen's file input, expecting every manifest title as an action row, a filmstrip frame and no error, then a non-zip, expecting Could not open. It fails on any console error or uncaught exception the page logs over WebDriver BiDi, and on any CSP violation, which a preload script reports to the console because BiDi carries no CSP message of the browser's own; it also requires the parse worker's script to have been requested. scheduled-hosted-player.yml runs the same check against vars.HOSTED_PLAYER_URL every six hours, after each Production deployment and on demand, skips while the variable is unset, and keeps one open issue for a failing check, closing it once it passes. A change to the landing screen, the action rows or the filmstrip that moves what page-scripts.ts reads updates it in the same PR.
A handful of tests need @wdio/devtools-script to be built first (the browser-injected bundle). CI test jobs sometimes run before that build step; those tests gate on it.skipIf after probing createRequire(import.meta.url).resolve('@wdio/devtools-script'). Locally they run normally.
The decision tree from ARCHITECTURE.md "Where things live" is the starting point. The general shape:
- Shared concept →
shared. - Pure transform producing trace-zip content →
trace. - Framework-agnostic capture/reporting logic →
core. - Framework-specific glue → the matching adapter.
- Server route/WS handler →
backend(contract insharedfirst). - UI →
app. - Code that runs in the browser under test →
script.
When the right place is ambiguous (something between shared and core, or between core and an adapter), the question that resolves it is: who else would want this? If the answer is "any future adapter would," it's core. If "only the framework with X-specific API does," it's the adapter. If "the backend would, to build a zip for an adapter that can't," it's trace.
- Boy-scout rule applies: when touching a file or section, leave it more aligned with these conventions than it was found. Touch a duplicated type, consolidate it into shared. Touch a section of a god-file, split that section out. Touch a magic-string framework check, replace it with
TestRunnerId. The cleanup scope matches the change scope — don't rewrite the whole file, but don't leave a clear convention violation in lines just touched. - New code doesn't introduce violations to match existing style. Where existing style violates these conventions, that's documented debt (§ Known debt), not a template.
pnpm build,pnpm test,pnpm lint. Don't push red.- A changeset for a published npm package, or a
changes/fragment for the Python adapter — see § Releasing a change. - For UI or runtime changes: verify in
examples/<framework>/. - Deeper security findings (taint flow, polynomial-redos with adjacent quantifiers) surface on the PR's CodeQL scan; review and fix those before merge.
Two mechanisms, and the Python one exists because the npm one cannot reach it. Changesets discovers packages through the pnpm workspace and identifies them by package.json; packages/selenium-devtools-py is in neither, so a changeset naming selenium-devtools-py does not degrade — it raises "not in the workspace", fails changeset version, and takes the npm release for every other package down with it.
- Published npm package changed →
pnpm changeset, committed as.changeset/*.md. packages/selenium-devtools-py/src/changed → a fragment underpackages/selenium-devtools-py/changes/, frontmatter carrying the bump level alone (patch/minor/major).python.ymlrefuses a branch that changessrc/and documents nothing. A directCHANGELOG.mdedit satisfies it only until the first release — before one there is nothing to bump from and the pending entry IS the changelog section; after one (detected by apy-v*tag existing) it documents the change but bumps nothing, so the release would find no fragment and republish a version the index already holds.
Neither is hand-versioned: both assemble the version and the changelog at release. The Python release additionally consumes its fragments, bumps __version__ (the single source — pyproject.toml reads it via dynamic = ["version"]), and tags py-v<version> after a successful publish, so the tag is an output pointing at the published tree rather than an input naming a version nothing has computed yet.
BACKEND_NPM_VERSION is the backend a pip install user actually runs, so the npm release goes first; release.yml opens the pin bump as a PR, and scripts/check_backend_pin.py refuses a PyPI publish whose pinned backend cannot serve the contract. That PR carries its own changes/ fragment, because python.yml refuses a branch that changes src/ and documents nothing — a pin-only PR would fail its own CI. It is a PR rather than a push because the pin is a claim about a published artifact and check_backend_pin.py is what adjudicates it; merging one queues a patch for the next PyPI release rather than bumping __version__ there and then. Raised with GITHUB_TOKEN it arrives with no checks at all — GitHub suppresses workflow runs for events its own token raises — so either set PIN_BUMP_TOKEN or close/reopen the PR to get CI onto it.
- Small, focused. Don't bundle unrelated changes.
- Imperative mood. The commit message explains why; the diff shows what.
- New commits, not amends to pushed/shared commits.
- No
--no-verifyto skip hooks. If a hook fails, the underlying issue gets fixed.
- One concern per PR. A refactor and a feature are two PRs.
- A PR touching more than one adapter package answers in its description: why isn't this in
core?
- User-facing docs live in two places that must stay in sync: this repo's
README.md(+ per-package READMEs) and the WebdriverIO devtools webpage (website/docs/devtools/**in thewebdriverio/webdriveriorepo — e.g.wdio/TraceMode.md). When a change adds, removes, or alters user-facing behavior (a new option, CLI, flag, output, or workflow), update the README here and mirror it to the matching webpage doc in the same change. A docs PR that updates only one side isn't complete. - The capability tables (the README's support, dashboard and mobile tables, the docs site's
CrossFrameworkSupport.mdandMobileTesting.md) are generated, never edited by hand.packages/conformance/src/features.tsdeclares every user-facing feature with a cell per adapter, typed as a complete map over the conformance suite's adapter ids, so a new adapter does not compile until each feature declares it. A partial or unsupported cell carries a required reason, which renders as the footnote. A supported cell carries its evidence: conformance rules the adapter has no known gap against, a test file in that adapter's own tests or a shared package's (core,trace,backend), or why it is checked by hand. The cell builders and rule groups are incapability-helpers.tsbeside it, sofeatures.tsstays the list alone. Two coverage checks keep a new feature from bypassing the list: every user-facing option (capability-options.ts, keyed on the real option types of the shared base and each JS adapter, plus the Pythonenable()keywords, pytest options and user-facingDEVTOOLS_*variables read from source by a test) names a feature id, typed so a misspelt id fails to compile, or says why it is not one; and every conformance rule backs a tested cell or is listed inINTERNAL_RULESwith a reason. A test fails when the README tables are out of date; the docs-site copy is refreshed withpnpm docs:capabilities --website <checkout>in the same change.
Documented divergences from the conventions above. They exist today as debt to be paid down, not exceptions to the rules. Each change reduces this list; new violations don't get added.
replaceCommandhas two semantics — Selenium mutates in place (preserves_id/idfor chained calls); Nightwatch splices and reissues. Both call the samecore/suite-helpersfactories; the storage strategy stays adapter-specific because runner integrations differ. Could be unified by parameterizing the policy if the divergence ever causes a real problem.patchNodeAssert(viacore/assert-patcher) is now wired in all three adapters, default-on behind each adapter'scaptureAssertionsoption (opt out withcaptureAssertions: false). Framework matcher libraries differ: Service taps expect-webdriverio'sbeforeAssertion/afterAssertionhooks so passing+failing matchers render asexpect.*actions (mechanism in the assert-capture entry below); Nightwatch nativeassert/verifyand Selenium'snode:assertalso surface passing+failing rows via their reconcile/patch paths. The remaining gap is Selenium's jest-styleexpect()(and chai): jest/vitest expose no assertion hook, so those matchers produce no rows at all, and a failing one only marks its test failed. Nightwatch's ownexpectAPI is not wrapped either, so it produces no rows.- BiDi is auto-attached in Service and Selenium; Nightwatch is opt-in via
bidi: trueand requireswebSocketUrl: truein capabilities. - Network has three sources, and the page's Resource Timing is the floor. BiDi (all four adapters) and Nightwatch's Chrome performance log are driver-side and carry headers and status. The page collector carries two: its patched
fetch/XMLHttpRequest(the only source with bodies) and the document's Resource Timing buffer (script/src/collectors/resourceTiming.ts): every load with timing phases and size, a status on Chromium only, never headers. Resource Timing is the only network source a browser without BiDi has, which is Safari and every Appium session (Appium's BiDi reference listssession.*alone). The collector reads the buffer at construction, which is what covers loads made before a<script>-injected collector existed, then observes; a second evaluation of the bundle in the same document keeps the first collector (next entry), so the buffer is read once per document. Its two page sources are deduplicated inside the collector: the patch claims each call (absolute URL without fragment, which is the form the buffer names, plus start time), afetch/xmlhttprequestentry within 1 s of a claim is taken so the row with headers and body is the one kept, and because the entry can arrive before the call has answered, a taken entry is held until the patch records the call (the entry is then its second listing and dropped) or drops it (no content-type, a rejected fetch, an XHRerror/abort/timeout: the held entry becomes the request's only record). Recording a failed call as a failed row instead was tried and reverted: the service and Nightwatch keep the page's patched rows beside BiDi's, so on Chrome every blocked beacon was listed twice (Nightwatch example 35 → 67 failure rows); without BiDi a failed call therefore reads as unknown rather than failed, which is the entry's own account of it. Against a driver-side source the gate isprocessTracePayload'sskipResourceTimingRequests, which drops only the Resource Timing rows and leaves the patched rows their bodies, and the gate is evidence, not a subscription: measured on Appium 3.7's XCUITest driver, selenium-webdriver's BiDi inspectors attach cleanly and then deliver nothing, so gating onbidiActive(or onbrowser.isBidi) withheld the page's rows and the trace had zero network rows. Each adapter therefore latches on the first BiDi network event it receives (service#bidiNetworkObserved, set inhandleNetworkRequestStarted; Selenium and NightwatchbidiNetworkObserved, set in theirbuildBidiSinks; Pythonstats["observed"], read bybidi.network_serving) and gates on that. Nightwatch's performance log is deliberately not a gate either: a capability can name a log the driver never serves (XCUITest), and the log is read after the drain in any case, soperfLogs.tsupgradeResourceTimingRowspairs each log entry with the page's row for the same load (URL, nearesttimestamp, the log's being dated from CDP'swallTimebecause the monotonic clock only diffs within a session) and lays the log's method, headers, status and size over that row, keeping the row's id, clock and measured phases, and re-sends it under the id, which the dashboard replaces in place. The reverse order is covered too: a load that completes between the drain and the log read reaches the session as the log's row first, so the session keeps it inloadsListedByLoguntil the next drain, wherewithoutLoadsTheLogListedspends it on the page's one row for it (same URL, nearesttimestampwithin 2 s). A ledger spent per load, not a scan of the rows the session holds: the scan read the collector's own patched fetch row, or a row the log had already upgraded, as the listing of a second request to that URL inside the window and dropped that request outright (a fetch answered with a content-type-less 204 after one answered with JSON lost its only row). Spent at the next drain that reaches the driver, a collector-less document included, and forgotten at a session replacement (armReplacedSession), because the page observed the load before the log could list it: a row absent from that drain is never coming, and a load kept longer would take the row of the next request to the same URL. Residual, Chrome only (Safari and Appium serve no log, so the ledger stays empty there): the log also lists the collector's own patched fetch/XHR calls, whose row is not a Resource Timing row, soupgradeResourceTimingRowsappends the log's row beside the patched one (main ingested both too) and the ledger can spend it on the row of a different request to that URL inside the window, as it can on the next document's load of a shared asset; the request then survives as its own log row, without the page's phases, so the cost is a row's shape rather than a request. Python withholds the whole page batch once BiDi is serving, as selenium-devtools does (whose console gate,bidiConsoleObserved, is evidence-based for the same reason); both lose the patched rows' bodies there rather than list each fetch twice beside its BiDi row, which is what the service's keep does. Residuals: a drain that lands before the first BiDi event of a page is delivered can pass that page's rows once, and Nightwatch clears both flags at a session rotation, so loads in the window before BiDi re-attaches can be listed by both.- A second evaluation of the collector bundle in one document keeps the first collector. Every adapter probes for the collector before injecting, but two callers can pass the probe before either injection has run (Nightwatch's navigation proxy and its drain's recovery, Selenium's navigation hook and its recovery), and the second evaluation used to replace
window.wdioTraceCollectorand drop everything the first had buffered. Measured on Android Chrome through Appium, where there is no BiDi and the<script>path is the only one: Nightwatch injected twice 11 ms apart and the trace had 0 network rows for a run that loaded two pages, because the replacement also could not re-read the Resource Timing buffer without listing the first collector's loads again.collector.tsnow exportsalreadyInstalledand keeps the instance it finds, andindex.tsskips its bootstrap on that flag, so the second script is a no-op rather than a second observer feeding the same collector; the adapters' probes only save the round trips. Thechrome-native:new-tab document a mobile Chrome session opens on is ignored with the rest of the browser's own schemes, since a session starts there before its first navigation; that list isshared/browser-schemes.ts, read by core's injection gate and the collector's request filter alike, so the two cannot drift again. - selenium-webdriver's
NetworkInspectorhands every network event to every subscriber's callback (_onadds one unfiltered socket listener per subscription), socore/bidi.tshandleBidiRequestSentalso receives response events andhandleBidiResponseCompletedrequest events. Unguarded, the request handler pushed a second, never-completed row per request and the response handler finalized rows on request events: measured, every request in a Selenium Chrome trace listed twice (a status-0 row beside the completed one) and 57 of 135 rows in a Nightwatch one. Each handler now ignores the other's event by shape (responsepresent or absent). Two more shapes the inspector does not model: a redirect hop re-sends the request under the same id, so rows are kept by id for the session (bounded at 5000, oldest out) and a request for a known id reopens its row under the first hop's clock rather than pushing one; and a failed fetch, which core closes as a failure (status 0, the browser'serrorText, as the WDIO service'shandleNetworkFetchErroralready did) through the inspector'sfetchErrorhook, readingnetwork.fetchErroroff the inspector's own connection only for an inspector without one; that handler ignores the request and response events the same dispatch hands it, byerrorText. Without that, every blocked analytics beacon sat in the Network tab as pending for the life of the session; a browser that refuses the subscription keeps the inspector and only that gap. - A Resource Timing row states
source: 'resource-timing', the list colours itunknownrather thanpendingwhen it has no status, the detail card names what it could not see, and the HAR writer adds_sourceand_resourceType: HAR's requiredstatusis written as 0 for it, which read back as-is is a transport failure, and an emptycontent.mimeTypecannot restore the type. The method is''for a navigation, fetch or beacon (Resource Timing names none) andGETonly for a parser-initiated load. A native app session has no document and therefore no page-side network capture. All four adapters read its traffic from the appium-interceptor plugin's proxy when the session setsappium:intercept: true:core/appium-interceptor.tsNativeNetworkCaptureowns the per-session listener and its drains, and each JS adapter supplies only an execute path that renders no rows (the service and Nightwatch raw WebDriver HTTP bound to the session, Selenium the unpatchedexecuteScript) plus its drain points (after each command, before a per-test slice is written, at session end). Every read returns the listener's whole history, bodies included, so the per-command drains are throttled toNATIVE_NETWORK_DRAIN_INTERVAL_MSwhile the test-end and session-end drains never are. Python mirrors it inselenium_devtools/interceptor_network.py, since it ships no Node, and drains when each test reports its outcome.appium:interceptis honoured flat or grouped underappium:options. The plugin records no timing, so each row is stamped when first read, and requests made before the listener starts (an app's launch traffic) are not listed.
- A second evaluation of the collector bundle in one document keeps the first collector. Every adapter probes for the collector before injecting, but two callers can pass the probe before either injection has run (Nightwatch's navigation proxy and its drain's recovery, Selenium's navigation hook and its recovery), and the second evaluation used to replace
- Retry-aware trace policies share one mechanism: adapters feed a per-attempt outcome ledger (
core/attempt-tracker.tsTestAttemptTracker.recordStart(uid, specFile)+recordOutcome(uid, state, attempt)) keyed by the retry-stable uid, and the finalizer reads the scoped views (all/forSpec/forTest) sotrace-retention.tsevaluates group-by-test —retain-on-failurekeys on each test's final attempt (no over-retaining a fail-then-pass),retain-on-first-failureon attempt 0.recordStarton a second attempt stamps the prior attemptfailed(a retry only follows a failure), which corrects runners that swallow the intermediate failure — e.g. Mocha via a--requireplugin never surfaces the retried attempt's failure, so the ledger would otherwise see[passed, passed]. An empty scoped view falls back totestMetadata(never fail-open-retains).- WDIO + Selenium: verified end-to-end (manual fail-then-pass runs: retained under
retain-on-first-failure, dropped underretain-on-failure). - Nightwatch:
retain-on-failureworks; the other retry-aware policies degrade. Its--retriesre-runs the testcase internally without re-firing the plugin's per-test hooks, and the per-testcase results carry no attempt/retry field (retries live only in undocumented, version-varying Nightwatch internals —suiteRetries.testRetriesCount/reporter.testResults.retryTest), so the ledger sees only the final attempt for thedescribe/itand exports-object interfaces. Cucumber scenarios expose per-scenario hooks, so the feed captures their attempts. Not cleanly fixable without depending on those internals. - WDIO
specFileRetriesspawns a fresh worker per retry, so cross-process attempts aren't in the (process-scoped) ledger.
- WDIO + Selenium: verified end-to-end (manual fail-then-pass runs: retained under
- Run identity across worker sockets is env-propagated.
core/run-id.tsresolveRunId()publishesDEVTOOLS_RUN_ID(RUNNER_ENV.RUN_ID) and every worker socket carries it as?runId=(WORKER_WS_QUERY), so the backend keeps accumulated run state when the next spec's worker connects and wipes it only for a genuinely new run. Without it every connect read as a new run: Preserve & Rerun 409'd for every spec except the last one that ran, and a dashboard opened mid-run replayed only the current spec. The WDIO service stamps it in the launcher'sonPrepare, before workers fork, so all workers of one run agree; single-process adapters self-stamp on first use. Gap: multi-process parallel runs in Selenium/Nightwatch (jest/vitest workers, nightwatchtest_workers) load the plugin per worker with no launcher-side hook to stamp first, so each worker generates its own id and still reads as a new run — the pre-fix behaviour, not a regression. Deriving the fallback fromprocess.ppidwould group those siblings, but would also make two sequential single-process runs share an id and inherit each other's state against a standalone dashboard, so the per-process fallback stands. - A rerun template selects EITHER by name pattern or by exact id, and the two cannot share a slot.
shared/src/runner.tsRERUN_SLOTnames both, andbackend/src/runner.ts#resolveGenericCommandbranches on which one the adapter's template carries:{{testName}}is filled fromlabel/fullTitlethroughescapeFilterRegexbecause mocha--grep, jest--testNamePatternand cucumber--nameall match by regex;{{testId}}is filled fromuid, shell-quoted and never escaped, because pytest selects by nodeid and matches it literally. Measured:pytest 'test_thing\.py::test_a'collects nothing and exits 0, so the escaped form fails as a rerun that appears to have run and passed. Which slot a payload can service also decidesisTargetedRerun— an id template needs auid, not a label.- A pytest nodeid addresses a file, a class or one test in one syntax (
file.py,file.py::Class,file.py::Class::test), and the Python adapter's uids already are nodeids at all three levels, so one slot covers every row the tree offers — no per-level filter flag and no cucumber-style feature special case. Verified end-to-end: substituting a test nodeid collects 1, a file nodeid collects 3. selenium-devtools-py/src/selenium_devtools/rerun.pyderives both commands from pytest's own view of its invocation (config.invocation_params.argsplusconfig.argsfor which of them were positional) rather than parsing argv itself: inferring positionals needs a table of every option that takes a value, and dropping a value while keeping its option makes that option swallow the appended id. Capabilities are derived from which commands got built, never declared — the backend's fallback for a rerun it was given no command for is the wdio binary, so an advertised-but-unserviceable control is worse than an absent one. A plain script publishes a launch command only and advertises Run-all alone.- Selectors are stripped from a targeted rerun (
-k,-m,--deselect,--lf/--ff/--swfamily,-n/--numprocesses/--dist): the rerun already names its test, so a surviving filter can only narrow further — usually to nothing, which pytest reports as a clean exit. Positionals go too, or a rerun's own child would union the inherited nodeid with the next one and each generation would run one test more. The xdist flags also go because each worker would connect under its own run id. - The rerun spawns in pytest's rootdir (
RUNNER_ENV.RUNNER_CWD, stamped before the backend is launched so its process inherits it): a nodeid is reported relative to rootdir while a positional path resolves against the process's cwd, so anywhere else makes every nodeid a path that does not exist. The launch command's positionals are absolutised for the same reason. The variable is replaced on a secondenable()in one process but only while it still holds the value we wrote (tracked, and deliberately survivingreset()): our leftover would otherwise spawn the next run's reruns in the previous project, while a value someone exported since is an instruction. A boolean "we wrote it once" cannot serve both — it says nothing about whether the current value is still ours. The remaining ambiguity is accepted and untouchable: a caller who exports the same path we already stamped is byte-identical to our leftover in the only channel there is, so that override is replaced; pinning a directory across runs works by exporting it before the firstenable(), which is never claimed as ours. Residual: an option carrying a relative path (-c,--junitxml) resolves against rootdir on a rerun, and an already-running dashboard keeps the directory it was started in. - Preserve & Rerun needed no adapter code at all — the blocker was the capability gate. The button renders on
hasFailed && !runDisabled, so an adapter advertising no run capabilities never showed it;baselineStoresnapshots from the stream every adapter already sends, andtoMsaccepts the ISO strings Python puts onSuiteStats.start/end. Verified end-to-end against a live backend with frames built by the adapter's ownframes/SessionCapturer: both attempts carried their commands, console and network with distinct windows and correct states. What was missing was coverage:preserveBaselineappeared in no test in the repo, and it is the one flag deciding whether the request that exists to compare wipes what it means to compare against. Preserved attempts live in#baselines, outside the#activeRunaccumulator, which is why a rerun's new run id resets capture without losing the snapshot — and why the order matters: preserving after a new run connects is a deliberate 409. - A rerun does not travel down the worker socket.
POST /api/tests/runspawns a fresh process; the socket carries onlyclientConnected/clientDisconnected. So the single-workerSocketlimitation is about which process the dashboard state belongs to underpytest -n, not about routing the rerun. - A spawned rerun must be pointed back at the backend that asked for it, or it reports into a dashboard nobody is looking at.
REUSE_ENV(DEVTOOLS_APP_REUSE/_HOST/_PORT) is how the backend does that, and an adapter that ignores it launches a second backend and a second window: measured on the Python adapter, a rerun opened a new dashboard carrying the rerun's data while the window the user pressed Rerun in stayed as it was — which reads as a rerun that captured nothing.backend.pyreuse_target()now attaches to it ahead ofDEVTOOLS_PORT(that variable is an ambient preference inherited from the parent; the handshake names the backend that requested this run), and the window gate lives inlifecycle.auto_open_enabled()rather than at theenable()call site so it is directly testable. An incomplete handshake deliberately still opens a window — no usable target means the child launched its own backend, and then the window is the only way to see it. - A plain script's tree is one synthetic suite holding one synthetic test, and both denote the whole run, so its launch command doubles as its rerun template (no slot — the backend substitutes nothing) and all three controls are honest. Refusing the row-scoped ones instead would disable the button beside the only row the tree has.
- Two unrelated events share the
clearExecutionDatascope, and the receiver cannot tell them apart from the uid. A run STARTING (backend/src/index.tshandleTestRun, one perPOST /api/tests/run) and ONE ENTRY resetting inside a run already in flight (nightwatch-devtools/src/cucumber-lifecycle.ts, which re-emits a scenario suite and must not wipe its siblings) arrive under the same scope with the same shape. The app inferred the difference by comparing the uid againstrerunState.activeRerunSuiteUid— a latch that outlived its rerun, so the next run start at a different scope read as a child clear of the last one and skipped its wipe entirely: rerun a suite, then the file or Tests, and the Actions/Console/Network tabs kept the previous run's rows and grew with each rerun.ClearExecutionDataWsPayload.runStartnow states it on the wire (it has to be on the wire, not local to the clicking window — popouts see only WS events), and the app clears both latches when it is set. A backend test asserts the flag actually ships: the app-side fix reads it, so dropping it would restore the bug with every app test still green.- Still open, same class:
app/src/components/browser/snapshot.ts#videosis only ever pushed to, so the screencast "Recording N" dropdown accumulates every session of every run for the life of the page (observed at 17). That component listens only to thescreencast-readywindow event and never learns a run started.
- Still open, same class:
- A rerun's process collects a SUBSET, so anything it derives from "this collection" is wrong for the tree it merges into. Three bugs of that one shape, all found by rerunning a single pytest test. The third is the one that shows the rule has a limit: (c) the launch command — what Run-all spawns — was built from the child's own invocation, which the backend had narrowed to a single nodeid, so one targeted rerun rescoped Run-all to that test permanently and the tree kept showing only what that child collected. Unlike (a) and (b) this is not recoverable inside the child: its arguments no longer mention what it was narrowed from. The original travels down instead, as
REUSE_ENV.LAUNCH_COMMANDbeside the rest of the reuse handshake; the rerun template stays locally derived, since only this process can say how its own interpreter selects a test. The inherit-or-derive resolution is Selenium'shelpers/launch-command.tsresolveLaunchCommand, read only while the reuse handshake is live (a leftover export must not pin Run all on a first run); it stays in the adapter until a second JS adapter needs it, since Python mirrors it inrerun.py_inherited_launchand cannot import core.captureLaunchCommandignoresnpm_lifecycle_eventwhennpm_commandisexec(npxsets the event tonpxandpnpm execsets none, so neither names a script to rerun) and shell-quotes each argument of its argv fallback, which the backend splits withshellParse. Nightwatch has no launch command, since the backend relaunches it through its framework path; the run that starts the backend records its CLI selection inRUNNER_ENV.NIGHTWATCH_INITIAL_ARGS(sharedencodeInitialArgs/parseInitialArgs), or clears the variable when it selected nothing, in its own process, which a rerun child attaching in reuse mode cannot touch. The selection is specs made absolute plus every other flag without--config; Nightwatch parses argv with minimist and declares no boolean flags, so a flag's following bare word is its value and is never rewritten as a path, and only an entry script in thenightwatchpackage'sbinfolder counts as the CLI. The backend'sframework-filters.tsrunAllFiltersreplays it for both Nightwatch runners, asWDIO_INITIAL_SPECSdoes for every other. The other two: (a)SuiteStats.order— whichtest-entry-state.tsorderedChildrensorts a suite's tests and child suites by — was pytest'senumerate(session.items)index, so a rerun restamped its one test as position 0 and the row jumped above the class it was written below. It is now the item's source line, a property of the test rather than of the collection; within a module pytest collects in definition order, so the two agree wherever both are meaningful (a plugin that reorders collection is the exception, and there the line is the more stable answer anyway). (b)suite-merge.tsresetStaleChildrenOnRerunflipped every settled child suite topendingwhenever an incoming suite arrivedpending— but a single-test rerun re-emits the parent aspendingcarrying only the one test it collected, so a sibling class suite was set spinning and never reported again, keeping the spinner for the rest of the session with all of its own tests still green.mergeTestsalready froze sibling tests onactiveRerunTestUid; that guard now covers child suites too. A suite on the path to the target is unaffected either way — it re-reports its own state.
- A pytest nodeid addresses a file, a class or one test in one syntax (
- A
wait()poll is the user's one call, not a row per tick. selenium-webdriver'sdriver.wait(until.…)runs its first poll inside the call, with the spec line on the stack, and every later one from a timer with no user frame, so a fold keyed on the call source left the first poll standing alone. The patchedwait()runs its condition inside anAsyncLocalStorage(wait-tracking.tswaitPoll), which stampsCapturedCommand.waitScope(one object perwait()call) on exactly its own polls, per driver and per async chain, andcaptureOrReplaceCommandfolds a poll only into the row an earlier poll of the same scope made for the same call (WaitScope.rows). The call is named at invocation (waitPollOf,CapturedCommand.pollKey) as the nth call with that command, args and target within one evaluation of the condition, since every evaluation repeats its calls in order and a timer poll has no call site to tell them apart: keyed on command and args alone, a condition readinga.getText()andb.getText()folded the second read over the first. A wait's rows never enter the ordinary retry tracker and reset it: replacing an older poll row moved onlylastId, leavinglastSignaming another command, so a later retry of that command overwrote the wrong row, and a helper's wait polling the samefindElementsas a row the user wrote just before it overwrote that row. A retry therefore never folds across a wait. Captures run one at a time per capturer, because the driver patcher does not awaitonCommandand a poll could otherwise arrive before the previous one has the row id it folds into. A call outside a wait folds only as a same-call-site retry. Accepted cost: a library that polls from its own timer rather than throughdriver.wait()shows a row per poll.- A wait is one row, and its polls are its children. The patched
wait()emits the wait's own row when it is CALLED (openWaitRow,CapturedCommand.waitnaming its scope,running), stamped at its start with no result or error, and emits it again when the promise settles, stamped at completion and carrying the rejection aserror;captureOrReplaceCommandcaptures the first and replaces it by id (WaitScope.rowId) with the second. Every row captured with thatwaitScopeis stampedparentId=rowId, which is set by then because captures run in issue order and the running row is emitted before the condition first runs. A wait inside another wait's condition is a row of the outer one, and because its scope is kept across outer polls (scopeFor) it is one row re-sent running on each outer poll, from its first start, not a row per poll. The name comes from what the wait waits for:tagElementConditionswrapsuntil.elementLocatedanduntil.elementIsVisibleand records each condition's target in aWeakMap, since the condition keeps its locator in a closure, so a locator wait iswaitForElementLocated(Element.waitForExist) with the locator'sselector, a visibility waitwaitForElementVisible(Element.waitForDisplayed) with the selector the element was found by, and any other conditionwait(Browser.waitForFunction) labelled with itsdescription(); a locator with no selector form (link text) keeps the element name and puts the label inargs. The running row takes no screenshot, drain or snapshot (handleOnCommandreturns after the capture). The patchedwait()returns a promise derived from selenium's that reports the outcome and passes it on, so an awaited failure rejects with the same error and an unawaited one still surfaces asunhandledRejection; an element wait is re-wrapped in selenium's ownWebElementPromise, whichdriver.wait(until.elementLocated(…)).getText()chains on. A row that cannot be built is logged and never replaces the wait's own outcome.
- A wait is one row, and its polls are its children. The patched
- Safari delivers a synthesized mouse click only to its frontmost window, and a live run opens the dashboard in another browser. safaridriver's
performInteractionSequenceanswers success either way, so a click issued while the Safari window is not frontmost reaches nothing: measured on desktop Safari 26.5 with no DevTools involved at all, the page reporteddocument.hasFocus() === false(oncevisibilityState: hidden), no pointer event fired, and the submit never happened in 3 of 3 attempts, while after activating Safari every click landed; the user's own manual click in that window logged in. Live mode is where it shows, because the dashboard window and the wait for it to connect are what move focus; trace mode mostly escapes it and Chrome never has it. This was earlier recorded as "the Python adapter's login flow on desktop Safari is flaky" with the per-command work as the suspect; that work only widens the window. The Selenium adapter switches to the session's own window handle before every element click, sendKeys, clear and submit on a Safari session (DriverPatcherHooks.beforeInput, the plugin'sfocusForInput): a WebDriver window switch brings exactly that window forward, one round trip, and holds whatever took the front in between, measured 3 of 3 live logins against 0 of 2 before.open -a Safariwas tried first and is not enough, since it activates Safari's most recent window, which can be the user's own (visibilityState: hiddenin the automation window afterwards). The WDIO service, Nightwatch and Python still usecore/browser-focus.tsrestoreBrowserFocus(open -a Safari) once after session bring-up in live mode, with that weakness; a per-input switch there is the next step. The Selenium dashboard also opens behind the current window on macOS for a Safari session (dashboardLaunchCommand,open -n -g). The Nightwatch BDD example's assertions are separately not Safari-proof (4 of 7 fail on Safari). - A trace archive is a full recording of the page, and there is no redaction policy anywhere in capture. Whatever the run put on screen or typed is in the zip, usually several times over: measured on the Python login example, its demo credential appears ~103 times across six places — the page's own displayed text (90x, the-internet prints it), the DOM mutation stream, the
Element.fillcommand args, the transcript, the captured test source, and*-elements.json.shared/element-scripts.tsblanks an<input type="password">value, which is worth having because nothing downstream reads that field, but it removes 2 of those ~103 and closes nothing on its own.buildElementScriptsnow projects a captured record down to what is actually read (selector+boundingBox+ context), sovalueandhrefleave the archive entirely — justified as dead data, not as a redaction: the same archive still carries 15 hrefs intrace.mutationsindependent ofelements.json, and 29valueattribute mutations recording a typed string keystroke by keystroke (t,to,tom, ...).@wdio/elementskeeps returning the fullBrowserElementInfofrom its own live call, which is its documented API. A real policy has to act at the collector and the command-arg serializer — a masking-selector ormaskInputsoption — not at one resource. Until then, treat a trace zip as sensitive as the run that produced it. - An
ActionSnapshotcarries no session identity, in any adapter.shared's type has never had one andcore/action-snapshot.tsrecords none, so per-action captures from two concurrently-driven sessions land in one list and are resolved purely by the command's completion timestamp.claimAfteris an exact keyed lookup, so the window is narrow — two commands completing in the same millisecond, wheretrace-frame-snapshots.tsbreaks the tie by "keep the richest capture" (largest screenshot), which is session-blind — plus the documentedlatestAtOrBeforefallback for a command that took no capture of its own. Python is not worse than the JS adapters here and leans on that fallback less, since it stamps each snapshot with its own command'srow["timestamp"]; reaching the failure at all needs threaded drivers in one process (pytest's function-scoped fixtures are sequential, and-nis multi-process). Fixing it is a shared-contract change:sessionIdon the snapshot, and an index keyed by the pair. - Chrome discards all WebDriver-synthesized input to a tab after a breached credential is submitted. The first time a test types a
(username, password)pair that Chrome's password-leak check finds in a breach corpus into an<input type="password">and submits a form whose destination no longer shows that login form, Chrome queriespasswordsleakcheck-pa.googleapis.comand ~0.3-0.9 s later stops delivering all synthesized input — mouse and keyboard — to that tab. chromedriver returns HTTP 200 for every subsequent Element Click / Send Keys; nothing reaches the page. Untrusted JS (element.click()) still works and direct CDPInput.dispatchMouseEvent/dispatchKeyEventare equally dead, so this is Chrome, not chromedriver and not our capture.tomsmith/SuperSecretPassword!— the-internet's demo credential — triggers it; changing only the username does not, nor does a random password.- Workaround: add
--host-resolver-rules=MAP passwordsleakcheck-pa.googleapis.com 127.0.0.1to the browser args. Every example that submits the demo credential carries it — WDIO, Nightwatch, and both Python ones (login.pywas missing it and its logout click silently did nothing, which is exactly the symptom). Verified 3/3 on the WDIO mocha example and on the Nightwatch example, where it also fixes the within-one-test logout click that a session reset never could.--guestalso works (3/3);--incognitoworks at the raw-WebDriver level but WebdriverIO rejects it at session creation; disabling the password manager viaprefsdoes not (6/6 still fail). - Not a version regression, not headless-specific, not the site, not "the Nth navigation". Measured identically on Chrome 149.0.7827.155 / 150.0.7871.124 / 151.0.7922.77 / 152.0.7977.30 with matched chromedrivers (5/5 each), headless and headed, and on a purely local two-page static form. It fires once per browser profile on a wall clock — a liveness probe that never navigates again goes dead 904 ms after the submit — so the historical ~25% intermittency was the race between the next input command and that round trip. Do not pin
browserVersionto 149; every part of the earlier "Chrome 150 regression, fixed in 151" attribution is contradicted. - Minimal reproduction (own HTTP server, raw
fetchto chromedriver, no repo, no client library, no framework) is in the session scratchpad asminimal-repro.mjs; it is what an upstream chromedriver bug report needs. If a session is already stuck, navigating away and back or opening a new tab restores input (4/4 each);refresh(), ESC, JS focus/blur and a 10 s wait do not (0/4 each).
- Workaround: add
- Live mode has no per-action DOM snapshot, so its replay is only as fresh as the last drain. Per-action snapshots cost two injected scripts plus a screenshot and stay trace-only; all three adapters instead drain the collector after a command that could have moved the page. Service:
#drainAfterLiveCommand. Selenium:commandPostActions.tswarrantsLiveDrain+SessionCapturer.drainAfterLiveCommand, the same deny-list shape over its own command vocabulary plusmapAssertCommand(a node:assert row never reaches the browser) — the predicate is not in core because the vocabularies are per-framework and only twoincludescalls would be shared. Without it Selenium drained only at navigation, and that hook is deferred behind an injection and a 500 ms settle: measured on the login example, 2 mutation entries and 2 anchors for a 16-row run, with the page test 1 spent most of its life on never anchored, so all 11 of its rows replayed the page the test ended on (2 → 24 entries, 2 → 3 anchors, 0 → 21 field-state mutations after the fix). Selenium's drain is serialized on a tail because the driver patcher does not awaitonCommand, and the app scans the mutation stream in order and stops at the first entry past a row's window — an overtaken batch strands every row after it. - A live client receives commands in ARRIVAL order, and one consumer assumed timeline order. Nightwatch withholds native asserts until their outcome is known and flushes them in one batch at test-end (BDD fires
afterEachonce per module, so a whole module's asserts arrive after every driver row). The display list already sorts andutils/elapsed.tsalready treats capture order as untrusted, butapp/src/components/browser/mutation-at-command.tsbounded a row's DOM bycommands[idx + 1]— the array neighbour — so a row was bounded by a time before it ran: measured, the run's lastwaitForElementVisible('#username')took its bound from an assert that had run 7.6 s earlier and replayed/secure. It now orders by(startTime ?? timestamp, sequence ?? 0, array index)— the keybuildActionEventsuses, with the index last so a chronologically ordered array (every trace) resolves to its own successor. Measured: live 5/21 → 1/21 rows on the wrong document, trace 0/21 with 0/21 selections differing. Live-mode anchoring itself is not the gap:processTracePayloadsends mutations upstream unconditionally, and a live run streams one anchor per document visited.- Residual: a submit click whose end, destination birth and next command's start land in the same millisecond still shows its pre-navigation page; and the app deliberately leaves the last row unbounded, rendering the newest DOM.
- The Nightwatch filmstrip was never losing frames across
browser.end(). Instrumented: 155 poll ticks, 129 frames appended, the 26 skipped only in the null-session gap, and the login-page image present once per session in the export. It works because Nightwatch mutatessessionIdin place on onebrowserobject and the screenshot probe reads it fresh. An observed 17→6 drop wasthinScreencastFrames' byte-identical dedup meeting a different failure profile — 10 of the 17 were a blinking text caret captured while awaitForElementVisiblesat 5 s on a focused form. Don't read a low polling-mode filmstrip count as frame loss without a sha1 histogram of the emitted events. What was real:#emitTestArtifactsreadrecorder?.frames ?? filmstripFrames, so once a recorder existed it dropped every frame from before a session change. - Eager per-test trace slice (Nightwatch + Selenium) can drop an action snapshot whose fire-and-forget capture hasn't resolved by
afterEach/ scenario end — the slice is written from whatever snapshots exist at flush time. The WDIO service is immune because it awaits each snapshot inline before flushing. - A command row is stamped at COMPLETION, and the DOM anchor carries the document's own birth time. These two together are what make the replay line up; both adapters got them wrong in the same way and the fix is symmetric. (a)
selenium-devtools/src/driverPatcher.tsandnightwatch-devtools/src/helpers/browserProxy.tsboth ran their capture at completion but stampedtimestampwith the invocation clock, keeping the invocation time asstartTimeonly after this fix. The page-side mutation stream is on real time, so an invocation-stamped row ended before its own effect landed and replayed the page from before it — the#usernamefill rendered an empty field, the#passwordfill rendered only the username, and a navigation row rendered the page it had just left. Rows also now span their real duration instead of a synthetic 1 ms. (b)collector.captureCurrentDom(the only producer of a mutation with aurl) stampsperformance.timeOrigin, not the drain clock. A drain is forced from Node whenever a collector might be fresh, which is always after the navigation — a round trip at best, a whole page load at worst — so drain-stamping put the anchor after several later actions (measured: 9/15 Selenium and 8/15 Nightwatch rows on the wrong DOM). With both in place a navigation row ends after its destination document was born, so the anchor needs no repositioning at all.core/trace-mutations.tsreattributeDomAnchorsremains as a narrow backstop for the one case the stamps can't cover: an anchor born after the last logged command, i.e. a click whose navigation commits once the click has already returned. It snaps such an anchor to the newest logged command, but only when no logged command completed after it — if one did, that command's row already resolves the anchor and pulling it earlier mis-credits it to a preceding action and steals the new page's DOM from rows still on the old one (measured: a 206 ms pull moved/loginonto two rows that were on/add_remove_elements). Anchors are only pulled earlier, never past the newest timestamp already in the stream, or replay would apply the outgoing document's refs to the incoming tree.- Residual, accepted: Nightwatch's
clickresolves before its navigation commits (measured 5 ms), so a submit-click row can still show its pre-navigation page. Selenium is immune — its click waits for page load. Not worth another heuristic; every heuristic tried here regressed a different row.
- A pushed screencast needs bounding at both ends, and the obvious bound biases toward the end of the run. Per-command capture is self-limiting — one frame per command, so the test's own length caps it — which is why
selenium-devtools-pyhad no frame cap at all. Chrome'sPage.startScreencastremoves that property:cdp_screencast.pysubscribes over a websocket of its OWN, which is a different connection from the session's command channel and therefore safe where the poll thread the module's docstring warns about was not. Every frame must be acked (Chrome sends nothing after an unacknowledged one, so a missed ack ends the recording rather than degrading it), and the rate is thinned at the source withevery_nth_framerather than buffered and discarded here.- The buffer cap then needs care. Halving the buffer and keeping first/last — core's documented
maxBufferFramesshape — drifts toward the run's end, because each decimation thins what is already held while new frames keep arriving unthinned: measured on a 40-frame run at a cap of 6, it kept frames 0, 1, 35, 37, 38, 39, i.e. the last moments and nothing from the middle._buffertherefore thins the INCOMING frames by the same factor it has halved the buffer (_stridedoubles per decimation), giving 0, 1, 11, 23, 31 for the same run and, at the real 2000 cap over 12000 frames, 1503 frames with 751 from the middle half. Thinning then costs the END of the run, because the last frame offered is only kept when the run happens to stop on a stride position — 41 frames at a cap of 6 ended on frame 31, eight frames stale, and 12000 at 2000 kept its last only because the two aligned. The newest skipped frame is therefore HELD rather than dropped and folded in by_keep_tailwhen the recorder stops (finalizestops before reading the buffer), so the video always ends where the run did — which is the part a failure is inspected for. Asserting only the endpoints does not catch this — tail truncation also leaves frame 0 plus whatever arrived since the last decimation, so the test has to assert something from the middle survives. driver.start_devtools()cannot be used for this, because selenium caches ONE_websocket_connectionper driver and hands it to whichever of BiDi or CDP asks first. The adapter attaches BiDi before arming the screencast, sostart_devtools()returned the BiDi socket andPage.startScreencastreached a BiDi endpoint: measured,unknown command: Unknown command 'Page.startScreencast', followed byBiDi command has no 'params' of type dictionary: {"method": "Page.stopScreencast"}— that second line being the proof of which endpoint it was, and coming from astopthe failed start should never have sent. BiDi carries console and network, so it keeps the shared connection and the screencast opens its own.- Resolving that endpoint needs BOTH routes.
se:cdpis a Grid capability and is absent for a locally started chromedriver — the common case, and the one the demo runs — so without thedebuggerAddress→/json/version→webSocketDebuggerUrllookup that selenium's own_get_cdp_detailsperforms, push mode would decline on every local run and the feature would be dead code. Done with stdlib urllib rather than that private method, since selenium moving internals is what broke network capture in #293. Anse:cdpequal towebSocketUrlis rejected as the BiDi socket. - Performance timings ride on the command ROW, not a scope of their own (
CommandLog.performance, pluscookies/documentInfo/result), so the row is sent when the command completes and sent again underreplaceCommandonce the page has answered — which is whycapture_commandreturns the row it built andsend_replace_commandkeys on itstimestamprather than the per-processidcounter. Python does not sleep before reading, where the JS adapters wait 500 ms: their navigation command can resolve before the load event, selenium'sget()returns after it, and a sleep on this thread would be a real delay in the user's test rather than a detached await. A read that lands early anyway carries nonavigationentry and is discarded rather than replacing a good row with an empty one. The read goes through_guarded_execute_script, or it lands in the sameexecutehook it was called from and shows up as anexecuteScriptrow beside every navigation — a fake driver whoseexecute_scriptdoes not route throughexecutecannot catch that, and did not. - Per-command screenshots keep being taken for the command ROWS while a stream is live, but stop feeding the video: the pushed frames already cover the timeline and interleaving would duplicate one of them a few milliseconds off.
- The buffer cap then needs care. Halving the buffer and keeping first/last — core's documented
- A drain must anchor the document it reads.
shared/page-scripts.tscollectorDrainExpression(forceAnchor)(re-exported by core'sscript-loader.ts) prependscaptureCurrentDom()so a freshly injected collector's async initial anchor is not lost: the collector schedules it afterwaitForBody, so a drain issued right after a navigation beats it, reads an empty buffer, and the destination's buffer then dies with the page, leaving the navigating action with no DOM. Every production caller in Selenium and Nightwatch passestrue(selenium'sdrainAfterLiveCommand, its re-inject-after-navigation and teardown paths; nightwatch's five sites); the WDIO service's pre-transition drain of the outgoing document is the onefalse. Python runs the forced drain the backend serves asPAGE_SCRIPTS_API'scollectorDrain, the same string, and has no flag of its own. Forcing is free after the first anchor of a document becausepackages/scriptguardscaptureCurrentDomwith an#anchoredflag that deliberately survives itsreset(), which is why selenium anchors on every live command and still emits ~3 anchors across a 16-row run rather than 16. - Document-start injection is what removes the whole race class; everything else is reconstruction.
<script>-append injection only instruments the document loaded at the time it runs, and a<script>dies with its document — so a navigation always yields a document we learn about afterwards, and every question that follows (when to re-inject, when to drain, which action owns the new DOM) is guesswork.core/bidi-preload.tsregisterCollectorPreloadregisters the collector via BiDiscript.addPreloadScriptwith no browsing-context id, which scopes it globally so contexts created later are covered: every document then instruments itself before any of its own script runs and anchors its own DOM at its ownperformance.timeOrigin. Measured on the Nightwatch example: 5 of 5 documents anchored and 0 of 19 rows on the wrong DOM, versus 4 of 5 and 1–5 wrong with the polling/attribution approach. The service has always done this (browser.scriptAddPreloadScript), which is why it never had this bug class.- All three adapters now register it. Selenium does so per driver in
session-lifecycle.tsregisterPreload, inside thePromise.allthatonDriverCreatedawaits — the patchedbuild()thenable waits on that, so the preload is live before the firstget;ensureBidiCapabilityalready setswebSocketUrl: trueon the Builder. Measured on a local two-page form, the appended-<script>path captured 21 of 29 input events (all 8 username keystrokes lost — the collector came up ~1 s afterget, behindinjectScript's ≥200 ms readiness poll andcapturePerformance's 500 ms settle) and 3 of 4 DOM anchors (a destination that lived 150 ms never anchored, the recovery injection's poll never finishing); with the preload, 29 of 29 and 4 of 4, 3/3 runs. On the cucumber example: 0 injections and 0 "collector missing" recoveries (was 6-9 per run), rows-on-wrong-document 0 of 15, trace zip 1.66-1.71 MB → 1.31-1.41 MB — the injected<script>'s own source is no longer part of the captured DOM. - The
<script>branches are gated onSessionCapturer.preloadRegistered, not deleted: they are the only capture when BiDi is absent, and a missing collector is the fallback's only "the document was replaced" signal — which the preload makes permanently false. Verified by forcing the helper to return false: the example reproduces the pre-change numbers exactly (46 mutations, 4 anchors, 42 input events, 6 injection lines). Selenium's navigation hook also drains beforecapturePerformance's 500 ms settle rather than after: the drain is what moves a destination's anchor out of the page, and a short-lived page was gone by the time the settle ended. Performance entries only get more complete with time; the anchor does not. - A preload dies with its session, and Nightwatch replaces sessions without saying so. Registration used to run once at bringup, so a mid-run
browser.end()left sessions 2..N with neither preload nor BiDi: measured 1 registration and 1 attach for a whole run, 4-6Collector missing … re-injectingrecoveries, and — becausebidiActivestayedtruefrom the dead session and gates the perf-log fallback off — zero network capture after the rotation (75 requests in session 1, 0 in session 2).session-init.tsrearmCaptureForSessionre-arms both off the same command-hook detection the screencast rotation uses (armedSessionId, stamped before the first await so the command flood latches out), clearingpreloadRegistered/bidiActiveat detection so the fallbacks are open in the gap. After: 2 registrations, 2 attaches, 0-1 recoveries, 125 network entries (75 + 50), and the accumulated stream untouched — 21 action rows split 12/9 across the two sessions, identical to before. It deliberately does not rebuild theSessionCapturer— see the entry below, which made that the rule for every path rather than just this one.- The preload registers before the BiDi attach, sequentially: behind the attach it lost the race it exists to win (the network subscribe took 6.2 s while the triggering command navigated, so registration landed after its own document was born), and
Promise.allraces selenium-webdriver's unsynchronizedgetBidi()cache into two websockets of whichquit()closes one. - Residual: detection is at command invocation and chromedriver serialises the handshake behind the in-flight command, so the first navigation after a rotation can still land before registration and falls back to
<script>injection (1 recovery in 3 of 4 runs, 0 in the fourth). Nightwatch exposes no pre-test session hook to a plugin.
- The preload registers before the BiDi attach, sequentially: behind the attach it lost the race it exists to win (the network subscribe took 6.2 s while the triggering command navigated, so registration landed after its own document was born), and
- The perf-log fallback only runs when the session asked for a perf log (
SessionCapturer.perfLogsRequested, set fromgoog:loggingPrefs.performanceat session init — the same reading that already warns "Network tab will be empty"). Without the capability the log cannot exist, so every per-command fetch is a round trip that can only fail, and Nightwatch's transport prints each failure: measured on an iOS Safari session, 21 error blocks in a two-test run that passed, 257 log lines and 12.3 s against 66 and 5.9 s once gated. Those numbers were taken when the read wasbrowser.getLog; it now goes over the raw WebDriver transport (POST /session/:id/se/log, the route selenium-webdriver, and so Nightwatch, sends), because the read is issued from the fire-and-forget drain and the navigation poll, and a queued command issued while the test's next command runs becomes that command's child and resolves its await early. Over HTTP a refusal resolves null without a printed error, but the gate still saves a round trip per command, and a runtime latch still cannot replace it:browser.getLog(...)yieldedundefinedrather than the 500's body, and the transport's null is equally indistinguishable from an empty log. XCUITest is why it has to be the capability and not a "is this Chrome" test: it listsperformanceamong its supported types and then serves none of it. - Deliberately not gated on Nightwatch's
bidioption. That option exists to avoid double-reporting console/network against the perf-log path; preload registration needs nothing but a session created withwebSocketUrl: true, and gating DOM capture on an unrelated opt-in that defaults tofalsewould leave the race in place for nearly every user.ScriptManager.initthrows without that capability, so the helper self-degrades to the<script>path and returns false. - The fallback path is kept and still works, but is strictly lower fidelity: measured with
bidi:false, 0 rows without DOM but the per-action field-state mutations don't survive and a navigating click keeps the ~5 ms residual. PreferwebSocketUrl: true. - The collector's bootstrap must not wait for an animation frame when the body already exists.
utils.tswaitForBodyalways waited for arequestAnimationFrame(orDOMContentLoaded), which a document-start preload needs but a<script>appended to a loaded document does not: Safari's automation window paints only when something forces it, so on desktop Safari the injected collector sat in the page with no anchor, no MutationObserver and noinputlistener until a screenshot happened to force a frame, and a live run replayed the bare anchor on every row with empty fields. Measured with the adapter in trace mode, 8 keystrokes typed after injection reached the document's own listeners every time and the collector 0 of them with per-command screenshots off, 8 with them on. The body is now checked synchronously first. - On the fallback path a navigation resolves to the test only once the collector is in the destination, and the anchor carries live field state. Selenium's injection used to run from the fire-and-forget
onCommandaftergetreturned, racing the test's next commands: on desktop Safari (no preload at all) the user's typing into both login fields finished before the collector landed, so the replay showed every row with empty fields and the username appeared only at the password row, carried by thechangeevent the username field fired when focus left it.driverPatcher.tsgateNavigationnow awaitsDriverPatcherHooks.afterNavigation(the plugin'sinstrumentNavigationTarget, which injects only whenpreloadRegisteredis false) before a navigation command's promise resolves; the row keeps its own completion time, and under the preload the hook returns at once. Independently,script/src/utils.tsrecordFieldStatewrites each form control's livevalueandcheckedstate into the anchor (parseNodereads attributes, which never hold typed text), so a collector that arrives after typing still anchors the form as it is. Residual: that anchor is stamped at document birth, so rows before the injection replay the state at injection rather than empty fields; the same imprecision the anchor has always had for DOM the page's own scripts changed before injection.
- All three adapters now register it. Selenium does so per driver in
- One
SessionCapturerper RUN, not per WebDriver session (Nightwatch). A replaced session used to tear the capturer down and rebuild it, discarding every command, console line, network entry and mutation accumulated so far — sotraceGranularity:'session'on a cucumber run (Nightwatch quits the browser per scenario) wrote one zip holding only the LAST scenario: measured 8 action rows and 1 scenario group where the run had 17 and 2. Nothing forced the rebuild — the browser object is the capturer's only session-bound field (session.tssetBrowser, mirroring selenium'ssetDriver), and everything else is a run-long accumulator. A replacement is now a re-target plus the per-session bringup that already existed: metadata,armReplacedSession,rotateScreencastForSession. Measured after: 17 rows (9+8), 3 groups nested feature→scenario, 4 DOM anchors, 0 of 17 rows replaying another document, 94 network entries (47+47); reproduced 2/2 runs.- Both halves are the same guarded helpers the command hook uses, because a session replacement now has two detectors: the hook (
armedSessionId) andensureSessionInitialized(lastSessionId).armReplacedSessionis gated onneedsCaptureRearmandrotateScreencastForSessionon thescreencastRotationlatch, so whichever detector arrives first latches the other out;rearmCaptureForSessionis just the fire-and-forget wrapper for the hook. Hand-rolling either body at the second call site re-armed a session the hook had already armed — dropping the live claims back to the<script>fallback mid-flight — and ran a second, unlatched screencast rotation. - Per-test slices are unaffected and now index into the run's arrays for a reason rather than by accident: the eager flush at each scenario's pre-quit hook is open-ended (no
nextRange) but runs before the next boundary is recorded, andbuildSpecCapturerslices synchronously from that call. Measured unchanged at 9 and 8 rows per scenario zip. metadatastays single-valued, so a run-spanning zip carries the last session's identity. The capabilities are the run's; freezing it at the first session would change the sessionId embedded in every per-test slice, which is the path that had to stay byte-stable.
- Both halves are the same guarded helpers the command hook uses, because a session replacement now has two detectors: the hook (
- Nightwatch navigation injection must not ride on a queued
perform().wrapNavusedchainable.perform()to run the injection after the navigation completed, but Nightwatch aborts the remaining queue when a test fails, so the queued callback is discarded: measured 2 of 3 navigations skipped entirely, and with them the first document's whole DOM — which is why trace quality appeared to vary run to run with no code change. The injection now hangs off the command's own thenable (the chainable is thenable, and resolving it means the navigation finished), andresultis returned unchanged sobrowser.url(…).waitFor…()chaining still works. - Nightwatch's page-side drain must not go through
browser.execute, and one drain cannot serve both purposes.browser.*are QUEUED commands, so the drain used to be deferred 200 ms viasetTimeoutjust to stay off Nightwatch's callback stack. The drain, the collector-ready probe and the injection now go overhelpers/webdriverHttp.ts, so they touch no queue and fire synchronously from the command callback; because a driver serialises requests per session, a drain issued there is served before the next queued command. That immediate drain is what captures the outgoing page's field edits — deferred, a submit click navigated first and they died with the page, so the#passwordfill row replayed with an empty password box.- A second drain anchors the destination, and it has to wait: a Nightwatch click resolves before its navigation commits (measured 6 ms), so the immediate drain still finds the outgoing collector, recovers nothing, and the new document is never anchored — every action on it then replays the page it came from (measured: 5 of 20 rows, with no
/secureanchor in the stream at all).session.tsanchorAfterNavigationpolls for the collector to disappear — the signal that the document was replaced — then drains with a forced anchor. Keyed on the document being replaced rather than on which command ran, so it covers any route to a new document without a command list to keep in sync; a command that navigated nowhere polls out and costs one drain of an empty buffer. Tracked insnapshotCapturesso finalize awaits it. - Deleting the (accidentally load-bearing)
browser.url()-getter injection is what exposed this: that spurious re-injection had been anchoring the destination as a side effect.
- A second drain anchors the destination, and it has to wait: a Nightwatch click resolves before its navigation commits (measured 6 ms), so the immediate drain still finds the outgoing collector, recovers nothing, and the new document is never anchored — every action on it then replays the page it came from (measured: 5 of 20 rows, with no
- A failing step needs a ROW before its error has anywhere to go.
ACTION_MAPcarried WDIO's explicit waits but none of Nightwatch's, sowaitForElementVisible— the command a Nightwatch test most often fails on — produced no action at all: the failure was absent from the action list and the Errors tab (which collects commands carryingerror), surviving only as console text. ThewaitForElement*family is now normalized onto the WDIO wait methods. Two derivations back it up intrace-action-events.tsactionError: a collapsedpassed: falseresult now marks any row failed rather than only assert-class rows, and a wait whose result isfalseis a timeout — Nightwatch reports those through its own assertion channel, not the command callback, which hands back{value: null}and collapses to a bare boolean, so that boolean is the row's only evidence. Thefalsereading is scoped to waits because it is a legitimate value for a boolean read likeisDisplayed, and WDIO's waits throw rather than return it. The message is synthesized from the command + selector; Nightwatch's own prose is not reachable from the callback. - A Nightwatch wait is one row, sent running, and what its condition issues names it as parent.
waitUntiland thewaitForElement*family (isWaitCommand, every command the vocabulary maps onto awaitFor*method) send their row at invocation,startTime=timestampand no result, throughSessionCapturer.openCommand, and end it byidin the capture callback (closeCommand), stamped at completion; a callback result offalsebecomes the row'serror, worded by sharedcommandFailure, so the live row is red where before only the trace derived it. Which wait issued a command is read from anAsyncLocalStoragescope (helpers/waitRows.ts) that thewaitUntilcondition runs inside on every poll: Nightwatch calls the condition from its own queue and exposes no queue node to a plugin, and a stack of open waits would parent anything that merely ran while a wait was open (a timer's command, a command the test issued concurrently), which the scope does not. A closed wait hands a call to the wait it was itself issued inside, because an async context outlives the call that made it. A condition that calls another wait opens a scope inside the first, so waits nest. The condition's polls fold per distinct call inside the wait (OpenWait.children, keyed by call signature) rather than only onto the last row, since a two-read condition alternates its reads. AwaitForElement*polls through the transport (ElementCommand.findElement), never throughbrowser, so its row has no children; verified on the conformance run, where no row falls inside its span.waitUntil(condition)with no other argument used to have its condition taken for the result callback; the condition is now never treated as one. Residual: the row is sent at invocation, so an un-awaitedwaitUntilchained behind other queued commands shows as running before they have run. browser.url()is both a navigation and a getter. With no argument it just reads the current url, which is howassert.urlContainsreads it from inside Nightwatch's queue. The nav proxy treated that read as a navigation — re-injecting the collector and forcing an anchored drain mid-test for a command that changed nothing, and logging the capture callback's whole function body as the "url". It now gates on a string first argument.- A Nightwatch command failure arrives as a callback RESULT, not a throw. Only a synchronous failure reaches the
try/catcharound the wrapped method; an async one (a command that times out waiting for an element) invokes the capture callback with an error-shaped object, and the driver response nests it one level down under the W3Cvaluewrapper.browserProxy.tscallbackErrorunwraps that and promotes it to the row'serror. Left inresult, the row kepterror: undefinedand rendered as a success — no red row, nothing in the Errors tab, the failure readable only as raw text in the result pane. A result carryingpassedis an assertion outcome and is deliberately not reinterpreted, since those have their own pass/fail path. - Row order comes from issue order (
CommandLog.sequence), not from the millisecond clock.browser.assert.*calls are enqueued synchronously and the next command is invoked in the same millisecond, so theirstartTimes tie; because assert rows are appended in the test-end batch while driver rows are appended at completion, the tie resolved in insertion order and put an assert after the command it preceded (measured:assert.textContainslanding below the logout click it ran before). Nightwatch stampssequencewhen the test issues a row — a driver command at invocation, an assert at enqueue — andbuildActionEventsuses it as the sort tiebreak. Adapters that emit no deferred rows leave it unset and keep insertion order.- Nightwatch's own per-assertion execution windows are unavailable here:
results.commandsis empty for the BDD interface, soassertCommandTimingsalways returns nulls and the rows keep their enqueue timestamp. That is why issue order, not reported timing, has to carry the ordering.
- Nightwatch's own per-assertion execution windows are unavailable here:
- Actions that only read the page inherit the preceding action's capture (
packages/trace/src/trace-frame-snapshots.tsclaimAfter). It is non-consuming and falls back to the most recent earlier capture instead of returning nothing, because several actions legitimately share one page state — Nightwatch emits its native assertion rows in a batch whose execution windows collapse onto one instant, and handing the capture to whichever claimed first left the rest of the batch with no DOM, no a11y tree and no screenshot. Nightwatch correspondingly takes no capture for an assertion row (captureAssertCommand): those rows are emitted at test-end but positioned back on their real execution window, so probing there recorded the page as it was then under a timestamp seconds earlier (an assert that ran on/securerendered the/loginpage the test later logged out to). - Every per-action snapshot probe is timeout-guarded (
core/action-snapshot.tsprobe,SNAPSHOT_DRIVER_PROBE_TIMEOUT_MS), not just the in-page scripts. Nightwatch'sbrowser.getCurrentUrl()/getTitle()are QUEUED commands: called from inside the plugin's own command hook they enqueue behind the command still running and never resolve, and one unguarded probe in the capture'sPromise.allstranded the whole snapshot — 10 of 14 captures never settled, so those actions reached the trace with no DOM, no a11y tree and no element rects. Nightwatch now runs all four probes (url/title/screenshot/script) over the raw WebDriver HTTP transport innightwatch-devtools/src/helpers/webdriverHttp.ts, bypassing the queue entirely — the patterntakeScreenshotViaHttpalready used. Relatedly,runWithtreats anullscript result as its fallback: a driver that answersnullinstead of rejecting (no-such-session, transport-swallowed script error) otherwise handed the serializers a non-array and lost the entire snapshot, screenshot and all. - The per-test
screenshotandvideooptions live on the WDIOServiceOptionsonly — notBaseDevToolsOptions— because only the service implements them (an option belongs on an adapter until a second adapter consumes it, mirroring the core-helper rule; putting them on the shared base made them appear available in Selenium/Nightwatch and broke those adapters'Required<>option types). The policy types (TraceScreenshotPolicy/TraceVideoPolicy) and the capture/slice/encode logic (core/screenshot-artifact.ts,core/video-slice.ts) are framework-agnostic, so Selenium/Nightwatch adoption was wiring-only — now done (Selenium adds the options on its ownDevToolsOptionswith full inline attach; Nightwatch adds them produce-only — see the Allure-attach entry below). All are gated totraceGranularity:'test'(per-test inline Allure); coarser granularities keep artifacts in the manifest. Video records the screencast continuously and slices per-test by wall-time — the session frame buffer is bounded bymaxBufferFrames(default 2000; decimates keeping first/last), and on non-Chrome the polling recorder issues manytakeScreenshots that flood@wdio/allure-reporter(pair withdisableWebdriverStepsReporting). - The
filmstripoption (dense screencast into the trace) is onBaseDevToolsOptions— the counterexample to the screenshot/video entry above — because all three adapters implement it (the "second consumer → base" rule realized). Core owns the work (core/screencast-trace.tsthinScreencastFrames/buildDenseScreencast; slice windowing inspec-trace-helpers.ts); adapters only default the option, un-gate the recorder in trace mode when it's set, and feedrecorder.framesinto the finalize context. Each adapter captures frames while the recorder is still alive (serviceonReload→#filmstripFrames; SeleniumonDriverEnddrain before nulling; Nightwatch#finalizeCurrentScreencastsnapshot before delegating), and each finalize context spreads[...accumulated, ...(live recorder frames)]so a mid-run per-spec/per-test slice flush (which fires before the recorder is drained) isn't blank. When dense frames are present they supersede the sparse per-action filmstrip (the per-action DOMelements/snapshotare carried independently by theframe-snapshotevents, so no DOM data is lost); a run without dense frames keeps the sparse filmstrip, byte-stable with before. Thinning is applied at export; the live session frame buffer is bounded bymaxBufferFrames(default 2000; see the screenshot/video entry above). Per-test filmstrip slicing follows the same per-test-hook availability astraceGranularity:'test'(works for WDIO mocha/cucumber, Selenium mocha, Nightwatch cucumber; Nightwatchdescribe/itand exports-object degrade to session scope per the entry below), and non-Chrome polling carries the same reporter-noise caveat. - Per-test artifact Allure attachment is cross-adapter via a pluggable sink in
core/allure-artifacts.ts(AllureAttachSink;captureAndAttachScreenshot/captureAndAttachVideo/attachTraceArtifact/lastRenderedScreenshot— moved out of the WDIO service). The service supplies a@wdio/allure-reportersink; Selenium supplies anallure-js-commonsattachment()sink (runtime-agnostic — attaches under any allure runner adapter — gated onglobalThis.allureTestRuntime, dynamic-imported as an optional peer dep). An undefined sink = produce-only (write file + manifest, skip attach). Caveats: (a) Selenium inline-attach needs awaited runner hooks — its mochaafterEach/jestafterEach/cucumberAfterare now async + awaited so the async produce+attach lands while the allure adapter still holds the current test open (fire-and-forget attaches to the next test or drops); GherkinAfterStepis awaited too, so a step's trace slice, native app requests included, is written before the next step records its boundary. (b) Nightwatch inline-attach is unsupported —nightwatch-allureis post-hoc with no live attach API, andallure-js-commonsno-ops in a Nightwatch run (nothing callssetGlobalTestRuntime); Nightwatch is produce-only. Revisit if anallure-js-commons-based Nightwatch runtime adapter appears (then it's a sink swap). (c) video is standalone in both adapters (recorder starts forvideo != offOR filmstrip in trace mode; the orphan session-webm encode is trace-gated to stop-only). - node:assert error display (
core/assert-patcher.tsdescribeAssertFailure): node auto-generates a per-character COLORED diff as the AssertionError message; once any consumer strips the ANSI (allure-mocha, the app console filter, plain terminals) the actual/expected interleave into mush ('ExampleThis DIs Nomt…'). The patcher pulls node's clean.actual/.expectedinto aCollapsedAssertResultAND rewrites the auto-generated message (and its echo in the stack) as a value-bearingExpected: … / Received: …block. BecausetoErrorreturns the thrown error itself, this rewrite reaches every consumer — the trace's Errors tab, the runner console, and allure-mocha's error box (which would otherwise show the ANSI-stripped mush). It only touches node's auto-generated messages; a user-supplied message and errors withoutactual/expected(e.g.assert.ok) pass through unchanged. This is a deliberate mutation of the thrown assert error — node's auto-message is a display artifact, and the rewrite is strictly more readable and matches@wdio/allure-reporter'sExpected/Receivedshape. - Nightwatch native asserts are buffered at call time and emitted in one batch at test-end (Nightwatch exposes no per-assertion execution/outcome hook reachable from the plugin —
client.queue.tree/client.reporteraren't onbrowser). Streaming mid-run would flash every assert green before its outcome is known, so rows are held and reconciled againstresults.testcases[*].assertionsatafterEach(the flatresults.assertionsonly reflects the last testcase). The exporter re-sorts commands by timestamp (buildActionEvents) so the batched rows land at their real timeline positions rather than clustering at flush time.actualis parsed from the failure message (but got: …, failures only) into a collapsed{passed, expected, actual}result;expectedis the assertion arg — the label still mirrors the call args, not the derived values. - Nightwatch's BDD
describe/itinterface fires the plugin's globalbeforeEach/afterEachonce per module (with an emptycurrentTest.name), not perit— Nightwatch runs the individualits internally with no per-testcase hook or event reachable from the plugin (the lib emits only transport-level events). SotraceGranularity:'test'records a single slice boundary (the first test) and collapses to one session-scoped slice; retry-aware/per-test retention (tracePolicy) degrades to session scope for this interface. Per-test slicing works for adapters/styles that expose per-test hooks (WDIO mocha/cucumber, Selenium mocha/jest/cucumber, Nightwatch cucumber). Nightwatch's exports-object style collapses the same way:lib/testsuite/index.jsruns the globalbeforeEach/afterEachonce per test module for both interfaces. Session-granularity trace is unaffected. No per-test support has been built for this interface — real per-itslicing needs a hook Nightwatch doesn't surface; deferred to a full-picture pass, not attempted yet.- Empirically confirmed (BDD example,
mode:'trace'+traceGranularity:'test'+tracePolicy:'retain-on-failure'+screenshot:'on'+video:'retain-on-failure'): the one collapsed slice is keyed to the first test's uid (the artifacts manifest'stests[]still carries every testcase with correctstate, so metadata capture is fine — it's the slice/artifact keying that collapses). Consequence: with a passing first test and a failing later test, the screenshot produces (policy'on'captures regardless of outcome, keyed to the first test) but the trace zip and video are dropped —retain-on-failureevaluates the first (passing) test's outcome, and the actually-failing test never gets its own slice to retain. The per-test produce-only path itself (screenshot/video write + manifest) is correct; only the BDD slice-keying limits it.
- Empirically confirmed (BDD example,
- Nightwatch cucumber: traces generate, capture asserts with real pass/fail, and carry DOM mutations (via the
test/harness, 2026-07; both remaining gaps closed and re-verified 2026-08-06). Three fixes made the cucumber runner emit a useful trace: (1) build —packages/nightwatch-devtools/tsup.config.tsnow compilessrc/helpers/cucumberHooks.cts→dist/helpers/cucumberHooks.cjsas a self-contained CJS bundle (@cucumber/cucumberexternal). Previously the build ran onlytsup src/index.ts --clean, so that file never existed and Cucumber'srequire:[cucumberHooksPath]registered no hooks (glob matched nothing) → zero capture.PLUGIN_GLOBAL_KEYmoved to a leaf moduleplugin-global-key.tsso the hooks bundle stays tiny and CJS-safe (importing it viaconstants.tsdragged in core →createRequire(import.meta.url), which throws when bundled to CJS). (2) capture ordering — a pre-quit cucumberAfterhook (order:1000,captureCucumberScenarioBeforeQuit) runs the trace capture + slice flush while the per-scenario browser session is live (theorder:-1finalize is post browser-quit, so the flush bailed on the absentsessionId). RequirestraceGranularity:'test'(per-scenario slices). (3) native asserts — the same pre-quit hook drainsbrowserProxy.drainNativeAssertCalls()and callscaptureNativeAssertions(theafterEachpath early-returns for cucumber), soassert.*rows now appear. Console/network (BiDi) + commands + asserts + frames + sources + transcript are captured. BDD and live mode are unaffected.- Assert outcomes are correlated off the assertion's own promise, because the results bag does not exist. Cucumber's Nightwatch client is built by
createClientwith no reporter, soSimplifiedReporter.logAssertResultno-ops andresults.assertions/results.testcasesare never populated — no scenario-level reconcile can recover them, which is whycurrentTest: undefinedwas a dead end.nativeAssertions.tsobservedAssertOutcomereads the outcome from the returned promise instead (lib/core/asynctree.jsshouldRejectNodePromise: a failingassert.*rejects its deferred, a failingverify.*resolves with the AssertionError, a pass resolves with the command value). A fulfilment ofundefinedstays neutral — that is an assertion enqueued but never executed after an earlierassert.*emptied the queue, and reading it as a pass would paint a never-run assertion green. The results bag still wins where it exists and the row's window comes from whichever source supplied the outcome, so the describe/it timeline is byte-stable. Measured: 4 of 4 assert rows with a real pass/fail (was 2 of 4 rows, 0 correlated), spanning real 44–372 ms windows instead of a synthetic 1 ms. - Relatedly, cucumber's per-step
resetCommandTracking()was wiping the native-assert buffer, so each scenario kept only its last step's assertions (measured 1 of 2). The buffer is per test unit —resetTestTracking()atwrapBrowserOncenow clears it. - DOM
mutationsARE captured, and the oldECONNRESET/ "collector not found" attribution is wrong — neither appears any more. The gap closed itself with document-start injection (core/bidi-preload.ts), confirmed on a baseline build so the credit belongs to that change and not to anything here. Measured per scenario withtraceGranularity:'test'+webSocketUrl: true: 39 and 19 mutation entries, 2 DOM anchors each, 0 of 19 rows on the wrong document. What remains is noise, not a gap: the screencast poller issues/screenshotevery 200 ms into the session Nightwatch quits per scenario, logging 9–26WARN webdriverHttp: … socket hang upper run. Fix is to stop the recorder before the per-scenario quit, or suppress the warn for a session in teardown.
- Assert outcomes are correlated off the assertion's own promise, because the results bag does not exist. Cucumber's Nightwatch client is built by
- A tsup entry that another entry imports is not a leaf, so an
import.meta.url === process.argv[1]self-start check inside it is dead code; a CLI has to be its own leaf entry. tsup hoists a module body shared by two entries intodist/chunk-*.js, and thereimport.meta.urlis the chunk's path, which can never equalprocess.argv[1].packages/backend/src/show-trace.tsimportsstartfromindex.ts, which is what made index shared, so index's old "start if run directly" guard was dead in every build:node dist/index.jsexited 0 without ever serving, whiledist/show-trace.jsself-started correctly for exactly the same reason inverted, being a leaf whose body stays in its own output file (it also compares realpaths, because the invoked path is thenode_modules/.binsymlink). The live dashboard server is therefore its own leaf entry,packages/backend/src/server.ts(shebang, built to an executabledist/server.js, shipped as thedevtools-backendbin, accepting--port,--hostname,-h/--help), andindex.tsstays library-only for the three adapters' in-processstart/stop. Same family as thecucumberHooks.ctsentry above: the tsup entry list is part of the contract, and in both cases the symptom was silence rather than an error. - A captured text locator is generated in the recording runner's dialect; every other branch is portable CSS.
shared/locator-dialect.tslocatorDialect(runner)is the one fact table — WDIO runners (mocha/jasmine/cucumber) geta*=Logout,nightwatch*/selenium-webdriverand an unidentified recorder get//a[contains(., "Logout")]. The id reaches the page script asCaptureActionSnapshotInput.runnerand the zip as an extension field oncontext-options, read back throughisTestRunnerIdontoMetadata.runner; absent in older and foreign zips, where the player shows no hint. Under WDIO a text carrying a"still emits XPath — WDIO compilestag*=to XPath with"quoting and would build a broken expression — and WDIO resolves//itself.locatorsMatchdecomposes both sides from either dialect, soafter.pointsurvives whichever way round the two grammars fall; theconcat()-stitched literal is still left to exact comparison.@wdio/elements' standalonegetSnapshotdeliberately keeps the portable XPath default (its output is pasted into arbitrary tools), so a WDIO run'sbrowser.getSnapshot()and its trace A11y tab disagree on that one branch. Metadata.runner(typedTestRunnerId) andmetadata.options.framework(untypedstring, read by the sidebar'sgetFramework) are two carriers of the same fact. All three adapters now set both; the next change to either should collapsegetFrameworkontoMetadata.runner.- Selenium's
ctx.runner(the detected JS runner — mocha/jest/cucumber, fromdetectRunner()) shadows the newMetadata.runnerconcept. Renamed todetectedRunnerat thedriverMetadataboundary only, where the two met inside one function and a swap would have been a silent dialect bug; the repo-wide rename inplugin-internals.ts/session-lifecycle.tsis still open. - A node:assert's target comes from VALUE PROVENANCE, not from the assert call. node:assert takes values, so
assert.equal(await el.getText(), …)names no element.core/read-value-locators.tsowns the value→locator registry (rememberReadValue/selectorForReadValue/resolveAssertTargetFromArgs; most-recent-producer-wins, 200 keys, ≤256 chars, primitives only), reached throughpatchNodeAssert's optionalresolveAssertTargethook, which walks the raw args (sanitizing destroys handle identity) onto the sharedCommandLog.selector. It lives in core because all three JS adapters feed it. The registry is scoped to one test:clearReadValuesempties it when a test or scenario starts (the service'sresetStackfrombeforeTest/beforeScenario, Selenium'sstartTest/startScenario, which a plain script reaches through the publicstartTest, and Nightwatch'sresetTestTracking), so a later test'sassert.ok(true)cannot box an element an earlier test readtruefrom. Nightwatch's plugin hook fires once per module on the BDD interface, soBrowserProxy.scopeReadValuesToRowTestalso clears when the runningit(rowTestUid) changes, before every record and every lookup; a value abeforeEachhook reads is therefore dropped at theit's first command or assertion, since Nightwatch still names the previousitwhile that hook runs. Python clears atpytest_runtest_logstart. The WDIO service feeds it fromSessionCapturer.afterCommandfor every row it logs: a command ACTION_MAP classesElement(command-selectors.tsisElementCommand) records the selector the row resolved, and every other read (getTitle,getUrl) records the null sentinel;wireAssertCapturepassesresolveAssertTargetFromArgstopatchNodeAssert. A node:assert row with no known producer names nothing rather than the last element a locator resolved, which is what boxedh2forassert.notEqual(await browser.getTitle(), …). The user's owngetTitleis aPage.getTitlerow: the service's snapshot probe reads the title from inside a hook whose stack holds the spec frame, soaction-snapshot.tsruns its own title read inside anAsyncLocalStoragecontext and both hooks skip agetTitleobserved inside it (isServiceProbe; WDIO awaits a command's hooks within the command, so they inherit its context). A count of probes in flight was not enough: core's probe timeout abandons agetTitlestill running, and the count then hid a usergetTitleissued in that window. ListinggetTitleinINTERNAL_COMMANDShid the user's call too. Python mirrors it inselenium_devtools/read_value_locators.py, since it cannot import core: every captured command records its result from the patchedexecute(a driver-level read recordsNone;get_attribute/is_displayed, which reach the wire as scripts, resolve the element passed as their argument), and a pytest or plain-script assertion row looks up the observed operand before the expected one. In both languages the first operand with a known producer decides, so a subject recorded as owned by no element (a title or url read) ends the lookup instead of letting the expected value borrow the box of an element that once read the same text. Selenium feeds it fromdriverPatcher's settle path and composes it with its handle WeakMap (helpers/element-locators.ts, adapter-local — it keys onWebElementidentity, which no other framework has). Nightwatch feeds it frombrowserProxy's capture callback viaassertTarget.commandTargetSelector, an allowlist of element-read commands; every other command — every driver-level read (title,getCurrentUrl,source) included — records the null sentinel, without which a page-title assertion inherits the box of an element that reads the same text (measured on selenium's mocha example,h1vsdocument.titleboth "Example Domain"; reproduced and fixed for Nightwatch on a local#hero/<title>collision page). Measured: Nightwatch node:assert rows with a locator 0/7 → 4/7 (the 3 without are a title read, the title/#herocollision, and a literal); Selenium unchanged at 2/4 mocha and 2/3 cucumber. Residuals: boolean reads are weakly discriminating, so a value collision with no intervening producer can mis-attribute; Nightwatch'selement()/.find()API and its ignored commands (execute,executeAsync,perform) are never wrapped, so their values neither claim nor clear.after.pointis deliberately not extended to assert rows —resolveActionPointgates onclass:'Element'+POINTABLE_METHODS, so WDIO's folded assertion rows get none either; a point draws a click marker an assertion never earned. - A Nightwatch NATIVE assert's target comes from the CALL, not from value provenance. Its element assertions take the element definition as their first argument (
textContains(definition, expected)) while the page-level ones take the expected value there (urlContains(expected)), sonightwatch-devtools/src/helpers/assertTarget.tsreads the locator straight off the recorded args. It is an allowlist transcribed from Nightwatch's own assertion signatures, never a "first string arg" heuristic — the same namespace also carriestitle*/url*and node:assert's value mirrors (ok,equal,strictEqual), whose first argument is a plain value. A{selector, locateStrategy}bag and a NightwatchElementare duck-typed on.selector; a definition built from a resolved handle yields none, andnot.*reads through the negated namespace. Measured: 2 of 4 assert rows carry#flashand bothurlContainsrows carry none, on both the BDD and cucumber interfaces, with action counts, DOM anchors and row→document mapping identical to a same-build baseline. - Two mechanisms resolve a command's target selector, and the WDIO one is wrong for interleaved handles. Selenium keys a
WeakMapon handle identity (WebElement.id_is a promise, so no id is readable when a command is invoked); the service uses a mutable last-selector inservice/src/command-selectors.ts, which stamps the wrong locator forconst a = await $('#a'); const b = await $('#b'); await a.click()(the Selenium side is covered inselenium-devtools/tests/element-locators.test.ts; the WDIO failure itself has no test — it is an unverified reading ofcommand-selectors.ts). They cannot share a registry — WDIO's hook sees a serialized handle carrying an id string and never the live object, Selenium has the object and no readable id. Unifying would need the policy parameterized rather than the storage shared; until then the WDIO path is knowingly wrong in that case. The service feeds core's value registry with that same selector, so a value read through an interleaved handle is claimed for the wrong locator as well. Nightwatch is a third mechanism: it reads arg 0 through a per-kind allowlist (assertTarget.ts) rather than tracking handles at all, because its classic API takes selector strings. The allowlists are deliberately not derived from shared'sACTION_MAP— that table says how a command renders (itsElemententries include WDIO commands called on a handle, with no selector argument), not whether arg 0 is an element definition. - Service renders expect-webdriverio matchers as single
expect.<matcher>rows by folding, not stack/depth suppression (the old#assertionDepth/#matcherStarted/self-heal machinery is gone). The matcher's value-read (toHaveText→getText,toExist→isExisting, …) is captured as a normal command;afterAssertionthen coalesces the synthesizedexpect.*row into that read in place — inheriting its callSource, screenshot, and timeline position — and the fold replaces by timestamp, because a top-level read carries no publicid: only a wait and the rows it issued do, drawn from core'scommandCounter, which starts at a random base per capturer so ids stay unique across specs and workers (entry below).beforeAssertionarms the pending matcher (depth-counted so aliases liketoBeChecked→toBeSelectedfold once); a matcher that hard-throws — element never resolves, so expect-webdriverio'swaitUntilrethrows andafterAssertionnever fires — is synthesized atafterTest/afterStepfrom the throwing read, so a failing assertion renders asexpect.<matcher>whether or not the element existed. Two limits: its error is then the read's (Can't call getText on … element wasn't found), not an assertion-phrased message; andMATCHER_READ_COMMANDSis a hand-maintained allowlist, so a matcher whose read isn't listed leaves its raw read visible alongside theexpect.*row. Plain-value jest matchers (expect(x).toBe(y)) don't fire the ewdio hooks, so they aren't captured as rows. Only these matcher rows inherit the selector resolved during the matcher (captureMatcherCommand); a node:assert row takes its target from the value registry (captureAssertCommand, see the value-provenance entry above).toHaveTitlefolds into the user-visiblegetTitleread like any other listed read, whilegetUrlis still inINTERNAL_COMMANDS, sotoHaveUrlemits a fresh row. - The WebdriverIO service logs the command on top of its stack, and inside a wait, the commands the wait issued. WDIO fires
beforeCommand/afterCommandfor every command at every depth, so the service has always kept a row only for the command a spec issued with nothing else running, and hidden what that command issued. A wait (waitUntiland the elementwaitFor*family, read off ACTION_MAP) is the one exception:command-stack.tsCommandStackopens a level under it, and every command the wait issued directly becomes a row with the wait'sidasparentId, while what those issue stays hidden, the same rule one level down, so a wait inside awaitUntilcondition nests again. The wait is sent running frombeforeCommandwith an id from core's random-base counter (wait-rows.tsWaitRows), replaced by that id at completion and moved to the end ofcommandsLog, which keeps the log in completion order for every scan that reads its tail (failLastAction, the matcher fold,#lastAction). Its children carry ids too, so each distinct call folds by id rather than by timestamp; rows outside a wait still fold by timestamp. Three details are load-bearing. An element wait polls through its ownwaitUntil, which is its implementation and never a row, and WDIO fires an element command's hooks twice, the first from its element wrapper with no arguments, so a wait opened with none skips the same command's next hook before it issued anything; that is decided by the arguments, not the stack, which carries the spec frame on both hooks once it is searched 40 frames deep. Commands the service issues from its hooks are told apart by anAsyncLocalStorage(runAsServiceWork), not by their stack, because a wait's polls carry no spec frame either. And the hook methods are deliberately notasync: WDIO keeps one module-globalinCommandHookflag and skips every command's hooks while any hook is running, so a hook that settles a few microtasks later drops a concurrent command's hooks (measured: anemithook running duringelementClickmade the submit click'safterCommandnever fire, and the click lost its row). The selector a condition located is restored when its wait ends, soel.click()after a wait still reads the element's own. A matcher never folds into a read a wait issued. - A wait is one row, and what it issued nests under it, in every adapter (#464). A wait is one row named after what it waited for (
Element.waitForExist/Element.waitForDisplayedon its selector for an element locator,Browser.waitForFunctionfor any other condition; a Selenium or Python locator wait is the commandwaitForElementLocated, a visibility waitwaitForElementVisible), carrying a numericid, sent when it starts with astartTimeand no result or error, and replaced by thatidwhen it ends, stamped at completion like every row. Every command its condition issued, an element wait's polls included, carries the wait'sidasCommandLog.parentId, which shared documents generically as "the row whose execution issued this one". SharednestCommandsturns a flat row list into that tree, keeping a row whose parent is absent at the top level, so a live list rebuilt on each update nests it once the parent arrives; the dashboard's live list (live-action-rows.ts) and the player's tree (action-tree.ts, aTraceActionCommandNodewithchildren) render the children under their row, collapsed unless the row failed or holds the active one. The trace exporter (trace-action-events.ts) writes a child's action with its parent's callId asparentIdand closes the parent after its last child; the reader keeps an action in the trace vocabulary that other actions name as parent as a row rather than a structural step, so a foreign runner's steps are unaffected, and a stream withoutparentIdexports byte for byte as before. Every adapter emits it: the service, Nightwatch and Selenium as their entries above describe, and Python as follows. Itswait_tracking.pysends the wait's row from the patchedWebDriverWait.until/until_notbefore the first poll and replaces it byidonce the wait returns or raises; every rowexecutecaptures inside the condition takes the wait's id asparentId(parent_of) and still folds per call (poll_of/note_poll_row). A wait inside another's condition is a child of it and keeps one row across the outer polls, as a poll does, with the start of its first poll.wait_row_namenames the row:EC.presence_of_element_locatediswaitForElementLocatedandEC.visibility_of_element_locatedwaitForElementVisible, on the locator converted the wayfind_elementconverts it (selenium'sLocatorConverter, soBy.IDreads#xlike the element rows); anything else,until_notincluded, iswaiton the condition's label, anexpected_conditionsclosure named after its factory and captured arguments since its own name is_predicate. Naming runs before the user's condition, so it never raises (_safe_label,_safe_row_name), and a row that cannot be sent leaves the wait running. The conformance rulewait-row-parents-its-commands(packages/conformance/src/wait-nesting.ts) checks every scenario test shows exactly the waits it writes, each wait carries an id and a start, every row that ran inside a wait's span names it, and a parent is sent before its children; no adapter holds a gap against it. The comparison nests child steps under their parent (NormalizedStep.children) and compares the children of two aligned steps as their own aligned sequence, moves included.- A poll that failed under a wait that did not fail is not a failure. Shared
retriedRowslists every failed row issued, at any depth, under a row that has not failed (a wait that passed, or one still running, whose outcome is not known yet), andeffectiveFailureiscommandFailureminus those. One rule serves every reader: the dashboard renders such a row neutral with its message in the row's title and leaves it out of the Errors tab, Copy prompt's Failures and a row's default expansion (actions.ts,errors/collect.ts,transcript.ts),transcript.mdmarks itRETRIED, the trace writes its message as the after event'sretriedErrorrather thanerror(trace-action-events.ts), the reader restores it ontoCommandLog.errorand restoresid/parentIdbetween action rows (linkIssuedRows) so the player judges it the same way, and the conformance comparison reads it asokand never as the row that explains a failed test. Under a wait that failed, its children keep their errors, since the last poll's is what explains the timeout. A Nightwatch wait the aborted queue never ran is closed with aNotRunErrorat the test unit's end, so it counts as failed and its rows, having none, are unaffected.
- A poll that failed under a wait that did not fail is not a failure. Shared
- What the conformance scenario shows the adapters doing differently. Each entry is a
KNOWN_GAPSreason inpackages/conformance/src/allowlist.ts, scoped to the differences it explains (one hunk, one moved step, or one test's reported state), and reported stale once they stop occurring, except an entry markedtimingDependent, which is never reported stale and has to be removed by hand.- Only the WebdriverIO service shows an element wait's polls (#464). The service nests the
isExistingpolls ofwaitForExist/waitForDisplayedunder the wait, one row per distinct call. No other adapter shows a poll there in the trace vocabulary: a NightwatchwaitForElement*polls through its own transport (ElementCommand.findElement), never throughbrowser, so the plugin never sees one, and a Selenium or Python element wait polls withfindElement(s), which ACTION_MAP leaves unmapped, so those polls are children live but absent from the trace. The adapters therefore differ inside each element wait and nowhere else.
- Only the WebdriverIO service shows an element wait's polls (#464). The service nests the
- Measure before proposing a change:
DEVTOOLS_CAPTURE_TIMING=1. Every adapter then times each probe of each capture (issue and settle, so a probe's own service time is separable from its wait behind the probes the driver served first) and, when the trace is written, prints a per-probe table and appends the raw records tocapture-timing-<pid>.ndjsonbeside the trace. The JS recording iscore/capture-timing.ts, wired once incaptureActionSnapshotand reported fromfinalizeTraceExport, so no adapter carries more than the capture's trigger (action, wait poll, matcher poll, final); Python mirrors it incapture_timing.py, held to the same summary bymirror-vectors.json. Off, it costs a flag read per capture. The per-platform commands are in the README's "Measuring capture cost". - One capture per action means two driver round trips per action, and the platform decides which half is expensive. Measured on Appium 3.7.0 / UiAutomator2 (emulator-5554, Android API 37, 1080×2424):
GET /screenshot1.18–2.22 s (steady ~1.20 s) at 1.86 MB,GET /source(page-source XML) 0.09–0.53 s at 40 KB,window/rect0.015 s — the screenshot dominates by ~10× on Android. iOS is unmeasured; the issue claims the split inverts there, and the native example (examples/wdio/mocha/wdio.native.conf.ts) is what makes it measurable. Measured per action: two captures 2.41 s against one capture 1.19 s; end-to-end on the native example 19.1 s against 12.4 s, with live mode (no per-action capture) at 5.9 s. That is the case for thebeforeCommandcapture, and the reason an eager post-action capture is not worth re-adding: it cost a second capture per action and existed only to be patched by areadyStatepoll that could not tell a document that had not navigated yet from one that was loading. - Locator generation from a native page source must stay linear, and iOS is where it did not. Every native action snapshot builds element locators from the page-source XML (
core/locators), and each uniqueness check was a//*[@name="…"]XPath run by thexpathlibrary over the whole document, several per element. Measured on the iOS Settings page source tiled up: 38 KB 82 ms, 188 KB 1.4 s, 752 KB 23.5 s, which is what a reporter saw as trace mode adding 1.5 min to a 2 min 20 s iOS test while Android, with smaller flatter trees, added 10 s.locators/xpath-index.tsindexes the document once pergenerateAllElementLocatorswalk (withXPathIndex) and answers//*,//Tagand//Tag[@a="x" and …]from it: 15 ms, 49 ms, 268 ms for the same three, identical elements. It is scoped to the walk so a caller of the exportedevaluateXPaththat edits its document is never answered stale, and it declines a namespaced document, where XPath name tests resolve namespaces and the index does not. The page-source request itself remains: 0.75-1.2 s per action on XCUITest even for Settings, against 0.09-0.5 s on UiAutomator2. - A filmstrip poll against a native session stacks requests:
setIntervalnever waits for its async handler, a native screenshot takes ~1.2 s against the 200 ms default, and a serialised driver serves that queue ahead of the test's own commands — measured, a 15 ms command took 4.5–7.8 s.ScreencastRecorderBase's#pollInFlightlatch bounds it to one outstanding shot, and#pollGenerationstops a shot orphaned bystop()from appending after the stop or clearing its successor's latch. start()andstop()are serialised against each other, because unserialised neither owns what it arms. Astop()landing mid-handshake returned at its#isRecordingguard — still false, the handshake not having finished — so it never reached its own CDP teardown, and thestart()it interrupted then returned on the generation mismatch without stopping what it had armed: the session and its frame listener stayed live after teardown. The frames themselves were moot — every adapter builds a fresh recorder per session (screencast-lifecycle.tsdoes so even acrossreloadSession), so the leaked session and its browser-side stream were the damage, not buffer pollution. The obvious repair — stopping the session on that mismatch path — is wrong, and it is the shape to keep out: it guards a field both CDP overrides write at handshake start (#cdpSession/#cdp) with a flag they set only at handshake end (#cdpActive), so a start arriving in that window is indistinguishable from no start at all and the stale path tears down the newer recording's session instead. Overlapping starts are the adapters' normal mode rather than a corner: Selenium'sdriverPatcherdocuments the second test's body racing an in-flightscreencast.start(), and Nightwatch's session rotation is fire-and-forget from a command hook. Queued, a stop always runs against a start that has finished arming and owns what it armed, which makes the mismatch branch unreachable and deleted — the tick-phase generation checks in#startPollingare not, since a shot orphaned by a stop still lands afterwards. Two consequences:stop()now waits on an in-flight handshake, and every awaited driver primitive in that handshake is ceilinged atSCREENCAST_HANDSHAKE_TIMEOUT_MS(the service'sgetPuppeteer()/pages()/createCDPSession()/Page.startScreencastand the stop-sidePage.stopScreencast, Selenium'screateCDPConnection, the polling path's first screenshot) — a driver that wedges gives up after the ceiling, falls back to polling, or reports the screencast unavailable when polling was what wedged. Nothing is claimed until the handshake has answered, so a timed-out start leaves nothing armed — and a session or connection that completes after the ceiling is detached (or its socket closed) when it lands, so no orphan outlives the recording. And a stop landing during a polling session's first screenshot now records that shot before tearing the loop down, where it previously cancelled the start outright; the interval is still never left armed underneath the stop.- Residual: a polling matcher fires one capture per poll — each poll is a top-level mapped read — so "one capture per action" undercounts a real suite. Pre-existing in
d924a02, unmeasured on a device, and the likeliest next lever. - The next test's first pre-capture used to be stamped at the previous test's last-action timestamp — the log it scans is run-long, so a test boundary was invisible to it. That slot already holds the previous test's finalize capture, and the richer-screenshot merge could replace it: with a
reloadSessionbetween the tests the row replayed the post-reload page. The capture now stampsDate.now()when the scanned timestamp predates#currentTestStartWallTime(0 without per-test hooks, so the standalone path is unchanged) — the same rule that makes a session's first capture its initial frame. Per-test slices were never affected:flushTestruns insideafterTest, before the next test's commands.
Most entries below don't trigger the max-lines lint rule after skipBlankLines/skipComments; they're documented because their raw line count is over 500, and the next substantive change to any of them should still look for an extraction opportunity. The service plugin is the exception — it's now over the logic-line cap.
packages/service/src/index.ts(679 logic / 990 raw by the linter's count). Still over the 500-logic cap. Which commands become rows, and the wait nesting that decides it, live incommand-stack.ts, and the wait rows themselves inwait-rows.ts. The screencast and trace-slice seams are extracted:screencast-lifecycle.ts(139 logic / 217 raw) owns every read and write of recorder frames — start, reload, finalize, the cross-reloadSessionfilmstrip buffer and the per-test video slice, two invariants that were previously produced and consumed 400 lines apart — andtrace-slices.ts(58 logic / 87 raw) owns boundary recording plus the eager per-test flush beside the flush I/O it already held. The only remaining cluster large enough to close the gap is the command-hook family (beforeCommand/afterCommand/#commandStack/#drainAfterLiveCommand, ~120 logic lines);before()is still over the function cap at 51 logic lines.packages/nightwatch-devtools/src/index.ts(783 raw / 676 logic). Cucumber/test/run-lifecycle, session-init, event-hub and now the screencast seam (plugin-screencast.ts, 105 raw / 60 logic) are extracted; the remainder is thePluginInternalsaccessor bag plus per-method delegators plus the factory. The bag is deliberately declarative — accept as-is.packages/selenium-devtools/src/index.ts(~644 raw, down from ~758 — the deadscriptInjectedaccessor pair and setter are gone). Session/test-lifecycle and the per-test-artifact seam are now extracted: the sink cache + input snapshot + produce/attach flow live inselenium-devtools/src/test-artifacts.tsasSeleniumTestArtifacts(mirrors Nightwatch's twin — a typed input bag threading the Allure sink + flushed-trace promise), and the plugin keeps only a thin bag-building delegator. Remainder is thePluginInternalsaccessor bag plus onCommand/onDriverCreated wiring. Still over the 500 raw soft cap (under the logic-line cap afterskipBlankLines/skipComments); the accessor bag / command wiring is the next extraction candidate if it grows.packages/nightwatch-devtools/src/session.ts(519 raw, under the logic-line cap afterskipBlankLines/skipComments).captureNetworkFromPerformanceLogs+drainCollectorare tightly coupled to NightwatchBrowser state. Coverage at 78% after recent backfill; further extraction would need rewriting the browser-coupling.packages/selenium-devtools-py/src/selenium_devtools/instrumentation.py(975 raw). Every seam here reads the module-level_statebag — the per-action snapshot capture, its two accessors and the element-scripts handoff all key offtrace/a11y/element_scriptsalongside the screencast and session entries — so an extraction is a move of state ownership, not a lift of a function. The next change that touches_stateitself should split it per concern first;action_snapshot.pyis then a clean lift.
Numbers reflect actual pnpm test:coverage output.
packages/selenium-devtools/src/session.ts— 83%. Remaining branches are inside http-error / no-such-session paths that need a real driver to exercise.packages/nightwatch-devtools/src/session.ts— 78%.takeScreenshotViaHttperror branches need real WebDriver.packages/service/src/screencast.ts— 76%. CDP fast-path branches hard to exercise without a real Chrome.packages/backend/src/baselineStore.ts— 91%. Remaining 9% is leaf-error paths.
The threshold gate in vitest.config.ts enforces the current floor — it ratchets upward as gaps close, never downward.
No known violations. New ones get tracked here as discovered.
This file evolves with the repo. When a convention turns out to be wrong in practice, the right fix is to update the convention, not to silently break it. When a recurring decision point isn't covered here, it gets added.