Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,20 @@ dist-ssr
/*.ts
/*.json
*.tgz
example/wdio-*.json
examples/wdio/wdio-*.json
examples/wdio/wdio-*.webm
examples/nightwatch/logs/

# Adapter-encoded screencasts (written next to the project root by default)
selenium-video-*.webm
nightwatch-video-*.webm
packages/nightwatch-devtools/nightwatch-video-*.webm

# trace.zip output (mode: 'trace')
trace-*.zip

# vitest --coverage output
coverage/

# pnpm state, cache, logs, and debug files
/packages/**/*.mjs
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,46 @@ Real-time capture of browser-side events through the WebDriver BiDi protocol —

When BiDi is active in Selenium or Nightwatch, the per-command Chrome performance-log network-capture path is gated off so requests don't appear twice in the dashboard. The attach + sink logic lives in `@wdio/devtools-core`'s `bidi.ts` — same module both adapters consume.

### 📦 Trace mode (trace.zip)

Headless capture path — no DevTools UI window opens. At session end the adapter writes a `trace-<sessionId>.zip` next to the user's spec / config file, suitable for offline replay, AI-agent diffing, or any consumer that prefers a portable artifact over a live UI.

| Adapter | How to enable |
|---|---|
| **WebdriverIO** | `services: [['devtools', { mode: 'trace' }]]` |
| **Selenium** | `DevTools.configure({ mode: 'trace' })` (before importing `selenium-webdriver`) |
| **Nightwatch** | `globals: nightwatchDevtools({ mode: 'trace' })` |

The zip contains:
- `trace.trace` — NDJSON `context-options` + `before`/`after` action events
- `trace.network` — HAR-style network entries derived from the existing capture
- `resources/page@<id>-<ts>.jpeg` — screenshot per user-facing action
- `resources/elements-page@<id>-<ts>.json` — flat interactable element list extracted by the page-injected scripts in `@wdio/devtools-core/element-scripts`
- `resources/snapshot-page@<id>-<ts>.txt` — depth-indented accessibility-tree snapshot (AI-friendly)

What counts as a user-facing action is filtered through an allow-list in `@wdio/devtools-core/action-mapping.ts` (`url`, `click`, `setValue`, `sendKeys`, `get`, etc.). Internal commands like `findElement`/`waitUntil`/`executeScript` don't produce trace entries.

Trace mode and live mode are **mutually exclusive** — `screencast` options are ignored in trace mode (live-mode feature). Live and trace serve different audiences (humans debugging vs. agents diffing), and stacking them only costs perf.

#### Output layout — `traceFormat`

`{ mode: 'trace', traceFormat: 'zip' | 'ndjson-directory' }`. Default `'zip'` writes a single `trace-<sessionId>.zip`; `'ndjson-directory'` unpacks the same `trace.trace` + `trace.network` + `resources/` files into a `trace-<sessionId>/` folder. Both render in `npx playwright show-trace <path>`. The unpacked form skips the unzip step for scripted / agentic consumers.

#### 📱 Mobile testing

Adapters detect mobile sessions via `platformName: 'android' | 'ios'` (case-insensitive) and adjust the per-action snapshot to extract elements from the mobile XML tree instead of the DOM. The trace's `context-options` records `title: 'android' — <deviceName>` / `'ios' — <deviceName>` so the viewer labels frames correctly.

A reference WDIO config is at [examples/wdio/wdio.mobile.conf.ts](examples/wdio/wdio.mobile.conf.ts). Prereqs to run it end-to-end with a local emulator:

