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/ Network Logs 2 +### 🎬 Session Screencast +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 + }) + }) +})