Skip to content

Commit

Permalink
feat(browser): expose CDP in the browser (#5938)
Browse files Browse the repository at this point in the history
  • Loading branch information
sheremet-va authored Jun 20, 2024
1 parent a17635b commit bec434c
Show file tree
Hide file tree
Showing 19 changed files with 372 additions and 11 deletions.
25 changes: 25 additions & 0 deletions docs/guide/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,8 @@ export const page: {
*/
screenshot: (options?: ScreenshotOptions) => Promise<string>
}

export const cdp: () => CDPSession
```
## Interactivity API
Expand Down Expand Up @@ -841,6 +843,29 @@ it('handles files', async () => {
})
```

## CDP Session

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.

```ts
import { cdp } from '@vitest/browser/context'

const input = document.createElement('input')
document.body.appendChild(input)
input.focus()

await cdp().send('Input.dispatchKeyEvent', {
type: 'keyDown',
text: 'a',
})

expect(input).toHaveValue('a')
```

::: warning
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.
:::

## Custom Commands

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:
Expand Down
5 changes: 5 additions & 0 deletions packages/browser/context.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export interface FsOptions {
flag?: string | number
}

export interface CDPSession {
// methods are defined by the provider type augmentation
}

export interface ScreenshotOptions {
element?: Element
/**
Expand Down Expand Up @@ -242,3 +246,4 @@ export interface BrowserPage {
}

export const page: BrowserPage
export const cdp: () => CDPSession
21 changes: 21 additions & 0 deletions packages/browser/providers/playwright.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import type {
Frame,
LaunchOptions,
Page,
CDPSession
} from 'playwright'
import { Protocol } from 'playwright-core/types/protocol'
import '../matchers.js'

declare module 'vitest/node' {
Expand Down Expand Up @@ -40,4 +42,23 @@ declare module '@vitest/browser/context' {
export interface UserEventDragOptions extends UserEventDragAndDropOptions {}

export interface ScreenshotOptions extends PWScreenshotOptions {}

export interface CDPSession {
send<T extends keyof Protocol.CommandParameters>(
method: T,
params?: Protocol.CommandParameters[T]
): Promise<Protocol.CommandReturnValues[T]>
on<T extends keyof Protocol.Events>(
event: T,
listener: (payload: Protocol.Events[T]) => void
): this;
once<T extends keyof Protocol.Events>(
event: T,
listener: (payload: Protocol.Events[T]) => void
): this;
off<T extends keyof Protocol.Events>(
event: T,
listener: (payload: Protocol.Events[T]) => void
): this;
}
}
7 changes: 7 additions & 0 deletions packages/browser/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ function createClient() {
}
getBrowserState().createTesters?.(files)
},
cdpEvent(event: string, payload: unknown) {
const cdp = getBrowserState().cdp
if (!cdp) {
return
}
cdp.emit(event, payload)
},
},
{
post: msg => ctx.ws.send(msg),
Expand Down
4 changes: 4 additions & 0 deletions packages/browser/src/client/tester/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ function getSimpleSelectOptions(element: Element, value: string | string[] | HTM
})
}

export function cdp() {
return runner().cdp!
}

const screenshotIds: Record<string, Record<string, string>> = {}
export const page: BrowserPage = {
get config() {
Expand Down
70 changes: 70 additions & 0 deletions packages/browser/src/client/tester/state.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { WorkerGlobalState } from 'vitest'
import { parse } from 'flatted'
import { getBrowserState } from '../utils'
import type { BrowserRPC } from '../client'

const config = getBrowserState().config
const contextId = getBrowserState().contextId

const providedContext = parse(getBrowserState().providedContext)

Expand Down Expand Up @@ -44,3 +46,71 @@ const state: WorkerGlobalState = {
globalThis.__vitest_browser__ = true
// @ts-expect-error not typed global
globalThis.__vitest_worker__ = state

getBrowserState().cdp = createCdp()

function rpc() {
return state.rpc as any as BrowserRPC
}

function createCdp() {
const listenersMap = new WeakMap<Function, string>()

function getId(listener: Function) {
const id = listenersMap.get(listener) || crypto.randomUUID()
listenersMap.set(listener, id)
return id
}

const listeners: Record<string, Function[]> = {}

const error = (err: unknown) => {
window.dispatchEvent(new ErrorEvent('error', { error: err }))
}

const cdp = {
send(method: string, params?: Record<string, any>) {
return rpc().sendCdpEvent(contextId, method, params)
},
on(event: string, listener: (payload: any) => void) {
const listenerId = getId(listener)
listeners[event] = listeners[event] || []
listeners[event].push(listener)
rpc().trackCdpEvent(contextId, 'on', event, listenerId).catch(error)
return cdp
},
once(event: string, listener: (payload: any) => void) {
const listenerId = getId(listener)
const handler = (data: any) => {
listener(data)
cdp.off(event, listener)
}
listeners[event] = listeners[event] || []
listeners[event].push(handler)
rpc().trackCdpEvent(contextId, 'once', event, listenerId).catch(error)
return cdp
},
off(event: string, listener: (payload: any) => void) {
const listenerId = getId(listener)
if (listeners[event]) {
listeners[event] = listeners[event].filter(l => l !== listener)
}
rpc().trackCdpEvent(contextId, 'off', event, listenerId).catch(error)
return cdp
},
emit(event: string, payload: unknown) {
if (listeners[event]) {
listeners[event].forEach((l) => {
try {
l(payload)
}
catch (err) {
error(err)
}
})
}
},
}

return cdp
}
7 changes: 7 additions & 0 deletions packages/browser/src/client/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export interface BrowserRunnerState {
contextId: string
runTests?: (tests: string[]) => Promise<void>
createTesters?: (files: string[]) => Promise<void>
cdp?: {
on: (event: string, listener: (payload: any) => void) => void
once: (event: string, listener: (payload: any) => void) => void
off: (event: string, listener: (payload: any) => void) => void
send: (method: string, params?: Record<string, unknown>) => Promise<unknown>
emit: (event: string, payload: unknown) => void
}
}

/* @__NO_SIDE_EFFECTS__ */
Expand Down
58 changes: 58 additions & 0 deletions packages/browser/src/node/cdp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { CDPSession } from 'vitest/node'
import type { WebSocketBrowserRPC } from './types'

export class BrowserServerCDPHandler {
private listenerIds: Record<string, string[]> = {}

private listeners: Record<string, (payload: unknown) => void> = {}

constructor(
private session: CDPSession,
private tester: WebSocketBrowserRPC,
) {}

send(method: string, params?: Record<string, unknown>) {
return this.session.send(method, params)
}

detach() {
return this.session.detach()
}

on(event: string, id: string, once = false) {
if (!this.listenerIds[event]) {
this.listenerIds[event] = []
}
this.listenerIds[event].push(id)

if (!this.listeners[event]) {
this.listeners[event] = (payload) => {
this.tester.cdpEvent(
event,
payload,
)
if (once) {
this.off(event, id)
}
}

this.session.on(event, this.listeners[event])
}
}

off(event: string, id: string) {
if (!this.listenerIds[event]) {
this.listenerIds[event] = []
}
this.listenerIds[event] = this.listenerIds[event].filter(l => l !== id)

if (!this.listenerIds[event].length) {
this.session.off(event, this.listeners[event])
delete this.listeners[event]
}
}

once(event: string, listener: string) {
this.on(event, listener, true)
}
}
2 changes: 1 addition & 1 deletion packages/browser/src/node/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => {
if (rawId.startsWith('/__virtual_vitest__')) {
const url = new URL(rawId, 'http://localhost')
if (!url.searchParams.has('id')) {
throw new TypeError(`Invalid virtual module id: ${rawId}, requires "id" query.`)
return
}

const id = decodeURIComponent(url.searchParams.get('id')!)
Expand Down
4 changes: 2 additions & 2 deletions packages/browser/src/node/plugins/pluginContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ async function generateContextFile(
const distContextPath = slash(`/@fs/${resolve(__dirname, 'context.js')}`)

return `
import { page, userEvent as __userEvent_CDP__ } from '${distContextPath}'
import { page, userEvent as __userEvent_CDP__, cdp } from '${distContextPath}'
${userEventNonProviderImport}
const filepath = () => ${filepathCode}
const rpc = () => __vitest_worker__.rpc
Expand All @@ -84,7 +84,7 @@ export const server = {
}
export const commands = server.commands
export const userEvent = ${getUserEvent(provider)}
export { page }
export { page, cdp }
`
}

Expand Down
23 changes: 23 additions & 0 deletions packages/browser/src/node/providers/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,29 @@ export class PlaywrightBrowserProvider implements BrowserProvider {
await browserPage.goto(url)
}

async getCDPSession(contextId: string) {
const page = this.getPage(contextId)
const cdp = await page.context().newCDPSession(page)
return {
async send(method: string, params: any) {
const result = await cdp.send(method as 'DOM.querySelector', params)
return result as unknown
},
on(event: string, listener: (...args: any[]) => void) {
cdp.on(event as 'Accessibility.loadComplete', listener)
},
off(event: string, listener: (...args: any[]) => void) {
cdp.off(event as 'Accessibility.loadComplete', listener)
},
once(event: string, listener: (...args: any[]) => void) {
cdp.once(event as 'Accessibility.loadComplete', listener)
},
detach() {
return cdp.detach()
},
}
}

async close() {
const browser = this.browser
this.browser = null
Expand Down
17 changes: 14 additions & 3 deletions packages/browser/src/node/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function setupBrowserRpc(
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request)

const rpc = setupClient(ws)
const rpc = setupClient(sessionId, ws)
const state = server.state
const clients = type === 'tester' ? state.testers : state.orchestrators
clients.set(sessionId, rpc)
Expand All @@ -50,6 +50,7 @@ export function setupBrowserRpc(
ws.on('close', () => {
debug?.('[%s] Browser API disconnected from %s', sessionId, type)
clients.delete(sessionId)
server.state.removeCDPHandler(sessionId)
})
})
})
Expand All @@ -62,7 +63,7 @@ export function setupBrowserRpc(
}
}

function setupClient(ws: WebSocket) {
function setupClient(sessionId: string, ws: WebSocket) {
const rpc = createBirpc<WebSocketBrowserEvents, WebSocketBrowserHandlers>(
{
async onUnhandledError(error, type) {
Expand Down Expand Up @@ -182,11 +183,21 @@ export function setupBrowserRpc(
}
})
},

// CDP
async sendCdpEvent(contextId: string, event: string, payload?: Record<string, unknown>) {
const cdp = await server.ensureCDPHandler(contextId, sessionId)
return cdp.send(event, payload)
},
async trackCdpEvent(contextId: string, type: 'on' | 'once' | 'off', event: string, listenerId: string) {
const cdp = await server.ensureCDPHandler(contextId, sessionId)
cdp[type](event, listenerId)
},
},
{
post: msg => ws.send(msg),
on: fn => ws.on('message', fn),
eventNames: ['onCancel'],
eventNames: ['onCancel', 'cdpEvent'],
serialize: (data: any) => stringify(data, stringifyReplace),
deserialize: parse,
onTimeoutError(functionName) {
Expand Down
Loading

0 comments on commit bec434c

Please sign in to comment.