A descriptive map of how the pieces fit together. For conventions and coding standards, see CLAUDE.md.
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 browser UI.
[user's test framework]
│
▼
[adapter] thin: framework-specific hooks + driver patching
│
▼
[core] framework-agnostic capture/reporting library
│
▼ (WS frames typed by shared)
[backend] Fastify + WS gateway + baseline store + rerun spawner
│
▼ (WS + HTTP, both typed by shared)
[app] Lit browser UI, framework-agnostic
A separate piece, packages/script, is injected into the browser under test (not Node) to capture DOM mutations from the page's own JS context. It communicates back through the adapter, not directly to the backend.
The workspace is a pnpm monorepo. Two of the packages (shared, core) are workspace-internal — they're marked "private": true and never published; consumers bundle their code into their own dist/.
Types, constants, enums, HTTP/WS contract definitions. Pure TypeScript, no runtime dependencies on any other package in the monorepo. Workspace-internal; inlined into every consumer at build time.
Contains the canonical definitions for:
- Domain types:
CommandLog,ConsoleLog,NetworkRequest,TraceMutation,Metadata,TraceLog,TraceType,TestStats,SuiteStats,TestStatus,TestError,ReporterError,PreservedAttempt,PreservedStep,PerformanceData,DocumentInfo,Viewport,ScreencastInfo,ScreencastFrame,ScreencastOptions,LogLevel,LogSource. - WS wire format:
SocketMessage<T>,WsMessageScope,WsPayloadFor<T>,ClearExecutionDataWsPayload,ReplaceCommandWsPayload. - Routing/scope constants:
WS_PATHS,WS_SCOPE,BASELINE_WS_SCOPE,TESTS_API,BASELINE_API. - Process-control env vars:
REUSE_ENV,RUNNER_ENV. - Defaults:
TIMING_BASE,DEFAULTS_BASE,SCREENCAST_DEFAULTS. - File patterns:
SPEC_FILE_RE,FEATURE_FILE_RE(the latter Cucumber-only). - Test-runner identification:
TestRunnerId = 'mocha' | 'jasmine' | 'cucumber' | 'nightwatch' | 'nightwatch-cucumber' | 'selenium-webdriver'.
Imports from: nothing. Imported by: every other package.
Framework-agnostic capture and reporting library. Workspace-internal; inlined into each adapter at build time.
Contains:
SessionCapturerBase— orchestrates per-session capture (console/stream patching, WS connection, command-id bookkeeping, upstream-send guard withonUpstreamDrophook).TestReporterBase— common reporter behavior, extended by Nightwatch + Selenium reporters (Service uses@wdio/reporterfrom WDIO directly).ScreencastRecorderBase— frame buffer + polling fallback shared by all three adapters.resolveAdapterOutputDir— the dir-resolution helper that picks where screencast/trace files land (test-file dir → config dir → cwd, with anode_modules/skip).- Pure helpers:
assert-patcher,bidi(attachBidiHandlers,loadSeleniumSubmodule,arrayHeadersToObject),console(stripAnsi,detectLogLevel,createConsoleLogEntry,mapChromeBrowserLogs,chromeLogLevelToLogLevel),error(serializeError,errorMessage),finalize-screencast,net(isPortInUse,findFreePort,getRequestType),performance-capture(CAPTURE_PERFORMANCE_SCRIPT,applyPerformanceData),retry-tracker,script-loader(loadInjectableScript,pollUntilReady),stack(isUserCodeFrame,normalizeFilePath,getCallSourceFromStack),suite-helpers,test-discovery(findTestDefinitions,extractTestMetadata),uid(generateStableUid,deterministicUid,resetSignatureCounters),video-encoder(encodeToVideo).
Imports from: shared. Imported by: all three adapter packages.
WebdriverIO-specific glue.
Contains: WDIO service hooks (beforeCommand, afterCommand, beforeTest, afterTest, beforeSession, afterSession, onPrepare, onComplete), a reporter that extends WDIO's Reporters.ReporterEntry, the BiDi listener wiring (bidi-listeners.ts), launcher entry point, cucumber step-definition AST scanning, and the standalone runner (standalone.ts).
Imports from: @wdio/types, @wdio/reporter, @wdio/logger, @wdio/protocols, webdriverio, core, shared.
Nightwatch-specific glue.
Contains:
- The
NightwatchDevToolsPluginclass + factory inindex.ts. - Lifecycle modules:
run-lifecycle.ts,test-lifecycle.ts,cucumber-lifecycle.ts,session-init.ts,event-hub.ts. BrowserProxy(inhelpers/) that wraps Nightwatch's browser API and forwards each command into the session capturer.- A
SessionCapturersubclass + a Nightwatch-flavoredSuiteManager/TestManager. - BiDi opt-in support (gated on
bidi: truein plugin options + thewebSocketUrl: truecapability). - Cucumber wiring:
cucumberHooks.cjs(registered via the Cucumberrequireoption), feature-file scanning, step-definition resolution. - A perf-log → NetworkRequest parser (
helpers/perfLogs.ts) for the CDP perf-log path when BiDi isn't attached.
Imports from: @wdio/logger, core, shared. Does not import: other adapter packages, backend, app.
Selenium-webdriver-specific glue.
Contains:
driverPatcher.ts— wrapsselenium-webdriver'sWebDriver/WebElement/Builderprototypes with command capture.- Per-runner hooks for Mocha, Jest, Jasmine, Vitest, and Cucumber (
runnerHooks/*.ts). - Native BiDi via
selenium-webdriver/bidi. - Driver-launch + dashboard-launch helpers, detached-backend mode, process-hook shutdown.
SessionCapturersubclass + Selenium-flavoredSuiteManager/TestManager.
Imports from: core, shared, selenium-webdriver (peer). Does not import: other adapter packages, backend, app.
The server adapters connect to and the app talks to.
Contains:
- Fastify HTTP server.
- WebSocket gateway: one connection per adapter worker, one per app client.
- Baseline store (in-memory) for preserve-and-rerun; reuses
sharedtypes directly via thin*Likealiases (baseline/types.ts). - Test runner spawner (
runner.ts) — spawns the user'swdio/nightwatch/seleniumbinary with rerun filters. - Framework-specific CLI args live in
framework-filters.ts— aswitchoverTestRunnerIdreturning the rightFilterBuilder. (The switch shape is deliberate: CodeQL trusts compile-time-known callable selection, table dispatch trips itsunvalidated-dynamic-method-callquery.) - Bin resolver (
bin-resolver.ts) — finds the WDIO/Nightwatch CLI in the user'snode_modules/ornpxcache. - Worker-message handler (
worker-message-handler.ts) — dispatches messages from spawned workers (config/sessionId/videoPath/...).
Framework awareness lives only in runner.ts and framework-filters.ts, always through TestRunnerId, never magic strings.
Imports from: shared. Does not import: any adapter package, app, or core (the backend doesn't capture; core is for capturers).
The browser UI.
Contains:
- Lit web components (sidebar/explorer, workbench/compare, workbench/console, workbench/network, workbench/snapshot, etc.).
- WebSocket client for the live event stream.
- Context providers (
@lit/context) for each data stream. DataManagerController— orchestrates the WS connection and the 11 context providers (one per scope).- Pure helpers: suite-merge logic, mark-running logic, run-detection logic, context-update transforms (
contextUpdates.ts), runner-capability derivations (runnerCapabilities.ts).
Imports from: shared. Does not import: any adapter package, backend directly (only via WS/HTTP), core.
Browser-injected runtime — runs inside the page under test, not in Node.
Contains: DOM mutation observers, page-side trace collection, a small logger. It's loaded into the page via loadInjectableScript() (which reads the built dist/script.js) and communicates back through the WebDriver bridge (executeScript / getLog), not directly to the backend.
The execution environment is the browser, not Node, so this package cannot import from core (Node-only) or from non-browser-safe parts of shared.
Per-framework demo projects used for manual verification.
examples/wdio/— WebdriverIO with Mocha (default). Run viapnpm demo:wdio.examples/nightwatch/— Nightwatch (both vanilla and Cucumber). Run viapnpm demo:nightwatch.examples/selenium/— Selenium with subdirs formocha-test/,jest-test/,cucumber-test/,jasmine-test/,vitest-test/.pnpm demo:seleniumruns mocha;pnpm --filter @wdio/selenium-devtools example:<runner>runs the others.
- The user runs their normal command (
wdio run …,nightwatch test,mocha + selenium, ...). - The framework loads its adapter via service/plugin config.
- The adapter constructs a
SessionCapturer(subclass ofcore'sSessionCapturerBase). The base class opens a WS connection to the backend, patchesconsole.*, intercepts stdout/stderr, and installs the upstream-send guard. - The framework fires lifecycle hooks (suite/test start, command, etc.). The adapter translates each into a
corecall. corebuilds the typed event persharedschema and pushes it through the WS.backendreceives the event, optionally persists it (baseline store, video registry), and broadcasts to every connected app client.appupdates its Lit components reactively via the context providers.
- User clicks "📌 Preserve & Rerun" on a failed test in the dashboard.
- App POSTs to
/api/baseline/preserve(typed contract inshared). - Backend snapshots the failing attempt into the baseline store, then spawns a rerun via
runner.ts. - The rerun goes through the normal flow above.
- App receives both attempts and renders the side-by-side compare view.
backend/src/runner.ts is the only place outside an adapter that knows about specific frameworks. It uses TestRunnerId from shared and dispatches via framework-filters.ts's switch:
cucumber:--spec <feature[:line]>and/or--cucumberOpts.name <regex>.mocha/jasmine:--spec <file>+--mochaOpts.grep/--jasmineOpts.grep.nightwatch: positional spec file + optional--testcase <name>.nightwatch-cucumber:--name <regex>(feature files viafeature_pathconfig).- Unknown/missing: spec-only fallback.
Everywhere else in the system, events are framework-agnostic.
Every data crossing between packages goes through a typed contract in shared:
| Boundary | Direction | Transport | Lives in |
|---|---|---|---|
| Adapter → backend | One-way events (command, console, network, mutation, …) | WebSocket frames | shared/ws.ts (SocketMessage<T>) |
| App → backend | Preserve, clear, run, stop, get-baseline | HTTP (Fastify) | shared/baseline.ts, shared/runner.ts |
| Backend → app | Live event broadcast + API responses | WebSocket + HTTP | shared/ws.ts, shared/baseline.ts |
| Backend → spawned worker | Run config, rerun env, video paths | Env vars + IPC | shared/runner.ts (REUSE_ENV, RUNNER_ENV) |
| Script → adapter | Mutation events, trace data | executeScript return values + getLog channel |
Implicit in adapter — script's payload shape is consumed by core's processTracePayload |
New events or HTTP routes start with a shared change. The other packages then import the contract.
The repo has converged on a clear ownership story. When in doubt, the top-down decision tree is:
- A type, constant, enum, schema, or contract used by more than one package →
shared. - Capture, parsing, normalization, sourcemap, UID, reporter, screencast, or WS-framing logic that doesn't depend on a specific framework's API →
core. - A specific framework's hook, driver patch, or runner integration → the matching adapter package. Adapter code calls
corefor the actual work and only owns the hook registration. - A backend HTTP route, WS handler, or rerun behavior →
backend, with the contract added tosharedfirst. - UI →
app, consumingsharedcontracts only. - Code that runs inside the browser under test →
script.
A few cross-cutting conventions follow from this layout:
- Adapter packages don't import each other. Anything two adapters would both want lives in
core. - Backend doesn't import adapter packages, and adapter packages don't import backend or app.
- The script package is a leaf — adapters load its built bundle as a string and inject it; they don't import from it at runtime.
sharedandcoreare private workspace packages. Consumers bundle them. The bundler config has to inline them (not externalize) or the published artifact won't resolve — see the build-config notes inCLAUDE.md.
The architecture above is the actual state of the repo. Where it diverges from the ideal, the divergences are tracked in CLAUDE.md §7.
Notable in-place pieces worth knowing about:
replaceCommandhas two semantics across adapters — Selenium mutates the existing entry in place (preserves_id/idcontinuity for chained calls); Nightwatch splices and reissues with a new_id. Both call the samecore/suite-helpersfactories; the storage strategy stays adapter-specific because the runner integrations differ.patchNodeAssertis wired only inselenium-devtools(Selenium's primary assertion style isnode:assert). The shared helper lives incore/assert-patcher; Service and Nightwatch can opt in via a one-line call when they need to, but it's not auto-enabled because both communities lean on chai/expect.- BiDi is auto-attached in Service and Selenium. Nightwatch is opt-in via
bidi: trueand requireswebSocketUrl: truein capabilities — historically Nightwatch users haven't all enabled BiDi by default. - Performance API capture (
CAPTURE_PERFORMANCE_SCRIPT) is identical across all three adapters; each wires it into its own afterCommand-equivalent path. - Output directory for screencast videos and trace files is resolved through
core/resolveAdapterOutputDir— adapters feeduserConfiguredDir(WDIO honorswdio.conf.ts'soutputDir/rootDir),testFilePath(Selenium/Nightwatch), andconfigPath(Nightwatch), and the helper picks the first writable, non-node_modules/candidate.
For per-package implementation details, see each package's README.md