diff options
Diffstat (limited to 'remote/test/puppeteer/src/common/Connection.ts')
-rw-r--r-- | remote/test/puppeteer/src/common/Connection.ts | 447 |
1 files changed, 447 insertions, 0 deletions
diff --git a/remote/test/puppeteer/src/common/Connection.ts b/remote/test/puppeteer/src/common/Connection.ts new file mode 100644 index 0000000000..541e225e4e --- /dev/null +++ b/remote/test/puppeteer/src/common/Connection.ts @@ -0,0 +1,447 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import {assert} from '../util/assert.js'; +import {debug} from './Debug.js'; +const debugProtocolSend = debug('puppeteer:protocol:SEND ►'); +const debugProtocolReceive = debug('puppeteer:protocol:RECV ◀'); + +import {Protocol} from 'devtools-protocol'; +import {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js'; +import {ConnectionTransport} from './ConnectionTransport.js'; +import {EventEmitter} from './EventEmitter.js'; +import {ProtocolError} from './Errors.js'; + +/** + * @public + */ +export {ConnectionTransport, ProtocolMapping}; + +/** + * @public + */ +export interface ConnectionCallback { + resolve(args: unknown): void; + reject(args: unknown): void; + error: ProtocolError; + method: string; +} + +/** + * Internal events that the Connection class emits. + * + * @internal + */ +export const ConnectionEmittedEvents = { + Disconnected: Symbol('Connection.Disconnected'), +} as const; + +/** + * @public + */ +export class Connection extends EventEmitter { + #url: string; + #transport: ConnectionTransport; + #delay: number; + #lastId = 0; + #sessions: Map<string, CDPSession> = new Map(); + #closed = false; + #callbacks: Map<number, ConnectionCallback> = new Map(); + #manuallyAttached = new Set<string>(); + + constructor(url: string, transport: ConnectionTransport, delay = 0) { + super(); + this.#url = url; + this.#delay = delay; + + this.#transport = transport; + this.#transport.onmessage = this.onMessage.bind(this); + this.#transport.onclose = this.#onClose.bind(this); + } + + static fromSession(session: CDPSession): Connection | undefined { + return session.connection(); + } + + /** + * @internal + */ + get _closed(): boolean { + return this.#closed; + } + + /** + * @internal + */ + get _sessions(): Map<string, CDPSession> { + return this.#sessions; + } + + /** + * @param sessionId - The session id + * @returns The current CDP session if it exists + */ + session(sessionId: string): CDPSession | null { + return this.#sessions.get(sessionId) || null; + } + + url(): string { + return this.#url; + } + + send<T extends keyof ProtocolMapping.Commands>( + method: T, + ...paramArgs: ProtocolMapping.Commands[T]['paramsType'] + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + // There is only ever 1 param arg passed, but the Protocol defines it as an + // array of 0 or 1 items See this comment: + // https://github.com/ChromeDevTools/devtools-protocol/pull/113#issuecomment-412603285 + // which explains why the protocol defines the params this way for better + // type-inference. + // So now we check if there are any params or not and deal with them accordingly. + const params = paramArgs.length ? paramArgs[0] : undefined; + const id = this._rawSend({method, params}); + return new Promise((resolve, reject) => { + this.#callbacks.set(id, { + resolve, + reject, + error: new ProtocolError(), + method, + }); + }); + } + + /** + * @internal + */ + _rawSend(message: Record<string, unknown>): number { + const id = ++this.#lastId; + const stringifiedMessage = JSON.stringify(Object.assign({}, message, {id})); + debugProtocolSend(stringifiedMessage); + this.#transport.send(stringifiedMessage); + return id; + } + + /** + * @internal + */ + protected async onMessage(message: string): Promise<void> { + if (this.#delay) { + await new Promise(f => { + return setTimeout(f, this.#delay); + }); + } + debugProtocolReceive(message); + const object = JSON.parse(message); + if (object.method === 'Target.attachedToTarget') { + const sessionId = object.params.sessionId; + const session = new CDPSession( + this, + object.params.targetInfo.type, + sessionId + ); + this.#sessions.set(sessionId, session); + this.emit('sessionattached', session); + const parentSession = this.#sessions.get(object.sessionId); + if (parentSession) { + parentSession.emit('sessionattached', session); + } + } else if (object.method === 'Target.detachedFromTarget') { + const session = this.#sessions.get(object.params.sessionId); + if (session) { + session._onClosed(); + this.#sessions.delete(object.params.sessionId); + this.emit('sessiondetached', session); + const parentSession = this.#sessions.get(object.sessionId); + if (parentSession) { + parentSession.emit('sessiondetached', session); + } + } + } + if (object.sessionId) { + const session = this.#sessions.get(object.sessionId); + if (session) { + session._onMessage(object); + } + } else if (object.id) { + const callback = this.#callbacks.get(object.id); + // Callbacks could be all rejected if someone has called `.dispose()`. + if (callback) { + this.#callbacks.delete(object.id); + if (object.error) { + callback.reject( + createProtocolError(callback.error, callback.method, object) + ); + } else { + callback.resolve(object.result); + } + } + } else { + this.emit(object.method, object.params); + } + } + + #onClose(): void { + if (this.#closed) { + return; + } + this.#closed = true; + this.#transport.onmessage = undefined; + this.#transport.onclose = undefined; + for (const callback of this.#callbacks.values()) { + callback.reject( + rewriteError( + callback.error, + `Protocol error (${callback.method}): Target closed.` + ) + ); + } + this.#callbacks.clear(); + for (const session of this.#sessions.values()) { + session._onClosed(); + } + this.#sessions.clear(); + this.emit(ConnectionEmittedEvents.Disconnected); + } + + dispose(): void { + this.#onClose(); + this.#transport.close(); + } + + /** + * @internal + */ + isAutoAttached(targetId: string): boolean { + return !this.#manuallyAttached.has(targetId); + } + + /** + * @internal + */ + async _createSession( + targetInfo: Protocol.Target.TargetInfo, + isAutoAttachEmulated = true + ): Promise<CDPSession> { + if (!isAutoAttachEmulated) { + this.#manuallyAttached.add(targetInfo.targetId); + } + const {sessionId} = await this.send('Target.attachToTarget', { + targetId: targetInfo.targetId, + flatten: true, + }); + this.#manuallyAttached.delete(targetInfo.targetId); + const session = this.#sessions.get(sessionId); + if (!session) { + throw new Error('CDPSession creation failed.'); + } + return session; + } + + /** + * @param targetInfo - The target info + * @returns The CDP session that is created + */ + async createSession( + targetInfo: Protocol.Target.TargetInfo + ): Promise<CDPSession> { + return await this._createSession(targetInfo, false); + } +} + +/** + * @public + */ +export interface CDPSessionOnMessageObject { + id?: number; + method: string; + params: Record<string, unknown>; + error: {message: string; data: any; code: number}; + result?: any; +} + +/** + * Internal events that the CDPSession class emits. + * + * @internal + */ +export const CDPSessionEmittedEvents = { + Disconnected: Symbol('CDPSession.Disconnected'), +} as const; + +/** + * The `CDPSession` instances are used to talk raw Chrome Devtools Protocol. + * + * @remarks + * + * Protocol methods can be called with {@link CDPSession.send} method and protocol + * events can be subscribed to with `CDPSession.on` method. + * + * Useful links: {@link https://chromedevtools.github.io/devtools-protocol/ | DevTools Protocol Viewer} + * and {@link https://github.com/aslushnikov/getting-started-with-cdp/blob/HEAD/README.md | Getting Started with DevTools Protocol}. + * + * @example + * + * ```ts + * const client = await page.target().createCDPSession(); + * await client.send('Animation.enable'); + * client.on('Animation.animationCreated', () => + * console.log('Animation created!') + * ); + * const response = await client.send('Animation.getPlaybackRate'); + * console.log('playback rate is ' + response.playbackRate); + * await client.send('Animation.setPlaybackRate', { + * playbackRate: response.playbackRate / 2, + * }); + * ``` + * + * @public + */ +export class CDPSession extends EventEmitter { + #sessionId: string; + #targetType: string; + #callbacks: Map<number, ConnectionCallback> = new Map(); + #connection?: Connection; + + /** + * @internal + */ + constructor(connection: Connection, targetType: string, sessionId: string) { + super(); + this.#connection = connection; + this.#targetType = targetType; + this.#sessionId = sessionId; + } + + connection(): Connection | undefined { + return this.#connection; + } + + send<T extends keyof ProtocolMapping.Commands>( + method: T, + ...paramArgs: ProtocolMapping.Commands[T]['paramsType'] + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + if (!this.#connection) { + return Promise.reject( + new Error( + `Protocol error (${method}): Session closed. Most likely the ${ + this.#targetType + } has been closed.` + ) + ); + } + + // See the comment in Connection#send explaining why we do this. + const params = paramArgs.length ? paramArgs[0] : undefined; + + const id = this.#connection._rawSend({ + sessionId: this.#sessionId, + method, + params, + }); + + return new Promise((resolve, reject) => { + this.#callbacks.set(id, { + resolve, + reject, + error: new ProtocolError(), + method, + }); + }); + } + + /** + * @internal + */ + _onMessage(object: CDPSessionOnMessageObject): void { + const callback = object.id ? this.#callbacks.get(object.id) : undefined; + if (object.id && callback) { + this.#callbacks.delete(object.id); + if (object.error) { + callback.reject( + createProtocolError(callback.error, callback.method, object) + ); + } else { + callback.resolve(object.result); + } + } else { + assert(!object.id); + this.emit(object.method, object.params); + } + } + + /** + * Detaches the cdpSession from the target. Once detached, the cdpSession object + * won't emit any events and can't be used to send messages. + */ + async detach(): Promise<void> { + if (!this.#connection) { + throw new Error( + `Session already detached. Most likely the ${ + this.#targetType + } has been closed.` + ); + } + await this.#connection.send('Target.detachFromTarget', { + sessionId: this.#sessionId, + }); + } + + /** + * @internal + */ + _onClosed(): void { + for (const callback of this.#callbacks.values()) { + callback.reject( + rewriteError( + callback.error, + `Protocol error (${callback.method}): Target closed.` + ) + ); + } + this.#callbacks.clear(); + this.#connection = undefined; + this.emit(CDPSessionEmittedEvents.Disconnected); + } + + /** + * Returns the session's id. + */ + id(): string { + return this.#sessionId; + } +} + +function createProtocolError( + error: ProtocolError, + method: string, + object: {error: {message: string; data: any; code: number}} +): Error { + let message = `Protocol error (${method}): ${object.error.message}`; + if ('data' in object.error) { + message += ` ${object.error.data}`; + } + return rewriteError(error, message, object.error.message); +} + +function rewriteError( + error: ProtocolError, + message: string, + originalMessage?: string +): Error { + error.message = message; + error.originalMessage = originalMessage ?? error.originalMessage; + return error; +} |