Skip to content

Commit 99dccd5

Browse files
committed
Unit test and readme update
1 parent 4c9fa2b commit 99dccd5

5 files changed

Lines changed: 482 additions & 1 deletion

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ Works with **WebdriverIO** and **[Nightwatch.js](./packages/nightwatch-devtools/
3737
- **Actions Tab Auto-Clear**: Execution data automatically clears and refreshes on reruns
3838
- **Metadata Tracking**: Test duration, status, and execution timestamps
3939

40+
### 🎬 Session Screencast
41+
- **Automatic Video Recording**: Captures a continuous `.webm` video of the browser session alongside the existing snapshot and DOM mutation views
42+
- **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
43+
- **Per-Session Videos**: Each browser session (including sessions created by `browser.reloadSession()`) produces its own recording, selectable from a dropdown in the UI
44+
- **Smart Trimming**: Leading blank frames before the first URL navigation are automatically removed so videos start at the first meaningful page action
45+
46+
> For setup, configuration options, and prerequisites see the **[service README](./packages/service/README.md#screencast-recording)**.
47+
4048
### 🔍︎ TestLens
4149
- **Code Intelligence**: View test definitions directly in your editor
4250
- **Run/Debug Actions**: Execute individual tests or suites with inline CodeLens actions
@@ -68,6 +76,9 @@ Works with **WebdriverIO** and **[Nightwatch.js](./packages/nightwatch-devtools/
6876

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

79+
### 🎬 Session Screencast
80+
81+
7182
## Installation
7283

7384
```bash

packages/backend/tests/index.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,29 @@ describe('backend index', () => {
2626
})
2727
})
2828

29+
describe('video endpoint', () => {
30+
it('should return 404 for unknown session and respect rate limit', async () => {
31+
vi.mocked(utils.getDevtoolsApp).mockResolvedValue('/mock/app/path')
32+
const { server } = await start({ port: 0 })
33+
34+
// Unknown sessionId → 404
35+
const res = await server?.inject({
36+
method: 'GET',
37+
url: '/api/video/unknown-session'
38+
})
39+
expect(res?.statusCode).toBe(404)
40+
expect(JSON.parse(res?.body || '{}')).toEqual({
41+
error: 'Video not found'
42+
})
43+
44+
// Rate limit header is present (proves preHandler is active)
45+
const headers = res?.headers || {}
46+
expect(headers['x-ratelimit-limit']).toBeDefined()
47+
48+
await server?.close()
49+
})
50+
})
51+
2952
describe('API endpoints', () => {
3053
it('should handle test run and stop requests with validation', async () => {
3154
vi.mocked(utils.getDevtoolsApp).mockResolvedValue('/mock/app/path')

packages/service/tests/index.test.ts

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@ const mockSessionCapturerInstance = {
1414
afterCommand: vi.fn(),
1515
sendUpstream: vi.fn(),
1616
injectScript: vi.fn().mockResolvedValue(undefined),
17+
cleanup: vi.fn(),
1718
commandsLog: [],
1819
sources: new Map(),
1920
mutations: [],
2021
traceLogs: [],
2122
consoleLogs: [],
22-
isReportingUpstream: false
23+
networkRequests: [],
24+
isReportingUpstream: false,
25+
metadata: { url: 'http://test.com', viewport: {} }
2326
}
2427

2528
vi.mock('../src/session.js', () => ({
@@ -28,6 +31,29 @@ vi.mock('../src/session.js', () => ({
2831
})
2932
}))
3033

34+
const mockScreencastRecorder = {
35+
start: vi.fn().mockResolvedValue(undefined),
36+
stop: vi.fn().mockResolvedValue(undefined),
37+
setStartMarker: vi.fn(),
38+
frames: [] as any[],
39+
duration: 0,
40+
isRecording: false
41+
}
42+
43+
vi.mock('../src/screencast.js', () => ({
44+
ScreencastRecorder: vi.fn(function () {
45+
return mockScreencastRecorder
46+
})
47+
}))
48+
49+
vi.mock('../src/video-encoder.js', () => ({
50+
encodeToVideo: vi.fn().mockResolvedValue(undefined)
51+
}))
52+
53+
vi.mock('node:fs/promises', () => ({
54+
default: { writeFile: vi.fn().mockResolvedValue(undefined) }
55+
}))
56+
3157
describe('DevtoolsService - Internal Command Filtering', () => {
3258
let service: DevToolsHookService
3359
const mockBrowser = {
@@ -112,3 +138,114 @@ describe('DevtoolsService - Internal Command Filtering', () => {
112138
})
113139
})
114140
})
141+
142+
describe('DevtoolsService - Screencast Integration', () => {
143+
let service: DevToolsHookService
144+
const mockBrowser = {
145+
isBidi: true,
146+
sessionId: 'session-123',
147+
scriptAddPreloadScript: vi.fn().mockResolvedValue(undefined),
148+
takeScreenshot: vi.fn().mockResolvedValue('screenshot'),
149+
execute: vi.fn().mockResolvedValue({
150+
width: 1200,
151+
height: 800,
152+
offsetLeft: 0,
153+
offsetTop: 0
154+
}),
155+
on: vi.fn(),
156+
emit: vi.fn(),
157+
options: { rootDir: '/project/example' },
158+
capabilities: { browserName: 'chrome' }
159+
} as any
160+
161+
beforeEach(() => {
162+
vi.clearAllMocks()
163+
mockScreencastRecorder.frames = []
164+
mockScreencastRecorder.duration = 0
165+
})
166+
167+
it('full lifecycle: start → setStartMarker on url → encode on after() → notify backend', async () => {
168+
const { encodeToVideo } = await import('../src/video-encoder.js')
169+
service = new DevToolsHookService({ screencast: { enabled: true } })
170+
await service.before({} as any, [], mockBrowser)
171+
172+
// Recorder started
173+
expect(mockScreencastRecorder.start).toHaveBeenCalledWith(mockBrowser)
174+
175+
// setStartMarker fires on 'url', not on 'click'
176+
service.beforeCommand('click' as any, ['.button'])
177+
expect(mockScreencastRecorder.setStartMarker).not.toHaveBeenCalled()
178+
service.beforeCommand('url' as any, ['https://example.com'])
179+
expect(mockScreencastRecorder.setStartMarker).toHaveBeenCalled()
180+
181+
// after() stops, encodes, and notifies
182+
mockScreencastRecorder.frames = Array(10).fill({
183+
data: 'framedata',
184+
timestamp: 1000
185+
})
186+
mockScreencastRecorder.duration = 5000
187+
await service.after()
188+
189+
expect(mockScreencastRecorder.stop).toHaveBeenCalled()
190+
expect(encodeToVideo).toHaveBeenCalledWith(
191+
mockScreencastRecorder.frames,
192+
expect.stringContaining('wdio-video-session-123.webm'),
193+
expect.any(Object)
194+
)
195+
expect(mockSessionCapturerInstance.sendUpstream).toHaveBeenCalledWith(
196+
'screencast',
197+
expect.objectContaining({
198+
sessionId: 'session-123',
199+
frameCount: 10,
200+
duration: 5000
201+
})
202+
)
203+
})
204+
205+
it('skips when disabled, skips ghost sessions, and swallows encode errors', async () => {
206+
const { encodeToVideo } = await import('../src/video-encoder.js')
207+
208+
// Disabled — recorder never starts
209+
service = new DevToolsHookService({})
210+
await service.before({} as any, [], mockBrowser)
211+
expect(mockScreencastRecorder.start).not.toHaveBeenCalled()
212+
213+
// Ghost session — <5 frames, encoding skipped
214+
service = new DevToolsHookService({ screencast: { enabled: true } })
215+
await service.before({} as any, [], mockBrowser)
216+
mockScreencastRecorder.frames = Array(3).fill({
217+
data: 'f',
218+
timestamp: 1000
219+
})
220+
vi.mocked(encodeToVideo).mockClear()
221+
await service.after()
222+
expect(encodeToVideo).not.toHaveBeenCalled()
223+
224+
// Encode error — swallowed, doesn't throw
225+
service = new DevToolsHookService({ screencast: { enabled: true } })
226+
await service.before({} as any, [], mockBrowser)
227+
mockScreencastRecorder.frames = Array(10).fill({
228+
data: 'f',
229+
timestamp: 1000
230+
})
231+
vi.mocked(encodeToVideo).mockRejectedValueOnce(new Error('ffmpeg missing'))
232+
await expect(service.after()).resolves.toBeUndefined()
233+
})
234+
235+
it('onReload finalizes old session and starts fresh recorder', async () => {
236+
const { ScreencastRecorder } = await import('../src/screencast.js')
237+
service = new DevToolsHookService({ screencast: { enabled: true } })
238+
await service.before({} as any, [], mockBrowser)
239+
vi.clearAllMocks()
240+
241+
mockScreencastRecorder.frames = Array(10).fill({
242+
data: 'f',
243+
timestamp: 1000
244+
})
245+
await service.onReload('old-session', 'new-session')
246+
247+
expect(mockScreencastRecorder.stop).toHaveBeenCalled()
248+
expect(ScreencastRecorder).toHaveBeenCalled()
249+
expect(mockScreencastRecorder.start).toHaveBeenCalledWith(mockBrowser)
250+
})
251+
})
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { ScreencastRecorder } from '../src/screencast.js'
3+
4+
vi.mock('@wdio/logger', () => {
5+
const mockLogger = {
6+
info: vi.fn(),
7+
warn: vi.fn(),
8+
debug: vi.fn(),
9+
error: vi.fn()
10+
}
11+
return { default: vi.fn(() => mockLogger) }
12+
})
13+
14+
/** Helper: create a CDP-backed recorder with a frame handler ref. */
15+
function createCdpSetup() {
16+
let frameHandler: (event: any) => void
17+
const cdpSession = {
18+
send: vi.fn().mockResolvedValue(undefined),
19+
on: vi.fn((event: string, handler: any) => {
20+
if (event === 'Page.screencastFrame') {
21+
frameHandler = handler
22+
}
23+
})
24+
}
25+
const browser = {
26+
getPuppeteer: vi.fn().mockResolvedValue({
27+
pages: vi
28+
.fn()
29+
.mockResolvedValue([
30+
{ createCDPSession: vi.fn().mockResolvedValue(cdpSession) }
31+
])
32+
})
33+
} as any
34+
35+
const pushFrame = (data: string, timestampSec: number, sessionId = 1) =>
36+
frameHandler({ data, metadata: { timestamp: timestampSec }, sessionId })
37+
38+
return { browser, cdpSession, pushFrame }
39+
}
40+
41+
describe('ScreencastRecorder', () => {
42+
beforeEach(() => {
43+
vi.clearAllMocks()
44+
vi.restoreAllMocks()
45+
})
46+
47+
it('CDP mode: start → collect frames with acks → stop', async () => {
48+
const { browser, cdpSession, pushFrame } = createCdpSetup()
49+
const recorder = new ScreencastRecorder()
50+
51+
// Start
52+
await recorder.start(browser)
53+
expect(recorder.isRecording).toBe(true)
54+
expect(cdpSession.send).toHaveBeenCalledWith(
55+
'Page.startScreencast',
56+
expect.objectContaining({ format: 'jpeg', quality: 70 })
57+
)
58+
59+
// Collect frames — timestamps are converted from seconds to ms
60+
pushFrame('frame1', 1.0, 1)
61+
pushFrame('frame2', 2.5, 2)
62+
expect(recorder.frames).toHaveLength(2)
63+
expect(recorder.frames[0]).toEqual({ data: 'frame1', timestamp: 1000 })
64+
expect(recorder.frames[1]).toEqual({ data: 'frame2', timestamp: 2500 })
65+
expect(cdpSession.send).toHaveBeenCalledWith('Page.screencastFrameAck', {
66+
sessionId: 1
67+
})
68+
69+
// Duration
70+
expect(recorder.duration).toBe(1500)
71+
72+
// Stop
73+
await recorder.stop()
74+
expect(recorder.isRecording).toBe(false)
75+
expect(cdpSession.send).toHaveBeenCalledWith('Page.stopScreencast')
76+
77+
// Stop again — no-op, no throw
78+
await recorder.stop()
79+
})
80+
81+
it('polling mode: fallback when CDP unavailable → collect at interval → stop', async () => {
82+
vi.useFakeTimers()
83+
const browser = {
84+
getPuppeteer: vi.fn().mockRejectedValue(new Error('No puppeteer')),
85+
takeScreenshot: vi
86+
.fn()
87+
.mockResolvedValueOnce('shot1')
88+
.mockResolvedValueOnce('shot2')
89+
.mockResolvedValueOnce('shot3')
90+
} as any
91+
92+
const recorder = new ScreencastRecorder({ pollIntervalMs: 200 })
93+
await recorder.start(browser)
94+
95+
// Immediate first frame + recording started
96+
expect(recorder.isRecording).toBe(true)
97+
expect(recorder.frames).toHaveLength(1)
98+
expect(recorder.frames[0].data).toBe('shot1')
99+
100+
// Interval ticks collect more frames
101+
await vi.advanceTimersByTimeAsync(200)
102+
expect(recorder.frames).toHaveLength(2)
103+
await vi.advanceTimersByTimeAsync(200)
104+
expect(recorder.frames).toHaveLength(3)
105+
106+
await recorder.stop()
107+
expect(recorder.isRecording).toBe(false)
108+
vi.useRealTimers()
109+
})
110+
111+
it('polling: screenshot failure stops timer, initial failure skips recording', async () => {
112+
// Mid-polling failure — timer cleared, no more frames
113+
vi.useFakeTimers()
114+
const failBrowser = {
115+
getPuppeteer: vi.fn().mockRejectedValue(new Error('No puppeteer')),
116+
takeScreenshot: vi
117+
.fn()
118+
.mockResolvedValueOnce('ok')
119+
.mockRejectedValueOnce(new Error('Session ended'))
120+
.mockResolvedValueOnce('should-not-appear')
121+
} as any
122+
123+
const rec1 = new ScreencastRecorder({ pollIntervalMs: 200 })
124+
await rec1.start(failBrowser)
125+
await vi.advanceTimersByTimeAsync(200) // failure tick
126+
const countAfterError = rec1.frames.length
127+
await vi.advanceTimersByTimeAsync(200) // timer should be cleared
128+
expect(rec1.frames).toHaveLength(countAfterError)
129+
vi.useRealTimers()
130+
131+
// Initial screenshot fails — recording never starts
132+
const noBrowser = {
133+
getPuppeteer: vi.fn().mockRejectedValue(new Error('No puppeteer')),
134+
takeScreenshot: vi.fn().mockRejectedValue(new Error('Not supported'))
135+
} as any
136+
const rec2 = new ScreencastRecorder()
137+
await rec2.start(noBrowser)
138+
expect(rec2.isRecording).toBe(false)
139+
expect(rec2.frames).toEqual([])
140+
})
141+
142+
it('setStartMarker trims leading frames and is idempotent', async () => {
143+
const { browser, pushFrame } = createCdpSetup()
144+
const recorder = new ScreencastRecorder()
145+
await recorder.start(browser)
146+
147+
// 2 blank frames before marker
148+
pushFrame('blank1', 1.0)
149+
pushFrame('blank2', 2.0)
150+
recorder.setStartMarker()
151+
152+
// 2 meaningful frames
153+
pushFrame('page1', 5.0)
154+
recorder.setStartMarker() // second call — ignored
155+
pushFrame('page2', 8.0)
156+
157+
// Only post-marker frames returned
158+
expect(recorder.frames).toHaveLength(2)
159+
expect(recorder.frames[0].data).toBe('page1')
160+
expect(recorder.frames[1].data).toBe('page2')
161+
162+
// Duration based on trimmed frames: 8000 - 5000
163+
expect(recorder.duration).toBe(3000)
164+
})
165+
166+
it('stop is safe when never started', async () => {
167+
const recorder = new ScreencastRecorder()
168+
expect(recorder.duration).toBe(0)
169+
await expect(recorder.stop()).resolves.toBeUndefined()
170+
})
171+
})

0 commit comments

Comments
 (0)