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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy