You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Spike: how to avoid re-implementing capture per language #298
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.
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.
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.
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.
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.
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.
Activity
vishnuv688 commentedon Aug 25, 2026
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,scriptandsharedare already common to every adapter — thebackend runs as a Node subprocess, the page collector is injected as JS at
runtime, and
shared's values are generated into_contract.pywith a driftguard.
coreis 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 thisissue'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, anadapter 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 leavesaction-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
coreandsharedare). Not intoshared: the app bundlessharedfor the browser, and
yazl+node:fshave no business there.What stays per-language
~1,137 lines stay in
coreas adapter orchestration:trace-finalizer(drives the adapter's finalize lifecycle),
spec-trace-helpers(slices by testboundaries only the adapter knows),
artifacts-manifestandattempt-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 ineslint.config.cjs,stated at CLAUDE.md:87 — because
coreis 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
actionSnapshotsis a definedTraceLogscope no adapter sends (coresynthesizes from command screenshots when it is absent), and
screencastFramesis 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.
vishnuv688 commentedon Aug 25, 2026
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.tsandartifact-naming.tsare used acrosscore(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 commandrenders as an action) and
console.ts(console-entry normalisation) areimported by the transforms and by
session-capturer/bidi/assert-patcher. Both depend on nothing but@wdio/devtools-shared, so theymove to
sharedrather than into the trace package — they are contractshaped, and putting them in a package named for trace assembly would be the
wrong home for
bidi.tsto import from.So: the trace package holds the transforms only,
sharedgains twodependency-free helpers, and
corekeeps its general utilities.Dependency direction, confirmed
session-capturer.tsalready imports a transform (reattributeDomAnchorsfromtrace-mutations), socorewill import the new package. That is one-way andcycle-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.cjsblocksbackend → corebecause coreis adapter logic, and the backend still will not import it.
vishnuv688 commentedon Aug 31, 2026
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:
packages/script/api/collector, and the adapter injects itcore/element-scripts.tscore/element-snapshot.tscore/action-snapshot.tselement-scripts.tssays so itself: "Browser-injectable script strings … eachfunction 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.jsonwhere 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.tsis a different case. It imports onlysharedand thelocator 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.tsto Python. Anything else re-creates theduplication this issue exists to prevent.