1. **Java JDK** — `brew install --cask temurin`
2. **Android SDK** — `brew install --cask android-commandlinetools` then `yes | sdkmanager --licenses && sdkmanager "platform-tools" "emulator" "system-images;android-34;google_apis_playstore;arm64-v8a"`. The brew cask installs sdkmanager under `/opt/homebrew/share/android-commandlinetools/`, and sdkmanager downloads other SDK pieces alongside it — set `ANDROID_HOME` to that path (not `~/Library/Android/sdk/`).
3. **AVD + emulator** — `avdmanager create avd -n devtools-test -k "system-images;android-34;google_apis_playstore;arm64-v8a" -d "pixel_7"`, then `emulator -avd devtools-test &` + `adb wait-for-device`.
4. **Appium + UiAutomator2 driver** — `sudo npm i -g appium && appium driver install uiautomator2`.
5. **Chromedriver pinning** — Appium's autodownload doesn't reach back far enough for the Chrome version that ships with most Android system images (e.g. Chrome 113 on Android 14). Manually download the matching Chromedriver and start Appium with `--default-capabilities '{"appium:chromedriverExecutableDir": "<path>"}'` plus `--allow-insecure=uiautomator2:chromedriver_autodownload`.
6. **Classic WebDriver protocol** — Appium 3's BiDi shim for UiAutomator2 doesn't implement every BiDi command (e.g. `script.addPreloadScript`). Set `'wdio:enforceWebDriverClassic': true` in the capability block so WDIO doesn't attempt the BiDi handshake.

These are emulator-specific issues; on a physical phone with USB debugging only steps 1, 4, 6 (and the Chromedriver pin if Chrome on the device is old) apply.

