[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Vladimir and committed by
GitHub
(Jun 20, 2024, 5:48 PM +0200) bec434cb a17635be

+372 -11
+25
docs/guide/browser.md
··· 453 453 */ 454 454 screenshot: (options?: ScreenshotOptions) => Promise<string> 455 455 } 456 + 457 + export const cdp: () => CDPSession 456 458 ``` 457 459 458 460 ## Interactivity API ··· 840 842 await removeFile(file) 841 843 }) 842 844 ``` 845 + 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 + ::: 843 868 844 869 ## Custom Commands 845 870
+5
packages/browser/context.d.ts
··· 19 19 flag?: string | number 20 20 } 21 21 22 + export interface CDPSession { 23 + // methods are defined by the provider type augmentation 24 + } 25 + 22 26 export interface ScreenshotOptions { 23 27 element?: Element 24 28 /** ··· 242 246 } 243 247 244 248 export const page: BrowserPage 249 + export const cdp: () => CDPSession
+21
packages/browser/providers/playwright.d.ts
··· 4 4 Frame, 5 5 LaunchOptions, 6 6 Page, 7 + CDPSession 7 8 } from 'playwright' 9 + import { Protocol } from 'playwright-core/types/protocol' 8 10 import '../matchers.js' 9 11 10 12 declare module 'vitest/node' { ··· 40 42 export interface UserEventDragOptions extends UserEventDragAndDropOptions {} 41 43 42 44 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 + } 43 64 }
+2 -2
test/browser/specs/runner.test.ts
··· 23 23 console.error(stderr) 24 24 }) 25 25 26 - expect(browserResultJson.testResults).toHaveLength(17) 27 - expect(passedTests).toHaveLength(15) 26 + expect(browserResultJson.testResults).toHaveLength(18) 27 + expect(passedTests).toHaveLength(16) 28 28 expect(failedTests).toHaveLength(2) 29 29 30 30 expect(stderr).not.toContain('has been externalized for browser compatibility')
+72
test/browser/test/cdp.test.ts
··· 1 + import { cdp, server } from '@vitest/browser/context' 2 + import { describe, expect, it, onTestFinished, vi } from 'vitest' 3 + 4 + describe.runIf( 5 + server.provider === 'playwright' && server.browser === 'chromium', 6 + )('cdp in chromium browsers', () => { 7 + it('cdp sends events correctly', async () => { 8 + const messageAdded = vi.fn() 9 + 10 + cdp().on('Console.messageAdded', messageAdded) 11 + 12 + await cdp().send('Console.enable') 13 + onTestFinished(async () => { 14 + await cdp().send('Console.disable') 15 + }) 16 + 17 + console.error('MESSAGE ADDED') 18 + 19 + await vi.waitFor(() => { 20 + expect(messageAdded).toHaveBeenCalledWith({ 21 + message: expect.objectContaining({ 22 + column: expect.any(Number), 23 + text: 'MESSAGE ADDED', 24 + source: 'console-api', 25 + url: expect.any(String), 26 + }), 27 + }) 28 + }) 29 + }) 30 + 31 + it('cdp keyboard works correctly', async () => { 32 + const input = document.createElement('input') 33 + document.body.appendChild(input) 34 + input.focus() 35 + 36 + await cdp().send('Input.dispatchKeyEvent', { 37 + type: 'keyDown', 38 + text: 'a', 39 + }) 40 + expect(input).toHaveValue('a') 41 + 42 + await cdp().send('Input.insertText', { 43 + text: 'some text', 44 + }) 45 + expect(input).toHaveValue('asome text') 46 + }) 47 + 48 + it('click events are fired correctly', async () => { 49 + const clickEvent = vi.fn() 50 + document.body.addEventListener('click', clickEvent) 51 + 52 + const parent = window.top 53 + const iframePosition = parent.document.querySelector('iframe').getBoundingClientRect() 54 + 55 + await cdp().send('Input.dispatchMouseEvent', { 56 + type: 'mousePressed', 57 + x: iframePosition.x + 10, 58 + y: iframePosition.y + 10, 59 + button: 'left', 60 + clickCount: 1, 61 + }) 62 + await cdp().send('Input.dispatchMouseEvent', { 63 + type: 'mouseReleased', 64 + x: iframePosition.x + 10, 65 + y: iframePosition.y + 10, 66 + button: 'left', 67 + clickCount: 1, 68 + }) 69 + 70 + expect(clickEvent).toHaveBeenCalledOnce() 71 + }) 72 + })
+7
packages/browser/src/client/client.ts
··· 56 56 } 57 57 getBrowserState().createTesters?.(files) 58 58 }, 59 + cdpEvent(event: string, payload: unknown) { 60 + const cdp = getBrowserState().cdp 61 + if (!cdp) { 62 + return 63 + } 64 + cdp.emit(event, payload) 65 + }, 59 66 }, 60 67 { 61 68 post: msg => ctx.ws.send(msg),
+7
packages/browser/src/client/utils.ts
··· 25 25 contextId: string 26 26 runTests?: (tests: string[]) => Promise<void> 27 27 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 + } 28 35 } 29 36 30 37 /* @__NO_SIDE_EFFECTS__ */
+58
packages/browser/src/node/cdp.ts
··· 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 + }
+1 -1
packages/browser/src/node/plugin.ts
··· 182 182 if (rawId.startsWith('/__virtual_vitest__')) { 183 183 const url = new URL(rawId, 'http://localhost') 184 184 if (!url.searchParams.has('id')) { 185 - throw new TypeError(`Invalid virtual module id: ${rawId}, requires "id" query.`) 185 + return 186 186 } 187 187 188 188 const id = decodeURIComponent(url.searchParams.get('id')!)
+14 -3
packages/browser/src/node/rpc.ts
··· 40 40 wss.handleUpgrade(request, socket, head, (ws) => { 41 41 wss.emit('connection', ws, request) 42 42 43 - const rpc = setupClient(ws) 43 + const rpc = setupClient(sessionId, ws) 44 44 const state = server.state 45 45 const clients = type === 'tester' ? state.testers : state.orchestrators 46 46 clients.set(sessionId, rpc) ··· 50 50 ws.on('close', () => { 51 51 debug?.('[%s] Browser API disconnected from %s', sessionId, type) 52 52 clients.delete(sessionId) 53 + server.state.removeCDPHandler(sessionId) 53 54 }) 54 55 }) 55 56 }) ··· 62 63 } 63 64 } 64 65 65 - function setupClient(ws: WebSocket) { 66 + function setupClient(sessionId: string, ws: WebSocket) { 66 67 const rpc = createBirpc<WebSocketBrowserEvents, WebSocketBrowserHandlers>( 67 68 { 68 69 async onUnhandledError(error, type) { ··· 182 183 } 183 184 }) 184 185 }, 186 + 187 + // CDP 188 + async sendCdpEvent(contextId: string, event: string, payload?: Record<string, unknown>) { 189 + const cdp = await server.ensureCDPHandler(contextId, sessionId) 190 + return cdp.send(event, payload) 191 + }, 192 + async trackCdpEvent(contextId: string, type: 'on' | 'once' | 'off', event: string, listenerId: string) { 193 + const cdp = await server.ensureCDPHandler(contextId, sessionId) 194 + cdp[type](event, listenerId) 195 + }, 185 196 }, 186 197 { 187 198 post: msg => ws.send(msg), 188 199 on: fn => ws.on('message', fn), 189 - eventNames: ['onCancel'], 200 + eventNames: ['onCancel', 'cdpEvent'], 190 201 serialize: (data: any) => stringify(data, stringifyReplace), 191 202 deserialize: parse, 192 203 onTimeoutError(functionName) {
+37
packages/browser/src/node/server.ts
··· 3 3 import type { 4 4 BrowserProvider, 5 5 BrowserScript, 6 + CDPSession, 6 7 BrowserServer as IBrowserServer, 7 8 Vite, 8 9 WorkspaceProject, ··· 12 13 import type { ResolvedConfig } from 'vitest' 13 14 import { BrowserServerState } from './state' 14 15 import { getBrowserProvider } from './utils' 16 + import { BrowserServerCDPHandler } from './cdp' 15 17 16 18 export class BrowserServer implements IBrowserServer { 17 19 public faviconUrl: string ··· 135 137 browser, 136 138 options: providerOptions, 137 139 }) 140 + } 141 + 142 + private cdpSessions = new Map<string, Promise<CDPSession>>() 143 + 144 + async ensureCDPHandler(contextId: string, sessionId: string) { 145 + const cachedHandler = this.state.cdps.get(sessionId) 146 + if (cachedHandler) { 147 + return cachedHandler 148 + } 149 + 150 + const provider = this.provider 151 + if (!provider.getCDPSession) { 152 + throw new Error(`CDP is not supported by the provider "${provider.name}".`) 153 + } 154 + 155 + const promise = this.cdpSessions.get(sessionId) ?? await (async () => { 156 + const promise = provider.getCDPSession!(contextId).finally(() => { 157 + this.cdpSessions.delete(sessionId) 158 + }) 159 + this.cdpSessions.set(sessionId, promise) 160 + return promise 161 + })() 162 + 163 + const session = await promise 164 + const rpc = this.state.testers.get(sessionId) 165 + if (!rpc) { 166 + throw new Error(`Tester RPC "${sessionId}" was not established.`) 167 + } 168 + 169 + const handler = new BrowserServerCDPHandler(session, rpc) 170 + this.state.cdps.set( 171 + sessionId, 172 + handler, 173 + ) 174 + return handler 138 175 } 139 176 140 177 async close() {
+9 -3
packages/browser/src/node/state.ts
··· 1 1 import { createDefer } from '@vitest/utils' 2 2 import type { BrowserServerStateContext, BrowserServerState as IBrowserServerState } from 'vitest/node' 3 3 import type { WebSocketBrowserRPC } from './types' 4 + import type { BrowserServerCDPHandler } from './cdp' 4 5 5 6 export class BrowserServerState implements IBrowserServerState { 6 - public orchestrators = new Map<string, WebSocketBrowserRPC>() 7 - public testers = new Map<string, WebSocketBrowserRPC>() 7 + public readonly orchestrators = new Map<string, WebSocketBrowserRPC>() 8 + public readonly testers = new Map<string, WebSocketBrowserRPC>() 9 + public readonly cdps = new Map<string, BrowserServerCDPHandler>() 8 10 9 - private contexts = new Map<string, BrowserServerStateContext >() 11 + private contexts = new Map<string, BrowserServerStateContext>() 10 12 11 13 getContext(contextId: string) { 12 14 return this.contexts.get(contextId) ··· 23 25 reject: defer.reject, 24 26 }) 25 27 return defer 28 + } 29 + 30 + async removeCDPHandler(sessionId: string) { 31 + this.cdps.delete(sessionId) 26 32 } 27 33 }
+5
packages/browser/src/node/types.ts
··· 40 40 getBrowserFileSourceMap: ( 41 41 id: string 42 42 ) => SourceMap | null | { mappings: '' } | undefined 43 + 44 + // cdp 45 + sendCdpEvent: (contextId: string, event: string, payload?: Record<string, unknown>) => unknown 46 + trackCdpEvent: (contextId: string, type: 'on' | 'once' | 'off', event: string, listenerId: string) => void 43 47 } 44 48 45 49 export interface WebSocketEvents ··· 58 62 export interface WebSocketBrowserEvents { 59 63 onCancel: (reason: CancelReason) => void 60 64 createTesters: (files: string[]) => Promise<void> 65 + cdpEvent: (event: string, payload: unknown) => void 61 66 } 62 67 63 68 export type WebSocketBrowserRPC = BirpcReturn<
+1
packages/vitest/src/node/index.ts
··· 25 25 export type { 26 26 BrowserProviderInitializationOptions, 27 27 BrowserProvider, 28 + CDPSession, 28 29 BrowserProviderModule, 29 30 ResolvedBrowserOptions, 30 31 BrowserProviderOptions,
+9
packages/vitest/src/types/browser.ts
··· 9 9 options?: BrowserProviderOptions 10 10 } 11 11 12 + export interface CDPSession { 13 + send: (method: string, params?: Record<string, unknown>) => Promise<unknown> 14 + on: (event: string, listener: (...args: unknown[]) => void) => void 15 + once: (event: string, listener: (...args: unknown[]) => void) => void 16 + off: (event: string, listener: (...args: unknown[]) => void) => void 17 + detach: () => Promise<void> 18 + } 19 + 12 20 export interface BrowserProvider { 13 21 name: string 14 22 /** ··· 20 28 afterCommand?: (command: string, args: unknown[]) => Awaitable<void> 21 29 getCommandsContext: (contextId: string) => Record<string, unknown> 22 30 openPage: (contextId: string, url: string) => Promise<void> 31 + getCDPSession?: (contextId: string) => Promise<CDPSession> 23 32 close: () => Awaitable<void> 24 33 // eslint-disable-next-line ts/method-signature-style -- we want to allow extended options 25 34 initialize(
+4
packages/browser/src/client/tester/context.ts
··· 163 163 }) 164 164 } 165 165 166 + export function cdp() { 167 + return runner().cdp! 168 + } 169 + 166 170 const screenshotIds: Record<string, Record<string, string>> = {} 167 171 export const page: BrowserPage = { 168 172 get config() {
+70
packages/browser/src/client/tester/state.ts
··· 1 1 import type { WorkerGlobalState } from 'vitest' 2 2 import { parse } from 'flatted' 3 3 import { getBrowserState } from '../utils' 4 + import type { BrowserRPC } from '../client' 4 5 5 6 const config = getBrowserState().config 7 + const contextId = getBrowserState().contextId 6 8 7 9 const providedContext = parse(getBrowserState().providedContext) 8 10 ··· 44 46 globalThis.__vitest_browser__ = true 45 47 // @ts-expect-error not typed global 46 48 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 + }
+2 -2
packages/browser/src/node/plugins/pluginContext.ts
··· 67 67 const distContextPath = slash(`/@fs/${resolve(__dirname, 'context.js')}`) 68 68 69 69 return ` 70 - import { page, userEvent as __userEvent_CDP__ } from '${distContextPath}' 70 + import { page, userEvent as __userEvent_CDP__, cdp } from '${distContextPath}' 71 71 ${userEventNonProviderImport} 72 72 const filepath = () => ${filepathCode} 73 73 const rpc = () => __vitest_worker__.rpc ··· 84 84 } 85 85 export const commands = server.commands 86 86 export const userEvent = ${getUserEvent(provider)} 87 - export { page } 87 + export { page, cdp } 88 88 ` 89 89 } 90 90
+23
packages/browser/src/node/providers/playwright.ts
··· 130 130 await browserPage.goto(url) 131 131 } 132 132 133 + async getCDPSession(contextId: string) { 134 + const page = this.getPage(contextId) 135 + const cdp = await page.context().newCDPSession(page) 136 + return { 137 + async send(method: string, params: any) { 138 + const result = await cdp.send(method as 'DOM.querySelector', params) 139 + return result as unknown 140 + }, 141 + on(event: string, listener: (...args: any[]) => void) { 142 + cdp.on(event as 'Accessibility.loadComplete', listener) 143 + }, 144 + off(event: string, listener: (...args: any[]) => void) { 145 + cdp.off(event as 'Accessibility.loadComplete', listener) 146 + }, 147 + once(event: string, listener: (...args: any[]) => void) { 148 + cdp.once(event as 'Accessibility.loadComplete', listener) 149 + }, 150 + detach() { 151 + return cdp.detach() 152 + }, 153 + } 154 + } 155 + 133 156 async close() { 134 157 const browser = this.browser 135 158 this.browser = null