mirror of
https://github.com/cypress-io/cypress.git
synced 2026-01-29 10:29:27 -06:00
178 lines
5.0 KiB
TypeScript
178 lines
5.0 KiB
TypeScript
import type { CDPClient } from '@packages/types/src/protocol'
|
|
import type Protocol from 'devtools-protocol/types/protocol.d'
|
|
import { EventEmitter } from 'stream'
|
|
import { randomUUID } from 'crypto'
|
|
import { decode, encode } from '../utils'
|
|
import Debug from 'debug'
|
|
|
|
const debugVerbose = Debug('cypress-verbose:server:socket:cdp-socket')
|
|
|
|
/**
|
|
* The goal of this class is to emulate the socket io server API, but using the Chrome DevTools Protocol.
|
|
*/
|
|
export class CDPSocketServer extends EventEmitter {
|
|
private _cdpSocket?: CDPSocket
|
|
private _fullNamespace: string
|
|
private _path?: string
|
|
private _namespaceMap: Record<string, CDPSocketServer> = {}
|
|
|
|
constructor ({ path = '', namespace = '/default' } = {}) {
|
|
super()
|
|
|
|
this._path = path
|
|
this._fullNamespace = `${path}${namespace}`
|
|
}
|
|
|
|
async attachCDPClient (cdpClient: CDPClient): Promise<void> {
|
|
this._cdpSocket = await CDPSocket.init(cdpClient, this._fullNamespace)
|
|
|
|
await Promise.all(Object.values(this._namespaceMap).map(async (server) => {
|
|
return server.attachCDPClient(cdpClient)
|
|
}))
|
|
|
|
// Simulate a connection event
|
|
super.emit('connection', this._cdpSocket)
|
|
}
|
|
|
|
// @ts-expect-error TODO: fix emit type
|
|
emit = async (event: string, ...args: any[]) => {
|
|
// tslint:disable-next-line:no-floating-promises
|
|
this._cdpSocket?.emit(event, ...args)
|
|
|
|
return true
|
|
}
|
|
|
|
of (namespace: string): CDPSocketServer {
|
|
const fullNamespace = `${this._path}${namespace}`
|
|
|
|
let server = this._namespaceMap[fullNamespace]
|
|
|
|
if (!server) {
|
|
server = new CDPSocketServer({ path: this._path, namespace })
|
|
this._namespaceMap[fullNamespace] = server
|
|
}
|
|
|
|
return server
|
|
}
|
|
|
|
// We want to match the socket io API, but we don't really need to support rooms, so we are just passing along the existing server in this case.
|
|
to (): CDPSocketServer {
|
|
return this
|
|
}
|
|
|
|
close (): void {
|
|
this._cdpSocket?.close()
|
|
this.removeAllListeners()
|
|
this._cdpSocket = undefined
|
|
|
|
Object.values(this._namespaceMap).forEach((server) => {
|
|
server.close()
|
|
})
|
|
}
|
|
|
|
disconnectSockets (close?: boolean): void {
|
|
this.close()
|
|
}
|
|
}
|
|
|
|
export class CDPSocket extends EventEmitter {
|
|
private _cdpClient?: CDPClient
|
|
private _namespace: string
|
|
private _executionContextId?: number
|
|
|
|
constructor (cdpClient: CDPClient, namespace: string) {
|
|
super()
|
|
|
|
this._cdpClient = cdpClient
|
|
this._namespace = namespace
|
|
|
|
this._cdpClient.on('Runtime.bindingCalled', this.processCDPRuntimeBinding)
|
|
}
|
|
|
|
static async init (cdpClient: CDPClient, namespace: string): Promise<CDPSocket> {
|
|
await cdpClient.send('Runtime.enable')
|
|
await cdpClient.send('Runtime.addBinding', {
|
|
name: `cypressSendToServer-${namespace}`,
|
|
})
|
|
|
|
return new CDPSocket(cdpClient, namespace)
|
|
}
|
|
|
|
join = (): void => {
|
|
return
|
|
}
|
|
|
|
// @ts-expect-error TODO: fix emit type
|
|
emit = async (event: string, ...args: any[]) => {
|
|
// Generate a unique callback event name
|
|
const uuid = randomUUID()
|
|
let callback: ((...args: any[]) => void) | undefined
|
|
|
|
if (typeof args[args.length - 1] === 'function') {
|
|
callback = args.pop()
|
|
}
|
|
|
|
if (callback) {
|
|
this.once(uuid, callback)
|
|
}
|
|
|
|
await encode([event, uuid, args], this._namespace).then((encoded: any) => {
|
|
const expression = `
|
|
if (window['cypressSocket-${this._namespace}'] && window['cypressSocket-${this._namespace}'].send) {
|
|
window['cypressSocket-${this._namespace}'].send('${JSON.stringify(encoded).replaceAll('\\', '\\\\').replaceAll('\'', '\\\'')}')
|
|
}
|
|
`
|
|
|
|
debugVerbose('sending message to browser %o', { expression })
|
|
|
|
this._cdpClient?.send('Runtime.evaluate', { expression, contextId: this._executionContextId }).then((result) => {
|
|
debugVerbose('successfully sent message to browser %o', result)
|
|
}).catch((error) => {
|
|
debugVerbose('error sending message to browser %o', { error })
|
|
})
|
|
})
|
|
|
|
return true
|
|
}
|
|
|
|
disconnect = () => {
|
|
this.close()
|
|
}
|
|
|
|
get connected (): boolean {
|
|
return !!this._cdpClient
|
|
}
|
|
|
|
close = () => {
|
|
this._cdpClient?.off('Runtime.bindingCalled', this.processCDPRuntimeBinding)
|
|
this._cdpClient = undefined
|
|
}
|
|
|
|
private processCDPRuntimeBinding = async (bindingCalledEvent: Protocol.Runtime.BindingCalledEvent) => {
|
|
const { name, payload } = bindingCalledEvent
|
|
|
|
if (name !== `cypressSendToServer-${this._namespace}`) {
|
|
return
|
|
}
|
|
|
|
debugVerbose('received message from browser %o', { payload })
|
|
|
|
this._executionContextId = bindingCalledEvent.executionContextId
|
|
|
|
const data = JSON.parse(payload)
|
|
|
|
await decode(data).then((decoded: any) => {
|
|
const [event, callbackEvent, args] = decoded
|
|
|
|
const callback = async (...callbackArgs: any[]) => {
|
|
debugVerbose('emitting callback from browser %o', { callbackEvent, callbackArgs })
|
|
await this.emit(callbackEvent, ...callbackArgs)
|
|
}
|
|
|
|
debugVerbose('emitting message from browser %o', { event, callbackEvent, args })
|
|
|
|
super.emit(event, ...args, callback)
|
|
})
|
|
}
|
|
}
|