### 🔍︎ TestLens
- **Code Intelligence**: View test definitions directly in your editor
- **Run/Debug Actions**: Execute individual tests or suites with inline CodeLens actions
Expand Down
1 change: 1 addition & 0 deletions examples/nightwatch/nightwatch.conf.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ module.exports = {
// off to avoid duplicate entries.
globals: nightwatchDevtools({
port: 3000,
mode: 'live',
screencast: { enabled: true, pollIntervalMs: 200 },
bidi: true
})
Expand Down
2 changes: 1 addition & 1 deletion examples/selenium/jest-test/test/example.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const VALID_USERNAME = 'tomsmith'
const VALID_PASSWORD = 'SuperSecretPassword!'

DevTools.configure({
screencast: { enabled: true, quality: 70, maxWidth: 1280, maxHeight: 720 },
mode: 'trace',
headless: true
})

Expand Down
3 changes: 2 additions & 1 deletion examples/wdio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"typescript": "^6.0.3"
},
"scripts": {
"wdio": "wdio run ./wdio.conf.ts"
"wdio": "wdio run ./wdio.conf.ts",
"mobile": "wdio run ./wdio.mobile.conf.ts"
}
}
20 changes: 7 additions & 13 deletions examples/wdio/wdio.conf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,19 +128,13 @@ export const config: Options.Testrunner = {
// your test setup with almost no effort. Unlike plugins, they don't add new
// commands. Instead, they hook themselves up into the test process.
services: [
'devtools'
// [
// 'devtools',
// {
// screencast: {
// enabled: true,
// captureFormat: 'jpeg', // 'jpeg' or 'png' — frame format sent by Chrome over CDP
// quality: 70, // JPEG quality 0–100
// maxWidth: 1280, // max frame width in px
// maxHeight: 720 // max frame height in px
// }
// }
// ]
[
'devtools',
{
mode: 'live' as const,
screencast: { enabled: true, pollIntervalMs: 200 }
}
]
],
//
// Framework you want to run your specs with.
Expand Down
79 changes: 79 additions & 0 deletions examples/wdio/wdio.mobile.conf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Mobile-web (Android Chrome via Appium) variant of wdio.conf.ts.
//
// Prerequisites:
// 1. Appium 2.x running locally: `appium --address 127.0.0.1 --port 4723`
// 2. UiAutomator2 driver installed: `appium driver install uiautomator2`
// 3. An Android emulator running with Chrome installed
// (or a real device with USB debugging on and `adb devices` listing it).
//
// Run (from inside examples/wdio):
// pnpm mobile
//
// The DevTools service detects `platformName: Android|iOS` via shared
// capabilities and adjusts the action-snapshot probe (mobile XML element
// extraction) and the trace's context naming accordingly.

import path from 'node:path'
import type { Options } from '@wdio/types'

const __dirname = path.resolve(path.dirname(new URL(import.meta.url).pathname))

export const config: Options.Testrunner = {
runner: 'local',

specs: ['./features/**/*.feature'],
exclude: [],

hostname: '127.0.0.1',
port: 4723,
path: '/',

maxInstances: 1,
// `wdio:enforceWebDriverClassic` isn't in @wdio/types yet but is honored
// at runtime — needed because Appium's BiDi shim for UiAutomator2 doesn't
// implement every BiDi command (e.g. script.addPreloadScript).
capabilities: [
{
platformName: 'Android',
'appium:automationName': 'UiAutomator2',
'appium:deviceName': 'emulator-5554',
browserName: 'Chrome',
'appium:chromedriverAutodownload': true,
'wdio:enforceWebDriverClassic': true
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any,

logLevel: 'info',
bail: 0,
baseUrl: 'http://localhost',
waitforTimeout: 15000,
connectionRetryTimeout: 120000,
connectionRetryCount: 3,
services: [
[
'devtools',
{
mode: 'trace' as const,
screencast: { enabled: true, pollIntervalMs: 250 }
}
]
],
framework: 'cucumber',
reporters: ['spec'],
cucumberOpts: {
require: [
path.resolve(__dirname, 'features', 'step-definitions', 'steps.ts')
],
backtrace: false,
requireModule: [],
dryRun: false,
failFast: false,
snippets: true,
source: true,
strict: false,
tagExpression: '',
timeout: 90000,
ignoreUndefinedDefinitions: false
}
}
22 changes: 21 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@
"./*": {
"types": "./src/*.ts",
"default": "./src/*.ts"
},
"./locators": {
"types": "./src/locators/index.ts",
"default": "./src/locators/index.ts"
},
"./element-snapshot": {
"types": "./src/element-snapshot.ts",
"default": "./src/element-snapshot.ts"
},
"./element-scripts": {
"types": "./src/element-scripts.ts",
"default": "./src/element-scripts.ts"
},
"./element-types": {
"types": "./src/element-types.ts",
"default": "./src/element-types.ts"
}
},
"types": "./src/index.ts",
Expand All @@ -27,9 +43,13 @@
"license": "MIT",
"devDependencies": {
"@types/ws": "^8.18.1",
"@types/yazl": "^2.4.6",
"@wdio/devtools-script": "workspace:*",
"@wdio/devtools-shared": "workspace:^",
"@xmldom/xmldom": "^0.9.8",
"stacktrace-parser": "^0.1.11",
"ws": "^8.21.0"
"ws": "^8.21.0",
"xpath": "^0.0.34",
"yazl": "^2.5.1"
}
}
65 changes: 65 additions & 0 deletions packages/core/src/action-mapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Allow-list mapping from runner-native command names to trace
// vocabulary. Ported from Vince Graics' PR #209 (`@wdio/tracing-service`); the
// existing devtools UI uses its own denylist (`INTERNAL_COMMANDS`) — this map
// is for the trace.zip exporter to filter + rename in one step.

export interface TraceAction {
class: string
method: string
}

const ACTION_MAP: Record<string, TraceAction> = {
// WDIO browser-level
url: { class: 'Page', method: 'navigate' },
navigateTo: { class: 'Page', method: 'navigate' },
back: { class: 'Page', method: 'goBack' },
forward: { class: 'Page', method: 'goForward' },
refresh: { class: 'Page', method: 'reload' },
newWindow: { class: 'Page', method: 'goto' },
// Selenium WebDriver navigation (driver.get, driver.navigate().to/back/forward/refresh)
get: { class: 'Page', method: 'navigate' },
to: { class: 'Page', method: 'navigate' },
// WDIO element-level
click: { class: 'Element', method: 'click' },
doubleClick: { class: 'Element', method: 'dblclick' },
setValue: { class: 'Element', method: 'fill' },
selectByVisibleText: { class: 'Element', method: 'selectOption' },
moveTo: { class: 'Element', method: 'hover' },
scrollIntoView: { class: 'Element', method: 'scrollIntoViewIfNeeded' },
dragAndDrop: { class: 'Element', method: 'dragTo' },
// Selenium WebElement actions
sendKeys: { class: 'Element', method: 'fill' },
clear: { class: 'Element', method: 'clear' },
submit: { class: 'Element', method: 'submit' },
// Cross-runner
keys: { class: 'Keyboard', method: 'press' },
execute: { class: 'Page', method: 'evaluate' },
executeAsync: { class: 'Page', method: 'evaluate' },
switchToFrame: { class: 'Frame', method: 'goto' },
touchAction: { class: 'Element', method: 'tap' }
}

// Excluded by design:
// clearValue / addValue — WDIO fires these inside setValue (duplicate events).
// executeScript — Selenium's `until` polling fires it ~50ms; also recurses
// because @wdio/elements uses executeScript inside captureActionSnapshot.
// WDIO's user-facing `execute`/`executeAsync` are still captured.

export function mapCommandToAction(command: string): TraceAction | null {
return ACTION_MAP[command] ?? null
}

export function formatActionTitle(
action: TraceAction,
args: unknown[],
params?: Record<string, unknown>
): string {
const firstArg = args[0] ?? params?.selector
if (firstArg === undefined) {
return `${action.class}.${action.method}()`
}
const label = (
typeof firstArg === 'object' ? JSON.stringify(firstArg) : String(firstArg)
).slice(0, 80)
return `${action.class}.${action.method}("${label}")`
}
66 changes: 66 additions & 0 deletions packages/core/src/action-snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Adapter-agnostic per-action snapshot capture. Each adapter wires its own
// `runScript`, `takeScreenshot`, etc. shim so the actual capture pipeline
// (timeouts, fallbacks, snapshot serialization) lives in one place.

import { accessibilityTreeScript, elementsScript } from './element-scripts.js'
import { serializeWebSnapshot } from './element-snapshot.js'
import type { AccessibilityNode, BrowserElementInfo } from './element-types.js'
import { SNAPSHOT_PROBE_TIMEOUT_MS, withTimeout } from './with-timeout.js'
import type { ActionSnapshot } from '@wdio/devtools-shared'

export type ScriptRunner = (scriptSrc: string) => Promise<unknown>

export interface CaptureActionSnapshotInput {
command: string
runScript: ScriptRunner
takeScreenshot?: () => Promise<string | null | undefined>
getUrl?: () => Promise<string | undefined>
getTitle?: () => Promise<string | undefined>
}

async function runWith<T>(
runScript: ScriptRunner,
scriptSrc: string,
fallback: T
): Promise<T> {
return withTimeout(
runScript(scriptSrc).then((r) => r as T),
SNAPSHOT_PROBE_TIMEOUT_MS,
fallback
).catch(() => fallback)
}

export async function captureActionSnapshot(
input: CaptureActionSnapshotInput
): Promise<ActionSnapshot | null> {
try {
const timestamp = Date.now()
const [shot, url, title, tree, elements] = await Promise.all([
input.takeScreenshot?.().catch(() => null) ?? Promise.resolve(null),
input.getUrl?.().catch(() => undefined) ?? Promise.resolve(undefined),
input.getTitle?.().catch(() => undefined) ?? Promise.resolve(undefined),
runWith<AccessibilityNode[]>(
input.runScript,
accessibilityTreeScript(true),
[]
),
runWith<BrowserElementInfo[]>(
input.runScript,
elementsScript(false, true),
[]
)
])
const snapshotText = serializeWebSnapshot(tree, { url, title })
return {
timestamp,
command: input.command,
url,
title,
screenshot: shot ?? undefined,
elements,
snapshotText
}
} catch {
return null
}
}
Loading
Loading