Skip to content

Spike: how to avoid re-implementing capture per language #298

Open

Description

@vishnuv688

Part of #278

Measured split of the current Python adapter. Not language-specific,
about 1,206 lines: frames 193, screencast 213, snapshot 197, capturer 152, types
115, plus utils, sources, constants and log capture. Genuinely per-language, about
1,595 lines: instrumentation 386, bidi 412, lifecycle 363, transport 188, pytest
plugin 124, backend resolution 122. Four levers to evaluate: move post-processing
behind the wire, push more work into the shared page script, generate contract
types rather than mirroring them, and add a cross-adapter conformance suite.

Acceptance criteria

A decision record naming which levers we take, what moves
where, and what stays irreducibly per-language. Feeds #288 directly.

Size: L.

Activity

  1. self-assigned this
    on Aug 12, 2026
  2. vishnuv688 commented on Aug 25, 2026

    @vishnuv688
    MemberAuthor

    Decision

    Keep the common parts common. The trace transforms move to a package the
    backend may legitimately import, so every language reuses one implementation
    instead of growing its own copy. The JS adapters keep calling them in-process;
    the backend gains an export path for adapters that only stream.

    Lever taken: #301, move stream post-processing behind the wire — scoped, see
    "What stays per-language" below. Levers not taken now: #299 and #300 stay open
    and are unaffected by this.

    Why this rather than porting to Python

    backend, script and shared are already common to every adapter — the
    backend runs as a Node subprocess, the page collector is injected as JS at
    runtime, and shared's values are generated into _contract.py with a drift
    guard. core is the exception: it is TypeScript, Python cannot import it,
    and everything it provides was therefore written a second time
    (frames.py, capturer.py, types.py, snapshot.py, screencast.py,
    performance.py — ~1,206 lines that are not language-specific, per this
    issue's own measurement).

    That gap has already cost us three times. Force-anchor drain (#285), recovery
    drain (#286) and document-start preload (#284) were each fixed in the JS
    adapters and never propagated to Python. Trace assembly is another ~2,600 lines;
    ported, it becomes the second copy, and Java would make it the third.

    What moves

    Measured by walking the import graph: every trace module depends only on
    @wdio/devtools-shared, yazl, and node builtins.
    None touches a driver, an
    adapter or a framework — they are pure transforms over shared types, which is
    what makes this possible at all.

    ~2,573 lines move — trace-exporter, trace-action-events,
    trace-frame-snapshots, trace-snapshots, trace-retention, trace-har,
    screencast-trace, trace-mutations, trace-transcript, plus their leaves
    action-mapping, trace-console, trace-sources, trace-zip-writer,
    trace-hierarchy, uid, artifact-naming, console, error.

    Destination: a new workspace-internal package (private, bundled into consumers
    like core and shared are). Not into shared: the app bundles shared
    for the browser, and yazl + node:fs have no business there.

    What stays per-language

    ~1,137 lines stay in core as adapter orchestration: trace-finalizer
    (drives the adapter's finalize lifecycle), spec-trace-helpers (slices by test
    boundaries only the adapter knows), artifacts-manifest and attempt-tracker
    (fed by adapter hooks).

    Irreducibly per-language, and unchanged by this: driver instrumentation, the
    BiDi/CDP bindings, session lifecycle, the transport, and the test-framework
    plugin. That is the ~1,595 lines this issue already measured as genuinely
    language-specific.

    The constraint this respects

    The backend is forbidden from importing core — enforced in eslint.config.cjs,
    stated at CLAUDE.md:87 — because core is adapter logic. That rule is kept,
    not weakened: trace assembly is pure data transformation and was arguably never
    adapter logic, so it moves out rather than the boundary moving.

    The constraint this does not break

    Trace mode deliberately runs no backend (launcher.ts: "Trace mode —
    skipping backend and Chrome window"
    ). The JS adapters keep calling the
    transforms in-process, so backend-free trace mode is untouched and its output
    stays byte-stable. Python's export goes through the backend — and Python has no
    backend-free trace mode to lose, because it has no trace mode at all today.

    Still to solve in #330

    actionSnapshots is a defined TraceLog scope no adapter sends (core
    synthesizes from command screenshots when it is absent), and screencastFrames
    is an exporter argument rather than a wire message. Both must travel for a
    stream-fed export to match in-process fidelity. Neither blocks the move.

  3. vishnuv688 commented on Aug 25, 2026

    @vishnuv688
    MemberAuthor

    Correction to the line counts above

    Walking the reverse dependencies changed the split. Two things I got wrong:

    1. Five modules I counted as movable are not trace-only. error.ts,
    uid.ts and artifact-naming.ts are used across core (assert-patcher, bidi,
    script-loader, test-reporter, screenshot-artifact, video-slice) and the trace
    transforms do not need them at all — they stay put. That removes ~230 lines from
    the moving set.

    2. Two genuinely are needed by both. action-mapping.ts (how a command
    renders as an action) and console.ts (console-entry normalisation) are
    imported by the transforms and by session-capturer / bidi /
    assert-patcher. Both depend on nothing but @wdio/devtools-shared, so they
    move to shared rather than into the trace package — they are contract
    shaped, and putting them in a package named for trace assembly would be the
    wrong home for bidi.ts to import from.

    So: the trace package holds the transforms only, shared gains two
    dependency-free helpers, and core keeps its general utilities.

    Dependency direction, confirmed

    session-capturer.ts already imports a transform (reattributeDomAnchors from
    trace-mutations), so core will import the new package. That is one-way and
    cycle-free: trace ← core ← adapters, and trace ← backend. The trace
    package imports only shared.

    Worth noting this leaves the enforced rule genuinely intact rather than
    technically satisfied — eslint.config.cjs blocks backend → core because core
    is adapter logic, and the backend still will not import it.

  4. vishnuv688 commented on Aug 31, 2026

    @vishnuv688
    MemberAuthor

    The fourth lever, evaluated

    The record above named three of the four levers this issue listed and skipped
    one: push more work into the shared page script. Filling that in, because
    #341 is about to run into it.

    Verdict: the page script is already the right home, and already common — the
    gap is not what runs there, it is who can reach it.

    Measured:

    lines runs where reachable from Python
    packages/script 503 the page yes — served at /api/collector, and the adapter injects it
    core/element-scripts.ts 425 the page no
    core/element-snapshot.ts 1057 Node no
    core/action-snapshot.ts 176 Node (adapter) no

    element-scripts.ts says so itself: "Browser-injectable script strings … each
    function returns a self-contained JavaScript string designed to run inside a
    browser page … must be ES5-compatible."
    That is 425 lines of page code sitting
    in a package Python cannot import — the same barrier the trace transforms hit,
    for the same reason, and it is why a Python trace carries 0
    *-elements.json where a JS Selenium trace carries 12.

    So this lever does not mean "write more browser code". The browser code exists.
    It means the page-side half is in the wrong package, and the route out is one
    that already works: the collector reaches Python over HTTP from the backend, so
    the element scripts can travel the same way rather than being ported.

    element-snapshot.ts is a different case. It imports only shared and the
    locator helpers, takes a tree and returns indented text — a pure transform,
    which is trace-shaped, not page-shaped. It is not part of this lever.

    What stays per-language is unchanged. Reading the page needs the live
    driver, so running the script and shipping the result is adapter work; only
    the script body is shared. That is the same split #301 drew — transforms
    shared, capture in-language.

    Concretely for #341: serve the element scripts the way the collector is served,
    rather than porting element-scripts.ts to Python. Anything else re-creates the
    duplication this issue exists to prevent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions