Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(browser): expose cdp in the browser #5938

Merged
merged 8 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -180,7 +180,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
Loading