Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e9a4b66
Phase 1: Introduce live and trace mode; Gate UI from launching for tr…
vishnuv688 Jun 4, 2026
e9bf75d
Phase 2: Incorporating @wdio/elements into devtools for the trace str…
vishnuv688 Jun 4, 2026
99ef6cb
Phase 3: Capture per-action accessibility/element snapshots in trace …
vishnuv688 Jun 4, 2026
c7f53e0
Phase 4: Export trace.zip from captured TraceLog (WDIO)
vishnuv688 Jun 4, 2026
f5abbf4
Phase 5: Emit trace.zip from Selenium + Nightwatch adapters via share…
vishnuv688 Jun 8, 2026
2d8f3a8
Phase 6: Per-action element/snapshot parity for Selenium and Nightwatch
vishnuv688 Jun 8, 2026
b756583
Update README
vishnuv688 Jun 8, 2026
4dc81e1
fix: CodeQl and lint fix
vishnuv688 Jun 8, 2026
60f76f9
Mobile-aware context-options, snapshot probe timeouts, chronological …
vishnuv688 Jun 9, 2026
7cda9ad
core: @wdio/elements → webdriverio peer dep leak through nightwatch/s…
vishnuv688 Jun 9, 2026
72a8894
core: Trace.zip viewer compat, traceFormat option, mobile example, Se…
vishnuv688 Jun 9, 2026
47225b3
Service+Nightwatch withTimeout timer leak
vishnuv688 Jun 10, 2026
40a910c
Bound polynomial regex in checkUniqueness xpath parser
vishnuv688 Jun 10, 2026
2880e4d
fix: Trace format with snapshots and elements for wdio examples
Winify Jun 10, 2026
2835c2d
fix: Network wallTime should be properly set with HAR format
Winify Jun 10, 2026
e1ff817
refactor: Use real start/end timestamps for actions
Winify Jun 10, 2026
c52941e
performance: Introduce guards on JS executions when native Mobile app…
Winify Jun 10, 2026
629b4c9
fix: Correctly chain commands' snapshot together
Winify Jun 10, 2026
55e8e5b
fix: INTERNAL_COMMAND leakage into trace
Winify Jun 10, 2026
8a5f959
fix: Cleaning up snapshot/screenshot mismatches
Winify Jun 10, 2026
579ba7e
chore: Patch packages/service by including necessary dependencies fro…
Winify Jun 10, 2026
08c35c6
chore: Address usability issues
Winify Jun 11, 2026
738eba4
refactor: Simplification across packages/core and service
Winify Jun 11, 2026
ebd5ba6
chore: Make @wdio/elements publishable; align xpath/xmldom deps acros…
vishnuv688 Jun 11, 2026
2dfe1b2
chore: Trace mode UI and screencast gaurd across all the devtools
vishnuv688 Jun 11, 2026
6745994
fix: Add @microsoft/api-extractor to @wdio/elements devDeps
vishnuv688 Jun 11, 2026
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
17 changes: 16 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
.tsup
*.local

# Editor directories and files
Expand All @@ -25,7 +26,21 @@ 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 output (mode: 'trace')
trace-*.zip
examples/**/trace-*/

# vitest --coverage output
coverage/

# pnpm state, cache, logs, and debug files
/packages/**/*.mjs
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,47 @@ 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)
- `transcript.md` — human/LLM-readable Markdown transcript of the captured actions, with timing, selectors, and value annotations

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
2 changes: 1 addition & 1 deletion eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const security = require('eslint-plugin-security')

module.exports = [
{
ignores: ['node_modules/**', '**/dist/**']
ignores: ['node_modules/**', '**/dist/**', '**/.tsup/**']
},
// Base JS config
{
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
4 changes: 3 additions & 1 deletion examples/wdio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"typescript": "^6.0.3"
},
"scripts": {
"wdio": "wdio run ./wdio.conf.ts"
"wdio": "wdio run ./wdio.conf.ts",
"mobile": "wdio run ./wdio.mobile.conf.ts",
"trace": "wdio run ./wdio.trace.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
}
}
64 changes: 64 additions & 0 deletions examples/wdio/wdio.trace.conf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import path from 'node:path'

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

export const config: WebdriverIO.Config = {
runner: 'local',
tsConfigPath: './tsconfig.json',

specs: ['./features/login.feature'],

maxInstances: 1,

capabilities: [
{
browserName: 'chrome',
'goog:chromeOptions': {
args: [
'--headless',
'--disable-gpu',
'--remote-allow-origins=*',
'--window-size=1600,1200'
]
}
}
],

logLevel: 'warn',

baseUrl: 'http://localhost',

waitforTimeout: 10000,
connectionRetryTimeout: 120000,
connectionRetryCount: 3,

services: [
[
'devtools',
{
mode: 'trace' as const
// traceFormat: 'ndjson-directory'
}
]
],

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: 60000,
ignoreUndefinedDefinitions: false
}
}
24 changes: 22 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,38 @@
"./*": {
"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",
"scripts": {
"lint": "eslint ."
"lint": "eslint . --fix"
},
"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"
}
}
70 changes: 70 additions & 0 deletions packages/core/src/action-mapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// 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)
return `${action.class}.${action.method}("${label}")`
}

/**
* Methods where the first positional argument should render as value= in the
* transcript line (e.g. setValue, selectByVisibleText).
*/
export const FILL_METHODS = new Set(['fill', 'selectOption'])
Loading
Loading