diff --git a/README.md b/README.md
index 51e76bc..70c2b19 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,17 @@ Works with **WebdriverIO** and **[Nightwatch.js](./packages/nightwatch-devtools/
- **Actions Tab Auto-Clear**: Execution data automatically clears and refreshes on reruns
- **Metadata Tracking**: Test duration, status, and execution timestamps
+### 🎬 Session Screencast
+- **Automatic Video Recording**: Captures a continuous `.webm` video of the browser session alongside the existing snapshot and DOM mutation views
+- **Cross-Browser**: Uses Chrome DevTools Protocol (CDP) push mode for Chrome/Chromium; automatically falls back to screenshot polling for Firefox, Safari, and other browsers (no configuration change needed)
+- **Per-Session Videos**: Each browser session (including sessions created by `browser.reloadSession()`) produces its own recording, selectable from a dropdown in the UI
+- **Smart Trimming**: Leading blank frames before the first URL navigation are automatically removed so videos start at the first meaningful page action
+
+> **Note:** Screencast recording is currently supported for **WebdriverIO only**. Nightwatch.js support is planned for a future release.
+>
+
+> For setup, configuration options, and prerequisites see the **[service README](./packages/service/README.md#screencast-recording)**.
+
### 🔍︎ TestLens
- **Code Intelligence**: View test definitions directly in your editor
- **Run/Debug Actions**: Execute individual tests or suites with inline CodeLens actions
@@ -68,6 +79,9 @@ Works with **WebdriverIO** and **[Nightwatch.js](./packages/nightwatch-devtools/
+### 🎬 Session Screencast
+
+
## Installation
```bash
diff --git a/example/wdio.conf.ts b/example/wdio.conf.ts
index 35a920c..24ddf97 100644
--- a/example/wdio.conf.ts
+++ b/example/wdio.conf.ts
@@ -63,7 +63,7 @@ export const config: Options.Testrunner = {
capabilities: [
{
browserName: 'chrome',
- browserVersion: '146.0.7680.178', // specify chromium browser version for testing
+ // browserVersion: '147.0.7727.56', // specify chromium browser version for testing
'goog:chromeOptions': {
args: [
'--headless',
@@ -127,7 +127,20 @@ export const config: Options.Testrunner = {
// Services take over a specific job you don't want to take care of. They enhance
// 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'],
+ services: [
+ [
+ '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
+ }
+ }
+ ]
+ ],
//
// Framework you want to run your specs with.
// The following are supported: Mocha, Jasmine, and Cucumber
diff --git a/packages/app/src/components/browser/snapshot.ts b/packages/app/src/components/browser/snapshot.ts
index 436de78..20024e5 100644
--- a/packages/app/src/components/browser/snapshot.ts
+++ b/packages/app/src/components/browser/snapshot.ts
@@ -1,5 +1,5 @@
import { Element } from '@core/element'
-import { html, css } from 'lit'
+import { html, css, nothing } from 'lit'
import { consume } from '@lit/context'
import { type ComponentChildren, h, render, type VNode } from 'preact'
@@ -19,6 +19,12 @@ import '../placeholder.js'
const MUTATION_SELECTOR = '__mutation-highlight__'
+declare global {
+ interface WindowEventMap {
+ 'screencast-ready': CustomEvent<{ sessionId: string }>
+ }
+}
+
function transform(node: any): VNode<{}> {
if (typeof node !== 'object' || node === null) {
// Plain string/number text node — return as-is for Preact to render as text.
@@ -47,6 +53,20 @@ export class DevtoolsBrowser extends Element {
#activeUrl?: string
/** Base64 PNG of the screenshot for the currently selected command, or null. */
#screenshotData: string | null = null
+ /**
+ * All recorded videos received from the backend, in arrival order.
+ * Each entry is { sessionId, url } — a new entry is pushed for every
+ * browser session (initial + after every reloadSession() call).
+ */
+ #videos: Array<{ sessionId: string; url: string }> = []
+ /** Index into #videos of the currently displayed video. */
+ #activeVideoIdx = 0
+ /**
+ * Which view is active in the browser panel.
+ * 'video' — always show the screencast player (default when a recording exists)
+ * 'snapshot' — show DOM mutations replay and per-command screenshots
+ */
+ #viewMode: 'snapshot' | 'video' = 'snapshot'
@consume({ context: metadataContext, subscribe: true })
metadata: Metadata | undefined = undefined
@@ -136,6 +156,15 @@ export class DevtoolsBrowser extends Element {
display: block;
}
+ .screencast-player {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ background: #111;
+ border-radius: 0 0 0.5rem 0.5rem;
+ display: block;
+ }
+
.iframe-wrapper {
position: relative;
flex: 1;
@@ -143,6 +172,47 @@ export class DevtoolsBrowser extends Element {
display: flex;
flex-direction: column;
}
+
+ .view-toggle {
+ display: flex;
+ gap: 2px;
+ margin-left: 0.5rem;
+ flex-shrink: 0;
+ }
+
+ .view-toggle button {
+ padding: 2px 10px;
+ font-size: 11px;
+ font-family: inherit;
+ border: 1px solid var(--vscode-editorSuggestWidget-border, #454545);
+ background: transparent;
+ color: var(--vscode-input-foreground, #ccc);
+ cursor: pointer;
+ border-radius: 3px;
+ line-height: 20px;
+ transition:
+ background 0.1s,
+ color 0.1s;
+ }
+
+ .view-toggle button.active {
+ background: var(--vscode-button-background, #0e639c);
+ color: var(--vscode-button-foreground, #fff);
+ border-color: transparent;
+ }
+
+ .video-select {
+ font-size: 11px;
+ font-family: inherit;
+ padding: 2px 4px;
+ border: 1px solid var(--vscode-dropdown-border, #454545);
+ border-radius: 3px;
+ background: var(--vscode-dropdown-background, #3c3c3c);
+ color: var(--vscode-dropdown-foreground, #ccc);
+ cursor: pointer;
+ line-height: 20px;
+ margin-left: 4px;
+ }
`
]
@@ -170,6 +240,10 @@ export class DevtoolsBrowser extends Element {
'show-command',
this.#handleShowCommand as EventListener
)
+ window.addEventListener(
+ 'screencast-ready',
+ this.#handleScreencastReady as EventListener
+ )
await this.updateComplete
}
@@ -215,8 +289,34 @@ export class DevtoolsBrowser extends Element {
(event as CustomEvent<{ command?: CommandLog }>).detail?.command
)
+ #handleScreencastReady = (event: Event) => {
+ const { sessionId } = (event as CustomEvent<{ sessionId: string }>).detail
+ this.#videos.push({ sessionId, url: `/api/video/${sessionId}` })
+ // Always show the latest video and switch to video mode automatically
+ this.#activeVideoIdx = this.#videos.length - 1
+ this.#viewMode = 'video'
+ this.requestUpdate()
+ }
+
+ #setViewMode(mode: 'snapshot' | 'video') {
+ this.#viewMode = mode
+ this.requestUpdate()
+ }
+
+ #setActiveVideo(idx: number) {
+ this.#activeVideoIdx = idx
+ this.requestUpdate()
+ }
+
+ /** URL of the currently selected video, or null when no videos exist. */
+ get #activeVideoUrl(): string | null {
+ return this.#videos[this.#activeVideoIdx]?.url ?? null
+ }
+
async #renderCommandScreenshot(command?: CommandLog) {
this.#screenshotData = command?.screenshot ?? null
+ // Switch to snapshot mode so the command screenshot is visible instead of the video.
+ this.#viewMode = 'snapshot'
this.requestUpdate()
}
@@ -461,32 +561,79 @@ export class DevtoolsBrowser extends Element {
>
${this.#activeUrl}
+ ${this.#videos.length > 0
+ ? html`
+
+
+
+ ${this.#videos.length > 1
+ ? html``
+ : nothing}
+
+ `
+ : nothing}
- ${this.#screenshotData
- ? html`
-
-

-
+ ${this.#viewMode === 'video' && this.#activeVideoUrl
+ ? html`
+
`
- : hasMutations
- ? html`
-
+ : this.#screenshotData
+ ? html`
+
+

+
`
- : displayScreenshot
- ? html`
-
-

-
+ : hasMutations
+ ? html`
+
`
- : html`
`}
+ : displayScreenshot
+ ? html`
+
+

+
+
`
+ : html`
`}
`
}
diff --git a/packages/app/src/controller/DataManager.ts b/packages/app/src/controller/DataManager.ts
index a70eb66..580ee17 100644
--- a/packages/app/src/controller/DataManager.ts
+++ b/packages/app/src/controller/DataManager.ts
@@ -308,6 +308,14 @@ export class DataManagerController implements ReactiveController {
return
}
+ if (scope === 'screencast') {
+ const { sessionId } = data as { sessionId: string }
+ window.dispatchEvent(
+ new CustomEvent('screencast-ready', { detail: { sessionId } })
+ )
+ return
+ }
+
if (scope === 'clearExecutionData') {
const { uid, entryType } =
data as SocketMessage<'clearExecutionData'>['data']
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 1aaae12..5eea41e 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -23,6 +23,7 @@
"lint": "eslint ."
},
"dependencies": {
+ "@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"@fastify/websocket": "^11.2.0",
"@wdio/cli": "9.27.0",
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index 6e162b5..b7980f3 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -1,7 +1,9 @@
+import fs from 'node:fs'
import url from 'node:url'
import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify'
import staticServer from '@fastify/static'
+import rateLimit from '@fastify/rate-limit'
import websocket from '@fastify/websocket'
import getPort from 'get-port'
import logger from '@wdio/logger'
@@ -22,6 +24,13 @@ interface DevtoolsBackendOptions {
const log = logger('@wdio/devtools-backend')
const clients = new Set
()
+/**
+ * Registry mapping sessionId → absolute path of the encoded .webm file.
+ * Populated when the service sends { scope: 'screencast', data: { sessionId, videoPath } }.
+ * Queried by GET /api/video/:sessionId.
+ */
+const videoRegistry = new Map()
+
export function broadcastToClients(message: string) {
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
@@ -30,6 +39,19 @@ export function broadcastToClients(message: string) {
})
}
+function serveVideo(sessionId: string, reply: any) {
+ const videoPath = videoRegistry.get(sessionId)
+ if (!videoPath) {
+ return reply.code(404).send({ error: 'Video not found' })
+ }
+ if (!fs.existsSync(videoPath)) {
+ return reply.code(404).send({ error: 'Video file missing from disk' })
+ }
+ return reply
+ .header('Content-Type', 'video/webm')
+ .send(fs.createReadStream(videoPath))
+}
+
export async function start(
opts: DevtoolsBackendOptions = {}
): Promise<{ server: FastifyInstance; port: number }> {
@@ -46,6 +68,10 @@ export async function start(
const appPath = await getDevtoolsApp()
server = Fastify({ logger: true })
+ await server.register(rateLimit, {
+ max: 100,
+ timeWindow: '1 minute'
+ })
await server.register(websocket)
await server.register(staticServer, {
root: appPath
@@ -102,21 +128,18 @@ export async function start(
`received ${message.length} byte message from worker to ${clients.size} client${clients.size > 1 ? 's' : ''}`
)
- // Parse message to check if it's a clearCommands message
+ // Parse message to check if it needs special handling
try {
const parsed = JSON.parse(message.toString())
- // If this is a clearCommands message, transform it to clear-execution-data format
+ // Transform clearCommands → clearExecutionData for the UI
if (parsed.scope === 'clearCommands') {
const testUid = parsed.data?.testUid
log.info(`Clearing commands for test: ${testUid || 'all'}`)
-
- // Create a synthetic message that DataManager will understand
const clearMessage = JSON.stringify({
scope: 'clearExecutionData',
data: { uid: testUid }
})
-
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(clearMessage)
@@ -124,6 +147,30 @@ export async function start(
})
return
}
+
+ // Intercept screencast messages: store the absolute videoPath in the
+ // registry (backend-only), then forward only the sessionId to the UI
+ // so the UI can request the video via GET /api/video/:sessionId.
+ if (parsed.scope === 'screencast' && parsed.data?.sessionId) {
+ const { sessionId, videoPath } = parsed.data
+ if (videoPath) {
+ videoRegistry.set(sessionId, videoPath)
+ log.info(
+ `Screencast registered for session ${sessionId}: ${videoPath}`
+ )
+ }
+ // Forward trimmed message (no videoPath) to UI clients
+ const uiMessage = JSON.stringify({
+ scope: 'screencast',
+ data: { sessionId }
+ })
+ clients.forEach((client) => {
+ if (client.readyState === WebSocket.OPEN) {
+ client.send(uiMessage)
+ }
+ })
+ return
+ }
} catch {
// Not JSON or parsing failed, forward as-is
}
@@ -138,6 +185,26 @@ export async function start(
}
)
+ // Serve recorded screencast videos. The service sends an absolute videoPath
+ // which is stored in videoRegistry; the UI only knows the sessionId and
+ // requests the file through this endpoint.
+ server.get(
+ '/api/video/:sessionId',
+ {
+ preHandler: server.rateLimit({
+ max: 30,
+ timeWindow: '1 minute'
+ })
+ },
+ async (
+ request: FastifyRequest<{ Params: { sessionId: string } }>,
+ reply
+ ) => {
+ const { sessionId } = request.params
+ return serveVideo(sessionId, reply)
+ }
+ )
+
log.info(`Starting WebdriverIO Devtools application on port ${port}`)
await server.listen({ port, host })
return { server, port }
diff --git a/packages/backend/tests/index.test.ts b/packages/backend/tests/index.test.ts
index 2cb81ee..9f23b05 100644
--- a/packages/backend/tests/index.test.ts
+++ b/packages/backend/tests/index.test.ts
@@ -26,6 +26,29 @@ describe('backend index', () => {
})
})
+ describe('video endpoint', () => {
+ it('should return 404 for unknown session and respect rate limit', async () => {
+ vi.mocked(utils.getDevtoolsApp).mockResolvedValue('/mock/app/path')
+ const { server } = await start({ port: 0 })
+
+ // Unknown sessionId → 404
+ const res = await server?.inject({
+ method: 'GET',
+ url: '/api/video/unknown-session'
+ })
+ expect(res?.statusCode).toBe(404)
+ expect(JSON.parse(res?.body || '{}')).toEqual({
+ error: 'Video not found'
+ })
+
+ // Rate limit header is present (proves preHandler is active)
+ const headers = res?.headers || {}
+ expect(headers['x-ratelimit-limit']).toBeDefined()
+
+ await server?.close()
+ })
+ })
+
describe('API endpoints', () => {
it('should handle test run and stop requests with validation', async () => {
vi.mocked(utils.getDevtoolsApp).mockResolvedValue('/mock/app/path')
diff --git a/packages/service/README.md b/packages/service/README.md
index 5e0cfe3..0d88f3f 100644
--- a/packages/service/README.md
+++ b/packages/service/README.md
@@ -1,32 +1,124 @@
# @wdio/devtools-service
-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.
+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.
## Installation
-Install the service in your project:
-
```sh
npm install @wdio/devtools-service --save-dev
-```
-
-or with pnpm:
-
-```sh
+# or
pnpm add -D @wdio/devtools-service
```
## Usage
-### WebdriverIO Test Runner
+### Test Runner
-Add the service to your `wdio.conf.ts`:
-
-```js
+```ts
+// wdio.conf.ts
export const config = {
- // ...
services: ['devtools'],
- // ...
}
```
+
+### Standalone
+
+```ts
+import { remote } from 'webdriverio'
+import { setupForDevtools } from '@wdio/devtools-service'
+
+const browser = await remote(setupForDevtools({
+ capabilities: { browserName: 'chrome' }
+}))
+await browser.url('https://example.com')
+await browser.deleteSession()
+```
+
+## Service Options
+
+```ts
+services: [['devtools', options]]
+```
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `port` | `number` | random | Port the DevTools UI server listens on |
+| `hostname` | `string` | `'localhost'` | Hostname the DevTools UI server binds to |
+| `devtoolsCapabilities` | `Capabilities` | Chrome 1600×1200 | Capabilities used to open the DevTools UI window |
+| `screencast` | `ScreencastOptions` | — | Session video recording (see below) |
+
+## Screencast Recording
+
+Records browser sessions as `.webm` videos. Videos are displayed in the DevTools UI alongside the snapshot and DOM mutation views.
+
+> **Note:** Screencast recording is currently supported for **WebdriverIO only**. Nightwatch.js support is planned for a future release.
+
+### Setup
+
+Screencast encoding requires **ffmpeg** on `PATH` and the `fluent-ffmpeg` package:
+
+```sh
+# Install ffmpeg — https://ffmpeg.org/download.html
+brew install ffmpeg # macOS
+sudo apt install ffmpeg # Ubuntu/Debian
+
+# Install fluent-ffmpeg
+npm install fluent-ffmpeg
+```
+
+### Configuration
+
+```ts
+services: [
+ [
+ 'devtools',
+ {
+ screencast: {
+ enabled: true,
+ captureFormat: 'jpeg',
+ quality: 70,
+ maxWidth: 1280,
+ maxHeight: 720,
+ }
+ }
+ ]
+]
+```
+
+### Options
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `enabled` | `boolean` | `false` | Enable session recording |
+| `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` |
+| `quality` | `number` | `70` | JPEG compression quality 0–100. Only applies in Chrome/Chromium CDP mode with `captureFormat: 'jpeg'` |
+| `maxWidth` | `number` | `1280` | Maximum frame width in pixels. **Chrome/Chromium only** — Chrome scales frames before sending over CDP. Ignored in polling mode |
+| `maxHeight` | `number` | `720` | Maximum frame height in pixels. **Chrome/Chromium only** — same as above |
+| `pollIntervalMs` | `number` | `200` | Screenshot interval in milliseconds for non-Chrome browsers (polling mode). Lower = smoother video but more WebDriver round-trips during test execution |
+
+### Browser support
+
+Recording works across all major browsers using automatic mode selection:
+
+| Browser | Mode | Notes |
+|---|---|---|
+| Chrome / Chromium / Edge | **CDP push** | Chrome pushes frames over the DevTools Protocol. Efficient — no impact on test command timing |
+| 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 |
+
+No configuration change is needed to switch modes — the service detects browser capabilities automatically and logs which mode is active.
+
+### Behaviour
+
+- Recording starts when the browser session opens and stops when it closes.
+- Leading blank frames (captured before the first URL navigation) are automatically trimmed so videos begin at the first meaningful page action.
+- 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.
+- When multiple recordings exist, the DevTools UI shows a **Recording N** dropdown to switch between them.
+- Output files are written to the directory containing `wdio.conf.ts` (WDIO's `rootDir`) or `outputDir` if explicitly configured.
+
+### Output files
+
+| File | Description |
+|---|---|
+| `wdio-trace-{sessionId}.json` | Full trace: DOM mutations, commands, screenshots, console logs, network requests |
+| `wdio-video-{sessionId}.webm` | Screencast video (only produced when `screencast.enabled: true`) |
diff --git a/packages/service/package.json b/packages/service/package.json
index b2be0a1..dd36114 100644
--- a/packages/service/package.json
+++ b/packages/service/package.json
@@ -42,6 +42,7 @@
"@wdio/logger": "9.18.0",
"@wdio/reporter": "9.27.0",
"@wdio/types": "9.27.0",
+ "fluent-ffmpeg": "^2.1.3",
"import-meta-resolve": "^4.1.0",
"stack-trace": "1.0.0-pre2",
"ws": "^8.18.3"
@@ -50,6 +51,7 @@
"devDependencies": {
"@types/babel__core": "^7.20.5",
"@types/babel__traverse": "^7.28.0",
+ "@types/fluent-ffmpeg": "^2.1.27",
"@types/stack-trace": "^0.0.33",
"@types/ws": "^8.18.1",
"@wdio/globals": "9.27.0",
diff --git a/packages/service/src/constants.ts b/packages/service/src/constants.ts
index 1df1a45..fc4ebe5 100644
--- a/packages/service/src/constants.ts
+++ b/packages/service/src/constants.ts
@@ -1,3 +1,14 @@
+import type { ScreencastOptions } from './types.js'
+
+export const SCREENCAST_DEFAULTS: Required = {
+ enabled: false,
+ captureFormat: 'jpeg',
+ quality: 70,
+ maxWidth: 1280,
+ maxHeight: 720,
+ pollIntervalMs: 200
+}
+
export const PAGE_TRANSITION_COMMANDS: string[] = [
'url',
'navigateTo',
@@ -32,7 +43,7 @@ export const LOG_LEVEL_PATTERNS: ReadonlyArray<{
/**
* Visual indicators that suggest error-level logs
*/
-export const ERROR_INDICATORS = ['✗', '✓', 'failed', 'failure'] as const
+export const ERROR_INDICATORS = ['✗', 'failed', 'failure'] as const
/**
* Console log source types
diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts
index 27b2ccf..c1b7ca5 100644
--- a/packages/service/src/index.ts
+++ b/packages/service/src/index.ts
@@ -11,8 +11,16 @@ import { SessionCapturer } from './session.js'
import { TestReporter } from './reporter.js'
import { DevToolsAppLauncher } from './launcher.js'
import { getBrowserObject } from './utils.js'
+import { ScreencastRecorder } from './screencast.js'
+import { encodeToVideo } from './video-encoder.js'
import { parse } from 'stack-trace'
-import { type TraceLog, TraceType, type ServiceOptions } from './types.js'
+import {
+ type TraceLog,
+ TraceType,
+ type ServiceOptions,
+ type ScreencastOptions,
+ type ScreencastInfo
+} from './types.js'
import {
INTERNAL_COMMANDS,
SPEC_FILE_PATTERN,
@@ -89,6 +97,12 @@ export default class DevToolsHookService implements Services.ServiceInstance {
#sessionCapturer = new SessionCapturer()
#browser?: WebdriverIO.Browser
#bidiListenersSetup = false
+ #screencastRecorder?: ScreencastRecorder
+ #screencastOptions?: ScreencastOptions
+
+ constructor(serviceOptions: ServiceOptions = {}) {
+ this.#screencastOptions = serviceOptions.screencast
+ }
/**
* 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 {
)
}
+ /**
+ * Start screencast recording if the user has enabled it.
+ * Options come from the service constructor (services: [['devtools', { screencast: { enabled: true } }]]).
+ * Failures are non-fatal — a warning is logged and the session continues.
+ */
+ if (this.#screencastOptions?.enabled) {
+ this.#screencastRecorder = new ScreencastRecorder(this.#screencastOptions)
+ await this.#screencastRecorder.start(browser)
+ }
+
/**
* propagate session metadata at the beginning of the session
*/
@@ -233,9 +257,14 @@ export default class DevToolsHookService implements Services.ServiceInstance {
}
/**
- * propagate url change to devtools app
+ * On the first URL navigation, mark this moment as the start of meaningful
+ * recording so leading blank/black frames (browser not yet loaded, pre-test
+ * pauses, etc.) are trimmed from the encoded video.
+ * This fires via beforeCommand regardless of test runner (Mocha, Jasmine,
+ * Cucumber, or standalone), making it universally applicable.
*/
if (command === 'url') {
+ this.#screencastRecorder?.setStartMarker()
this.#sessionCapturer.sendUpstream('metadata', { url: args[0] })
}
@@ -334,7 +363,11 @@ export default class DevToolsHookService implements Services.ServiceInstance {
if (!this.#browser) {
return
}
- const outputDir = this.#browser.options.outputDir || process.cwd()
+
+ // Stop and encode the screencast for the current session.
+ await this.#finalizeScreencast(this.#browser.sessionId)
+
+ const outputDir = this.#outputDir
const { ...options } = this.#browser.options
const traceLog: TraceLog = {
mutations: this.#sessionCapturer.mutations,
@@ -363,6 +396,88 @@ export default class DevToolsHookService implements Services.ServiceInstance {
this.#sessionCapturer.cleanup()
}
+ /**
+ * Called by WebdriverIO after browser.reloadSession() completes.
+ * The old browser session (and its CDP connection) is destroyed at this
+ * point, so any in-flight screencast is already dead. We encode whatever
+ * frames were captured for the old session and then start a fresh recorder
+ * on the new session so the second scenario is also covered.
+ */
+ async onReload(oldSessionId: string, _newSessionId: string) {
+ if (!this.#screencastOptions?.enabled || !this.#browser) {
+ return
+ }
+
+ // Finalize the recording from the old session (CDP is already gone, so
+ // stop() will fail gracefully and we encode whatever frames arrived).
+ await this.#finalizeScreencast(oldSessionId)
+
+ // Start a new recorder for the new session.
+ this.#screencastRecorder = new ScreencastRecorder(this.#screencastOptions)
+ await this.#screencastRecorder.start(this.#browser)
+ }
+
+ /**
+ * Resolves the directory where devtools output files (trace JSON, video WebM)
+ * should be written, using the following priority:
+ * 1. `outputDir` if the user explicitly set it in wdio.conf — respected as-is.
+ * 2. `rootDir` — WDIO automatically sets this to the directory containing
+ * wdio.conf.ts, so files always land next to the config file
+ * regardless of where the `wdio` command is invoked from.
+ * 3. `process.cwd()` — last-resort fallback.
+ *
+ * NOTE: Avoid setting `outputDir` in wdio.conf just to fix the output path —
+ * doing so redirects WDIO worker logs to files and silences the terminal.
+ * Rely on `rootDir` instead (it is set automatically by WDIO).
+ */
+ get #outputDir(): string {
+ const opts = this.#browser?.options as any
+ return opts?.outputDir || opts?.rootDir || process.cwd()
+ }
+
+ /**
+ * Stops the current screencast recorder, encodes collected frames into a
+ * .webm file, and notifies the backend. Safe to call even if recording
+ * never started or the CDP session died early.
+ */
+ async #finalizeScreencast(sessionId: string) {
+ if (!this.#screencastRecorder) {
+ return
+ }
+
+ await this.#screencastRecorder.stop()
+
+ // Skip ghost sessions: browser.reloadSession() creates a new session at the
+ // end of a test run that has no steps — it captures at most a handful of
+ // frames before teardown. Require at least 5 frames so we don't produce
+ // empty videos for these ephemeral sessions.
+ if (this.#screencastRecorder.frames.length < 5) {
+ return
+ }
+
+ const outputDir = this.#outputDir
+ const videoFile = `wdio-video-${sessionId}.webm`
+ const videoPath = path.join(outputDir, videoFile)
+ try {
+ await encodeToVideo(this.#screencastRecorder.frames, videoPath, {
+ captureFormat: this.#screencastOptions?.captureFormat
+ })
+ const screencastInfo: ScreencastInfo = {
+ sessionId,
+ videoPath,
+ videoFile,
+ frameCount: this.#screencastRecorder.frames.length,
+ duration: this.#screencastRecorder.duration
+ }
+ // Notify the backend (and then the UI) that a video is ready.
+ // The backend stores the absolute videoPath and exposes it via
+ // GET /api/video/:sessionId, forwarding only { sessionId } to the UI.
+ this.#sessionCapturer.sendUpstream('screencast', screencastInfo)
+ } catch (encodeErr) {
+ log.warn(`Screencast encode failed: ${(encodeErr as Error).message}`)
+ }
+ }
+
/**
* Synchronous injection that blocks until complete
*/
diff --git a/packages/service/src/screencast.ts b/packages/service/src/screencast.ts
new file mode 100644
index 0000000..a034e7a
--- /dev/null
+++ b/packages/service/src/screencast.ts
@@ -0,0 +1,232 @@
+import logger from '@wdio/logger'
+
+import { SCREENCAST_DEFAULTS } from './constants.js'
+import type { ScreencastFrame, ScreencastOptions } from './types.js'
+
+const log = logger('@wdio/devtools-service:ScreencastRecorder')
+
+/**
+ * Manages session screencast recording with automatic browser detection.
+ *
+ * Recording strategy (chosen automatically at start time):
+ * 1. CDP push mode — Chrome/Chromium only. Chrome pushes frames over the
+ * DevTools Protocol; each frame is ack'd immediately. Efficient with no
+ * impact on test command timing.
+ * 2. BiDi polling — all other browsers (Firefox, Safari, Edge Legacy, …).
+ * Falls back to calling browser.takeScreenshot() at a fixed interval.
+ * Works wherever WebDriver screenshots are supported; adds a small
+ * round-trip overhead proportional to pollIntervalMs.
+ *
+ * Usage:
+ * const recorder = new ScreencastRecorder(options)
+ * await recorder.start(browser) // in before() hook
+ * // ... test runs ...
+ * await recorder.stop() // in after() hook
+ * const frames = recorder.frames // feed to encodeToVideo()
+ */
+export class ScreencastRecorder {
+ #frames: ScreencastFrame[] = []
+ /** Puppeteer CDPSession — set only in CDP mode. */
+ #cdpSession: any = undefined
+ /** setInterval handle — set only in polling mode. */
+ #pollTimer: ReturnType | undefined = undefined
+ #isRecording = false
+ #options: Required
+ /**
+ * Index into #frames where meaningful recording begins.
+ * Frames before this index (blank browser before first navigation) are
+ * excluded from encoding. Set once via setStartMarker().
+ */
+ #startIndex = 0
+ #startMarkerSet = false
+
+ constructor(options: ScreencastOptions = {}) {
+ this.#options = { ...SCREENCAST_DEFAULTS, ...options }
+ }
+
+ // ─── public API ───────────────────────────────────────────────────────────
+
+ /**
+ * Start recording. Tries CDP (Chrome) first; falls back to BiDi polling
+ * for all other browsers. Safe to call even if the browser does not support
+ * screenshots — the failure is logged and recording is simply skipped.
+ */
+ async start(browser: WebdriverIO.Browser): Promise {
+ const cdpStarted = await this.#startCdp(browser)
+ if (!cdpStarted) {
+ await this.#startPolling(browser)
+ }
+ }
+
+ /**
+ * Stop recording and release resources.
+ * Safe to call even if start() was never called or failed.
+ */
+ async stop(): Promise {
+ if (!this.#isRecording) {
+ return
+ }
+
+ if (this.#cdpSession) {
+ await this.#stopCdp()
+ } else if (this.#pollTimer !== undefined) {
+ this.#stopPolling()
+ }
+
+ this.#isRecording = false
+ }
+
+ /**
+ * Mark the current frame position as the start of meaningful recording.
+ * Frames captured before this call (blank browser, pre-navigation pauses)
+ * are excluded from the encoded video.
+ * Safe to call multiple times — only the first call takes effect.
+ */
+ setStartMarker() {
+ if (!this.#startMarkerSet) {
+ this.#startMarkerSet = true
+ this.#startIndex = this.#frames.length
+ }
+ }
+
+ /** Frames to encode — everything from the first meaningful action onwards. */
+ get frames(): ScreencastFrame[] {
+ return this.#frames.slice(this.#startIndex)
+ }
+
+ /**
+ * Duration in milliseconds between first and last captured frame.
+ * Returns 0 if fewer than 2 frames were collected.
+ */
+ get duration(): number {
+ const f = this.frames
+ if (f.length < 2) {
+ return 0
+ }
+ return f[f.length - 1].timestamp - f[0].timestamp
+ }
+
+ get isRecording(): boolean {
+ return this.#isRecording
+ }
+
+ // ─── CDP mode (Chrome/Chromium) ───────────────────────────────────────────
+
+ /**
+ * Attempt to start recording via the Chrome DevTools Protocol.
+ * Returns true on success, false if CDP is unavailable (non-Chrome browser
+ * or remote grid without debug-port access).
+ */
+ async #startCdp(browser: WebdriverIO.Browser): Promise {
+ try {
+ const puppeteer = await (browser as any).getPuppeteer()
+ const pages = await puppeteer.pages()
+ if (!pages.length) {
+ return false
+ }
+
+ const page = pages[0]
+ this.#cdpSession = await page.createCDPSession()
+
+ await this.#cdpSession.send('Page.startScreencast', {
+ format: this.#options.captureFormat,
+ quality: this.#options.quality,
+ maxWidth: this.#options.maxWidth,
+ maxHeight: this.#options.maxHeight
+ })
+
+ this.#cdpSession.on('Page.screencastFrame', async (event: any) => {
+ // CDP timestamp is seconds (float); convert to ms.
+ this.#frames.push({
+ data: event.data,
+ timestamp: Math.round(event.metadata.timestamp * 1000)
+ })
+ // Chrome stops sending frames if acks are not sent promptly.
+ try {
+ await this.#cdpSession.send('Page.screencastFrameAck', {
+ sessionId: event.sessionId
+ })
+ } catch (ackErr) {
+ log.warn(
+ `Screencast: failed to ack frame — ${(ackErr as Error).message}`
+ )
+ }
+ })
+
+ this.#isRecording = true
+ log.info('✓ Screencast recording started (CDP mode)')
+ return true
+ } catch {
+ // CDP not available — caller will try polling fallback.
+ return false
+ }
+ }
+
+ async #stopCdp(): Promise {
+ try {
+ await this.#cdpSession.send('Page.stopScreencast')
+ log.info(
+ `✓ Screencast stopped — ${this.#frames.length} frame(s) collected`
+ )
+ } catch (err) {
+ const msg = (err as Error).message ?? ''
+ if (msg.includes('Session closed') || msg.includes('Target closed')) {
+ // Browser shut down before after() completed — frames already buffered.
+ log.debug(
+ 'Screencast: CDP session already closed (expected during teardown)'
+ )
+ } else {
+ log.warn(`Screencast: error stopping CDP — ${msg}`)
+ }
+ } finally {
+ this.#cdpSession = undefined
+ }
+ }
+
+ // ─── Polling mode (all other browsers) ───────────────────────────────────
+
+ /**
+ * Attempt to start recording via periodic browser.takeScreenshot() calls.
+ * Works for any browser that supports WebDriver screenshots (Firefox,
+ * Safari, etc.). Adds a small round-trip overhead per interval tick.
+ */
+ async #startPolling(browser: WebdriverIO.Browser): Promise {
+ try {
+ // Capture one frame immediately to verify screenshots work before
+ // committing to the polling loop.
+ const firstShot = await browser.takeScreenshot()
+ this.#frames.push({ data: firstShot, timestamp: Date.now() })
+
+ const intervalMs = this.#options.pollIntervalMs
+ this.#pollTimer = setInterval(async () => {
+ try {
+ const data = await browser.takeScreenshot()
+ this.#frames.push({ data, timestamp: Date.now() })
+ } catch {
+ // Session ended mid-interval — stop polling gracefully.
+ this.#stopPolling()
+ }
+ }, intervalMs)
+
+ this.#isRecording = true
+ log.info(
+ `✓ Screencast recording started (polling mode, ${intervalMs} ms interval)`
+ )
+ } catch (err) {
+ log.warn(
+ `Screencast unavailable (${(err as Error).message}). ` +
+ 'Recording will be skipped.'
+ )
+ }
+ }
+
+ #stopPolling(): void {
+ if (this.#pollTimer !== undefined) {
+ clearInterval(this.#pollTimer)
+ this.#pollTimer = undefined
+ log.info(
+ `✓ Screencast stopped — ${this.#frames.length} frame(s) collected`
+ )
+ }
+ }
+}
diff --git a/packages/service/src/session.ts b/packages/service/src/session.ts
index 4310115..bb4c3c8 100644
--- a/packages/service/src/session.ts
+++ b/packages/service/src/session.ts
@@ -20,49 +20,26 @@ import { type CommandLog, type TraceLog, type LogLevel } from './types.js'
const log = logger('@wdio/devtools-service:SessionCapturer')
-/**
- * Generic helper to strip ANSI escape codes from text
- */
-const stripAnsiCodes = (text: string): string => text.replace(ANSI_REGEX, '')
-
-/**
- * Generic helper to detect log level from text content
- */
-const detectLogLevel = (text: string): LogLevel => {
- const cleanText = stripAnsiCodes(text).toLowerCase()
+const stripAnsi = (text: string) => text.replace(ANSI_REGEX, '')
- // Check log level patterns in priority order
+const detectLogLevel = (text: string): LogLevel => {
+ const t = stripAnsi(text).toLowerCase()
for (const { level, pattern } of LOG_LEVEL_PATTERNS) {
- if (pattern.test(cleanText)) {
+ if (pattern.test(t)) {
return level
}
}
-
- // Check for error indicators
- if (
- ERROR_INDICATORS.some((indicator) =>
- cleanText.includes(indicator.toLowerCase())
- )
- ) {
+ if (ERROR_INDICATORS.some((i) => t.includes(i.toLowerCase()))) {
return 'error'
}
-
return 'log'
}
-/**
- * Generic helper to create a console log entry
- */
-const createConsoleLogEntry = (
+const toConsoleEntry = (
type: LogLevel,
args: any[],
source: (typeof LOG_SOURCES)[keyof typeof LOG_SOURCES]
-): ConsoleLogs => ({
- timestamp: Date.now(),
- type,
- args,
- source
-})
+): ConsoleLogs => ({ timestamp: Date.now(), type, args, source })
export class SessionCapturer {
#ws: WebSocket | undefined
@@ -70,12 +47,16 @@ export class SessionCapturer {
#originalConsoleMethods: Record<
(typeof CONSOLE_METHODS)[number],
typeof console.log
- >
- #originalProcessMethods: {
- stdoutWrite: typeof process.stdout.write
- stderrWrite: typeof process.stderr.write
+ > = {
+ log: console.log,
+ info: console.info,
+ warn: console.warn,
+ error: console.error
}
- #isCapturingConsole = false
+ #originalStdoutWrite = process.stdout.write.bind(process.stdout)
+ #originalStderrWrite = process.stderr.write.bind(process.stderr)
+ /** True while we are inside the patched console call — prevents double-capture via stream. */
+ #insideConsole = false
commandsLog: CommandLog[] = []
sources = new Map()
mutations: TraceMutation[] = []
@@ -114,112 +95,95 @@ export class SessionCapturer {
)
}
- this.#originalConsoleMethods = {
- log: console.log,
- info: console.info,
- warn: console.warn,
- error: console.error
- }
-
- this.#originalProcessMethods = {
- stdoutWrite: process.stdout.write.bind(process.stdout),
- stderrWrite: process.stderr.write.bind(process.stderr)
- }
-
this.#patchConsole()
- this.#interceptProcessStreams()
+ this.#patchStreams()
}
+ /**
+ * Patch Node.js console methods so every console.log/info/warn/error call in
+ * the test runner process (test files, page-object helpers, etc.) is forwarded
+ * to the UI Console tab with source='test'.
+ */
#patchConsole() {
CONSOLE_METHODS.forEach((method) => {
- const originalMethod = this.#originalConsoleMethods[method]
- console[method] = (...consoleArgs: any[]) => {
- const serializedArgs = consoleArgs.map((arg) =>
- typeof arg === 'object' && arg !== null
+ const original = this.#originalConsoleMethods[method]
+ console[method] = (...args: any[]) => {
+ const serialized = args.map((a) =>
+ typeof a === 'object' && a !== null
? (() => {
try {
- return JSON.stringify(arg)
+ return JSON.stringify(a)
} catch {
- return String(arg)
+ return String(a)
}
})()
- : String(arg)
+ : String(a)
)
+ const entry = toConsoleEntry(method, serialized, LOG_SOURCES.TEST)
+ this.consoleLogs.push(entry)
+ this.sendUpstream('consoleLogs', [entry])
- const logEntry = createConsoleLogEntry(
- method,
- serializedArgs,
- LOG_SOURCES.TEST
- )
- this.consoleLogs.push(logEntry)
- this.sendUpstream('consoleLogs', [logEntry])
-
- this.#isCapturingConsole = true
- const result = originalMethod.apply(console, consoleArgs)
- this.#isCapturingConsole = false
+ this.#insideConsole = true
+ const result = original.apply(console, args)
+ this.#insideConsole = false
return result
}
})
}
- #interceptProcessStreams() {
- const captureTerminalOutput = (outputData: string | Uint8Array) => {
- const outputText =
- typeof outputData === 'string' ? outputData : outputData.toString()
- if (!outputText?.trim()) {
+ /**
+ * Patch process.stdout / process.stderr so all terminal output (WDIO
+ * framework logs, reporter output, etc.) is also forwarded to the UI
+ * Console tab with source='terminal'. The original write is always
+ * called first so actual terminal output is never suppressed.
+ */
+ #patchStreams() {
+ const forward = (raw: string | Uint8Array) => {
+ const text = typeof raw === 'string' ? raw : raw.toString()
+ if (!text.trim()) {
return
}
-
- outputText
+ text
.split('\n')
- .filter((line) => line.trim())
+ .filter((l) => l.trim())
.forEach((line) => {
- const logEntry = createConsoleLogEntry(
+ const entry = toConsoleEntry(
detectLogLevel(line),
- [stripAnsiCodes(line)],
+ [stripAnsi(line)],
LOG_SOURCES.TERMINAL
)
- this.consoleLogs.push(logEntry)
- this.sendUpstream('consoleLogs', [logEntry])
+ this.consoleLogs.push(entry)
+ this.sendUpstream('consoleLogs', [entry])
})
}
- const interceptStreamWrite = (
+ const wrap = (
stream: NodeJS.WriteStream,
- originalWriteMethod: (...args: any[]) => boolean
+ original: (...a: any[]) => boolean
) => {
- const capturer = this
- stream.write = function (chunk: any, ...additionalArgs: any[]): boolean {
- const writeResult = originalWriteMethod.call(
- stream,
- chunk,
- ...additionalArgs
- )
- if (chunk && !capturer.#isCapturingConsole) {
- captureTerminalOutput(chunk)
+ stream.write = ((chunk: any, ...rest: any[]): boolean => {
+ const result = original.call(stream, chunk, ...rest)
+ if (chunk && !this.#insideConsole) {
+ forward(chunk)
}
- return writeResult
- } as any
+ return result
+ }) as any
}
- interceptStreamWrite(
- process.stdout,
- this.#originalProcessMethods.stdoutWrite
- )
- interceptStreamWrite(
- process.stderr,
- this.#originalProcessMethods.stderrWrite
- )
+ wrap(process.stdout, this.#originalStdoutWrite)
+ wrap(process.stderr, this.#originalStderrWrite)
}
- #restoreConsole() {
+ /**
+ * Restore all patched methods. Must be called in after() so subsequent
+ * test runs (or the WDIO reporter teardown) see the real stdout/stderr.
+ */
+ cleanup() {
CONSOLE_METHODS.forEach((method) => {
console[method] = this.#originalConsoleMethods[method]
})
- }
-
- cleanup() {
- this.#restoreConsole()
+ process.stdout.write = this.#originalStdoutWrite as any
+ process.stderr.write = this.#originalStderrWrite as any
}
get isReportingUpstream() {
diff --git a/packages/service/src/types.ts b/packages/service/src/types.ts
index e98a69d..a7fe992 100644
--- a/packages/service/src/types.ts
+++ b/packages/service/src/types.ts
@@ -13,6 +13,59 @@ export interface CommandLog {
testUid?: string
}
+export interface ScreencastFrame {
+ /** Base64-encoded image data — JPEG/PNG from CDP push mode or PNG from browser.takeScreenshot() in polling mode */
+ data: string
+ /** Unix timestamp in milliseconds */
+ timestamp: number
+}
+
+export interface ScreencastOptions {
+ /** Enable screencast recording for this session (default: false) */
+ enabled?: boolean
+ /**
+ * Image format for individual frames (default: 'jpeg').
+ * - Chrome/Chromium (CDP mode): controls the format Chrome sends over CDP.
+ * - Other browsers (polling mode): screenshots are always PNG; this option
+ * is ignored.
+ * Does NOT affect the output video container, which is always WebM.
+ */
+ captureFormat?: 'jpeg' | 'png'
+ /**
+ * JPEG quality 0–100 (default: 70).
+ * Only applies in Chrome/Chromium CDP mode with captureFormat 'jpeg'.
+ */
+ quality?: number
+ /**
+ * Max frame width in pixels Chrome sends over CDP (default: 1280).
+ * Only applies in Chrome/Chromium CDP mode.
+ */
+ maxWidth?: number
+ /**
+ * Max frame height in pixels Chrome sends over CDP (default: 720).
+ * Only applies in Chrome/Chromium CDP mode.
+ */
+ maxHeight?: number
+ /**
+ * Screenshot polling interval in milliseconds for non-Chrome browsers
+ * (default: 200 ms ≈ 5 fps).
+ * Polling calls browser.takeScreenshot() at this interval. A lower value
+ * gives smoother video but adds more WebDriver round-trips during the test.
+ */
+ pollIntervalMs?: number
+}
+
+export interface ScreencastInfo {
+ sessionId?: string
+ /** Absolute path to the encoded video file on disk */
+ videoPath?: string
+ /** Filename only, e.g. wdio-video-{sessionId}.webm */
+ videoFile?: string
+ frameCount?: number
+ /** Duration in milliseconds between first and last frame */
+ duration?: number
+}
+
export enum TraceType {
Standalone = 'standalone',
Testrunner = 'testrunner'
@@ -49,6 +102,7 @@ export interface TraceLog {
commands: CommandLog[]
sources: Record
suites?: Record[]
+ screencast?: ScreencastInfo
}
export interface ExtendedCapabilities extends WebdriverIO.Capabilities {
@@ -79,6 +133,12 @@ export interface ServiceOptions {
* }
*/
devtoolsCapabilities?: WebdriverIO.Capabilities
+ /**
+ * Screencast recording options. When enabled, a continuous video of the
+ * browser session is recorded and saved as a .webm file. Chrome/Chromium
+ * uses CDP push mode; all other browsers fall back to screenshot polling.
+ */
+ screencast?: ScreencastOptions
}
declare namespace WebdriverIO {
diff --git a/packages/service/src/video-encoder.ts b/packages/service/src/video-encoder.ts
new file mode 100644
index 0000000..d92cc02
--- /dev/null
+++ b/packages/service/src/video-encoder.ts
@@ -0,0 +1,151 @@
+import fs from 'node:fs/promises'
+import path from 'node:path'
+import os from 'node:os'
+import { createRequire } from 'node:module'
+
+import logger from '@wdio/logger'
+
+import type { ScreencastFrame, ScreencastOptions } from './types.js'
+
+// fluent-ffmpeg uses `export =` (CommonJS). With module:NodeNext, dynamic
+// import() of such modules doesn't resolve .default correctly in TypeScript.
+// createRequire is the idiomatic way to load CJS modules in ESM.
+const require = createRequire(import.meta.url)
+
+const log = logger('@wdio/devtools-service:VideoEncoder')
+
+/**
+ * Encodes an array of CDP screencast frames into a .webm video file using
+ * ffmpeg (via fluent-ffmpeg) and the VP8 codec (libvpx).
+ *
+ * Strategy:
+ * 1. Write each frame as a JPEG (or PNG) file in a temp directory.
+ * 2. Write an ffconcat manifest that assigns each frame its exact display
+ * duration based on the inter-frame timestamp delta. This produces a
+ * variable-frame-rate video that accurately reflects real timing even
+ * when commands cause long pauses between frames.
+ * 3. Run ffmpeg with the concat demuxer → libvpx (VP8) → .webm output.
+ * 4. Clean up the temp directory regardless of success or failure.
+ *
+ * @throws If no frames are provided, if fluent-ffmpeg is not installed, or if
+ * the ffmpeg binary is not found on PATH.
+ */
+export async function encodeToVideo(
+ frames: ScreencastFrame[],
+ outputPath: string,
+ options: Pick = {}
+): Promise {
+ if (frames.length === 0) {
+ throw new Error('VideoEncoder: no frames to encode')
+ }
+
+ // Load fluent-ffmpeg via require so TypeScript is happy with the export=
+ // style module. Wrap in try/catch for a clear missing-package message.
+ // fluent-ffmpeg is an optional peer dependency so we use `any` here.
+
+ let ffmpeg: any
+ try {
+ ffmpeg = require('fluent-ffmpeg')
+ } catch {
+ throw new Error(
+ 'VideoEncoder: fluent-ffmpeg is required for screencast encoding. ' +
+ 'Install it with: npm install fluent-ffmpeg'
+ )
+ }
+
+ const ext = options.captureFormat === 'png' ? 'png' : 'jpg'
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wdio-screencast-'))
+
+ try {
+ // ── Step 1: write frame files ──────────────────────────────────────────
+ const manifestLines: string[] = ['ffconcat version 1.0']
+
+ for (let i = 0; i < frames.length; i++) {
+ const frameName = `frame-${String(i).padStart(6, '0')}.${ext}`
+ const framePath = path.join(tmpDir, frameName)
+
+ await fs.writeFile(framePath, Buffer.from(frames[i].data, 'base64'))
+
+ // Duration = time until the NEXT frame (or 100 ms for the last frame).
+ const nextTs = frames[i + 1]?.timestamp ?? frames[i].timestamp + 100
+ const durationSecs = Math.max((nextTs - frames[i].timestamp) / 1000, 0.01)
+
+ manifestLines.push(`file '${framePath}'`)
+ manifestLines.push(`duration ${durationSecs.toFixed(6)}`)
+ }
+
+ // ffconcat requires the last file entry to be listed a second time without
+ // a duration so the muxer knows where the last frame ends.
+ const lastFramePath = path.join(
+ tmpDir,
+ `frame-${String(frames.length - 1).padStart(6, '0')}.${ext}`
+ )
+ manifestLines.push(`file '${lastFramePath}'`)
+
+ const manifestPath = path.join(tmpDir, 'manifest.txt')
+ await fs.writeFile(manifestPath, manifestLines.join('\n'))
+
+ // ── Step 2: encode with ffmpeg ─────────────────────────────────────────
+ log.info(`VideoEncoder: encoding ${frames.length} frames → ${outputPath}`)
+
+ await new Promise((resolve, reject) => {
+ ffmpeg()
+ .input(manifestPath)
+ .inputOptions(['-f', 'concat', '-safe', '0'])
+ // VP8 (libvpx) produces broadly compatible WebM that plays in Chrome,
+ // Firefox, VS Code's built-in media player, and most video players.
+ // VP9 CRF mode has widespread issues with incorrect color-space metadata
+ // (bt470bg instead of bt709) and missing stream PTS that cause players to
+ // report "invalid file" even when the container is well-formed.
+ .videoCodec('libvpx')
+ .outputOptions([
+ // 1 Mbit/s target — good quality at reasonable file size for screencasts
+ '-b:v',
+ '1M',
+ // Standard chroma subsampling required for VP8
+ '-pix_fmt',
+ 'yuv420p',
+ // Preserve the variable frame rate from the concat manifest timestamps.
+ // Without this ffmpeg re-timestamps frames to a fixed rate and the
+ // per-frame durations written in the manifest are ignored.
+ '-vsync',
+ 'vfr',
+ // Disable alt-ref frames — required for WebM muxer compatibility
+ '-auto-alt-ref',
+ '0',
+ // Mark the video stream as the default track so Chrome/VS Code
+ // select it automatically without needing an explicit track selection
+ '-disposition:v',
+ 'default'
+ ])
+ .output(outputPath)
+ .on('end', () => resolve())
+ .on('error', (err: Error) => {
+ const msg = err.message || ''
+ if (
+ msg.includes('Cannot find ffmpeg') ||
+ msg.includes('ENOENT') ||
+ msg.includes('spawn') ||
+ msg.includes('not found')
+ ) {
+ reject(
+ new Error(
+ 'VideoEncoder: ffmpeg binary not found on PATH. ' +
+ 'Install ffmpeg: https://ffmpeg.org/download.html'
+ )
+ )
+ } else {
+ reject(new Error(`VideoEncoder: ffmpeg error — ${msg}`))
+ }
+ })
+ .run()
+ })
+
+ log.info(`✓ Screencast video saved: ${outputPath}`)
+ } finally {
+ // Always clean up temp files, even if encoding failed.
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch((rmErr) => {
+ log.warn(`VideoEncoder: failed to clean temp dir — ${rmErr.message}`)
+ })
+ }
+}
diff --git a/packages/service/tests/index.test.ts b/packages/service/tests/index.test.ts
index 9d39c59..25a6e78 100644
--- a/packages/service/tests/index.test.ts
+++ b/packages/service/tests/index.test.ts
@@ -14,12 +14,15 @@ const mockSessionCapturerInstance = {
afterCommand: vi.fn(),
sendUpstream: vi.fn(),
injectScript: vi.fn().mockResolvedValue(undefined),
+ cleanup: vi.fn(),
commandsLog: [],
sources: new Map(),
mutations: [],
traceLogs: [],
consoleLogs: [],
- isReportingUpstream: false
+ networkRequests: [],
+ isReportingUpstream: false,
+ metadata: { url: 'http://test.com', viewport: {} }
}
vi.mock('../src/session.js', () => ({
@@ -28,6 +31,29 @@ vi.mock('../src/session.js', () => ({
})
}))
+const mockScreencastRecorder = {
+ start: vi.fn().mockResolvedValue(undefined),
+ stop: vi.fn().mockResolvedValue(undefined),
+ setStartMarker: vi.fn(),
+ frames: [] as any[],
+ duration: 0,
+ isRecording: false
+}
+
+vi.mock('../src/screencast.js', () => ({
+ ScreencastRecorder: vi.fn(function () {
+ return mockScreencastRecorder
+ })
+}))
+
+vi.mock('../src/video-encoder.js', () => ({
+ encodeToVideo: vi.fn().mockResolvedValue(undefined)
+}))
+
+vi.mock('node:fs/promises', () => ({
+ default: { writeFile: vi.fn().mockResolvedValue(undefined) }
+}))
+
describe('DevtoolsService - Internal Command Filtering', () => {
let service: DevToolsHookService
const mockBrowser = {
@@ -112,3 +138,114 @@ describe('DevtoolsService - Internal Command Filtering', () => {
})
})
})
+
+describe('DevtoolsService - Screencast Integration', () => {
+ let service: DevToolsHookService
+ const mockBrowser = {
+ isBidi: true,
+ sessionId: 'session-123',
+ scriptAddPreloadScript: vi.fn().mockResolvedValue(undefined),
+ takeScreenshot: vi.fn().mockResolvedValue('screenshot'),
+ execute: vi.fn().mockResolvedValue({
+ width: 1200,
+ height: 800,
+ offsetLeft: 0,
+ offsetTop: 0
+ }),
+ on: vi.fn(),
+ emit: vi.fn(),
+ options: { rootDir: '/project/example' },
+ capabilities: { browserName: 'chrome' }
+ } as any
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockScreencastRecorder.frames = []
+ mockScreencastRecorder.duration = 0
+ })
+
+ it('full lifecycle: start → setStartMarker on url → encode on after() → notify backend', async () => {
+ const { encodeToVideo } = await import('../src/video-encoder.js')
+ service = new DevToolsHookService({ screencast: { enabled: true } })
+ await service.before({} as any, [], mockBrowser)
+
+ // Recorder started
+ expect(mockScreencastRecorder.start).toHaveBeenCalledWith(mockBrowser)
+
+ // setStartMarker fires on 'url', not on 'click'
+ service.beforeCommand('click' as any, ['.button'])
+ expect(mockScreencastRecorder.setStartMarker).not.toHaveBeenCalled()
+ service.beforeCommand('url' as any, ['https://example.com'])
+ expect(mockScreencastRecorder.setStartMarker).toHaveBeenCalled()
+
+ // after() stops, encodes, and notifies
+ mockScreencastRecorder.frames = Array(10).fill({
+ data: 'framedata',
+ timestamp: 1000
+ })
+ mockScreencastRecorder.duration = 5000
+ await service.after()
+
+ expect(mockScreencastRecorder.stop).toHaveBeenCalled()
+ expect(encodeToVideo).toHaveBeenCalledWith(
+ mockScreencastRecorder.frames,
+ expect.stringContaining('wdio-video-session-123.webm'),
+ expect.any(Object)
+ )
+ expect(mockSessionCapturerInstance.sendUpstream).toHaveBeenCalledWith(
+ 'screencast',
+ expect.objectContaining({
+ sessionId: 'session-123',
+ frameCount: 10,
+ duration: 5000
+ })
+ )
+ })
+
+ it('skips when disabled, skips ghost sessions, and swallows encode errors', async () => {
+ const { encodeToVideo } = await import('../src/video-encoder.js')
+
+ // Disabled — recorder never starts
+ service = new DevToolsHookService({})
+ await service.before({} as any, [], mockBrowser)
+ expect(mockScreencastRecorder.start).not.toHaveBeenCalled()
+
+ // Ghost session — <5 frames, encoding skipped
+ service = new DevToolsHookService({ screencast: { enabled: true } })
+ await service.before({} as any, [], mockBrowser)
+ mockScreencastRecorder.frames = Array(3).fill({
+ data: 'f',
+ timestamp: 1000
+ })
+ vi.mocked(encodeToVideo).mockClear()
+ await service.after()
+ expect(encodeToVideo).not.toHaveBeenCalled()
+
+ // Encode error — swallowed, doesn't throw
+ service = new DevToolsHookService({ screencast: { enabled: true } })
+ await service.before({} as any, [], mockBrowser)
+ mockScreencastRecorder.frames = Array(10).fill({
+ data: 'f',
+ timestamp: 1000
+ })
+ vi.mocked(encodeToVideo).mockRejectedValueOnce(new Error('ffmpeg missing'))
+ await expect(service.after()).resolves.toBeUndefined()
+ })
+
+ it('onReload finalizes old session and starts fresh recorder', async () => {
+ const { ScreencastRecorder } = await import('../src/screencast.js')
+ service = new DevToolsHookService({ screencast: { enabled: true } })
+ await service.before({} as any, [], mockBrowser)
+ vi.clearAllMocks()
+
+ mockScreencastRecorder.frames = Array(10).fill({
+ data: 'f',
+ timestamp: 1000
+ })
+ await service.onReload('old-session', 'new-session')
+
+ expect(mockScreencastRecorder.stop).toHaveBeenCalled()
+ expect(ScreencastRecorder).toHaveBeenCalled()
+ expect(mockScreencastRecorder.start).toHaveBeenCalledWith(mockBrowser)
+ })
+})
diff --git a/packages/service/tests/screencast.test.ts b/packages/service/tests/screencast.test.ts
new file mode 100644
index 0000000..acfd90c
--- /dev/null
+++ b/packages/service/tests/screencast.test.ts
@@ -0,0 +1,171 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { ScreencastRecorder } from '../src/screencast.js'
+
+vi.mock('@wdio/logger', () => {
+ const mockLogger = {
+ info: vi.fn(),
+ warn: vi.fn(),
+ debug: vi.fn(),
+ error: vi.fn()
+ }
+ return { default: vi.fn(() => mockLogger) }
+})
+
+/** Helper: create a CDP-backed recorder with a frame handler ref. */
+function createCdpSetup() {
+ let frameHandler: (event: any) => void
+ const cdpSession = {
+ send: vi.fn().mockResolvedValue(undefined),
+ on: vi.fn((event: string, handler: any) => {
+ if (event === 'Page.screencastFrame') {
+ frameHandler = handler
+ }
+ })
+ }
+ const browser = {
+ getPuppeteer: vi.fn().mockResolvedValue({
+ pages: vi
+ .fn()
+ .mockResolvedValue([
+ { createCDPSession: vi.fn().mockResolvedValue(cdpSession) }
+ ])
+ })
+ } as any
+
+ const pushFrame = (data: string, timestampSec: number, sessionId = 1) =>
+ frameHandler({ data, metadata: { timestamp: timestampSec }, sessionId })
+
+ return { browser, cdpSession, pushFrame }
+}
+
+describe('ScreencastRecorder', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.restoreAllMocks()
+ })
+
+ it('CDP mode: start → collect frames with acks → stop', async () => {
+ const { browser, cdpSession, pushFrame } = createCdpSetup()
+ const recorder = new ScreencastRecorder()
+
+ // Start
+ await recorder.start(browser)
+ expect(recorder.isRecording).toBe(true)
+ expect(cdpSession.send).toHaveBeenCalledWith(
+ 'Page.startScreencast',
+ expect.objectContaining({ format: 'jpeg', quality: 70 })
+ )
+
+ // Collect frames — timestamps are converted from seconds to ms
+ pushFrame('frame1', 1.0, 1)
+ pushFrame('frame2', 2.5, 2)
+ expect(recorder.frames).toHaveLength(2)
+ expect(recorder.frames[0]).toEqual({ data: 'frame1', timestamp: 1000 })
+ expect(recorder.frames[1]).toEqual({ data: 'frame2', timestamp: 2500 })
+ expect(cdpSession.send).toHaveBeenCalledWith('Page.screencastFrameAck', {
+ sessionId: 1
+ })
+
+ // Duration
+ expect(recorder.duration).toBe(1500)
+
+ // Stop
+ await recorder.stop()
+ expect(recorder.isRecording).toBe(false)
+ expect(cdpSession.send).toHaveBeenCalledWith('Page.stopScreencast')
+
+ // Stop again — no-op, no throw
+ await recorder.stop()
+ })
+
+ it('polling mode: fallback when CDP unavailable → collect at interval → stop', async () => {
+ vi.useFakeTimers()
+ const browser = {
+ getPuppeteer: vi.fn().mockRejectedValue(new Error('No puppeteer')),
+ takeScreenshot: vi
+ .fn()
+ .mockResolvedValueOnce('shot1')
+ .mockResolvedValueOnce('shot2')
+ .mockResolvedValueOnce('shot3')
+ } as any
+
+ const recorder = new ScreencastRecorder({ pollIntervalMs: 200 })
+ await recorder.start(browser)
+
+ // Immediate first frame + recording started
+ expect(recorder.isRecording).toBe(true)
+ expect(recorder.frames).toHaveLength(1)
+ expect(recorder.frames[0].data).toBe('shot1')
+
+ // Interval ticks collect more frames
+ await vi.advanceTimersByTimeAsync(200)
+ expect(recorder.frames).toHaveLength(2)
+ await vi.advanceTimersByTimeAsync(200)
+ expect(recorder.frames).toHaveLength(3)
+
+ await recorder.stop()
+ expect(recorder.isRecording).toBe(false)
+ vi.useRealTimers()
+ })
+
+ it('polling: screenshot failure stops timer, initial failure skips recording', async () => {
+ // Mid-polling failure — timer cleared, no more frames
+ vi.useFakeTimers()
+ const failBrowser = {
+ getPuppeteer: vi.fn().mockRejectedValue(new Error('No puppeteer')),
+ takeScreenshot: vi
+ .fn()
+ .mockResolvedValueOnce('ok')
+ .mockRejectedValueOnce(new Error('Session ended'))
+ .mockResolvedValueOnce('should-not-appear')
+ } as any
+
+ const rec1 = new ScreencastRecorder({ pollIntervalMs: 200 })
+ await rec1.start(failBrowser)
+ await vi.advanceTimersByTimeAsync(200) // failure tick
+ const countAfterError = rec1.frames.length
+ await vi.advanceTimersByTimeAsync(200) // timer should be cleared
+ expect(rec1.frames).toHaveLength(countAfterError)
+ vi.useRealTimers()
+
+ // Initial screenshot fails — recording never starts
+ const noBrowser = {
+ getPuppeteer: vi.fn().mockRejectedValue(new Error('No puppeteer')),
+ takeScreenshot: vi.fn().mockRejectedValue(new Error('Not supported'))
+ } as any
+ const rec2 = new ScreencastRecorder()
+ await rec2.start(noBrowser)
+ expect(rec2.isRecording).toBe(false)
+ expect(rec2.frames).toEqual([])
+ })
+
+ it('setStartMarker trims leading frames and is idempotent', async () => {
+ const { browser, pushFrame } = createCdpSetup()
+ const recorder = new ScreencastRecorder()
+ await recorder.start(browser)
+
+ // 2 blank frames before marker
+ pushFrame('blank1', 1.0)
+ pushFrame('blank2', 2.0)
+ recorder.setStartMarker()
+
+ // 2 meaningful frames
+ pushFrame('page1', 5.0)
+ recorder.setStartMarker() // second call — ignored
+ pushFrame('page2', 8.0)
+
+ // Only post-marker frames returned
+ expect(recorder.frames).toHaveLength(2)
+ expect(recorder.frames[0].data).toBe('page1')
+ expect(recorder.frames[1].data).toBe('page2')
+
+ // Duration based on trimmed frames: 8000 - 5000
+ expect(recorder.duration).toBe(3000)
+ })
+
+ it('stop is safe when never started', async () => {
+ const recorder = new ScreencastRecorder()
+ expect(recorder.duration).toBe(0)
+ await expect(recorder.stop()).resolves.toBeUndefined()
+ })
+})
diff --git a/packages/service/tests/video-encoder.test.ts b/packages/service/tests/video-encoder.test.ts
new file mode 100644
index 0000000..be8a577
--- /dev/null
+++ b/packages/service/tests/video-encoder.test.ts
@@ -0,0 +1,139 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import fs from 'node:fs/promises'
+import path from 'node:path'
+
+import { encodeToVideo } from '../src/video-encoder.js'
+import type { ScreencastFrame } from '../src/types.js'
+
+vi.mock('@wdio/logger', () => {
+ const mockLogger = {
+ info: vi.fn(),
+ warn: vi.fn(),
+ debug: vi.fn(),
+ error: vi.fn()
+ }
+ return { default: vi.fn(() => mockLogger) }
+})
+
+const mockFfmpegInstance = {
+ input: vi.fn().mockReturnThis(),
+ inputOptions: vi.fn().mockReturnThis(),
+ videoCodec: vi.fn().mockReturnThis(),
+ outputOptions: vi.fn().mockReturnThis(),
+ output: vi.fn().mockReturnThis(),
+ on: vi.fn().mockReturnThis(),
+ run: vi.fn()
+}
+const mockFfmpeg = vi.fn(() => mockFfmpegInstance)
+
+vi.mock('node:module', () => ({
+ createRequire: vi.fn(() => {
+ return (moduleName: string) => {
+ if (moduleName === 'fluent-ffmpeg') {
+ return mockFfmpeg
+ }
+ throw new Error(`Cannot find module '${moduleName}'`)
+ }
+ })
+}))
+
+vi.mock('node:fs/promises')
+vi.mock('node:os', () => ({
+ default: { tmpdir: vi.fn(() => '/tmp') }
+}))
+
+const makeFrames = (timestamps: number[]): ScreencastFrame[] =>
+ timestamps.map((ts, i) => ({
+ data: Buffer.from(`frame-${i}`).toString('base64'),
+ timestamp: ts
+ }))
+
+describe('encodeToVideo', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(fs.mkdtemp).mockResolvedValue('/tmp/wdio-screencast-abc123')
+ vi.mocked(fs.writeFile).mockResolvedValue(undefined)
+ vi.mocked(fs.rm).mockResolvedValue(undefined)
+
+ mockFfmpegInstance.on.mockImplementation(function (
+ this: typeof mockFfmpegInstance,
+ event: string,
+ handler: any
+ ) {
+ if (event === 'end') {
+ ;(this as any)._endHandler = handler
+ }
+ if (event === 'error') {
+ ;(this as any)._errorHandler = handler
+ }
+ return this
+ })
+ mockFfmpegInstance.run.mockImplementation(function (
+ this: typeof mockFfmpegInstance
+ ) {
+ ;(this as any)._endHandler?.()
+ })
+ })
+
+ it('should throw when no frames are provided', async () => {
+ await expect(encodeToVideo([], '/out/video.webm')).rejects.toThrow(
+ 'no frames to encode'
+ )
+ })
+
+ it('should write frames, build manifest with correct durations, and invoke ffmpeg', async () => {
+ const frames = makeFrames([1000, 1500, 2200])
+ await encodeToVideo(frames, '/out/video.webm')
+
+ // Temp dir created
+ expect(fs.mkdtemp).toHaveBeenCalledWith(
+ path.join('/tmp', 'wdio-screencast-')
+ )
+
+ // 3 frame files + 1 manifest = 4 writes
+ expect(fs.writeFile).toHaveBeenCalledTimes(4)
+ expect(fs.writeFile).toHaveBeenCalledWith(
+ '/tmp/wdio-screencast-abc123/frame-000000.jpg',
+ expect.any(Buffer)
+ )
+
+ // Manifest has correct variable-rate durations
+ const manifestCall = vi
+ .mocked(fs.writeFile)
+ .mock.calls.find((call) => String(call[0]).includes('manifest.txt'))
+ const manifest = String(manifestCall![1])
+ expect(manifest).toContain('ffconcat version 1.0')
+ expect(manifest).toContain('duration 0.500000') // 1000→1500
+ expect(manifest).toContain('duration 0.700000') // 1500→2200
+ expect(manifest).toContain('duration 0.100000') // last frame default
+
+ // ffmpeg called with VP8 codec
+ expect(mockFfmpegInstance.videoCodec).toHaveBeenCalledWith('libvpx')
+ expect(mockFfmpegInstance.output).toHaveBeenCalledWith('/out/video.webm')
+
+ // Temp dir cleaned up
+ expect(fs.rm).toHaveBeenCalledWith('/tmp/wdio-screencast-abc123', {
+ recursive: true,
+ force: true
+ })
+ })
+
+ it('should clean up temp dir and surface helpful error on ffmpeg failure', async () => {
+ // Missing ffmpeg binary
+ mockFfmpegInstance.run.mockImplementation(function (
+ this: typeof mockFfmpegInstance
+ ) {
+ ;(this as any)._errorHandler?.(new Error('Cannot find ffmpeg'))
+ })
+
+ await expect(
+ encodeToVideo(makeFrames([1000]), '/out/video.webm')
+ ).rejects.toThrow('ffmpeg binary not found')
+
+ // Temp dir still cleaned up on failure
+ expect(fs.rm).toHaveBeenCalledWith('/tmp/wdio-screencast-abc123', {
+ recursive: true,
+ force: true
+ })
+ })
+})