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

Lines changed: 25 additions & 0 deletions
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

Lines changed: 5 additions & 0 deletions
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

Lines changed: 21 additions & 0 deletions
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

Lines changed: 7 additions & 0 deletions
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

Lines changed: 4 additions & 0 deletions
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

Lines changed: 70 additions & 0 deletions
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

Lines changed: 7 additions & 0 deletions
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

Lines changed: 58 additions & 0 deletions
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 2 additions & 2 deletions
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

0 commit comments

Comments
 (0)