From bec434cb686ca15db11a291f22ae4f29f63f382a Mon Sep 17 00:00:00 2001 From: Vladimir Date: Thu, 20 Jun 2024 17:48:00 +0200 Subject: [PATCH] feat(browser): expose CDP in the browser (#5938) --- docs/guide/browser.md | 25 +++++++ packages/browser/context.d.ts | 5 ++ packages/browser/providers/playwright.d.ts | 21 ++++++ packages/browser/src/client/client.ts | 7 ++ packages/browser/src/client/tester/context.ts | 4 ++ packages/browser/src/client/tester/state.ts | 70 ++++++++++++++++++ packages/browser/src/client/utils.ts | 7 ++ packages/browser/src/node/cdp.ts | 58 +++++++++++++++ packages/browser/src/node/plugin.ts | 2 +- .../browser/src/node/plugins/pluginContext.ts | 4 +- .../browser/src/node/providers/playwright.ts | 23 ++++++ packages/browser/src/node/rpc.ts | 17 ++++- packages/browser/src/node/server.ts | 37 ++++++++++ packages/browser/src/node/state.ts | 12 +++- packages/browser/src/node/types.ts | 5 ++ packages/vitest/src/node/index.ts | 1 + packages/vitest/src/types/browser.ts | 9 +++ test/browser/specs/runner.test.ts | 4 +- test/browser/test/cdp.test.ts | 72 +++++++++++++++++++ 19 files changed, 372 insertions(+), 11 deletions(-) create mode 100644 packages/browser/src/node/cdp.ts create mode 100644 test/browser/test/cdp.test.ts diff --git a/docs/guide/browser.md b/docs/guide/browser.md index e7163737057a..6d18fa7f9674 100644 --- a/docs/guide/browser.md +++ b/docs/guide/browser.md @@ -453,6 +453,8 @@ export const page: { */ screenshot: (options?: ScreenshotOptions) => Promise } + +export const cdp: () => CDPSession ``` ## Interactivity API @@ -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: diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index 2abe969d8a59..2b9bd673a2f4 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -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 /** @@ -242,3 +246,4 @@ export interface BrowserPage { } export const page: BrowserPage +export const cdp: () => CDPSession diff --git a/packages/browser/providers/playwright.d.ts b/packages/browser/providers/playwright.d.ts index c5ffd2f4f46b..61a3a5fabc2f 100644 --- a/packages/browser/providers/playwright.d.ts +++ b/packages/browser/providers/playwright.d.ts @@ -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' { @@ -40,4 +42,23 @@ declare module '@vitest/browser/context' { export interface UserEventDragOptions extends UserEventDragAndDropOptions {} export interface ScreenshotOptions extends PWScreenshotOptions {} + + export interface CDPSession { + send( + method: T, + params?: Protocol.CommandParameters[T] + ): Promise + on( + event: T, + listener: (payload: Protocol.Events[T]) => void + ): this; + once( + event: T, + listener: (payload: Protocol.Events[T]) => void + ): this; + off( + event: T, + listener: (payload: Protocol.Events[T]) => void + ): this; + } } diff --git a/packages/browser/src/client/client.ts b/packages/browser/src/client/client.ts index 8f54dc788987..a950bb2b52ff 100644 --- a/packages/browser/src/client/client.ts +++ b/packages/browser/src/client/client.ts @@ -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), diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index 062b372aaf9e..f7b9d522d3b0 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -163,6 +163,10 @@ function getSimpleSelectOptions(element: Element, value: string | string[] | HTM }) } +export function cdp() { + return runner().cdp! +} + const screenshotIds: Record> = {} export const page: BrowserPage = { get config() { diff --git a/packages/browser/src/client/tester/state.ts b/packages/browser/src/client/tester/state.ts index 411a0ffe7914..21a2c65b6c0f 100644 --- a/packages/browser/src/client/tester/state.ts +++ b/packages/browser/src/client/tester/state.ts @@ -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) @@ -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 getId(listener: Function) { + const id = listenersMap.get(listener) || crypto.randomUUID() + listenersMap.set(listener, id) + return id + } + + const listeners: Record = {} + + const error = (err: unknown) => { + window.dispatchEvent(new ErrorEvent('error', { error: err })) + } + + const cdp = { + send(method: string, params?: Record) { + 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 +} diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts index 9c3321594ab3..0633a238b9be 100644 --- a/packages/browser/src/client/utils.ts +++ b/packages/browser/src/client/utils.ts @@ -25,6 +25,13 @@ export interface BrowserRunnerState { contextId: string runTests?: (tests: string[]) => Promise createTesters?: (files: string[]) => Promise + 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) => Promise + emit: (event: string, payload: unknown) => void + } } /* @__NO_SIDE_EFFECTS__ */ diff --git a/packages/browser/src/node/cdp.ts b/packages/browser/src/node/cdp.ts new file mode 100644 index 000000000000..fac363340a07 --- /dev/null +++ b/packages/browser/src/node/cdp.ts @@ -0,0 +1,58 @@ +import type { CDPSession } from 'vitest/node' +import type { WebSocketBrowserRPC } from './types' + +export class BrowserServerCDPHandler { + private listenerIds: Record = {} + + private listeners: Record void> = {} + + constructor( + private session: CDPSession, + private tester: WebSocketBrowserRPC, + ) {} + + send(method: string, params?: Record) { + 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) + } +} diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts index e0da948bf81a..dbc23d12b4cd 100644 --- a/packages/browser/src/node/plugin.ts +++ b/packages/browser/src/node/plugin.ts @@ -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')!) diff --git a/packages/browser/src/node/plugins/pluginContext.ts b/packages/browser/src/node/plugins/pluginContext.ts index 664e78db79b1..6802ca12e874 100644 --- a/packages/browser/src/node/plugins/pluginContext.ts +++ b/packages/browser/src/node/plugins/pluginContext.ts @@ -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 @@ -84,7 +84,7 @@ export const server = { } export const commands = server.commands export const userEvent = ${getUserEvent(provider)} -export { page } +export { page, cdp } ` } diff --git a/packages/browser/src/node/providers/playwright.ts b/packages/browser/src/node/providers/playwright.ts index 4765a84ec7a1..024204c8fbf8 100644 --- a/packages/browser/src/node/providers/playwright.ts +++ b/packages/browser/src/node/providers/playwright.ts @@ -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 diff --git a/packages/browser/src/node/rpc.ts b/packages/browser/src/node/rpc.ts index 0a69eb389eec..ab3aa5df5eb9 100644 --- a/packages/browser/src/node/rpc.ts +++ b/packages/browser/src/node/rpc.ts @@ -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) @@ -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) }) }) }) @@ -62,7 +63,7 @@ export function setupBrowserRpc( } } - function setupClient(ws: WebSocket) { + function setupClient(sessionId: string, ws: WebSocket) { const rpc = createBirpc( { async onUnhandledError(error, type) { @@ -182,11 +183,21 @@ export function setupBrowserRpc( } }) }, + + // CDP + async sendCdpEvent(contextId: string, event: string, payload?: Record) { + 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) { diff --git a/packages/browser/src/node/server.ts b/packages/browser/src/node/server.ts index f76f124d28c4..46f3c2d98fd5 100644 --- a/packages/browser/src/node/server.ts +++ b/packages/browser/src/node/server.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url' import type { BrowserProvider, BrowserScript, + CDPSession, BrowserServer as IBrowserServer, Vite, WorkspaceProject, @@ -12,6 +13,7 @@ import { slash } from '@vitest/utils' import type { ResolvedConfig } from 'vitest' import { BrowserServerState } from './state' import { getBrowserProvider } from './utils' +import { BrowserServerCDPHandler } from './cdp' export class BrowserServer implements IBrowserServer { public faviconUrl: string @@ -137,6 +139,41 @@ export class BrowserServer implements IBrowserServer { }) } + private cdpSessions = new Map>() + + async ensureCDPHandler(contextId: string, sessionId: string) { + const cachedHandler = this.state.cdps.get(sessionId) + if (cachedHandler) { + return cachedHandler + } + + const provider = this.provider + if (!provider.getCDPSession) { + throw new Error(`CDP is not supported by the provider "${provider.name}".`) + } + + const promise = this.cdpSessions.get(sessionId) ?? await (async () => { + const promise = provider.getCDPSession!(contextId).finally(() => { + this.cdpSessions.delete(sessionId) + }) + this.cdpSessions.set(sessionId, promise) + return promise + })() + + const session = await promise + const rpc = this.state.testers.get(sessionId) + if (!rpc) { + throw new Error(`Tester RPC "${sessionId}" was not established.`) + } + + const handler = new BrowserServerCDPHandler(session, rpc) + this.state.cdps.set( + sessionId, + handler, + ) + return handler + } + async close() { await this.vite.close() } diff --git a/packages/browser/src/node/state.ts b/packages/browser/src/node/state.ts index cd127623f165..9f008a6ff83b 100644 --- a/packages/browser/src/node/state.ts +++ b/packages/browser/src/node/state.ts @@ -1,12 +1,14 @@ import { createDefer } from '@vitest/utils' import type { BrowserServerStateContext, BrowserServerState as IBrowserServerState } from 'vitest/node' import type { WebSocketBrowserRPC } from './types' +import type { BrowserServerCDPHandler } from './cdp' export class BrowserServerState implements IBrowserServerState { - public orchestrators = new Map() - public testers = new Map() + public readonly orchestrators = new Map() + public readonly testers = new Map() + public readonly cdps = new Map() - private contexts = new Map() + private contexts = new Map() getContext(contextId: string) { return this.contexts.get(contextId) @@ -24,4 +26,8 @@ export class BrowserServerState implements IBrowserServerState { }) return defer } + + async removeCDPHandler(sessionId: string) { + this.cdps.delete(sessionId) + } } diff --git a/packages/browser/src/node/types.ts b/packages/browser/src/node/types.ts index 575e00cf3b4e..ea766de7b424 100644 --- a/packages/browser/src/node/types.ts +++ b/packages/browser/src/node/types.ts @@ -40,6 +40,10 @@ export interface WebSocketBrowserHandlers { getBrowserFileSourceMap: ( id: string ) => SourceMap | null | { mappings: '' } | undefined + + // cdp + sendCdpEvent: (contextId: string, event: string, payload?: Record) => unknown + trackCdpEvent: (contextId: string, type: 'on' | 'once' | 'off', event: string, listenerId: string) => void } export interface WebSocketEvents @@ -58,6 +62,7 @@ export interface WebSocketEvents export interface WebSocketBrowserEvents { onCancel: (reason: CancelReason) => void createTesters: (files: string[]) => Promise + cdpEvent: (event: string, payload: unknown) => void } export type WebSocketBrowserRPC = BirpcReturn< diff --git a/packages/vitest/src/node/index.ts b/packages/vitest/src/node/index.ts index ad2640aab95d..c2fba5f8fdb1 100644 --- a/packages/vitest/src/node/index.ts +++ b/packages/vitest/src/node/index.ts @@ -25,6 +25,7 @@ export { BaseSequencer } from './sequencers/BaseSequencer' export type { BrowserProviderInitializationOptions, BrowserProvider, + CDPSession, BrowserProviderModule, ResolvedBrowserOptions, BrowserProviderOptions, diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts index 305550a4a4c5..c1c0affa7f53 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -9,6 +9,14 @@ export interface BrowserProviderInitializationOptions { options?: BrowserProviderOptions } +export interface CDPSession { + send: (method: string, params?: Record) => Promise + on: (event: string, listener: (...args: unknown[]) => void) => void + once: (event: string, listener: (...args: unknown[]) => void) => void + off: (event: string, listener: (...args: unknown[]) => void) => void + detach: () => Promise +} + export interface BrowserProvider { name: string /** @@ -20,6 +28,7 @@ export interface BrowserProvider { afterCommand?: (command: string, args: unknown[]) => Awaitable getCommandsContext: (contextId: string) => Record openPage: (contextId: string, url: string) => Promise + getCDPSession?: (contextId: string) => Promise close: () => Awaitable // eslint-disable-next-line ts/method-signature-style -- we want to allow extended options initialize( diff --git a/test/browser/specs/runner.test.ts b/test/browser/specs/runner.test.ts index 3fcc77469f10..539511dec7bd 100644 --- a/test/browser/specs/runner.test.ts +++ b/test/browser/specs/runner.test.ts @@ -23,8 +23,8 @@ describe('running browser tests', async () => { console.error(stderr) }) - expect(browserResultJson.testResults).toHaveLength(17) - expect(passedTests).toHaveLength(15) + expect(browserResultJson.testResults).toHaveLength(18) + expect(passedTests).toHaveLength(16) expect(failedTests).toHaveLength(2) expect(stderr).not.toContain('has been externalized for browser compatibility') diff --git a/test/browser/test/cdp.test.ts b/test/browser/test/cdp.test.ts new file mode 100644 index 000000000000..026d9adbe200 --- /dev/null +++ b/test/browser/test/cdp.test.ts @@ -0,0 +1,72 @@ +import { cdp, server } from '@vitest/browser/context' +import { describe, expect, it, onTestFinished, vi } from 'vitest' + +describe.runIf( + server.provider === 'playwright' && server.browser === 'chromium', +)('cdp in chromium browsers', () => { + it('cdp sends events correctly', async () => { + const messageAdded = vi.fn() + + cdp().on('Console.messageAdded', messageAdded) + + await cdp().send('Console.enable') + onTestFinished(async () => { + await cdp().send('Console.disable') + }) + + console.error('MESSAGE ADDED') + + await vi.waitFor(() => { + expect(messageAdded).toHaveBeenCalledWith({ + message: expect.objectContaining({ + column: expect.any(Number), + text: 'MESSAGE ADDED', + source: 'console-api', + url: expect.any(String), + }), + }) + }) + }) + + it('cdp keyboard works correctly', async () => { + const input = document.createElement('input') + document.body.appendChild(input) + input.focus() + + await cdp().send('Input.dispatchKeyEvent', { + type: 'keyDown', + text: 'a', + }) + expect(input).toHaveValue('a') + + await cdp().send('Input.insertText', { + text: 'some text', + }) + expect(input).toHaveValue('asome text') + }) + + it('click events are fired correctly', async () => { + const clickEvent = vi.fn() + document.body.addEventListener('click', clickEvent) + + const parent = window.top + const iframePosition = parent.document.querySelector('iframe').getBoundingClientRect() + + await cdp().send('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: iframePosition.x + 10, + y: iframePosition.y + 10, + button: 'left', + clickCount: 1, + }) + await cdp().send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: iframePosition.x + 10, + y: iframePosition.y + 10, + button: 'left', + clickCount: 1, + }) + + expect(clickEvent).toHaveBeenCalledOnce() + }) +})