Skip to content

Commit bec434c

Browse files
authored
feat(browser): expose CDP in the browser (#5938)
1 parent a17635b commit bec434c

File tree

19 files changed

+372
-11
lines changed

19 files changed

+372
-11
lines changed

docs/guide/browser.md

+25
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,8 @@ export const page: {
453453
*/
454454
screenshot: (options?: ScreenshotOptions) => Promise<string>
455455
}
456+
457+
export const cdp: () => CDPSession
456458
```
457459
458460
## Interactivity API
@@ -841,6 +843,29 @@ it('handles files', async () => {
841843
})
842844
```
843845

846+
## CDP Session
847+
848+
Vitest exposes access to raw Chrome Devtools Protocol via the `cdp` method exported from `@vitest/browser/context`. It is mostly useful to library authors to build tools on top of it.
849+
850+
```ts
851+
import { cdp } from '@vitest/browser/context'
852+
853+
const input = document.createElement('input')
854+
document.body.appendChild(input)
855+
input.focus()
856+
857+
await cdp().send('Input.dispatchKeyEvent', {
858+
type: 'keyDown',
859+
text: 'a',
860+
})
861+
862+
expect(input).toHaveValue('a')
863+
```
864+
865+
::: warning
866+
CDP session works only with `playwright` provider and only when using `chromium` browser. You can read more about it in playwright's [`CDPSession`](https://playwright.dev/docs/api/class-cdpsession) documentation.
867+
:::
868+
844869
## Custom Commands
845870

846871
You can also add your own commands via [`browser.commands`](/config/#browser-commands) config option. If you develop a library, you can provide them via a `config` hook inside a plugin:

packages/browser/context.d.ts

+5
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ export interface FsOptions {
1919
flag?: string | number
2020
}
2121

22+
export interface CDPSession {
23+
// methods are defined by the provider type augmentation
24+
}
25+
2226
export interface ScreenshotOptions {
2327
element?: Element
2428
/**
@@ -242,3 +246,4 @@ export interface BrowserPage {
242246
}
243247

244248
export const page: BrowserPage
249+
export const cdp: () => CDPSession

packages/browser/providers/playwright.d.ts

+21
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import type {
44
Frame,
55
LaunchOptions,
66
Page,
7+
CDPSession
78
} from 'playwright'
9+
import { Protocol } from 'playwright-core/types/protocol'
810
import '../matchers.js'
911

1012
declare module 'vitest/node' {
@@ -40,4 +42,23 @@ declare module '@vitest/browser/context' {
4042
export interface UserEventDragOptions extends UserEventDragAndDropOptions {}
4143

4244
export interface ScreenshotOptions extends PWScreenshotOptions {}
45+
46+
export interface CDPSession {
47+
send<T extends keyof Protocol.CommandParameters>(
48+
method: T,
49+
params?: Protocol.CommandParameters[T]
50+
): Promise<Protocol.CommandReturnValues[T]>
51+
on<T extends keyof Protocol.Events>(
52+
event: T,
53+
listener: (payload: Protocol.Events[T]) => void
54+
): this;
55+
once<T extends keyof Protocol.Events>(
56+
event: T,
57+
listener: (payload: Protocol.Events[T]) => void
58+
): this;
59+
off<T extends keyof Protocol.Events>(
60+
event: T,
61+
listener: (payload: Protocol.Events[T]) => void
62+
): this;
63+
}
4364
}

packages/browser/src/client/client.ts

+7
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ function createClient() {
5656
}
5757
getBrowserState().createTesters?.(files)
5858
},
59+
cdpEvent(event: string, payload: unknown) {
60+
const cdp = getBrowserState().cdp
61+
if (!cdp) {
62+
return
63+
}
64+
cdp.emit(event, payload)
65+
},
5966
},
6067
{
6168
post: msg => ctx.ws.send(msg),

packages/browser/src/client/tester/context.ts

+4
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,10 @@ function getSimpleSelectOptions(element: Element, value: string | string[] | HTM
163163
})
164164
}
165165

166+
export function cdp() {
167+
return runner().cdp!
168+
}
169+
166170
const screenshotIds: Record<string, Record<string, string>> = {}
167171
export const page: BrowserPage = {
168172
get config() {

packages/browser/src/client/tester/state.ts

+70
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { WorkerGlobalState } from 'vitest'
22
import { parse } from 'flatted'
33
import { getBrowserState } from '../utils'
4+
import type { BrowserRPC } from '../client'
45

56
const config = getBrowserState().config
7+
const contextId = getBrowserState().contextId
68

79
const providedContext = parse(getBrowserState().providedContext)
810

@@ -44,3 +46,71 @@ const state: WorkerGlobalState = {
4446
globalThis.__vitest_browser__ = true
4547
// @ts-expect-error not typed global
4648
globalThis.__vitest_worker__ = state
49+
50+
getBrowserState().cdp = createCdp()
51+
52+
function rpc() {
53+
return state.rpc as any as BrowserRPC
54+
}
55+
56+
function createCdp() {
57+
const listenersMap = new WeakMap<Function, string>()
58+
59+
function getId(listener: Function) {
60+
const id = listenersMap.get(listener) || crypto.randomUUID()
61+
listenersMap.set(listener, id)
62+
return id
63+
}
64+
65+
const listeners: Record<string, Function[]> = {}
66+
67+
const error = (err: unknown) => {
68+
window.dispatchEvent(new ErrorEvent('error', { error: err }))
69+
}
70+
71+
const cdp = {
72+
send(method: string, params?: Record<string, any>) {
73+
return rpc().sendCdpEvent(contextId, method, params)
74+
},
75+
on(event: string, listener: (payload: any) => void) {
76+
const listenerId = getId(listener)
77+
listeners[event] = listeners[event] || []
78+
listeners[event].push(listener)
79+
rpc().trackCdpEvent(contextId, 'on', event, listenerId).catch(error)
80+
return cdp
81+
},
82+
once(event: string, listener: (payload: any) => void) {
83+
const listenerId = getId(listener)
84+
const handler = (data: any) => {
85+
listener(data)
86+
cdp.off(event, listener)
87+
}
88+
listeners[event] = listeners[event] || []
89+
listeners[event].push(handler)
90+
rpc().trackCdpEvent(contextId, 'once', event, listenerId).catch(error)
91+
return cdp
92+
},
93+
off(event: string, listener: (payload: any) => void) {
94+
const listenerId = getId(listener)
95+
if (listeners[event]) {
96+
listeners[event] = listeners[event].filter(l => l !== listener)
97+
}
98+
rpc().trackCdpEvent(contextId, 'off', event, listenerId).catch(error)
99+
return cdp
100+
},
101+
emit(event: string, payload: unknown) {
102+
if (listeners[event]) {
103+
listeners[event].forEach((l) => {
104+
try {
105+
l(payload)
106+
}
107+
catch (err) {
108+
error(err)
109+
}
110+
})
111+
}
112+
},
113+
}
114+
115+
return cdp
116+
}

packages/browser/src/client/utils.ts

+7
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ export interface BrowserRunnerState {
2525
contextId: string
2626
runTests?: (tests: string[]) => Promise<void>
2727
createTesters?: (files: string[]) => Promise<void>
28+
cdp?: {
29+
on: (event: string, listener: (payload: any) => void) => void
30+
once: (event: string, listener: (payload: any) => void) => void
31+
off: (event: string, listener: (payload: any) => void) => void
32+
send: (method: string, params?: Record<string, unknown>) => Promise<unknown>
33+
emit: (event: string, payload: unknown) => void
34+
}
2835
}
2936

3037
/* @__NO_SIDE_EFFECTS__ */

packages/browser/src/node/cdp.ts

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { CDPSession } from 'vitest/node'
2+
import type { WebSocketBrowserRPC } from './types'
3+
4+
export class BrowserServerCDPHandler {
5+
private listenerIds: Record<string, string[]> = {}
6+
7+
private listeners: Record<string, (payload: unknown) => void> = {}
8+
9+
constructor(
10+
private session: CDPSession,
11+
private tester: WebSocketBrowserRPC,
12+
) {}
13+
14+
send(method: string, params?: Record<string, unknown>) {
15+
return this.session.send(method, params)
16+
}
17+
18+
detach() {
19+
return this.session.detach()
20+
}
21+
22+
on(event: string, id: string, once = false) {
23+
if (!this.listenerIds[event]) {
24+
this.listenerIds[event] = []
25+
}
26+
this.listenerIds[event].push(id)
27+
28+
if (!this.listeners[event]) {
29+
this.listeners[event] = (payload) => {
30+
this.tester.cdpEvent(
31+
event,
32+
payload,
33+
)
34+
if (once) {
35+
this.off(event, id)
36+
}
37+
}
38+
39+
this.session.on(event, this.listeners[event])
40+
}
41+
}
42+
43+
off(event: string, id: string) {
44+
if (!this.listenerIds[event]) {
45+
this.listenerIds[event] = []
46+
}
47+
this.listenerIds[event] = this.listenerIds[event].filter(l => l !== id)
48+
49+
if (!this.listenerIds[event].length) {
50+
this.session.off(event, this.listeners[event])
51+
delete this.listeners[event]
52+
}
53+
}
54+
55+
once(event: string, listener: string) {
56+
this.on(event, listener, true)
57+
}
58+
}

packages/browser/src/node/plugin.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => {
182182
if (rawId.startsWith('/__virtual_vitest__')) {
183183
const url = new URL(rawId, 'http://localhost')
184184
if (!url.searchParams.has('id')) {
185-
throw new TypeError(`Invalid virtual module id: ${rawId}, requires "id" query.`)
185+
return
186186
}
187187

188188
const id = decodeURIComponent(url.searchParams.get('id')!)

packages/browser/src/node/plugins/pluginContext.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ async function generateContextFile(
6767
const distContextPath = slash(`/@fs/${resolve(__dirname, 'context.js')}`)
6868

6969
return `
70-
import { page, userEvent as __userEvent_CDP__ } from '${distContextPath}'
70+
import { page, userEvent as __userEvent_CDP__, cdp } from '${distContextPath}'
7171
${userEventNonProviderImport}
7272
const filepath = () => ${filepathCode}
7373
const rpc = () => __vitest_worker__.rpc
@@ -84,7 +84,7 @@ export const server = {
8484
}
8585
export const commands = server.commands
8686
export const userEvent = ${getUserEvent(provider)}
87-
export { page }
87+
export { page, cdp }
8888
`
8989
}
9090

packages/browser/src/node/providers/playwright.ts

+23
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,29 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
130130
await browserPage.goto(url)
131131
}
132132

133+
async getCDPSession(contextId: string) {
134+
const page = this.getPage(contextId)
135+
const cdp = await page.context().newCDPSession(page)
136+
return {
137+
async send(method: string, params: any) {
138+
const result = await cdp.send(method as 'DOM.querySelector', params)
139+
return result as unknown
140+
},
141+
on(event: string, listener: (...args: any[]) => void) {
142+
cdp.on(event as 'Accessibility.loadComplete', listener)
143+
},
144+
off(event: string, listener: (...args: any[]) => void) {
145+
cdp.off(event as 'Accessibility.loadComplete', listener)
146+
},
147+
once(event: string, listener: (...args: any[]) => void) {
148+
cdp.once(event as 'Accessibility.loadComplete', listener)
149+
},
150+
detach() {
151+
return cdp.detach()
152+
},
153+
}
154+
}
155+
133156
async close() {
134157
const browser = this.browser
135158
this.browser = null

packages/browser/src/node/rpc.ts

+14-3
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export function setupBrowserRpc(
4040
wss.handleUpgrade(request, socket, head, (ws) => {
4141
wss.emit('connection', ws, request)
4242

43-
const rpc = setupClient(ws)
43+
const rpc = setupClient(sessionId, ws)
4444
const state = server.state
4545
const clients = type === 'tester' ? state.testers : state.orchestrators
4646
clients.set(sessionId, rpc)
@@ -50,6 +50,7 @@ export function setupBrowserRpc(
5050
ws.on('close', () => {
5151
debug?.('[%s] Browser API disconnected from %s', sessionId, type)
5252
clients.delete(sessionId)
53+
server.state.removeCDPHandler(sessionId)
5354
})
5455
})
5556
})
@@ -62,7 +63,7 @@ export function setupBrowserRpc(
6263
}
6364
}
6465

65-
function setupClient(ws: WebSocket) {
66+
function setupClient(sessionId: string, ws: WebSocket) {
6667
const rpc = createBirpc<WebSocketBrowserEvents, WebSocketBrowserHandlers>(
6768
{
6869
async onUnhandledError(error, type) {
@@ -182,11 +183,21 @@ export function setupBrowserRpc(
182183
}
183184
})
184185
},
186+
187+
// CDP
188+
async sendCdpEvent(contextId: string, event: string, payload?: Record<string, unknown>) {
189+
const cdp = await server.ensureCDPHandler(contextId, sessionId)
190+
return cdp.send(event, payload)
191+
},
192+
async trackCdpEvent(contextId: string, type: 'on' | 'once' | 'off', event: string, listenerId: string) {
193+
const cdp = await server.ensureCDPHandler(contextId, sessionId)
194+
cdp[type](event, listenerId)
195+
},
185196
},
186197
{
187198
post: msg => ws.send(msg),
188199
on: fn => ws.on('message', fn),
189-
eventNames: ['onCancel'],
200+
eventNames: ['onCancel', 'cdpEvent'],
190201
serialize: (data: any) => stringify(data, stringifyReplace),
191202
deserialize: parse,
192203
onTimeoutError(functionName) {

0 commit comments

Comments
 (0)