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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -68,6 +79,9 @@ Works with **WebdriverIO** and **[Nightwatch.js](./packages/nightwatch-devtools/

<img src="https://github.com/user-attachments/assets/0f81e0af-75b5-454f-bffb-e40654c89908" alt="Network Logs 2" width="400" />

### 🎬 Session Screencast
<img src="https://github.com/user-attachments/assets/65914424-480f-4b50-b54a-69e659710f15" alt="Screencast" width="400" />

## Installation

```bash
Expand Down
17 changes: 15 additions & 2 deletions example/wdio.conf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand Down
193 changes: 170 additions & 23 deletions packages/app/src/components/browser/snapshot.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -136,13 +156,63 @@ 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;
min-height: 0;
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;
}
`
]

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -461,32 +561,79 @@ export class DevtoolsBrowser extends Element {
></icon-mdi-world>
<span class="truncate">${this.#activeUrl}</span>
</div>
${this.#videos.length > 0
? html`
<div class="view-toggle">
<button
class=${this.#viewMode === 'snapshot' ? 'active' : ''}
@click=${() => this.#setViewMode('snapshot')}
>
Snapshot
</button>
<button
class=${this.#viewMode === 'video' ? 'active' : ''}
@click=${() => this.#setViewMode('video')}
>
Screencast
</button>
${this.#videos.length > 1
? html`<select
class="video-select"
@change=${(e: Event) => {
this.#setActiveVideo(
Number((e.target as HTMLSelectElement).value)
)
this.#setViewMode('video')
}}
>
${this.#videos.map(
(_v, i) =>
html`<option
value=${i}
?selected=${this.#activeVideoIdx === i}
>
Recording ${i + 1}
</option>`
)}
</select>`
: nothing}
</div>
`
: nothing}
</header>
${this.#screenshotData
? html` <div class="iframe-wrapper">
<div
class="screenshot-overlay"
style="position:relative;flex:1;min-height:0;"
>
<img src="data:image/png;base64,${this.#screenshotData}" />
</div>
${this.#viewMode === 'video' && this.#activeVideoUrl
? html`<div class="iframe-wrapper">
<video
class="screencast-player"
src="${this.#activeVideoUrl}"
controls
></video>
</div>`
: hasMutations
? html` <div class="iframe-wrapper">
<iframe class="origin-top-left"></iframe>
: this.#screenshotData
? html`<div class="iframe-wrapper">
<div
class="screenshot-overlay"
style="position:relative;flex:1;min-height:0;"
>
<img src="data:image/png;base64,${this.#screenshotData}" />
</div>
</div>`
: displayScreenshot
? html` <div class="iframe-wrapper">
<div
class="screenshot-overlay"
style="position:relative;flex:1;min-height:0;"
>
<img src="data:image/png;base64,${displayScreenshot}" />
</div>
: hasMutations
? html`<div class="iframe-wrapper">
<iframe class="origin-top-left"></iframe>
</div>`
: html`<wdio-devtools-placeholder
style="height: 100%"
></wdio-devtools-placeholder>`}
: displayScreenshot
? html`<div class="iframe-wrapper">
<div
class="screenshot-overlay"
style="position:relative;flex:1;min-height:0;"
>
<img src="data:image/png;base64,${displayScreenshot}" />
</div>
</div>`
: html`<wdio-devtools-placeholder
style="height: 100%"
></wdio-devtools-placeholder>`}
</section>
`
}
Expand Down
8 changes: 8 additions & 0 deletions packages/app/src/controller/DataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
1 change: 1 addition & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading