Skip to content

Commit 9726be7

Browse files
committed
Devtools screencast feature followup
1 parent 0566c12 commit 9726be7

6 files changed

Lines changed: 377 additions & 124 deletions

File tree

example/wdio.conf.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export const config: Options.Testrunner = {
6363
capabilities: [
6464
{
6565
browserName: 'chrome',
66-
browserVersion: '146.0.7680.178', // specify chromium browser version for testing
66+
browserVersion: '147.0.7727.56', // specify chromium browser version for testing
6767
'goog:chromeOptions': {
6868
args: [
6969
'--headless',
@@ -127,7 +127,20 @@ export const config: Options.Testrunner = {
127127
// Services take over a specific job you don't want to take care of. They enhance
128128
// your test setup with almost no effort. Unlike plugins, they don't add new
129129
// commands. Instead, they hook themselves up into the test process.
130-
services: ['devtools'],
130+
services: [
131+
[
132+
'devtools',
133+
{
134+
screencast: {
135+
enabled: true,
136+
captureFormat: 'jpeg', // 'jpeg' or 'png' — frame format sent by Chrome over CDP
137+
quality: 70, // JPEG quality 0–100
138+
maxWidth: 1280, // max frame width in px
139+
maxHeight: 720 // max frame height in px
140+
}
141+
}
142+
]
143+
],
131144
//
132145
// Framework you want to run your specs with.
133146
// The following are supported: Mocha, Jasmine, and Cucumber

packages/service/README.md

Lines changed: 104 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,122 @@
11

22
# @wdio/devtools-service
33

4-
DevTools is a UI test runner for WebdriverIO. It provides a user interface for running, debugging, and inspecting your browser automation tests, along with advanced features like network interception, performance tracing, and more.
4+
A WebdriverIO service that provides a developer tools UI for running, debugging, and inspecting browser automation tests. Features include DOM mutation replay, per-command screenshots, network request inspection, console log capture, and session screencast recording.
55

66
## Installation
77

8-
Install the service in your project:
9-
108
```sh
119
npm install @wdio/devtools-service --save-dev
12-
```
13-
14-
or with pnpm:
15-
16-
```sh
10+
# or
1711
pnpm add -D @wdio/devtools-service
1812
```
1913

2014
## Usage
2115

22-
### WebdriverIO Test Runner
16+
### Test Runner
2317

24-
Add the service to your `wdio.conf.ts`:
25-
26-
```js
18+
```ts
19+
// wdio.conf.ts
2720
export const config = {
28-
// ...
2921
services: ['devtools'],
30-
// ...
3122
}
3223
```
24+
25+
### Standalone
26+
27+
```ts
28+
import { remote } from 'webdriverio'
29+
import { setupForDevtools } from '@wdio/devtools-service'
30+
31+
const browser = await remote(setupForDevtools({
32+
capabilities: { browserName: 'chrome' }
33+
}))
34+
await browser.url('https://example.com')
35+
await browser.deleteSession()
36+
```
37+
38+
## Service Options
39+
40+
```ts
41+
services: [['devtools', options]]
42+
```
43+
44+
| Option | Type | Default | Description |
45+
|---|---|---|---|
46+
| `port` | `number` | random | Port the DevTools UI server listens on |
47+
| `hostname` | `string` | `'localhost'` | Hostname the DevTools UI server binds to |
48+
| `devtoolsCapabilities` | `Capabilities` | Chrome 1600×1200 | Capabilities used to open the DevTools UI window |
49+
| `screencast` | `ScreencastOptions` || Session video recording (see below) |
50+
51+
## Screencast Recording
52+
53+
Records browser sessions as `.webm` videos using the Chrome DevTools Protocol. Videos are displayed in the DevTools UI alongside the snapshot and DOM mutation views.
54+
55+
### Setup
56+
57+
Screencast encoding requires **ffmpeg** on `PATH` and the `fluent-ffmpeg` package:
58+
59+
```sh
60+
# Install ffmpeg — https://ffmpeg.org/download.html
61+
brew install ffmpeg # macOS
62+
sudo apt install ffmpeg # Ubuntu/Debian
63+
64+
# Install fluent-ffmpeg
65+
npm install fluent-ffmpeg
66+
```
67+
68+
### Configuration
69+
70+
```ts
71+
services: [
72+
[
73+
'devtools',
74+
{
75+
screencast: {
76+
enabled: true,
77+
captureFormat: 'jpeg',
78+
quality: 70,
79+
maxWidth: 1280,
80+
maxHeight: 720,
81+
}
82+
}
83+
]
84+
]
85+
```
86+
87+
### Options
88+
89+
| Option | Type | Default | Description |
90+
|---|---|---|---|
91+
| `enabled` | `boolean` | `false` | Enable session recording |
92+
| `captureFormat` | `'jpeg' \| 'png'` | `'jpeg'` | Frame image format. **Chrome/Chromium only** — controls the format Chrome sends over CDP. Ignored in polling mode (Firefox, Safari) where screenshots are always PNG. Does not affect the output video container, which is always `.webm` |
93+
| `quality` | `number` | `70` | JPEG compression quality 0–100. Only applies in Chrome/Chromium CDP mode with `captureFormat: 'jpeg'` |
94+
| `maxWidth` | `number` | `1280` | Maximum frame width in pixels. **Chrome/Chromium only** — Chrome scales frames before sending over CDP. Ignored in polling mode |
95+
| `maxHeight` | `number` | `720` | Maximum frame height in pixels. **Chrome/Chromium only** — same as above |
96+
| `pollIntervalMs` | `number` | `200` | Screenshot interval in milliseconds for non-Chrome browsers (polling mode). Lower = smoother video but more WebDriver round-trips during test execution |
97+
98+
### Browser support
99+
100+
Recording works across all major browsers using automatic mode selection:
101+
102+
| Browser | Mode | Notes |
103+
|---|---|---|
104+
| Chrome / Chromium / Edge | **CDP push** | Chrome pushes frames over the DevTools Protocol. Efficient — no impact on test command timing |
105+
| Firefox / Safari / others | **BiDi polling** | Falls back to calling `browser.takeScreenshot()` at `pollIntervalMs` intervals. Works wherever WebDriver screenshots are supported; adds a small overhead proportional to the interval |
106+
107+
No configuration change is needed to switch modes — the service detects browser capabilities automatically and logs which mode is active.
108+
109+
### Behaviour
110+
111+
- Recording starts when the browser session opens and stops when it closes.
112+
- Leading blank frames (captured before the first URL navigation) are automatically trimmed so videos begin at the first meaningful page action.
113+
- If `browser.reloadSession()` is called mid-run, the service finalises the current recording and starts a fresh one for the new session. Each session produces its own `.webm` file.
114+
- When multiple recordings exist, the DevTools UI shows a **Recording N** dropdown to switch between them.
115+
- Output files are written to the directory containing `wdio.conf.ts` (WDIO's `rootDir`) or `outputDir` if explicitly configured.
116+
117+
### Output files
118+
119+
| File | Description |
120+
|---|---|
121+
| `wdio-trace-{sessionId}.json` | Full trace: DOM mutations, commands, screenshots, console logs, network requests |
122+
| `wdio-video-{sessionId}.webm` | Screencast video (only produced when `screencast.enabled: true`) |

packages/service/src/constants.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
import type { ScreencastOptions } from './types.js'
2+
3+
export const SCREENCAST_DEFAULTS: Required<ScreencastOptions> = {
4+
enabled: false,
5+
captureFormat: 'jpeg',
6+
quality: 70,
7+
maxWidth: 1280,
8+
maxHeight: 720,
9+
pollIntervalMs: 200
10+
}
11+
112
export const PAGE_TRANSITION_COMMANDS: string[] = [
213
'url',
314
'navigateTo',
@@ -32,7 +43,7 @@ export const LOG_LEVEL_PATTERNS: ReadonlyArray<{
3243
/**
3344
* Visual indicators that suggest error-level logs
3445
*/
35-
export const ERROR_INDICATORS = ['✗', '✓', 'failed', 'failure'] as const
46+
export const ERROR_INDICATORS = ['✗', 'failed', 'failure'] as const
3647

3748
/**
3849
* Console log source types

packages/service/src/index.ts

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,16 @@ import { SessionCapturer } from './session.js'
1111
import { TestReporter } from './reporter.js'
1212
import { DevToolsAppLauncher } from './launcher.js'
1313
import { getBrowserObject } from './utils.js'
14+
import { ScreencastRecorder } from './screencast.js'
15+
import { encodeToVideo } from './video-encoder.js'
1416
import { parse } from 'stack-trace'
15-
import { type TraceLog, TraceType, type ServiceOptions } from './types.js'
17+
import {
18+
type TraceLog,
19+
TraceType,
20+
type ServiceOptions,
21+
type ScreencastOptions,
22+
type ScreencastInfo
23+
} from './types.js'
1624
import {
1725
INTERNAL_COMMANDS,
1826
SPEC_FILE_PATTERN,
@@ -89,6 +97,12 @@ export default class DevToolsHookService implements Services.ServiceInstance {
8997
#sessionCapturer = new SessionCapturer()
9098
#browser?: WebdriverIO.Browser
9199
#bidiListenersSetup = false
100+
#screencastRecorder?: ScreencastRecorder
101+
#screencastOptions?: ScreencastOptions
102+
103+
constructor(serviceOptions: ServiceOptions = {}) {
104+
this.#screencastOptions = serviceOptions.screencast
105+
}
92106

93107
/**
94108
* This is used to capture the command stack to ensure that we only capture
@@ -136,6 +150,16 @@ export default class DevToolsHookService implements Services.ServiceInstance {
136150
)
137151
}
138152

153+
/**
154+
* Start screencast recording if the user has enabled it.
155+
* Options come from the service constructor (services: [['devtools', { screencast: { enabled: true } }]]).
156+
* Failures are non-fatal — a warning is logged and the session continues.
157+
*/
158+
if (this.#screencastOptions?.enabled) {
159+
this.#screencastRecorder = new ScreencastRecorder(this.#screencastOptions)
160+
await this.#screencastRecorder.start(browser)
161+
}
162+
139163
/**
140164
* propagate session metadata at the beginning of the session
141165
*/
@@ -233,9 +257,14 @@ export default class DevToolsHookService implements Services.ServiceInstance {
233257
}
234258

235259
/**
236-
* propagate url change to devtools app
260+
* On the first URL navigation, mark this moment as the start of meaningful
261+
* recording so leading blank/black frames (browser not yet loaded, pre-test
262+
* pauses, etc.) are trimmed from the encoded video.
263+
* This fires via beforeCommand regardless of test runner (Mocha, Jasmine,
264+
* Cucumber, or standalone), making it universally applicable.
237265
*/
238266
if (command === 'url') {
267+
this.#screencastRecorder?.setStartMarker()
239268
this.#sessionCapturer.sendUpstream('metadata', { url: args[0] })
240269
}
241270

@@ -334,7 +363,11 @@ export default class DevToolsHookService implements Services.ServiceInstance {
334363
if (!this.#browser) {
335364
return
336365
}
337-
const outputDir = this.#browser.options.outputDir || process.cwd()
366+
367+
// Stop and encode the screencast for the current session.
368+
await this.#finalizeScreencast(this.#browser.sessionId)
369+
370+
const outputDir = this.#outputDir
338371
const { ...options } = this.#browser.options
339372
const traceLog: TraceLog = {
340373
mutations: this.#sessionCapturer.mutations,
@@ -363,6 +396,88 @@ export default class DevToolsHookService implements Services.ServiceInstance {
363396
this.#sessionCapturer.cleanup()
364397
}
365398

399+
/**
400+
* Called by WebdriverIO after browser.reloadSession() completes.
401+
* The old browser session (and its CDP connection) is destroyed at this
402+
* point, so any in-flight screencast is already dead. We encode whatever
403+
* frames were captured for the old session and then start a fresh recorder
404+
* on the new session so the second scenario is also covered.
405+
*/
406+
async onReload(oldSessionId: string, _newSessionId: string) {
407+
if (!this.#screencastOptions?.enabled || !this.#browser) {
408+
return
409+
}
410+
411+
// Finalize the recording from the old session (CDP is already gone, so
412+
// stop() will fail gracefully and we encode whatever frames arrived).
413+
await this.#finalizeScreencast(oldSessionId)
414+
415+
// Start a new recorder for the new session.
416+
this.#screencastRecorder = new ScreencastRecorder(this.#screencastOptions)
417+
await this.#screencastRecorder.start(this.#browser)
418+
}
419+
420+
/**
421+
* Resolves the directory where devtools output files (trace JSON, video WebM)
422+
* should be written, using the following priority:
423+
* 1. `outputDir` if the user explicitly set it in wdio.conf — respected as-is.
424+
* 2. `rootDir` — WDIO automatically sets this to the directory containing
425+
* wdio.conf.ts, so files always land next to the config file
426+
* regardless of where the `wdio` command is invoked from.
427+
* 3. `process.cwd()` — last-resort fallback.
428+
*
429+
* NOTE: Avoid setting `outputDir` in wdio.conf just to fix the output path —
430+
* doing so redirects WDIO worker logs to files and silences the terminal.
431+
* Rely on `rootDir` instead (it is set automatically by WDIO).
432+
*/
433+
get #outputDir(): string {
434+
const opts = this.#browser?.options as any
435+
return opts?.outputDir || opts?.rootDir || process.cwd()
436+
}
437+
438+
/**
439+
* Stops the current screencast recorder, encodes collected frames into a
440+
* .webm file, and notifies the backend. Safe to call even if recording
441+
* never started or the CDP session died early.
442+
*/
443+
async #finalizeScreencast(sessionId: string) {
444+
if (!this.#screencastRecorder) {
445+
return
446+
}
447+
448+
await this.#screencastRecorder.stop()
449+
450+
// Skip ghost sessions: browser.reloadSession() creates a new session at the
451+
// end of a test run that has no steps — it captures at most a handful of
452+
// frames before teardown. Require at least 5 frames so we don't produce
453+
// empty videos for these ephemeral sessions.
454+
if (this.#screencastRecorder.frames.length < 5) {
455+
return
456+
}
457+
458+
const outputDir = this.#outputDir
459+
const videoFile = `wdio-video-${sessionId}.webm`
460+
const videoPath = path.join(outputDir, videoFile)
461+
try {
462+
await encodeToVideo(this.#screencastRecorder.frames, videoPath, {
463+
captureFormat: this.#screencastOptions?.captureFormat
464+
})
465+
const screencastInfo: ScreencastInfo = {
466+
sessionId,
467+
videoPath,
468+
videoFile,
469+
frameCount: this.#screencastRecorder.frames.length,
470+
duration: this.#screencastRecorder.duration
471+
}
472+
// Notify the backend (and then the UI) that a video is ready.
473+
// The backend stores the absolute videoPath and exposes it via
474+
// GET /api/video/:sessionId, forwarding only { sessionId } to the UI.
475+
this.#sessionCapturer.sendUpstream('screencast', screencastInfo)
476+
} catch (encodeErr) {
477+
log.warn(`Screencast encode failed: ${(encodeErr as Error).message}`)
478+
}
479+
}
480+
366481
/**
367482
* Synchronous injection that blocks until complete
368483
*/

0 commit comments

Comments
 (0)