Skip to content

Latest commit

 

History

History
362 lines (254 loc) · 88 KB

File metadata and controls

362 lines (254 loc) · 88 KB

Repo conventions

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."


What this repo is

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.


Commands

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.


Path aliases

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.


Conventions

One source of truth per concept

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.

Framework-agnostic logic lives in core

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.

Trace-format transforms live in trace, one layer below 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.

Adapters are thin and isolated

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 are framework-agnostic

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.

Boundaries have typed contracts

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.

Workspace-internal packages stay bundled

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 devDependencies with workspace:^, never in dependencies. Vite and tsup both externalize anything in dependencies by default; devDependencies is what gets inlined.

  • None of them is added to a bundler's external config. Vite's external callback 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 a PRIVATE_WORKSPACE_PACKAGES array rather than a chain of ||s for exactly that reason. Adding a workspace package also means adding it to pnpm-workspace.yaml, whose packages: 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 with ERR_MODULE_NOT_FOUND at install time.

  • packages/service/vite.config.ts is 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/*.js should return nothing. That's how you catch the absolute-path leak. Match on the from/require( prefix, not the bare package name: LIBRARY_NAME = "@wdio/devtools-core" (written into the trace's context-options) and Symbol.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 require into a shim that throws Dynamic require of "fs" is not supported the moment the module loads. Declaring it in dependencies is what externalizes it; that is why all three adapters — and now backend — list yazl there rather than in devDependencies. This is the opposite of the workspace-internal rule above, and for the same underlying reason: dependencies is externalized, devDependencies is inlined. Neither pnpm build nor pnpm test nor the leak grep notices — every one of them passes on a dist that dies on first import — so packages/backend/tests/dist-bundling.test.ts asserts the shim is absent.

Bundlers in use: vite for app, service, script; tsup for backend, nightwatch-devtools, selenium-devtools.

Separation of concerns within a file

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.

TypeScript

  • strict: true is on (root tsconfig.json).
  • No any. If a framework or library forces it, the any is isolated at the boundary and cast to a typed shape with a one-line comment explaining why. As of writing, there are no no-explicit-any warnings repo-wide.
  • No as unknown as X double-casts unless the reason is documented inline.
  • type for unions, interface for object shapes that may be extended.
  • Names exported from shared and core are public API of those packages — renames are breaking changes for downstream consumers.

Naming

  • One name per concept across the whole repo. The canonical test-status name is TestStatus in shared; the sidebar TestState is a value-only enum-style accessor over the same string union.
  • Constants are SCREAMING_SNAKE_CASE. Types are PascalCase. Functions and variables are camelCase. Files are kebab-case.ts unless they match a class name (SessionCapturer.ts).

File and function size

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.

Comments

  • 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 sync aren'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.

Error handling

  • Validation happens at boundaries (HTTP input, WS messages, framework callbacks). Internal code is trusted.
  • Errors aren't swallowed silently. catch only adds context, then rethrows or logs with enough detail to debug. Empty catches don't appear in production code.

Dead 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.


Testing

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.

What gets tested

  • shared and core: 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.

Adapter and backend logic

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.

Manual verification

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.

Skipping tests that depend on workspace-internal build artifacts

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.


Workflow

When adding code

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 in shared first).
  • 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.

While editing

  • 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.

Before pushing

  • 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.

Commits

  • 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-verify to skip hooks. If a hook fails, the underlying issue gets fixed.

PRs

  • 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?

Documentation

  • 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 the webdriverio/webdriverio repo — 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.

Known debt

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.

Architecture

  • replaceCommand has two semantics — Selenium mutates in place (preserves _id/id for chained calls); Nightwatch splices and reissues. Both call the same core/suite-helpers factories; 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 (via core/assert-patcher) is now wired in all three adapters, default-on behind each adapter's captureAssertions option (opt out with captureAssertions: false). Framework matcher libraries differ: Service taps expect-webdriverio's beforeAssertion/afterAssertion hooks so passing+failing matchers render as expect.* actions (mechanism in the assert-capture entry below); Nightwatch native assert/verify and Selenium's node:assert also surface passing+failing rows via their reconcile/patch paths. The remaining gap is Selenium's jest-style expect() (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: true and requires webSocketUrl: true in capabilities.
  • Retry-aware trace policies share one mechanism: adapters feed a per-attempt outcome ledger (core/attempt-tracker.ts TestAttemptTracker.recordStart(uid, specFile) + recordOutcome(uid, state, attempt)) keyed by the retry-stable uid, and the finalizer reads the scoped views (all/forSpec/forTest) so trace-retention.ts evaluates group-by-testretain-on-failure keys on each test's final attempt (no over-retaining a fail-then-pass), retain-on-first-failure on attempt 0. recordStart on a second attempt stamps the prior attempt failed (a retry only follows a failure), which corrects runners that swallow the intermediate failure — e.g. Mocha via a --require plugin never surfaces the retried attempt's failure, so the ledger would otherwise see [passed, passed]. An empty scoped view falls back to testMetadata (never fail-open-retains).
    • WDIO + Selenium: verified end-to-end (manual fail-then-pass runs: retained under retain-on-first-failure, dropped under retain-on-failure).
    • Nightwatch: retain-on-failure works; the other retry-aware policies degrade. Its --retries re-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 the describe/it and exports-object interfaces. Cucumber scenarios expose per-scenario hooks, so the feed captures their attempts. Not cleanly fixable without depending on those internals.
    • WDIO specFileRetries spawns a fresh worker per retry, so cross-process attempts aren't in the (process-scoped) ledger.
  • Run identity across worker sockets is env-propagated. core/run-id.ts resolveRunId() publishes DEVTOOLS_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's onPrepare, 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, nightwatch test_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 from process.ppid would 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.ts RERUN_SLOT names both, and backend/src/runner.ts #resolveGenericCommand branches on which one the adapter's template carries: {{testName}} is filled from label/fullTitle through escapeFilterRegex because mocha --grep, jest --testNamePattern and cucumber --name all match by regex; {{testId}} is filled from uid, 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 decides isTargetedRerun — an id template needs a uid, 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.py derives both commands from pytest's own view of its invocation (config.invocation_params.args plus config.args for 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/--sw family, -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 second enable() in one process but only while it still holds the value we wrote (tracked, and deliberately surviving reset()): 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 first enable(), 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; baselineStore snapshots from the stream every adapter already sends, and toMs accepts the ISO strings Python puts on SuiteStats.start/end. Verified end-to-end against a live backend with frames built by the adapter's own frames/SessionCapturer: both attempts carried their commands, console and network with distinct windows and correct states. What was missing was coverage: preserveBaseline appeared 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 #activeRun accumulator, 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/run spawns a fresh process; the socket carries only clientConnected/clientDisconnected. So the single-workerSocket limitation is about which process the dashboard state belongs to under pytest -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.py reuse_target() now attaches to it ahead of DEVTOOLS_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 in lifecycle.auto_open_enabled() rather than at the enable() 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 clearExecutionData scope, and the receiver cannot tell them apart from the uid. A run STARTING (backend/src/index.ts handleTestRun, one per POST /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 against rerunState.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.runStart now 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 #videos is 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 the screencast-ready window event and never learns a run started.
    • 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 — which test-entry-state.ts orderedChildren sorts a suite's tests and child suites by — was pytest's enumerate(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.ts resetStaleChildrenOnRerun flipped every settled child suite to pending whenever an incoming suite arrived pending — but a single-test rerun re-emits the parent as pending carrying 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. mergeTests already froze sibling tests on activeRerunTestUid; 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 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.fill command args, the transcript, the captured test source, and *-elements.json. shared/element-scripts.ts blanks 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. buildElementScripts now projects a captured record down to what is actually read (selector + boundingBox + context), so value and href leave the archive entirely — justified as dead data, not as a redaction: the same archive still carries 15 hrefs in trace.mutations independent of elements.json, and 29 value attribute mutations recording a typed string keystroke by keystroke (t, to, tom, ...). @wdio/elements keeps returning the full BrowserElementInfo from 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 or maskInputs option — not at one resource. Until then, treat a trace zip as sensitive as the run that produced it.
  • An ActionSnapshot carries no session identity, in any adapter. shared's type has never had one and core/action-snapshot.ts records none, so per-action captures from two concurrently-driven sessions land in one list and are resolved purely by the command's completion timestamp. claimAfter is an exact keyed lookup, so the window is narrow — two commands completing in the same millisecond, where trace-frame-snapshots.ts breaks the tie by "keep the richest capture" (largest screenshot), which is session-blind — plus the documented latestAtOrBefore fallback 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's row["timestamp"]; reaching the failure at all needs threaded drivers in one process (pytest's function-scoped fixtures are sequential, and -n is multi-process). Fixing it is a shared-contract change: sessionId on 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 queries passwordsleakcheck-pa.googleapis.com and ~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 CDP Input.dispatchMouseEvent/dispatchKeyEvent are 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.1 to the browser args. Every example that submits the demo credential carries it — WDIO, Nightwatch, and both Python ones (login.py was 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. --guest also works (3/3); --incognito works at the raw-WebDriver level but WebdriverIO rejects it at session creation; disabling the password manager via prefs does 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 browserVersion to 149; every part of the earlier "Chrome 150 regression, fixed in 151" attribution is contradicted.
    • Minimal reproduction (own HTTP server, raw fetch to chromedriver, no repo, no client library, no framework) is in the session scratchpad as minimal-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).
  • 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.ts warrantsLiveDrain + SessionCapturer.drainAfterLiveCommand, the same deny-list shape over its own command vocabulary plus mapAssertCommand (a node:assert row never reaches the browser) — the predicate is not in core because the vocabularies are per-framework and only two includes calls 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 await onCommand, 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 afterEach once per module, so a whole module's asserts arrive after every driver row). The display list already sorts and utils/elapsed.ts already treats capture order as untrusted, but app/src/components/browser/mutation-at-command.ts bounded a row's DOM by commands[idx + 1] — the array neighbour — so a row was bounded by a time before it ran: measured, the run's last waitForElementVisible('#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 key buildActionEvents uses, 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: processTracePayload sends 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 mutates sessionId in place on one browser object and the screenshot probe reads it fresh. An observed 17→6 drop was thinScreencastFrames' byte-identical dedup meeting a different failure profile — 10 of the 17 were a blinking text caret captured while a waitForElementVisible sat 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: #emitTestArtifacts read recorder?.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.ts and nightwatch-devtools/src/helpers/browserProxy.ts both ran their capture at completion but stamped timestamp with the invocation clock, keeping the invocation time as startTime only 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 #username fill rendered an empty field, the #password fill 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 a url) stamps performance.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.ts reattributeDomAnchors remains 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 /login onto 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 click resolves 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-py had no frame cap at all. Chrome's Page.startScreencast removes that property: cdp_screencast.py subscribes 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 with every_nth_frame rather than buffered and discarded here.
    • The buffer cap then needs care. Halving the buffer and keeping first/last — core's documented maxBufferFrames shape — 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. _buffer therefore thins the INCOMING frames by the same factor it has halved the buffer (_stride doubles 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_tail when the recorder stops (finalize stops 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_connection per driver and hands it to whichever of BiDi or CDP asks first. The adapter attaches BiDi before arming the screencast, so start_devtools() returned the BiDi socket and Page.startScreencast reached a BiDi endpoint: measured, unknown command: Unknown command 'Page.startScreencast', followed by BiDi command has no 'params' of type dictionary: {"method": "Page.stopScreencast"} — that second line being the proof of which endpoint it was, and coming from a stop the 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:cdp is a Grid capability and is absent for a locally started chromedriver — the common case, and the one the demo runs — so without the debuggerAddress/json/versionwebSocketDebuggerUrl lookup that selenium's own _get_cdp_details performs, 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. An se:cdp equal to webSocketUrl is rejected as the BiDi socket.
    • Performance timings ride on the command ROW, not a scope of their own (CommandLog.performance, plus cookies/documentInfo/result), so the row is sent when the command completes and sent again under replaceCommand once the page has answered — which is why capture_command returns the row it built and send_replace_command keys on its timestamp rather than the per-process id counter. Python does not sleep before reading, where the JS adapters wait 500 ms: their navigation command can resolve before the load event, selenium's get() 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 no navigation entry 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 same execute hook it was called from and shows up as an executeScript row beside every navigation — a fake driver whose execute_script does not route through execute cannot 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.
  • A drain must anchor the document it reads, and the flag for that has only ever had one value. core/script-loader.ts collectorDrainExpression(forceAnchor) prepends captureCurrentDom() so a freshly injected collector's async initial anchor is not lost: the collector schedules it after waitForBody, 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 passes true (selenium's drainAfterLiveCommand, its re-inject-after-navigation and teardown paths; nightwatch's five sites), so the false default is vestigial. Python's drain read getTraceData() 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_SCRIPT now forces it unconditionally and carries no flag — one setting is not a knob. Forcing is free after the first anchor of a document because packages/script guards captureCurrentDom with an #anchored flag that deliberately survives its reset(), 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.ts registerCollectorPreload registers the collector via BiDi script.addPreloadScript with 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 own performance.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.ts registerPreload, inside the Promise.all that onDriverCreated awaits — the patched build() thenable waits on that, so the preload is live before the first get; ensureBidiCapability already sets webSocketUrl: true on 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 after get, behind injectScript's ≥200 ms readiness poll and capturePerformance'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 on SessionCapturer.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 before capturePerformance'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-6 Collector missing … re-injecting recoveries, and — because bidiActive stayed true from 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.ts rearmCaptureForSession re-arms both off the same command-hook detection the screencast rotation uses (armedSessionId, stamped before the first await so the command flood latches out), clearing preloadRegistered/bidiActive at 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 the SessionCapturer — 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.all races selenium-webdriver's unsynchronized getBidi() cache into two websockets of which quit() 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.
    • Deliberately not gated on Nightwatch's bidi option. That option exists to avoid double-reporting console/network against the perf-log path; preload registration needs nothing but a session created with webSocketUrl: true, and gating DOM capture on an unrelated opt-in that defaults to false would leave the race in place for nearly every user. ScriptManager.init throws 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. Prefer webSocketUrl: true.
  • One SessionCapturer per 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 — so traceGranularity:'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.ts setBrowser, mirroring selenium's setDriver), 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) and ensureSessionInitialized (lastSessionId). armReplacedSession is gated on needsCaptureRearm and rotateScreencastForSession on the screencastRotation latch, so whichever detector arrives first latches the other out; rearmCaptureForSession is 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, and buildSpecCapturer slices synchronously from that call. Measured unchanged at 9 and 8 rows per scenario zip.
    • metadata stays 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.
  • Nightwatch navigation injection must not ride on a queued perform(). wrapNav used chainable.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), and result is returned unchanged so browser.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 via setTimeout just to stay off Nightwatch's callback stack. The drain, the collector-ready probe and the injection now go over helpers/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 #password fill 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 /secure anchor in the stream at all). session.ts anchorAfterNavigation polls 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 in snapshotCaptures so 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 failing step needs a ROW before its error has anywhere to go. ACTION_MAP carried WDIO's explicit waits but none of Nightwatch's, so waitForElementVisible — 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 carrying error), surviving only as console text. The waitForElement* family is now normalized onto the WDIO wait methods. Two derivations back it up in trace-action-events.ts actionError: a collapsed passed: false result now marks any row failed rather than only assert-class rows, and a wait whose result is false is 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. The false reading is scoped to waits because it is a legitimate value for a boolean read like isDisplayed, 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 how assert.urlContains reads 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/catch around 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 W3C value wrapper. browserProxy.ts callbackError unwraps that and promotes it to the row's error. Left in result, the row kept error: undefined and 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 carrying passed is 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 their startTimes 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.textContains landing below the logout click it ran before). Nightwatch stamps sequence when the test issues a row — a driver command at invocation, an assert at enqueue — and buildActionEvents uses 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.commands is empty for the BDD interface, so assertCommandTimings always returns nulls and the rows keep their enqueue timestamp. That is why issue order, not reported timing, has to carry the ordering.
  • Actions that only read the page inherit the preceding action's capture (core/trace-frame-snapshots.ts claimAfter). 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 /secure rendered the /login page the test later logged out to).
  • Every per-action snapshot probe is timeout-guarded (core/action-snapshot.ts probe, SNAPSHOT_DRIVER_PROBE_TIMEOUT_MS), not just the in-page scripts. Nightwatch's browser.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's Promise.all stranded 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 in nightwatch-devtools/src/helpers/webdriverHttp.ts, bypassing the queue entirely — the pattern takeScreenshotViaHttp already used. Relatedly, runWith treats a null script result as its fallback: a driver that answers null instead 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 screenshot and video options live on the WDIO ServiceOptions only — not BaseDevToolsOptions — 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 own DevToolsOptions with full inline attach; Nightwatch adds them produce-only — see the Allure-attach entry below). All are gated to traceGranularity:'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 by maxBufferFrames (default 2000; decimates keeping first/last), and on non-Chrome the polling recorder issues many takeScreenshots that flood @wdio/allure-reporter (pair with disableWebdriverStepsReporting).
  • The filmstrip option (dense screencast into the trace) is on BaseDevToolsOptions — 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.ts thinScreencastFrames/buildDenseScreencast; slice windowing in spec-trace-helpers.ts); adapters only default the option, un-gate the recorder in trace mode when it's set, and feed recorder.frames into the finalize context. Each adapter captures frames while the recorder is still alive (service onReload#filmstripFrames; Selenium onDriverEnd drain before nulling; Nightwatch #finalizeCurrentScreencast snapshot 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 DOM elements/snapshot are carried independently by the frame-snapshot events, 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 by maxBufferFrames (default 2000; see the screenshot/video entry above). Per-test filmstrip slicing follows the same per-test-hook availability as traceGranularity:'test' (works for WDIO mocha/cucumber, Selenium mocha, Nightwatch exports-object/cucumber; Nightwatch BDD describe/it degrades 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-reporter sink; Selenium supplies an allure-js-commons attachment() sink (runtime-agnostic — attaches under any allure runner adapter — gated on globalThis.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 mocha afterEach/jest afterEach/cucumber After are 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); Gherkin AfterSteponTestEnd stays fire-and-forget. (b) Nightwatch inline-attach is unsupportednightwatch-allure is post-hoc with no live attach API, and allure-js-commons no-ops in a Nightwatch run (nothing calls setGlobalTestRuntime); Nightwatch is produce-only. Revisit if an allure-js-commons-based Nightwatch runtime adapter appears (then it's a sink swap). (c) video is standalone in both adapters (recorder starts for video != off OR filmstrip in trace mode; the orphan session-webm encode is trace-gated to stop-only).
  • node:assert error display (core/assert-patcher.ts describeAssertFailure): 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/.expected into a CollapsedAssertResult AND rewrites the auto-generated message (and its echo in the stack) as a value-bearing Expected: … / Received: … block. Because toError returns 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 without actual/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's Expected/Received shape.
  • 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.reporter aren't on browser). Streaming mid-run would flash every assert green before its outcome is known, so rows are held and reconciled against results.testcases[*].assertions at afterEach (the flat results.assertions only 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. actual is parsed from the failure message (but got: …, failures only) into a collapsed {passed, expected, actual} result; expected is the assertion arg — the label still mirrors the call args, not the derived values.
  • Nightwatch's BDD describe/it interface fires the plugin's global beforeEach/afterEach once per module (with an empty currentTest.name), not per it — Nightwatch runs the individual its internally with no per-testcase hook or event reachable from the plugin (the lib emits only transport-level events). So traceGranularity:'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-it slicing 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's tests[] still carries every testcase with correct state, 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 droppedretain-on-failure evaluates 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.
  • 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) buildpackages/nightwatch-devtools/tsup.config.ts now compiles src/helpers/cucumberHooks.ctsdist/helpers/cucumberHooks.cjs as a self-contained CJS bundle (@cucumber/cucumber external). Previously the build ran only tsup src/index.ts --clean, so that file never existed and Cucumber's require:[cucumberHooksPath] registered no hooks (glob matched nothing) → zero capture. PLUGIN_GLOBAL_KEY moved to a leaf module plugin-global-key.ts so the hooks bundle stays tiny and CJS-safe (importing it via constants.ts dragged in core → createRequire(import.meta.url), which throws when bundled to CJS). (2) capture ordering — a pre-quit cucumber After hook (order:1000, captureCucumberScenarioBeforeQuit) runs the trace capture + slice flush while the per-scenario browser session is live (the order:-1 finalize is post browser-quit, so the flush bailed on the absent sessionId). Requires traceGranularity:'test' (per-scenario slices). (3) native asserts — the same pre-quit hook drains browserProxy.drainNativeAssertCalls() and calls captureNativeAssertions (the afterEach path early-returns for cucumber), so assert.* 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 createClient with no reporter, so SimplifiedReporter.logAssertResult no-ops and results.assertions/results.testcases are never populated — no scenario-level reconcile can recover them, which is why currentTest: undefined was a dead end. nativeAssertions.ts observedAssertOutcome reads the outcome from the returned promise instead (lib/core/asynctree.js shouldRejectNodePromise: a failing assert.* rejects its deferred, a failing verify.* resolves with the AssertionError, a pass resolves with the command value). A fulfilment of undefined stays neutral — that is an assertion enqueued but never executed after an earlier assert.* 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 unitresetTestTracking() at wrapBrowserOnce now clears it.
    • DOM mutations ARE captured, and the old ECONNRESET / "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 with traceGranularity:'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 /screenshot every 200 ms into the session Nightwatch quits per scenario, logging 9–26 WARN webdriverHttp: … socket hang up per run. Fix is to stop the recorder before the per-scenario quit, or suppress the warn for a session in teardown.
  • 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 into dist/chunk-*.js, and there import.meta.url is the chunk's path, which can never equal process.argv[1]. packages/backend/src/show-trace.ts imports start from index.ts, which is what made index shared, so index's old "start if run directly" guard was dead in every build: node dist/index.js exited 0 without ever serving, while dist/show-trace.js self-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 the node_modules/.bin symlink). The live dashboard server is therefore its own leaf entry, packages/backend/src/server.ts (shebang, built to an executable dist/server.js, shipped as the devtools-backend bin, accepting --port, --hostname, -h/--help), and index.ts stays library-only for the three adapters' in-process start/stop. Same family as the cucumberHooks.cts entry 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.ts locatorDialect(runner) is the one fact table — WDIO runners (mocha/jasmine/cucumber) get a*=Logout, nightwatch*/selenium-webdriver and an unidentified recorder get //a[contains(., "Logout")]. The id reaches the page script as CaptureActionSnapshotInput.runner and the zip as an extension field on context-options, read back through isTestRunnerId onto Metadata.runner; absent in older and foreign zips, where the player shows no hint. Under WDIO a text carrying a " still emits XPath — WDIO compiles tag*= to XPath with " quoting and would build a broken expression — and WDIO resolves // itself. locatorsMatch decomposes both sides from either dialect, so after.point survives whichever way round the two grammars fall; the concat()-stitched literal is still left to exact comparison. @wdio/elements' standalone getSnapshot deliberately keeps the portable XPath default (its output is pasted into arbitrary tools), so a WDIO run's browser.getSnapshot() and its trace A11y tab disagree on that one branch.
  • Metadata.runner (typed TestRunnerId) and metadata.options.framework (untyped string, read by the sidebar's getFramework) are two carriers of the same fact. All three adapters now set both; the next change to either should collapse getFramework onto Metadata.runner.
  • Selenium's ctx.runner (the detected JS runner — mocha/jest/cucumber, from detectRunner()) shadows the new Metadata.runner concept. Renamed to detectedRunner at the driverMetadata boundary only, where the two met inside one function and a swap would have been a silent dialect bug; the repo-wide rename in plugin-internals.ts/session-lifecycle.ts is 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.ts owns the value→locator registry (rememberReadValue/selectorForReadValue/resolveAssertTargetFromArgs; most-recent-producer-wins, 200 keys, ≤256 chars, primitives only), reached through patchNodeAssert's optional resolveAssertTarget hook, which walks the raw args (sanitizing destroys handle identity) onto the shared CommandLog.selector. It lives in core because two adapters feed it. Selenium feeds it from driverPatcher's settle path and composes it with its handle WeakMap (helpers/element-locators.ts, adapter-local — it keys on WebElement identity, which no other framework has). Nightwatch feeds it from browserProxy's capture callback via assertTarget.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, h1 vs document.title both "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/#hero collision, 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's element()/.find() API and its ignored commands (execute, executeAsync, perform) are never wrapped, so their values neither claim nor clear. after.point is deliberately not extended to assert rowsresolveActionPoint gates on class:'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)), so nightwatch-devtools/src/helpers/assertTarget.ts reads 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 carries title*/url* and node:assert's value mirrors (ok, equal, strictEqual), whose first argument is a plain value. A {selector, locateStrategy} bag and a Nightwatch Element are duck-typed on .selector; a definition built from a resolved handle yields none, and not.* reads through the negated namespace. Measured: 2 of 4 assert rows carry #flash and both urlContains rows 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 WeakMap on handle identity (WebElement.id_ is a promise, so no id is readable when a command is invoked); the service uses a mutable last-selector in service/src/command-selectors.ts, which stamps the wrong locator for const a = await $('#a'); const b = await $('#b'); await a.click() (the Selenium side is covered in selenium-devtools/tests/element-locators.test.ts; the WDIO failure itself has no test — it is an unverified reading of command-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's ACTION_MAP — that table says how a command renders (its Element entries 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 (toHaveTextgetText, toExistisExisting, …) is captured as a normal command; afterAssertion then coalesces the synthesized expect.* row into that read in place — inheriting its callSource, screenshot, and timeline position — and the fold replaces by timestamp, never a public id: id is the per-worker commandCounter, which resets per spec, so stamping one lets the app's id-first replaceCommand swap a same-id row from another spec (duplicate rows + a fold from another spec vanishing, in multi-spec live mode). beforeAssertion arms the pending matcher (depth-counted so aliases like toBeCheckedtoBeSelected fold once); a matcher that hard-throws — element never resolves, so expect-webdriverio's waitUntil rethrows and afterAssertion never fires — is synthesized at afterTest/afterStep from the throwing read, so a failing assertion renders as expect.<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; and MATCHER_READ_COMMANDS is a hand-maintained allowlist, so a matcher whose read isn't listed leaves its raw read visible alongside the expect.* row. Plain-value jest matchers (expect(x).toBe(y)) don't fire the ewdio hooks, so they aren't captured as rows.

File-size (raw line counts; soft cap is 500 logic lines)

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-reloadSession filmstrip buffer and the per-test video slice, two invariants that were previously produced and consumed 400 lines apart — and trace-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 the PluginInternals accessor 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 dead scriptInjected accessor 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 in selenium-devtools/src/test-artifacts.ts as SeleniumTestArtifacts (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 the PluginInternals accessor bag plus onCommand/onDriverCreated wiring. Still over the 500 raw soft cap (under the logic-line cap after skipBlankLines/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 after skipBlankLines/skipComments). captureNetworkFromPerformanceLogs + captureBrowserLogs + drainCollector are 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 _state bag — the per-action snapshot capture, its two accessors and the element-scripts handoff all key off trace/a11y/element_scripts alongside 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 _state itself should split it per concern first; action_snapshot.py is then a clean lift.

Test coverage gaps (worst-risk-first)

Numbers reflect actual pnpm test:coverage output.

  • packages/selenium-devtools/src/session.ts83%. Remaining branches are inside http-error / no-such-session paths that need a real driver to exercise.
  • packages/nightwatch-devtools/src/session.ts78%. takeScreenshotViaHttp error branches need real WebDriver.
  • packages/service/src/screencast.ts76%. CDP fast-path branches hard to exercise without a real Chrome.
  • packages/backend/src/baselineStore.ts91%. 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.

Type-safety

No known violations. New ones get tracked here as discovered.


Living document

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.