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 |
Run the per-framework example projects. Useful for manual verification of UI or runtime changes. |
pnpm dev |
Run all packages in parallel dev mode. |
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/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. 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 a zip? If yes, trace. If it needs a driver, a framework hook, or a capture session, core.
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 only (for contracts) and from each other via the WS/HTTP boundary. Neither imports an adapter package.
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.
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.
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.- 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.
- 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.
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 pass+fail assertion hook, so only failing matchers surface there.- BiDi is auto-attached in Service and Selenium; Nightwatch is opt-in via
bidi: trueand requireswebSocketUrl: truein capabilities. - 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. Two bugs of that one shape, both found by rerunning a single pytest test: (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 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, and the flag for that has only ever had one value.
core/script-loader.tscollectorDrainExpression(forceAnchor)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 both JS adapters passestrue(selenium'sdrainAfterLiveCommand, its re-inject-after-navigation and teardown paths; nightwatch's five sites), so thefalsedefault is vestigial. Python's drain readgetTraceData()with no anchor at all, which is the same missing backstop the preload does not cover;selenium-devtools-py/src/selenium_devtools/snapshot.py_DRAIN_SCRIPTnow forces it unconditionally and carries no flag — one setting is not a knob. 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
- 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.
- 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. 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 (
core/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 exports-object/cucumber; Nightwatch BDDdescribe/itdegrades 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); GherkinAfterStep→onTestEndstays fire-and-forget. (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; likely Nightwatch's exports-object style, unverified). 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 two adapters feed it. 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. 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, never a publicid:idis the per-workercommandCounter, which resets per spec, so stamping one lets the app's id-firstreplaceCommandswap a same-id row from another spec (duplicate rows + a fold from another spec vanishing, in multi-spec live mode).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.
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(602 logic / 843 raw, was 729/1043). Still over the 500-logic cap. 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/#markDocument/#drainAfterLiveCommand, ~120 logic lines);before()is still over the function cap at 62 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+captureBrowserLogs+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.