Skip to content

Commit 0566c12

Browse files
committed
Devtools screencast feature
1 parent 6847346 commit 0566c12

6 files changed

Lines changed: 620 additions & 28 deletions

File tree

packages/app/src/components/browser/snapshot.ts

Lines changed: 170 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Element } from '@core/element'
2-
import { html, css } from 'lit'
2+
import { html, css, nothing } from 'lit'
33
import { consume } from '@lit/context'
44

55
import { type ComponentChildren, h, render, type VNode } from 'preact'
@@ -19,6 +19,12 @@ import '../placeholder.js'
1919

2020
const MUTATION_SELECTOR = '__mutation-highlight__'
2121

22+
declare global {
23+
interface WindowEventMap {
24+
'screencast-ready': CustomEvent<{ sessionId: string }>
25+
}
26+
}
27+
2228
function transform(node: any): VNode<{}> {
2329
if (typeof node !== 'object' || node === null) {
2430
// Plain string/number text node — return as-is for Preact to render as text.
@@ -47,6 +53,20 @@ export class DevtoolsBrowser extends Element {
4753
#activeUrl?: string
4854
/** Base64 PNG of the screenshot for the currently selected command, or null. */
4955
#screenshotData: string | null = null
56+
/**
57+
* All recorded videos received from the backend, in arrival order.
58+
* Each entry is { sessionId, url } — a new entry is pushed for every
59+
* browser session (initial + after every reloadSession() call).
60+
*/
61+
#videos: Array<{ sessionId: string; url: string }> = []
62+
/** Index into #videos of the currently displayed video. */
63+
#activeVideoIdx = 0
64+
/**
65+
* Which view is active in the browser panel.
66+
* 'video' — always show the screencast player (default when a recording exists)
67+
* 'snapshot' — show DOM mutations replay and per-command screenshots
68+
*/
69+
#viewMode: 'snapshot' | 'video' = 'snapshot'
5070

5171
@consume({ context: metadataContext, subscribe: true })
5272
metadata: Metadata | undefined = undefined
@@ -136,13 +156,63 @@ export class DevtoolsBrowser extends Element {
136156
display: block;
137157
}
138158
159+
.screencast-player {
160+
width: 100%;
161+
height: 100%;
162+
object-fit: contain;
163+
background: #111;
164+
border-radius: 0 0 0.5rem 0.5rem;
165+
display: block;
166+
}
167+
139168
.iframe-wrapper {
140169
position: relative;
141170
flex: 1;
142171
min-height: 0;
143172
display: flex;
144173
flex-direction: column;
145174
}
175+
176+
.view-toggle {
177+
display: flex;
178+
gap: 2px;
179+
margin-left: 0.5rem;
180+
flex-shrink: 0;
181+
}
182+
183+
.view-toggle button {
184+
padding: 2px 10px;
185+
font-size: 11px;
186+
font-family: inherit;
187+
border: 1px solid var(--vscode-editorSuggestWidget-border, #454545);
188+
background: transparent;
189+
color: var(--vscode-input-foreground, #ccc);
190+
cursor: pointer;
191+
border-radius: 3px;
192+
line-height: 20px;
193+
transition:
194+
background 0.1s,
195+
color 0.1s;
196+
}
197+
198+
.view-toggle button.active {
199+
background: var(--vscode-button-background, #0e639c);
200+
color: var(--vscode-button-foreground, #fff);
201+
border-color: transparent;
202+
}
203+
204+
.video-select {
205+
font-size: 11px;
206+
font-family: inherit;
207+
padding: 2px 4px;
208+
border: 1px solid var(--vscode-dropdown-border, #454545);
209+
border-radius: 3px;
210+
background: var(--vscode-dropdown-background, #3c3c3c);
211+
color: var(--vscode-dropdown-foreground, #ccc);
212+
cursor: pointer;
213+
line-height: 20px;
214+
margin-left: 4px;
215+
}
146216
`
147217
]
148218

@@ -170,6 +240,10 @@ export class DevtoolsBrowser extends Element {
170240
'show-command',
171241
this.#handleShowCommand as EventListener
172242
)
243+
window.addEventListener(
244+
'screencast-ready',
245+
this.#handleScreencastReady as EventListener
246+
)
173247
await this.updateComplete
174248
}
175249

@@ -215,8 +289,34 @@ export class DevtoolsBrowser extends Element {
215289
(event as CustomEvent<{ command?: CommandLog }>).detail?.command
216290
)
217291

292+
#handleScreencastReady = (event: Event) => {
293+
const { sessionId } = (event as CustomEvent<{ sessionId: string }>).detail
294+
this.#videos.push({ sessionId, url: `/api/video/${sessionId}` })
295+
// Always show the latest video and switch to video mode automatically
296+
this.#activeVideoIdx = this.#videos.length - 1
297+
this.#viewMode = 'video'
298+
this.requestUpdate()
299+
}
300+
301+
#setViewMode(mode: 'snapshot' | 'video') {
302+
this.#viewMode = mode
303+
this.requestUpdate()
304+
}
305+
306+
#setActiveVideo(idx: number) {
307+
this.#activeVideoIdx = idx
308+
this.requestUpdate()
309+
}
310+
311+
/** URL of the currently selected video, or null when no videos exist. */
312+
get #activeVideoUrl(): string | null {
313+
return this.#videos[this.#activeVideoIdx]?.url ?? null
314+
}
315+
218316
async #renderCommandScreenshot(command?: CommandLog) {
219317
this.#screenshotData = command?.screenshot ?? null
318+
// Switch to snapshot mode so the command screenshot is visible instead of the video.
319+
this.#viewMode = 'snapshot'
220320
this.requestUpdate()
221321
}
222322

@@ -461,32 +561,79 @@ export class DevtoolsBrowser extends Element {
461561
></icon-mdi-world>
462562
<span class="truncate">${this.#activeUrl}</span>
463563
</div>
564+
${this.#videos.length > 0
565+
? html`
566+
<div class="view-toggle">
567+
<button
568+
class=${this.#viewMode === 'snapshot' ? 'active' : ''}
569+
@click=${() => this.#setViewMode('snapshot')}
570+
>
571+
Snapshot
572+
</button>
573+
<button
574+
class=${this.#viewMode === 'video' ? 'active' : ''}
575+
@click=${() => this.#setViewMode('video')}
576+
>
577+
Video
578+
</button>
579+
${this.#videos.length > 1
580+
? html`<select
581+
class="video-select"
582+
@change=${(e: Event) => {
583+
this.#setActiveVideo(
584+
Number((e.target as HTMLSelectElement).value)
585+
)
586+
this.#setViewMode('video')
587+
}}
588+
>
589+
${this.#videos.map(
590+
(_v, i) =>
591+
html`<option
592+
value=${i}
593+
?selected=${this.#activeVideoIdx === i}
594+
>
595+
Recording ${i + 1}
596+
</option>`
597+
)}
598+
</select>`
599+
: nothing}
600+
</div>
601+
`
602+
: nothing}
464603
</header>
465-
${this.#screenshotData
466-
? html` <div class="iframe-wrapper">
467-
<div
468-
class="screenshot-overlay"
469-
style="position:relative;flex:1;min-height:0;"
470-
>
471-
<img src="data:image/png;base64,${this.#screenshotData}" />
472-
</div>
604+
${this.#viewMode === 'video' && this.#activeVideoUrl
605+
? html`<div class="iframe-wrapper">
606+
<video
607+
class="screencast-player"
608+
src="${this.#activeVideoUrl}"
609+
controls
610+
></video>
473611
</div>`
474-
: hasMutations
475-
? html` <div class="iframe-wrapper">
476-
<iframe class="origin-top-left"></iframe>
612+
: this.#screenshotData
613+
? html`<div class="iframe-wrapper">
614+
<div
615+
class="screenshot-overlay"
616+
style="position:relative;flex:1;min-height:0;"
617+
>
618+
<img src="data:image/png;base64,${this.#screenshotData}" />
619+
</div>
477620
</div>`
478-
: displayScreenshot
479-
? html` <div class="iframe-wrapper">
480-
<div
481-
class="screenshot-overlay"
482-
style="position:relative;flex:1;min-height:0;"
483-
>
484-
<img src="data:image/png;base64,${displayScreenshot}" />
485-
</div>
621+
: hasMutations
622+
? html`<div class="iframe-wrapper">
623+
<iframe class="origin-top-left"></iframe>
486624
</div>`
487-
: html`<wdio-devtools-placeholder
488-
style="height: 100%"
489-
></wdio-devtools-placeholder>`}
625+
: displayScreenshot
626+
? html`<div class="iframe-wrapper">
627+
<div
628+
class="screenshot-overlay"
629+
style="position:relative;flex:1;min-height:0;"
630+
>
631+
<img src="data:image/png;base64,${displayScreenshot}" />
632+
</div>
633+
</div>`
634+
: html`<wdio-devtools-placeholder
635+
style="height: 100%"
636+
></wdio-devtools-placeholder>`}
490637
</section>
491638
`
492639
}

packages/app/src/controller/DataManager.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,14 @@ export class DataManagerController implements ReactiveController {
308308
return
309309
}
310310

311+
if (scope === 'screencast') {
312+
const { sessionId } = data as { sessionId: string }
313+
window.dispatchEvent(
314+
new CustomEvent('screencast-ready', { detail: { sessionId } })
315+
)
316+
return
317+
}
318+
311319
if (scope === 'clearExecutionData') {
312320
const { uid, entryType } =
313321
data as SocketMessage<'clearExecutionData'>['data']

packages/backend/src/index.ts

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fs from 'node:fs'
12
import url from 'node:url'
23

34
import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify'
@@ -22,6 +23,13 @@ interface DevtoolsBackendOptions {
2223
const log = logger('@wdio/devtools-backend')
2324
const clients = new Set<WebSocket>()
2425

26+
/**
27+
* Registry mapping sessionId → absolute path of the encoded .webm file.
28+
* Populated when the service sends { scope: 'screencast', data: { sessionId, videoPath } }.
29+
* Queried by GET /api/video/:sessionId.
30+
*/
31+
const videoRegistry = new Map<string, string>()
32+
2533
export function broadcastToClients(message: string) {
2634
clients.forEach((client) => {
2735
if (client.readyState === WebSocket.OPEN) {
@@ -102,28 +110,49 @@ export async function start(
102110
`received ${message.length} byte message from worker to ${clients.size} client${clients.size > 1 ? 's' : ''}`
103111
)
104112

105-
// Parse message to check if it's a clearCommands message
113+
// Parse message to check if it needs special handling
106114
try {
107115
const parsed = JSON.parse(message.toString())
108116

109-
// If this is a clearCommands message, transform it to clear-execution-data format
117+
// Transform clearCommands → clearExecutionData for the UI
110118
if (parsed.scope === 'clearCommands') {
111119
const testUid = parsed.data?.testUid
112120
log.info(`Clearing commands for test: ${testUid || 'all'}`)
113-
114-
// Create a synthetic message that DataManager will understand
115121
const clearMessage = JSON.stringify({
116122
scope: 'clearExecutionData',
117123
data: { uid: testUid }
118124
})
119-
120125
clients.forEach((client) => {
121126
if (client.readyState === WebSocket.OPEN) {
122127
client.send(clearMessage)
123128
}
124129
})
125130
return
126131
}
132+
133+
// Intercept screencast messages: store the absolute videoPath in the
134+
// registry (backend-only), then forward only the sessionId to the UI
135+
// so the UI can request the video via GET /api/video/:sessionId.
136+
if (parsed.scope === 'screencast' && parsed.data?.sessionId) {
137+
const { sessionId, videoPath } = parsed.data
138+
if (videoPath) {
139+
videoRegistry.set(sessionId, videoPath)
140+
log.info(
141+
`Screencast registered for session ${sessionId}: ${videoPath}`
142+
)
143+
}
144+
// Forward trimmed message (no videoPath) to UI clients
145+
const uiMessage = JSON.stringify({
146+
scope: 'screencast',
147+
data: { sessionId }
148+
})
149+
clients.forEach((client) => {
150+
if (client.readyState === WebSocket.OPEN) {
151+
client.send(uiMessage)
152+
}
153+
})
154+
return
155+
}
127156
} catch {
128157
// Not JSON or parsing failed, forward as-is
129158
}
@@ -138,6 +167,29 @@ export async function start(
138167
}
139168
)
140169

170+
// Serve recorded screencast videos. The service sends an absolute videoPath
171+
// which is stored in videoRegistry; the UI only knows the sessionId and
172+
// requests the file through this endpoint.
173+
server.get(
174+
'/api/video/:sessionId',
175+
async (
176+
request: FastifyRequest<{ Params: { sessionId: string } }>,
177+
reply
178+
) => {
179+
const { sessionId } = request.params
180+
const videoPath = videoRegistry.get(sessionId)
181+
if (!videoPath) {
182+
return reply.code(404).send({ error: 'Video not found' })
183+
}
184+
if (!fs.existsSync(videoPath)) {
185+
return reply.code(404).send({ error: 'Video file missing from disk' })
186+
}
187+
return reply
188+
.header('Content-Type', 'video/webm')
189+
.send(fs.createReadStream(videoPath))
190+
}
191+
)
192+
141193
log.info(`Starting WebdriverIO Devtools application on port ${port}`)
142194
await server.listen({ port, host })
143195
return { server, port }

packages/service/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@wdio/logger": "9.18.0",
4343
"@wdio/reporter": "9.27.0",
4444
"@wdio/types": "9.27.0",
45+
"fluent-ffmpeg": "^2.1.3",
4546
"import-meta-resolve": "^4.1.0",
4647
"stack-trace": "1.0.0-pre2",
4748
"ws": "^8.18.3"
@@ -50,6 +51,7 @@
5051
"devDependencies": {
5152
"@types/babel__core": "^7.20.5",
5253
"@types/babel__traverse": "^7.28.0",
54+
"@types/fluent-ffmpeg": "^2.1.27",
5355
"@types/stack-trace": "^0.0.33",
5456
"@types/ws": "^8.18.1",
5557
"@wdio/globals": "9.27.0",

0 commit comments

Comments
 (0)