Skip to content

Latest commit

 

History

History
433 lines (306 loc) · 146 KB

CLAUDE.md

File metadata and controls

433 lines (306 loc) · 146 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 / pnpm demo:python:pytest Run the per-framework example projects, live by default; DEVTOOLS_MODE=trace flips them to trace mode. The Python adapter does not read that variable, so the pytest example's conftest.py maps it onto the plugin's options (per-test archives, retain-on-failure), yielding to anything set on the command line, in its pytest.ini or in the environment. Useful for manual verification of UI or runtime changes. No package pins a browser driver, deliberately: a pinned chromedriver rots against whatever Chrome the developer has, and because pnpm puts the package's node_modules/.bin on PATH, Selenium Manager finds it there, prefers it over resolving, and on a major mismatch only warns before returning it anyway. The session then fails to start. With nothing on PATH both adapters resolve a matching driver (Nightwatch's Chrome service builder sets requiresDriverBinary: false and passes an unset server_path through to the same resolver), and nightwatch declares chromedriver an optional peer.
pnpm demo:wdio:mobile / :selenium:mobile / :nightwatch:mobile / :python:mobile The same, against Appium. All four build the same capability bag and drive the Clock app that ships with every Android system image (keying a duration on the timer keypad, never starting a timer), so a native example needs no .apk. DEVTOOLS_MOBILE_PLATFORM=ios runs the iOS spec instead — all four adapters — which drives Settings because the simulator ships no Clock, from a separate spec per platform rather than a branch. The simulator is chosen by udid and defaults to whichever is already booted, and an unmatched IOS_DEVICE_NAME is refused: naming one that does not exist makes the XCUITest driver create and boot it, every run, rather than fail. That refusal and the booted-simulator preflight are local policy — xcrun simctl enumerates local simulators and nothing else — so IOS_UDID and a non-local APPIUM_HOST both bypass them, or a real device and a cloud grid would be refused a run they were correctly configured for. DEVTOOLS_MOBILE=web drives the device's own browser on both platforms — Chrome on Android, Safari on iOS, which the XCUITest driver serves without a chromedriver. examples/MOBILE.md holds the prerequisites and the DEVTOOLS_MOBILE / APPIUM_APP switches; DEVTOOLS_MODE=trace flips any demo to trace mode.
pnpm dev Run all packages in parallel dev mode.
pnpm smoke:fixtures [--port N] Serve the local fixture site the desktop examples drive (a copy of the-internet's login flow: /, /login, /secure, /logout; default http://127.0.0.1:4567). Every desktop login example reads DEVTOOLS_EXAMPLE_BASE_URL (trailing slash ignored); unset, they drive the public site.
pnpm smoke:assert --expect <name> [--exit-code N] [--log file] <zip-or-dir>... Check trace zips against packages/conformance/smoke/expectations/<name>.json: the listed rows under each test's own tracing group (or the whole run for an entry with scope: "run" and a reason), an error row on each deliberately failing test, a retried test's attempt count with its other checks read from its final attempt alone (attempts), title fragments such as a failed wait's condition, a call site on every row of a per-test entry (at least one for a run-scoped entry) and every call site inside the example's own sourceFiles, the run's exit code, no wait timeout, in any adapter's wording (TIMEOUT_PATTERNS in smoke/constants.ts), in the trace's stdout or the run log other than the lines of a test marked allowTimeoutError, and the zips' combined size against the file's required traceBudget.maxBytes.
pnpm player:hosted Build the hosted trace player (the app with --mode hosted, no backend) and serve it at http://localhost:4173 under the headers vercel.json sends from trace.webdriver.io.
pnpm player:e2e [--headed] [--port N] Build the hosted trace player, serve it under the vercel.json headers and open trace zips through it in headless Chrome with WebdriverIO (packages/conformance/hosted-player/). pnpm exec tsx packages/conformance/hosted-player/run.ts --url <player> runs the same check against a deployed page without building.
pnpm docs:capabilities Render the per-adapter capability tables from packages/conformance/src/features.ts into README.md; --website <webdriverio checkout> also renders the docs-site tables, --check fails instead of writing, and --parity lists every feature some adapter lacks plus the evidence counts.
python3 packages/selenium-devtools-py/scripts/changes.py next-version The version a Python-adapter release would publish, from the fragments pending in changes/. check --base <ref> is the CI gate; apply is what the release runs.

selenium-devtools exposes per-runner variants of its example via pnpm --filter @wdio/selenium-devtools example:mocha / :mocha:allure / :jest / :cucumber.


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/nightwatch-devtools/* packages/nightwatch-devtools/src/* (subpaths only, for type imports such as the option types)
@wdio/devtools-shared / * packages/shared/src/...
@wdio/devtools-core / * packages/core/src/...
@wdio/devtools-trace / * packages/trace/src/...
@wdio/elements / * packages/elements/src/...

These exist so imports stay short and grep-able. Long relative paths (../../../components/…) aren't used.

The @core/* name is a historical alias for app-internal helpers and predates packages/core. They don't collide because they resolve to different roots, but the names are confusable.


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 — and the reader that turns a zip back into a player payload (trace-reader.ts), so the format has one owner in both directions. core keeps the adapter-side orchestration and policy that calls them: trace-finalizer, spec-trace-helpers, trace-retention.

The split is not aesthetic. backend may not import core (it would pull framework-adapter logic into the server), but it does need to build a trace on behalf of an adapter that can't — the Python adapter ships no Node. trace is the layer both can reach, so it may import shared and nothing else; a single import of core from it re-creates the cycle the split exists to remove, and ESLint enforces that.

The test for a new helper: would the backend ever need this to build or read a zip? If yes, trace. If it needs a driver, a framework hook, or a capture session, core.

One DOM snapshot per action, taken before it

The per-action trace snapshot is captured in beforeCommand, stamped at the previous action's end, so an action's result IS the next action's "before" and every row resolves to a state the driver was idle for. afterCommand captures nothing in trace mode. An eager post-action capture — added and removed twice — lands while the screen is still moving, which is the only reason waitForActionResult and the __wdioSnapMark document tag existed; both are deleted, and both should stay that way. The last action has no successor, so #finalizePerScenario supplies it and names its capture after that action — lastRenderedScreenshot skips a capture named __final__, which is reserved for a session that ran no action at all. What that one capture waits on is settleAfterLastAction, and the wait is gated, not timed: the drain that runs immediately before it (captureTrace(browser, true)) anchors each document once, so SessionCapturer.replacedDocumentInLastDrain says whether the last action navigated to a document the session had not seen. No → return at once (the app has been at rest since the last action, so the test's own teardown is the gap that let the paint land). Yes → waitUntil(document.readyState === 'complete'), which is only meaningful once you know the document being described is the incoming one: ungated, the OUTGOING document already reports 'complete' right after a click, so a blind poll returns instantly and captures the page the test just left, and the old body.childElementCount > 0 clause that papered over that made a legitimately blank destination a guaranteed 8 s timeout per test. Native pauses 250 ms instead (no document to poll; a capture at a 0 s gap measures 359–476 KB against 1.87 MB settled on Appium). Residual: a navigation that has not committed when the anchor is read still reads as "no navigation" — the same blind spot the deleted document tag had. Don't reintroduce a poll without a gate, or a tag.

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 (for contracts) and from each other via the WS/HTTP boundary. Neither imports an adapter package. backend also imports trace, to write and read zips; app imports only trace's reader, which the hosted player (trace.webdriver.io, built with pnpm player:hosted, deployed by Vercel from vercel.json) runs in the browser to open a dropped zip with no backend at all. It runs the reader in a module Web Worker loaded from the player's own origin (app/src/controller/trace-parse.worker.ts, which script-src 'self' already admits), so the tab stays responsive and the landing screen names the phase the open is on; the live build never references the worker. That build is HOSTED_PLAYER in app/src/controller/constants.ts; it never writes the trace to browser storage, and vercel.json is also what vite preview serves, so its CSP is checked locally before it ships.

Framework-specific behavior in the backend is contained in two files: runner.ts and framework-filters.ts. Both branch on a typed TestRunnerId from shared, never on a magic string. The framework-filters dispatch is a switch over TestRunnerId (not a table lookup) so CodeQL's unvalidated-dynamic-method-call query trusts the call site.

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.

  • A published package that ships a BUNDLE declares its build libraries as devDependencies. app and script each publish a vite build with everything inlined — the app's dist carries no bare import of lit, preact or codemirror, the script's none of htm, parse5 or preact — so listing those under dependencies installed a whole toolchain on every consumer that needed none of it. script also listed vite-plugin-singlefile, which pulls vite, rolldown and lightningcss; app listed @wdio/devtools-service, which it never imports and which pulls webdriverio. Measured against the registry: installing @wdio/devtools-backend cost 338 packages / 264 MB, against ~85 / ~27 MB once both were moved. Every adapter paid that; the Python one pays it hardest, since it fetches the backend at runtime. The test is the same grep as above — a bare import surviving in dist/ means the dependency is real and belongs in dependencies; nothing surviving means it was build-time. Note this is the opposite default from the workspace-internal rule: there devDependencies is chosen so code is inlined, here it is chosen because the code already is.

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.

Examples smoke job

ci.yml's examples-smoke job runs one desktop example per adapter (WDIO mocha, Selenium mocha, Nightwatch BDD, Python pytest) in trace mode in the runner's headless Chrome, against the fixture site (pnpm smoke:fixtures) via DEVTOOLS_EXAMPLE_BASE_URL, so no third-party page change can break it. .github/scripts/examples-smoke/run-example.sh collects the zips each run wrote and hands them to pnpm smoke:assert; each red step names its example and each FAIL line names the expectation, and the zips and run logs are uploaded as the examples-smoke artifact. It checks that capture still produces the rows the examples imply, and that an example's zips stay inside its byte budget, not how they render, so dashboard visuals, mobile and Safari still need a person. A change that alters an example's rows updates its expectation file in the same PR. A budget is the measured total plus about 25% and is lowered, with a new measuredBytes, when a change shrinks traces, so the win cannot be undone silently; the artifact is uploaded only on failure, so a passing job's measurement is its log's trace zips total line.

The venv's editable install of selenium_devtools points at the primary checkout, so in a worktree every Python example, and the backend and page collector it resolves through _find_monorepo_backend, come from main unless PYTHONPATH=packages/selenium-devtools-py/src is exported first. A Python run that reports the old behaviour from a worktree is usually this, not the change.

Logic implemented in both TypeScript and Python

The Python adapter cannot import core, so some pure logic exists twice. packages/shared/mirror-vectors.json holds input/expected-output cases for it, one section per mirrored function; packages/selenium-devtools/tests/mirror-vectors.test.ts runs every section against the TypeScript side under pnpm test, and packages/selenium-devtools-py/tests/test_mirror_vectors.py runs the same file against the Python side in the Python CI job (whose path filter includes the file). Both runners refuse a section they do not handle. Covered today: assertion target resolution, native-session detection, run id, output directory, command row id range, wait poll keys, nested-wait identity, wait row naming (whose negated case the TypeScript runner skips, selenium-webdriver having no negated wait), performance payload shaping and the capture timing summary; notCovered in the file says why screencast thinning is not. Add a case whenever a change touches either side of a mirrored function, and a section when new logic is mirrored, provided its output is deterministic without a driver. A deliberate difference between the two goes in the section's notes, not around the case.

Trace zips from older writers

The hosted player runs the newest reader against zips any adapter version wrote. packages/trace/tests/fixtures/trace-formats/ holds real zips from older writers, one per adapter, reduced by reduce-fixture.ts beside them (images become 1x1 images of the same format; every other entry keeps its bytes), each with a manifest counted from its raw entries; trace-format-fixtures.test.ts checks the current reader still yields what each one held. A release that changes what the writer puts in a zip adds a fixture for it, as the README there describes, and older ones stay.

Hosted player check

ci.yml's hosted-player job runs pnpm player:e2e: it serves the hosted build under the vercel.json headers and, in headless Chrome, opens each committed older-format fixture and a zip written by this checkout's writer (hosted-player/current-trace.ts) through the landing screen's file input, expecting every manifest title as an action row, a filmstrip frame and no error, then a non-zip, expecting Could not open. It fails on any console error or uncaught exception the page logs over WebDriver BiDi, and on any CSP violation, which a preload script reports to the console because BiDi carries no CSP message of the browser's own; it also requires the parse worker's script to have been requested. scheduled-hosted-player.yml runs the same check against vars.HOSTED_PLAYER_URL every six hours, after each Production deployment and on demand, skips while the variable is unset, and keeps one open issue for a failing check, closing it once it passes. A change to the landing screen, the action rows or the filmstrip that moves what page-scripts.ts reads updates it in the same PR.

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.
  • A changeset for a published npm package, or a changes/ fragment for the Python adapter — see § Releasing a change.
  • For UI or runtime changes: verify in examples/<framework>/.
  • Deeper security findings (taint flow, polynomial-redos with adjacent quantifiers) surface on the PR's CodeQL scan; review and fix those before merge.

Releasing a change

Two mechanisms, and the Python one exists because the npm one cannot reach it. Changesets discovers packages through the pnpm workspace and identifies them by package.json; packages/selenium-devtools-py is in neither, so a changeset naming selenium-devtools-py does not degrade — it raises "not in the workspace", fails changeset version, and takes the npm release for every other package down with it.

  • Published npm package changed → pnpm changeset, committed as .changeset/*.md.
  • packages/selenium-devtools-py/src/ changed → a fragment under packages/selenium-devtools-py/changes/, frontmatter carrying the bump level alone (patch/minor/major). python.yml refuses a branch that changes src/ and documents nothing. A direct CHANGELOG.md edit satisfies it only until the first release — before one there is nothing to bump from and the pending entry IS the changelog section; after one (detected by a py-v* tag existing) it documents the change but bumps nothing, so the release would find no fragment and republish a version the index already holds.

Neither is hand-versioned: both assemble the version and the changelog at release. The Python release additionally consumes its fragments, bumps __version__ (the single source — pyproject.toml reads it via dynamic = ["version"]), and tags py-v<version> after a successful publish, so the tag is an output pointing at the published tree rather than an input naming a version nothing has computed yet.

BACKEND_NPM_VERSION is the backend a pip install user actually runs, so the npm release goes first; release.yml opens the pin bump as a PR, and scripts/check_backend_pin.py refuses a PyPI publish whose pinned backend cannot serve the contract. That PR carries its own changes/ fragment, because python.yml refuses a branch that changes src/ and documents nothing — a pin-only PR would fail its own CI. It is a PR rather than a push because the pin is a claim about a published artifact and check_backend_pin.py is what adjudicates it; merging one queues a patch for the next PyPI release rather than bumping __version__ there and then. Raised with GITHUB_TOKEN it arrives with no checks at all — GitHub suppresses workflow runs for events its own token raises — so either set PIN_BUMP_TOKEN or close/reopen the PR to get CI onto it.

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.
  • The capability tables (the README's support, dashboard and mobile tables, the docs site's CrossFrameworkSupport.md and MobileTesting.md) are generated, never edited by hand. packages/conformance/src/features.ts declares every user-facing feature with a cell per adapter, typed as a complete map over the conformance suite's adapter ids, so a new adapter does not compile until each feature declares it. A partial or unsupported cell carries a required reason, which renders as the footnote. A supported cell carries its evidence: conformance rules the adapter has no known gap against, a test file in that adapter's own tests or a shared package's (core, trace, backend), or why it is checked by hand. The cell builders and rule groups are in capability-helpers.ts beside it, so features.ts stays the list alone. Two coverage checks keep a new feature from bypassing the list: every user-facing option (capability-options.ts, keyed on the real option types of the shared base and each JS adapter, plus the Python enable() keywords, pytest options and user-facing DEVTOOLS_* variables read from source by a test) names a feature id, typed so a misspelt id fails to compile, or says why it is not one; and every conformance rule backs a tested cell or is listed in INTERNAL_RULES with a reason. A test fails when the README tables are out of date; the docs-site copy is refreshed with pnpm docs:capabilities --website <checkout> in the same change.

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 assertion hook, so those matchers produce no rows at all, and a failing one only marks its test failed. Nightwatch's own expect API is not wrapped either, so it produces no rows.
  • BiDi is auto-attached in Service and Selenium; Nightwatch is opt-in via bidi: true and requires webSocketUrl: true in capabilities.
  • Network has three sources, and the page's Resource Timing is the floor. BiDi (all four adapters) and Nightwatch's Chrome performance log are driver-side and carry headers and status. The page collector carries two: its patched fetch/XMLHttpRequest (the only source with bodies) and the document's Resource Timing buffer (script/src/collectors/resourceTiming.ts): every load with timing phases and size, a status on Chromium only, never headers. Resource Timing is the only network source a browser without BiDi has, which is Safari and every Appium session (Appium's BiDi reference lists session.* alone). The collector reads the buffer at construction, which is what covers loads made before a <script>-injected collector existed, then observes; a second evaluation of the bundle in the same document keeps the first collector (next entry), so the buffer is read once per document. Its two page sources are deduplicated inside the collector: the patch claims each call (absolute URL without fragment, which is the form the buffer names, plus start time), a fetch/xmlhttprequest entry within 1 s of a claim is taken so the row with headers and body is the one kept, and because the entry can arrive before the call has answered, a taken entry is held until the patch records the call (the entry is then its second listing and dropped) or drops it (no content-type, a rejected fetch, an XHR error/abort/timeout: the held entry becomes the request's only record). Recording a failed call as a failed row instead was tried and reverted: the service and Nightwatch keep the page's patched rows beside BiDi's, so on Chrome every blocked beacon was listed twice (Nightwatch example 35 → 67 failure rows); without BiDi a failed call therefore reads as unknown rather than failed, which is the entry's own account of it. Against a driver-side source the gate is processTracePayload's skipResourceTimingRequests, which drops only the Resource Timing rows and leaves the patched rows their bodies, and the gate is evidence, not a subscription: measured on Appium 3.7's XCUITest driver, selenium-webdriver's BiDi inspectors attach cleanly and then deliver nothing, so gating on bidiActive (or on browser.isBidi) withheld the page's rows and the trace had zero network rows. Each adapter therefore latches on the first BiDi network event it receives (service #bidiNetworkObserved, set in handleNetworkRequestStarted; Selenium and Nightwatch bidiNetworkObserved, set in their buildBidiSinks; Python stats["observed"], read by bidi.network_serving) and gates on that. Nightwatch's performance log is deliberately not a gate either: a capability can name a log the driver never serves (XCUITest), and the log is read after the drain in any case, so perfLogs.ts upgradeResourceTimingRows pairs each log entry with the page's row for the same load (URL, nearest timestamp, the log's being dated from CDP's wallTime because the monotonic clock only diffs within a session) and lays the log's method, headers, status and size over that row, keeping the row's id, clock and measured phases, and re-sends it under the id, which the dashboard replaces in place. The reverse order is covered too: a load that completes between the drain and the log read reaches the session as the log's row first, so the session keeps it in loadsListedByLog until the next drain, where withoutLoadsTheLogListed spends it on the page's one row for it (same URL, nearest timestamp within 2 s). A ledger spent per load, not a scan of the rows the session holds: the scan read the collector's own patched fetch row, or a row the log had already upgraded, as the listing of a second request to that URL inside the window and dropped that request outright (a fetch answered with a content-type-less 204 after one answered with JSON lost its only row). Spent at the next drain that reaches the driver, a collector-less document included, and forgotten at a session replacement (armReplacedSession), because the page observed the load before the log could list it: a row absent from that drain is never coming, and a load kept longer would take the row of the next request to the same URL. Residual, Chrome only (Safari and Appium serve no log, so the ledger stays empty there): the log also lists the collector's own patched fetch/XHR calls, whose row is not a Resource Timing row, so upgradeResourceTimingRows appends the log's row beside the patched one (main ingested both too) and the ledger can spend it on the row of a different request to that URL inside the window, as it can on the next document's load of a shared asset; the request then survives as its own log row, without the page's phases, so the cost is a row's shape rather than a request. Python withholds the whole page batch once BiDi is serving, as selenium-devtools does (whose console gate, bidiConsoleObserved, is evidence-based for the same reason); both lose the patched rows' bodies there rather than list each fetch twice beside its BiDi row, which is what the service's keep does. Residuals: a drain that lands before the first BiDi event of a page is delivered can pass that page's rows once, and Nightwatch clears both flags at a session rotation, so loads in the window before BiDi re-attaches can be listed by both.
    • A second evaluation of the collector bundle in one document keeps the first collector. Every adapter probes for the collector before injecting, but two callers can pass the probe before either injection has run (Nightwatch's navigation proxy and its drain's recovery, Selenium's navigation hook and its recovery), and the second evaluation used to replace window.wdioTraceCollector and drop everything the first had buffered. Measured on Android Chrome through Appium, where there is no BiDi and the <script> path is the only one: Nightwatch injected twice 11 ms apart and the trace had 0 network rows for a run that loaded two pages, because the replacement also could not re-read the Resource Timing buffer without listing the first collector's loads again. collector.ts now exports alreadyInstalled and keeps the instance it finds, and index.ts skips its bootstrap on that flag, so the second script is a no-op rather than a second observer feeding the same collector; the adapters' probes only save the round trips. The chrome-native: new-tab document a mobile Chrome session opens on is ignored with the rest of the browser's own schemes, since a session starts there before its first navigation; that list is shared/browser-schemes.ts, read by core's injection gate and the collector's request filter alike, so the two cannot drift again.
    • selenium-webdriver's NetworkInspector hands every network event to every subscriber's callback (_on adds one unfiltered socket listener per subscription), so core/bidi.ts handleBidiRequestSent also receives response events and handleBidiResponseCompleted request events. Unguarded, the request handler pushed a second, never-completed row per request and the response handler finalized rows on request events: measured, every request in a Selenium Chrome trace listed twice (a status-0 row beside the completed one) and 57 of 135 rows in a Nightwatch one. Each handler now ignores the other's event by shape (response present or absent). Two more shapes the inspector does not model: a redirect hop re-sends the request under the same id, so rows are kept by id for the session (bounded at 5000, oldest out) and a request for a known id reopens its row under the first hop's clock rather than pushing one; and a failed fetch, which core closes as a failure (status 0, the browser's errorText, as the WDIO service's handleNetworkFetchError already did) through the inspector's fetchError hook, reading network.fetchError off the inspector's own connection only for an inspector without one; that handler ignores the request and response events the same dispatch hands it, by errorText. Without that, every blocked analytics beacon sat in the Network tab as pending for the life of the session; a browser that refuses the subscription keeps the inspector and only that gap.
    • A Resource Timing row states source: 'resource-timing', the list colours it unknown rather than pending when it has no status, the detail card names what it could not see, and the HAR writer adds _source and _resourceType: HAR's required status is written as 0 for it, which read back as-is is a transport failure, and an empty content.mimeType cannot restore the type. The method is '' for a navigation, fetch or beacon (Resource Timing names none) and GET only for a parser-initiated load. A native app session has no document and therefore no page-side network capture. All four adapters read its traffic from the appium-interceptor plugin's proxy when the session sets appium:intercept: true: core/appium-interceptor.ts NativeNetworkCapture owns the per-session listener and its drains, and each JS adapter supplies only an execute path that renders no rows (the service and Nightwatch raw WebDriver HTTP bound to the session, Selenium the unpatched executeScript) plus its drain points (after each command, before a per-test slice is written, at session end). Every read returns the listener's whole history, bodies included, so the per-command drains are throttled to NATIVE_NETWORK_DRAIN_INTERVAL_MS while the test-end and session-end drains never are. Python mirrors it in selenium_devtools/interceptor_network.py, since it ships no Node, and drains when each test reports its outcome. appium:intercept is honoured flat or grouped under appium:options. The plugin records no timing, so each row is stamped when first read, and requests made before the listener starts (an app's launch traffic) are not listed.
  • 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-test — retain-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. Three bugs of that one shape, all found by rerunning a single pytest test. The third is the one that shows the rule has a limit: (c) the launch command — what Run-all spawns — was built from the child's own invocation, which the backend had narrowed to a single nodeid, so one targeted rerun rescoped Run-all to that test permanently and the tree kept showing only what that child collected. Unlike (a) and (b) this is not recoverable inside the child: its arguments no longer mention what it was narrowed from. The original travels down instead, as REUSE_ENV.LAUNCH_COMMAND beside the rest of the reuse handshake; the rerun template stays locally derived, since only this process can say how its own interpreter selects a test. The inherit-or-derive resolution is Selenium's helpers/launch-command.ts resolveLaunchCommand, read only while the reuse handshake is live (a leftover export must not pin Run all on a first run); it stays in the adapter until a second JS adapter needs it, since Python mirrors it in rerun.py _inherited_launch and cannot import core. captureLaunchCommand ignores npm_lifecycle_event when npm_command is exec (npx sets the event to npx and pnpm exec sets none, so neither names a script to rerun) and shell-quotes each argument of its argv fallback, which the backend splits with shellParse. Nightwatch has no launch command, since the backend relaunches it through its framework path; the run that starts the backend records its CLI selection in RUNNER_ENV.NIGHTWATCH_INITIAL_ARGS (shared encodeInitialArgs / parseInitialArgs), or clears the variable when it selected nothing, in its own process, which a rerun child attaching in reuse mode cannot touch. The selection is specs made absolute plus every other flag without --config; Nightwatch parses argv with minimist and declares no boolean flags, so a flag's following bare word is its value and is never rewritten as a path, and only an entry script in the nightwatch package's bin folder counts as the CLI. The backend's framework-filters.ts runAllFilters replays it for both Nightwatch runners, as WDIO_INITIAL_SPECS does for every other. The other two: (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 wait() poll is the user's one call, not a row per tick. selenium-webdriver's driver.wait(until.…) runs its first poll inside the call, with the spec line on the stack, and every later one from a timer with no user frame, so a fold keyed on the call source left the first poll standing alone. The patched wait() runs its condition inside an AsyncLocalStorage (wait-tracking.ts waitPoll), which stamps CapturedCommand.waitScope (one object per wait() call) on exactly its own polls, per driver and per async chain, and captureOrReplaceCommand folds a poll only into the row an earlier poll of the same scope made for the same call (WaitScope.rows). The call is named at invocation (waitPollOf, CapturedCommand.pollKey) as the nth call with that command, args and target within one evaluation of the condition, since every evaluation repeats its calls in order and a timer poll has no call site to tell them apart: keyed on command and args alone, a condition reading a.getText() and b.getText() folded the second read over the first. A wait's rows never enter the ordinary retry tracker and reset it: replacing an older poll row moved only lastId, leaving lastSig naming another command, so a later retry of that command overwrote the wrong row, and a helper's wait polling the same findElements as a row the user wrote just before it overwrote that row. A retry therefore never folds across a wait. Captures run one at a time per capturer, because the driver patcher does not await onCommand and a poll could otherwise arrive before the previous one has the row id it folds into. A call outside a wait folds only as a same-call-site retry. Accepted cost: a library that polls from its own timer rather than through driver.wait() shows a row per poll.
    • A wait is one row, and its polls are its children. The patched wait() emits the wait's own row when it is CALLED (openWaitRow, CapturedCommand.wait naming its scope, running), stamped at its start with no result or error, and emits it again when the promise settles, stamped at completion and carrying the rejection as error; captureOrReplaceCommand captures the first and replaces it by id (WaitScope.rowId) with the second. Every row captured with that waitScope is stamped parentId = rowId, which is set by then because captures run in issue order and the running row is emitted before the condition first runs. A wait inside another wait's condition is a row of the outer one, and because its scope is kept across outer polls (scopeFor) it is one row re-sent running on each outer poll, from its first start, not a row per poll. The name comes from what the wait waits for: tagElementConditions wraps until.elementLocated and until.elementIsVisible and records each condition's target in a WeakMap, since the condition keeps its locator in a closure, so a locator wait is waitForElementLocated (Element.waitForExist) with the locator's selector, a visibility wait waitForElementVisible (Element.waitForDisplayed) with the selector the element was found by, and any other condition wait (Browser.waitForFunction) labelled with its description(); a locator with no selector form (link text) keeps the element name and puts the label in args. The running row takes no screenshot, drain or snapshot (handleOnCommand returns after the capture). The patched wait() returns a promise derived from selenium's that reports the outcome and passes it on, so an awaited failure rejects with the same error and an unawaited one still surfaces as unhandledRejection; an element wait is re-wrapped in selenium's own WebElementPromise, which driver.wait(until.elementLocated(…)).getText() chains on. A row that cannot be built is logged and never replaces the wait's own outcome.
  • Safari delivers a synthesized mouse click only to its frontmost window, and a live run opens the dashboard in another browser. safaridriver's performInteractionSequence answers success either way, so a click issued while the Safari window is not frontmost reaches nothing: measured on desktop Safari 26.5 with no DevTools involved at all, the page reported document.hasFocus() === false (once visibilityState: hidden), no pointer event fired, and the submit never happened in 3 of 3 attempts, while after activating Safari every click landed; the user's own manual click in that window logged in. Live mode is where it shows, because the dashboard window and the wait for it to connect are what move focus; trace mode mostly escapes it and Chrome never has it. This was earlier recorded as "the Python adapter's login flow on desktop Safari is flaky" with the per-command work as the suspect; that work only widens the window. The Selenium adapter switches to the session's own window handle before every element click, sendKeys, clear and submit on a Safari session (DriverPatcherHooks.beforeInput, the plugin's focusForInput): a WebDriver window switch brings exactly that window forward, one round trip, and holds whatever took the front in between, measured 3 of 3 live logins against 0 of 2 before. open -a Safari was tried first and is not enough, since it activates Safari's most recent window, which can be the user's own (visibilityState: hidden in the automation window afterwards). The WDIO service, Nightwatch and Python still use core/browser-focus.ts restoreBrowserFocus (open -a Safari) once after session bring-up in live mode, with that weakness; a per-input switch there is the next step. The Selenium dashboard also opens behind the current window on macOS for a Safari session (dashboardLaunchCommand, open -n -g). The Nightwatch BDD example's assertions are separately not Safari-proof (4 of 7 fail on Safari).
  • A trace archive is a full recording of the page, and there is no redaction policy anywhere in capture. Whatever the run put on screen or typed is in the zip, usually several times over: measured on the Python login example, its demo credential appears ~103 times across six places — the page's own displayed text (90x, the-internet prints it), the DOM mutation stream, the Element.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/version → webSocketDebuggerUrl 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. shared/page-scripts.ts collectorDrainExpression(forceAnchor) (re-exported by core's script-loader.ts) 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 Selenium and Nightwatch passes true (selenium's drainAfterLiveCommand, its re-inject-after-navigation and teardown paths; nightwatch's five sites); the WDIO service's pre-transition drain of the outgoing document is the one false. Python runs the forced drain the backend serves as PAGE_SCRIPTS_API's collectorDrain, the same string, and has no flag of its own. 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.
    • The perf-log fallback only runs when the session asked for a perf log (SessionCapturer.perfLogsRequested, set from goog:loggingPrefs.performance at session init — the same reading that already warns "Network tab will be empty"). Without the capability the log cannot exist, so every per-command fetch is a round trip that can only fail, and Nightwatch's transport prints each failure: measured on an iOS Safari session, 21 error blocks in a two-test run that passed, 257 log lines and 12.3 s against 66 and 5.9 s once gated. Those numbers were taken when the read was browser.getLog; it now goes over the raw WebDriver transport (POST /session/:id/se/log, the route selenium-webdriver, and so Nightwatch, sends), because the read is issued from the fire-and-forget drain and the navigation poll, and a queued command issued while the test's next command runs becomes that command's child and resolves its await early. Over HTTP a refusal resolves null without a printed error, but the gate still saves a round trip per command, and a runtime latch still cannot replace it: browser.getLog(...) yielded undefined rather than the 500's body, and the transport's null is equally indistinguishable from an empty log. XCUITest is why it has to be the capability and not a "is this Chrome" test: it lists performance among its supported types and then serves none of it.
    • 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.
    • The collector's bootstrap must not wait for an animation frame when the body already exists. utils.ts waitForBody always waited for a requestAnimationFrame (or DOMContentLoaded), which a document-start preload needs but a <script> appended to a loaded document does not: Safari's automation window paints only when something forces it, so on desktop Safari the injected collector sat in the page with no anchor, no MutationObserver and no input listener until a screenshot happened to force a frame, and a live run replayed the bare anchor on every row with empty fields. Measured with the adapter in trace mode, 8 keystrokes typed after injection reached the document's own listeners every time and the collector 0 of them with per-command screenshots off, 8 with them on. The body is now checked synchronously first.
    • On the fallback path a navigation resolves to the test only once the collector is in the destination, and the anchor carries live field state. Selenium's injection used to run from the fire-and-forget onCommand after get returned, racing the test's next commands: on desktop Safari (no preload at all) the user's typing into both login fields finished before the collector landed, so the replay showed every row with empty fields and the username appeared only at the password row, carried by the change event the username field fired when focus left it. driverPatcher.ts gateNavigation now awaits DriverPatcherHooks.afterNavigation (the plugin's instrumentNavigationTarget, which injects only when preloadRegistered is false) before a navigation command's promise resolves; the row keeps its own completion time, and under the preload the hook returns at once. Independently, script/src/utils.ts recordFieldState writes each form control's live value and checked state into the anchor (parseNode reads attributes, which never hold typed text), so a collector that arrives after typing still anchors the form as it is. Residual: that anchor is stamped at document birth, so rows before the injection replay the state at injection rather than empty fields; the same imprecision the anchor has always had for DOM the page's own scripts changed before injection.
  • 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.
  • A Nightwatch wait is one row, sent running, and what its condition issues names it as parent. waitUntil and the waitForElement* family (isWaitCommand, every command the vocabulary maps onto a waitFor* method) send their row at invocation, startTime = timestamp and no result, through SessionCapturer.openCommand, and end it by id in the capture callback (closeCommand), stamped at completion; a callback result of false becomes the row's error, worded by shared commandFailure, so the live row is red where before only the trace derived it. Which wait issued a command is read from an AsyncLocalStorage scope (helpers/waitRows.ts) that the waitUntil condition runs inside on every poll: Nightwatch calls the condition from its own queue and exposes no queue node to a plugin, and a stack of open waits would parent anything that merely ran while a wait was open (a timer's command, a command the test issued concurrently), which the scope does not. A closed wait hands a call to the wait it was itself issued inside, because an async context outlives the call that made it. A condition that calls another wait opens a scope inside the first, so waits nest. The condition's polls fold per distinct call inside the wait (OpenWait.children, keyed by call signature) rather than only onto the last row, since a two-read condition alternates its reads. A waitForElement* polls through the transport (ElementCommand.findElement), never through browser, so its row has no children; verified on the conformance run, where no row falls inside its span. waitUntil(condition) with no other argument used to have its condition taken for the result callback; the condition is now never treated as one. Residual: the row is sent at invocation, so an un-awaited waitUntil chained behind other queued commands shows as running before they have run.
  • browser.url() is both a navigation and a getter. With no argument it just reads the current url, which is 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 (packages/trace/src/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 cucumber; Nightwatch describe/it and exports-object degrade to session scope per the entry below), and non-Chrome polling carries the same reporter-noise caveat.
  • Per-test artifact Allure attachment is cross-adapter via a pluggable sink in core/allure-artifacts.ts (AllureAttachSink; captureAndAttachScreenshot/captureAndAttachVideo/attachTraceArtifact/lastRenderedScreenshot — moved out of the WDIO service). The service supplies a @wdio/allure-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 AfterStep is awaited too, so a step's trace slice, native app requests included, is written before the next step records its boundary. (b) Nightwatch inline-attach is unsupported — nightwatch-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/jest/cucumber, Nightwatch cucumber). Nightwatch's exports-object style collapses the same way: lib/testsuite/index.js runs the global beforeEach/afterEach once per test module for both interfaces. 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 dropped — retain-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) build — packages/nightwatch-devtools/tsup.config.ts now compiles src/helpers/cucumberHooks.cts → dist/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 unit — resetTestTracking() 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 all three JS adapters feed it. The registry is scoped to one test: clearReadValues empties it when a test or scenario starts (the service's resetStack from beforeTest/beforeScenario, Selenium's startTest/startScenario, which a plain script reaches through the public startTest, and Nightwatch's resetTestTracking), so a later test's assert.ok(true) cannot box an element an earlier test read true from. Nightwatch's plugin hook fires once per module on the BDD interface, so BrowserProxy.scopeReadValuesToRowTest also clears when the running it (rowTestUid) changes, before every record and every lookup; a value a beforeEach hook reads is therefore dropped at the it's first command or assertion, since Nightwatch still names the previous it while that hook runs. Python clears at pytest_runtest_logstart. The WDIO service feeds it from SessionCapturer.afterCommand for every row it logs: a command ACTION_MAP classes Element (command-selectors.ts isElementCommand) records the selector the row resolved, and every other read (getTitle, getUrl) records the null sentinel; wireAssertCapture passes resolveAssertTargetFromArgs to patchNodeAssert. A node:assert row with no known producer names nothing rather than the last element a locator resolved, which is what boxed h2 for assert.notEqual(await browser.getTitle(), …). The user's own getTitle is a Page.getTitle row: the service's snapshot probe reads the title from inside a hook whose stack holds the spec frame, so action-snapshot.ts runs its own title read inside an AsyncLocalStorage context and both hooks skip a getTitle observed inside it (isServiceProbe; WDIO awaits a command's hooks within the command, so they inherit its context). A count of probes in flight was not enough: core's probe timeout abandons a getTitle still running, and the count then hid a user getTitle issued in that window. Listing getTitle in INTERNAL_COMMANDS hid the user's call too. Python mirrors it in selenium_devtools/read_value_locators.py, since it cannot import core: every captured command records its result from the patched execute (a driver-level read records None; get_attribute/is_displayed, which reach the wire as scripts, resolve the element passed as their argument), and a pytest or plain-script assertion row looks up the observed operand before the expected one. In both languages the first operand with a known producer decides, so a subject recorded as owned by no element (a title or url read) ends the lookup instead of letting the expected value borrow the box of an element that once read the same text. Selenium feeds it 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 rows — resolveActionPoint 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. The service feeds core's value registry with that same selector, so a value read through an interleaved handle is claimed for the wrong locator as well. Nightwatch is a third mechanism: it reads arg 0 through a per-kind allowlist (assertTarget.ts) rather than tracking handles at all, because its classic API takes selector strings. The allowlists are deliberately not derived from shared'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 (toHaveText→getText, toExist→isExisting, …) 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, because a top-level read carries no public id: only a wait and the rows it issued do, drawn from core's commandCounter, which starts at a random base per capturer so ids stay unique across specs and workers (entry below). beforeAssertion arms the pending matcher (depth-counted so aliases like toBeChecked→toBeSelected 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. Only these matcher rows inherit the selector resolved during the matcher (captureMatcherCommand); a node:assert row takes its target from the value registry (captureAssertCommand, see the value-provenance entry above). toHaveTitle folds into the user-visible getTitle read like any other listed read, while getUrl is still in INTERNAL_COMMANDS, so toHaveUrl emits a fresh row.
  • The WebdriverIO service logs the command on top of its stack, and inside a wait, the commands the wait issued. WDIO fires beforeCommand/afterCommand for every command at every depth, so the service has always kept a row only for the command a spec issued with nothing else running, and hidden what that command issued. A wait (waitUntil and the element waitFor* family, read off ACTION_MAP) is the one exception: command-stack.ts CommandStack opens a level under it, and every command the wait issued directly becomes a row with the wait's id as parentId, while what those issue stays hidden, the same rule one level down, so a wait inside a waitUntil condition nests again. The wait is sent running from beforeCommand with an id from core's random-base counter (wait-rows.ts WaitRows), replaced by that id at completion and moved to the end of commandsLog, which keeps the log in completion order for every scan that reads its tail (failLastAction, the matcher fold, #lastAction). Its children carry ids too, so each distinct call folds by id rather than by timestamp; rows outside a wait still fold by timestamp. Three details are load-bearing. An element wait polls through its own waitUntil, which is its implementation and never a row, and WDIO fires an element command's hooks twice, the first from its element wrapper with no arguments, so a wait opened with none skips the same command's next hook before it issued anything; that is decided by the arguments, not the stack, which carries the spec frame on both hooks once it is searched 40 frames deep. Commands the service issues from its hooks are told apart by an AsyncLocalStorage (runAsServiceWork), not by their stack, because a wait's polls carry no spec frame either. And the hook methods are deliberately not async: WDIO keeps one module-global inCommandHook flag and skips every command's hooks while any hook is running, so a hook that settles a few microtasks later drops a concurrent command's hooks (measured: an emit hook running during elementClick made the submit click's afterCommand never fire, and the click lost its row). The selector a condition located is restored when its wait ends, so el.click() after a wait still reads the element's own. A matcher never folds into a read a wait issued.
  • A wait is one row, and what it issued nests under it, in every adapter (#464). A wait is one row named after what it waited for (Element.waitForExist/Element.waitForDisplayed on its selector for an element locator, Browser.waitForFunction for any other condition; a Selenium or Python locator wait is the command waitForElementLocated, a visibility wait waitForElementVisible), carrying a numeric id, sent when it starts with a startTime and no result or error, and replaced by that id when it ends, stamped at completion like every row. Every command its condition issued, an element wait's polls included, carries the wait's id as CommandLog.parentId, which shared documents generically as "the row whose execution issued this one". Shared nestCommands turns a flat row list into that tree, keeping a row whose parent is absent at the top level, so a live list rebuilt on each update nests it once the parent arrives; the dashboard's live list (live-action-rows.ts) and the player's tree (action-tree.ts, a TraceActionCommandNode with children) render the children under their row, collapsed unless the row failed or holds the active one. The trace exporter (trace-action-events.ts) writes a child's action with its parent's callId as parentId and closes the parent after its last child; the reader keeps an action in the trace vocabulary that other actions name as parent as a row rather than a structural step, so a foreign runner's steps are unaffected, and a stream without parentId exports byte for byte as before. Every adapter emits it: the service, Nightwatch and Selenium as their entries above describe, and Python as follows. Its wait_tracking.py sends the wait's row from the patched WebDriverWait.until/until_not before the first poll and replaces it by id once the wait returns or raises; every row execute captures inside the condition takes the wait's id as parentId (parent_of) and still folds per call (poll_of / note_poll_row). A wait inside another's condition is a child of it and keeps one row across the outer polls, as a poll does, with the start of its first poll. wait_row_name names the row: EC.presence_of_element_located is waitForElementLocated and EC.visibility_of_element_located waitForElementVisible, on the locator converted the way find_element converts it (selenium's LocatorConverter, so By.ID reads #x like the element rows); anything else, until_not included, is wait on the condition's label, an expected_conditions closure named after its factory and captured arguments since its own name is _predicate. Naming runs before the user's condition, so it never raises (_safe_label, _safe_row_name), and a row that cannot be sent leaves the wait running. The conformance rule wait-row-parents-its-commands (packages/conformance/src/wait-nesting.ts) checks every scenario test shows exactly the waits it writes, each wait carries an id and a start, every row that ran inside a wait's span names it, and a parent is sent before its children; no adapter holds a gap against it. The comparison nests child steps under their parent (NormalizedStep.children) and compares the children of two aligned steps as their own aligned sequence, moves included.
    • A poll that failed under a wait that did not fail is not a failure. Shared retriedRows lists every failed row issued, at any depth, under a row that has not failed (a wait that passed, or one still running, whose outcome is not known yet), and effectiveFailure is commandFailure minus those. One rule serves every reader: the dashboard renders such a row neutral with its message in the row's title and leaves it out of the Errors tab, Copy prompt's Failures and a row's default expansion (actions.ts, errors/collect.ts, transcript.ts), transcript.md marks it RETRIED, the trace writes its message as the after event's retriedError rather than error (trace-action-events.ts), the reader restores it onto CommandLog.error and restores id/parentId between action rows (linkIssuedRows) so the player judges it the same way, and the conformance comparison reads it as ok and never as the row that explains a failed test. Under a wait that failed, its children keep their errors, since the last poll's is what explains the timeout. A Nightwatch wait the aborted queue never ran is closed with a NotRunError at the test unit's end, so it counts as failed and its rows, having none, are unaffected.
  • What the conformance scenario shows the adapters doing differently. Each entry is a KNOWN_GAPS reason in packages/conformance/src/allowlist.ts, scoped to the differences it explains (one hunk, one moved step, or one test's reported state), and reported stale once they stop occurring, except an entry marked timingDependent, which is never reported stale and has to be removed by hand.
    • Only the WebdriverIO service shows an element wait's polls (#464). The service nests the isExisting polls of waitForExist/waitForDisplayed under the wait, one row per distinct call. No other adapter shows a poll there in the trace vocabulary: a Nightwatch waitForElement* polls through its own transport (ElementCommand.findElement), never through browser, so the plugin never sees one, and a Selenium or Python element wait polls with findElement(s), which ACTION_MAP leaves unmapped, so those polls are children live but absent from the trace. The adapters therefore differ inside each element wait and nowhere else.

What a per-action trace capture costs

  • Measure before proposing a change: DEVTOOLS_CAPTURE_TIMING=1. Every adapter then times each probe of each capture (issue and settle, so a probe's own service time is separable from its wait behind the probes the driver served first) and, when the trace is written, prints a per-probe table and appends the raw records to capture-timing-<pid>.ndjson beside the trace. The JS recording is core/capture-timing.ts, wired once in captureActionSnapshot and reported from finalizeTraceExport, so no adapter carries more than the capture's trigger (action, wait poll, matcher poll, final); Python mirrors it in capture_timing.py, held to the same summary by mirror-vectors.json. Off, it costs a flag read per capture. The per-platform commands are in the README's "Measuring capture cost".
  • One capture per action means two driver round trips per action, and the platform decides which half is expensive. Measured on Appium 3.7.0 / UiAutomator2 (emulator-5554, Android API 37, 1080×2424): GET /screenshot 1.18–2.22 s (steady ~1.20 s) at 1.86 MB, GET /source (page-source XML) 0.09–0.53 s at 40 KB, window/rect 0.015 s — the screenshot dominates by ~10× on Android. iOS is unmeasured; the issue claims the split inverts there, and the native example (examples/wdio/mocha/wdio.native.conf.ts) is what makes it measurable. Measured per action: two captures 2.41 s against one capture 1.19 s; end-to-end on the native example 19.1 s against 12.4 s, with live mode (no per-action capture) at 5.9 s. That is the case for the beforeCommand capture, and the reason an eager post-action capture is not worth re-adding: it cost a second capture per action and existed only to be patched by a readyState poll that could not tell a document that had not navigated yet from one that was loading.
  • Locator generation from a native page source must stay linear, and iOS is where it did not. Every native action snapshot builds element locators from the page-source XML (core/locators), and each uniqueness check was a //*[@name="…"] XPath run by the xpath library over the whole document, several per element. Measured on the iOS Settings page source tiled up: 38 KB 82 ms, 188 KB 1.4 s, 752 KB 23.5 s, which is what a reporter saw as trace mode adding 1.5 min to a 2 min 20 s iOS test while Android, with smaller flatter trees, added 10 s. locators/xpath-index.ts indexes the document once per generateAllElementLocators walk (withXPathIndex) and answers //*, //Tag and //Tag[@a="x" and …] from it: 15 ms, 49 ms, 268 ms for the same three, identical elements. It is scoped to the walk so a caller of the exported evaluateXPath that edits its document is never answered stale, and it declines a namespaced document, where XPath name tests resolve namespaces and the index does not. The page-source request itself remains: 0.75-1.2 s per action on XCUITest even for Settings, against 0.09-0.5 s on UiAutomator2.
  • A filmstrip poll against a native session stacks requests: setInterval never waits for its async handler, a native screenshot takes ~1.2 s against the 200 ms default, and a serialised driver serves that queue ahead of the test's own commands — measured, a 15 ms command took 4.5–7.8 s. ScreencastRecorderBase's #pollInFlight latch bounds it to one outstanding shot, and #pollGeneration stops a shot orphaned by stop() from appending after the stop or clearing its successor's latch.
  • start() and stop() are serialised against each other, because unserialised neither owns what it arms. A stop() landing mid-handshake returned at its #isRecording guard — still false, the handshake not having finished — so it never reached its own CDP teardown, and the start() it interrupted then returned on the generation mismatch without stopping what it had armed: the session and its frame listener stayed live after teardown. The frames themselves were moot — every adapter builds a fresh recorder per session (screencast-lifecycle.ts does so even across reloadSession), so the leaked session and its browser-side stream were the damage, not buffer pollution. The obvious repair — stopping the session on that mismatch path — is wrong, and it is the shape to keep out: it guards a field both CDP overrides write at handshake start (#cdpSession / #cdp) with a flag they set only at handshake end (#cdpActive), so a start arriving in that window is indistinguishable from no start at all and the stale path tears down the newer recording's session instead. Overlapping starts are the adapters' normal mode rather than a corner: Selenium's driverPatcher documents the second test's body racing an in-flight screencast.start(), and Nightwatch's session rotation is fire-and-forget from a command hook. Queued, a stop always runs against a start that has finished arming and owns what it armed, which makes the mismatch branch unreachable and deleted — the tick-phase generation checks in #startPolling are not, since a shot orphaned by a stop still lands afterwards. Two consequences: stop() now waits on an in-flight handshake, and every awaited driver primitive in that handshake is ceilinged at SCREENCAST_HANDSHAKE_TIMEOUT_MS (the service's getPuppeteer()/pages()/createCDPSession()/Page.startScreencast and the stop-side Page.stopScreencast, Selenium's createCDPConnection, the polling path's first screenshot) — a driver that wedges gives up after the ceiling, falls back to polling, or reports the screencast unavailable when polling was what wedged. Nothing is claimed until the handshake has answered, so a timed-out start leaves nothing armed — and a session or connection that completes after the ceiling is detached (or its socket closed) when it lands, so no orphan outlives the recording. And a stop landing during a polling session's first screenshot now records that shot before tearing the loop down, where it previously cancelled the start outright; the interval is still never left armed underneath the stop.
  • Residual: a polling matcher fires one capture per poll — each poll is a top-level mapped read — so "one capture per action" undercounts a real suite. Pre-existing in d924a02, unmeasured on a device, and the likeliest next lever.
  • The next test's first pre-capture used to be stamped at the previous test's last-action timestamp — the log it scans is run-long, so a test boundary was invisible to it. That slot already holds the previous test's finalize capture, and the richer-screenshot merge could replace it: with a reloadSession between the tests the row replayed the post-reload page. The capture now stamps Date.now() when the scanned timestamp predates #currentTestStartWallTime (0 without per-test hooks, so the standalone path is unchanged) — the same rule that makes a session's first capture its initial frame. Per-test slices were never affected: flushTest runs inside afterTest, before the next test's commands.

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 (679 logic / 990 raw by the linter's count). Still over the 500-logic cap. Which commands become rows, and the wait nesting that decides it, live in command-stack.ts, and the wait rows themselves in wait-rows.ts. The screencast and trace-slice seams are extracted: screencast-lifecycle.ts (139 logic / 217 raw) owns every read and write of recorder frames — start, reload, finalize, the cross-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/#drainAfterLiveCommand, ~120 logic lines); before() is still over the function cap at 51 logic lines.
  • packages/nightwatch-devtools/src/index.ts (783 raw / 676 logic). Cucumber/test/run-lifecycle, session-init, event-hub and now the screencast seam (plugin-screencast.ts, 105 raw / 60 logic) are extracted; the remainder is 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 + 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.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%. takeScreenshotViaHttp error 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.

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.