From a9d55735b28cd3b1adf4d3281b4ad5bf449e2dff Mon Sep 17 00:00:00 2001 From: Kris Selden Date: Thu, 20 Jun 2019 15:02:31 -0700 Subject: [PATCH] [fix]: type cleanup Fix some types and add a couple convenience methods. Add convenience for Browser.close since chrome sometimes exits before sending the response to Browser.close. --- .../protocol-connection/src/newEventHook.ts | 72 ++++++---- .../src/newProtocolConnection.ts | 87 ++++++++--- @tracerbench/protocol-connection/types.d.ts | 135 ++++++++++++++++-- @tracerbench/spawn/src/newProcess.ts | 1 + @tracerbench/spawn/types.d.ts | 2 + chrome-debugging-client/src/index.ts | 56 ++++++-- test/spawnChromeTest.js | 31 ++-- 7 files changed, 305 insertions(+), 79 deletions(-) diff --git a/@tracerbench/protocol-connection/src/newEventHook.ts b/@tracerbench/protocol-connection/src/newEventHook.ts index 01b8e36..2db09a5 100644 --- a/@tracerbench/protocol-connection/src/newEventHook.ts +++ b/@tracerbench/protocol-connection/src/newEventHook.ts @@ -1,15 +1,24 @@ import Protocol from "devtools-protocol"; -import { ProtocolConnection, SessionID, TargetID, TargetInfo } from "../types"; +import { + SessionConnection, + SessionID, + SessionIdentifier, + TargetID, + TargetInfo, +} from "../types"; const CONNECTION = Symbol("connection"); -export type NewConnection = (session: Session) => ProtocolConnection; +export type NewConnection = (session: Session) => SessionConnection; export type DestroyConnection = (sessionId: SessionID) => void; export type EventHook = (event: string, params?: any) => void; -export type GetConnection = ( - session: SessionIdentifier, -) => ProtocolConnection | undefined; +export type GetConnection = { + (session: SessionIdentifier, throwIfNotAttached?: true): SessionConnection; + (session: SessionIdentifier, throwIfNotAttached: boolean | undefined): + | SessionConnection + | undefined; +}; export type ClearSessions = () => void; export interface Session { @@ -22,18 +31,9 @@ interface Attachment { sessionId: SessionID; targetId: TargetID; targetInfo: TargetInfo; - [CONNECTION]: ProtocolConnection | undefined; + [CONNECTION]: SessionConnection | undefined; } -export type SessionIdentifier = - | SessionID - | { - targetId: TargetID; - } - | { - sessionId: SessionID; - }; - export default function newEventHook( newConnection: NewConnection, detachConnection: DestroyConnection, @@ -57,7 +57,7 @@ export default function newEventHook( } } - function getSessionId(session: SessionIdentifier) { + function getSessionId(session: SessionIdentifier, throwIfNotAttached = true) { let sessionId: SessionID | undefined; if (typeof session === "string") { sessionId = session; @@ -69,19 +69,31 @@ export default function newEventHook( if ("sessionId" in session) { sessionId = session.sessionId; } else if ("targetId" in session) { + const { targetId } = session; sessionId = sessionIds.get(session.targetId); + if (!sessionId && throwIfNotAttached) { + throw new Error(`Target ${targetId} is not attached.`); + } } } return sessionId; } - function getSession(session: SessionIdentifier) { - if (attachments === undefined) { + function getSession(session: SessionIdentifier, throwIfNotAttached = true) { + const sessionId = getSessionId(session, throwIfNotAttached); + if (sessionId === undefined) { return; } - const sessionId = getSessionId(session); - if (sessionId !== undefined) { - return attachments.get(sessionId); + + if (attachments !== undefined) { + const attachment = attachments.get(sessionId); + if (attachment !== undefined) { + return attachment; + } + } + + if (throwIfNotAttached) { + throw new Error(`Session ${sessionId} is no longer attached.`); } } @@ -128,14 +140,26 @@ export default function newEventHook( function targetInfoChanged({ targetInfo, }: Protocol.Target.TargetInfoChangedEvent) { - const attachment = getSession(targetInfo); + const attachment = getSession(targetInfo, false); if (attachment !== undefined) { attachment.targetInfo = targetInfo; } } - function getConnection(session: SessionIdentifier) { - const attachment = getSession(session); + function getConnection(session: SessionIdentifier): SessionConnection; + function getConnection( + session: SessionIdentifier, + throwIfNotAttached?: true, + ): SessionConnection; + function getConnection( + session: SessionIdentifier, + throwIfNotAttached: boolean | undefined, + ): SessionConnection | undefined; + function getConnection( + session: SessionIdentifier, + throwIfNotAttached = true, + ) { + const attachment = getSession(session, throwIfNotAttached); if (attachment === undefined) { return; } diff --git a/@tracerbench/protocol-connection/src/newProtocolConnection.ts b/@tracerbench/protocol-connection/src/newProtocolConnection.ts index d28c5c8..caae5fa 100644 --- a/@tracerbench/protocol-connection/src/newProtocolConnection.ts +++ b/@tracerbench/protocol-connection/src/newProtocolConnection.ts @@ -2,13 +2,21 @@ import { AttachProtocolTransport, AttachSession, } from "@tracerbench/protocol-transport"; +import Protocol from "devtools-protocol"; import { combineRaceCancellation, disposablePromise, RaceCancellation, throwIfCancelled, } from "race-cancellation"; -import { NewEventEmitter, ProtocolConnection, SessionID } from "../types"; +import { + NewEventEmitter, + ProtocolConnection, + RootConnection, + SessionConnection, + SessionID, + TargetID, +} from "../types"; import newEventHook, { Session } from "./newEventHook"; /** @@ -21,7 +29,7 @@ import newEventHook, { Session } from "./newEventHook"; export default function newRootConnection( attach: AttachProtocolTransport, newEventEmitter: NewEventEmitter, -): ProtocolConnection { +): RootConnection { return newProtocolConnection(attach, newEventEmitter); } @@ -29,7 +37,7 @@ function newSessionConnection( attachSession: AttachSession, newEventEmitter: NewEventEmitter, session: Session, -) { +): SessionConnection { return newProtocolConnection( attachSession(session.sessionId), newEventEmitter, @@ -37,6 +45,15 @@ function newSessionConnection( ); } +function newProtocolConnection( + attachTransport: AttachProtocolTransport, + newEventEmitter: NewEventEmitter, + session: Session, +): SessionConnection; +function newProtocolConnection( + attachTransport: AttachProtocolTransport, + newEventEmitter: NewEventEmitter, +): RootConnection; function newProtocolConnection( attachTransport: AttachProtocolTransport, newEventEmitter: NewEventEmitter, @@ -58,35 +75,67 @@ function newProtocolConnection( onTargetDetached, ); - return { + const base: RootConnection = { + attachToTarget, connection, + off: emitter.removeListener.bind(emitter), on: emitter.on.bind(emitter), once: emitter.once.bind(emitter), raceDetached, removeAllListeners: emitter.removeAllListeners.bind(emitter), removeListener: emitter.removeListener.bind(emitter), send, + setAutoAttach, until, get isDetached() { return isDetached; }, - get targetId() { - if (session !== undefined) { - return session.targetId; - } - }, - get sessionId() { - if (session !== undefined) { - return session.sessionId; - } - }, - get targetInfo() { - if (session !== undefined) { - return session.targetInfo; - } - }, }; + if (session !== undefined) { + return Object.create(base, { + sessionId: { + get: () => session.sessionId, + }, + targetId: { + get: () => session.targetId, + }, + targetInfo: { + get: () => session.targetInfo, + }, + }); + } + + return base; + + async function attachToTarget(targetId: TargetID | { targetId: TargetID }) { + if (typeof targetId === "object" && targetId !== null) { + targetId = targetId.targetId; + } + const request = { flatten: true, targetId }; + const conn = connection(request, false); + if (conn !== undefined) { + return conn; + } + const resp: Protocol.Target.AttachToTargetResponse = await send( + "Target.attachToTarget", + request, + ); + return connection(resp); + } + + async function setAutoAttach( + autoAttach: boolean, + waitForDebuggerOnStart = false, + ) { + const request: Protocol.Target.SetAutoAttachRequest = { + autoAttach, + flatten: true, + waitForDebuggerOnStart, + }; + await send("Target.setAutoAttach", request); + } + function onEvent(event: string, params: any) { eventHook(event, params); emitter.emit(event, params); diff --git a/@tracerbench/protocol-connection/types.d.ts b/@tracerbench/protocol-connection/types.d.ts index 9a10aad..a22ff4b 100644 --- a/@tracerbench/protocol-connection/types.d.ts +++ b/@tracerbench/protocol-connection/types.d.ts @@ -2,22 +2,105 @@ import Protocol from "devtools-protocol"; import { ProtocolMapping } from "devtools-protocol/types/protocol-mapping"; import { RaceCancellation } from "race-cancellation"; -export interface ProtocolConnection { +export type ProtocolConnection = SessionConnection | RootConnection; + +export interface SessionConnection extends ProtocolConnectionBase { + readonly sessionId: SessionID; + readonly targetId: TargetID; + readonly targetInfo: TargetInfo; +} + +export interface RootConnection extends ProtocolConnectionBase { + readonly sessionId?: undefined; + readonly targetId?: undefined; + readonly targetInfo?: undefined; +} + +export interface ProtocolConnectionBase { + /** + * Whether the connection is still attached. + */ readonly isDetached: boolean; + + /** + * Use to race an async task against the connection detaching. + */ readonly raceDetached: RaceCancellation; - readonly sessionId?: SessionID; - readonly targetId?: TargetID; - readonly targetInfo?: TargetInfo; + /** + * Get a connection for a currently attached session. + * + * If the session is not attached this will throw. + * + * Should be used with Target.attachToTarget or Target.createTarget + * + * @param sessionId the session id or a obj with a sessionId or a targetId + */ + connection( + sessionId: SessionIdentifier, + throwIfNotAttached?: true, + ): SessionConnection; /** * Get a connection for a currently attached session. * - * If the session is not attached this will return undefined. + * If throwIfNotAttached is undefined or true, it will throw if session is not attached, + * otherwise it returns undefined. + * + * @param sessionId the session id or a obj with a sessionId or a targetId + * @param throwIfNotAttached whether to throw if session is not attached, defaults to true. */ connection( - sessionId: SessionID | { sessionId: SessionID } | { targetId: TargetID }, - ): ProtocolConnection | undefined; + sessionId: SessionIdentifier, + throwIfNotAttached: boolean | undefined, + ): SessionConnection | undefined; + + /** + * Attaches to a target and returns the SessionConnection, if the target is already attached, + * it returns the existing SessionConnection. + * + * This is a convenience for ensuring that flattened sessions are used it is the same as + * ```js + * conn.connection(await conn.send("Target.attachToTarget", { targetId, flatten: true })); + * ``` + * + * You can either use with Target.createTarget or the Target.setDiscoverTargets method and + * Target.targetCreated events. + * + * https://chromedevtools.github.io/devtools-protocol/tot/Target#method-attachToTarget + */ + attachToTarget( + targetId: TargetID | { targetId: TargetID }, + ): Promise; + + /** + * This will cause Target.attachedToTarget events to fire for related targets. + * + * This is a convenience for ensuring that flattened sessions are used it is the same as + * + * ```js + * await conn.send("Target.setAutoAttach", { autoAttach, waitForDebuggerOnStart, flatten: true }); + * ``` + * https://chromedevtools.github.io/devtools-protocol/tot/Target#method-setAutoAttach + * + * You suscribe to the Target.attachedToTarget event and use the connection method to get + * the connection for the attached session. If it isn't a target you are interested in + * you must detach from it or it will stay alive until you set auto attach to false. This + * can be an issue with testing service workers. + * + * ```js + * conn.on("Target.attachedToTarget", event => { + * const session = conn.connection(event); + * }) + * ``` + * + * @param autoAttach auto attach flag + * @param waitForDebuggerOnStart whether the debugger should wait target to be attached. + */ + setAutoAttach( + autoAttach: boolean, + waitForDebuggerOnStart?: boolean, + ): Promise; /** * Cancellable send of a request and wait for response. This @@ -31,7 +114,7 @@ export interface ProtocolConnection { * peer dependency. * * @param method the DevTools API method - * @param request the method's params + * @param request required request parameters * @param raceCancellation * @returns a promise of the method's result. */ @@ -53,7 +136,7 @@ export interface ProtocolConnection { * peer dependency. * * @param method the DevTools API method - * @param request the method's params + * @param request optional request parameters * @param raceCancellation * @returns a promise of the method's result. */ @@ -76,12 +159,13 @@ export interface ProtocolConnection { * peer dependency. * * @param method the DevTools API method + * @param request this request takes no params or empty pojo * @param raceCancellation * @returns a promise of the method's result. */ send( method: M, - request?: undefined, + request?: object, raceCancellation?: RaceCancellation, ): Promise; @@ -98,6 +182,7 @@ export interface ProtocolConnection { * peer dependency. * * @param method the DevTools API method + * @param request this request takes no params or empty pojo * @param raceCancellation * @returns a promise of the method completion. */ @@ -107,12 +192,29 @@ export interface ProtocolConnection { raceCancellation?: RaceCancellation, ): Promise; + /** + * Subscribe to an event. + * @param event name of event + * @param listener callback with event object + */ on( event: E, listener: (event: EventMapping[E]) => void, ): void; + + /** + * Subscribe to an event. + * @param event name of event + * @param listener void callback + */ on(event: VoidEvent, listener: () => void): void; + off( + event: E, + listener: (event: EventMapping[E]) => void, + ): void; + off(event: VoidEvent, listener: () => void): void; + once( event: E, listener: (event: EventMapping[E]) => void, @@ -137,7 +239,7 @@ export interface ProtocolConnection { * Event types are provided by the devtools-protocol peer dependency. * * @param event the name of the event - * @param predicate test event whether to resolve the until + * @param predicate optional callback to test event object whether to resolve the until * @param raceCancellation additional cancellation concern, until already races against session detached/transport close. */ until( @@ -153,11 +255,12 @@ export interface ProtocolConnection { * https://chromedevtools.github.io/devtools-protocol/tot * * @param event the name of the event + * @param predicate this event doesn't have an object so this will be undefined * @param raceCancellation additional cancellation concern, until already races against detachment. */ until( event: VoidEvent, - predicate?: undefined, + predicate?: () => boolean, raceCancellation?: RaceCancellation, ): Promise; } @@ -246,3 +349,11 @@ export type VoidEvent = | "detached"; export type MappedEvent = Exclude | "error"; +export type SessionIdentifier = + | SessionID + | { + targetId: TargetID; + } + | { + sessionId: SessionID; + }; diff --git a/@tracerbench/spawn/src/newProcess.ts b/@tracerbench/spawn/src/newProcess.ts index 987683b..7047a7d 100644 --- a/@tracerbench/spawn/src/newProcess.ts +++ b/@tracerbench/spawn/src/newProcess.ts @@ -38,6 +38,7 @@ export default function newProcess( dispose, hasExited: () => hasExited, kill, + off: emitter.removeListener.bind(emitter), on: emitter.on.bind(emitter), once: emitter.once.bind(emitter), raceExit, diff --git a/@tracerbench/spawn/types.d.ts b/@tracerbench/spawn/types.d.ts index 1b26368..96d4524 100644 --- a/@tracerbench/spawn/types.d.ts +++ b/@tracerbench/spawn/types.d.ts @@ -35,6 +35,8 @@ export interface Process { on(event: "exit", listener: () => void): void; once(event: "error", listener: (error: Error) => void): void; once(event: "exit", listener: () => void): void; + off(event: "error", listener: (error: Error) => void): void; + off(event: "exit", listener: () => void): void; removeListener(event: "error", listener: (error: Error) => void): void; removeListener(event: "exit", listener: () => void): void; removeAllListeners(event?: "error" | "exit"): void; diff --git a/chrome-debugging-client/src/index.ts b/chrome-debugging-client/src/index.ts index beb522c..7e0ef21 100644 --- a/chrome-debugging-client/src/index.ts +++ b/chrome-debugging-client/src/index.ts @@ -1,6 +1,6 @@ import { AttachMessageTransport } from "@tracerbench/message-transport"; import _newProtocolConnection, { - ProtocolConnection, + RootConnection, } from "@tracerbench/protocol-connection"; import _spawn, { Process, @@ -15,7 +15,14 @@ import { combineRaceCancellation, RaceCancellation } from "race-cancellation"; const debugCallback = debug("chrome-debugging-client"); -export function spawnChrome(options?: SpawnOptions): ChromeWithPipeConnection { +export * from "@tracerbench/message-transport"; +export * from "@tracerbench/protocol-connection/types"; +export * from "@tracerbench/spawn/types"; +export * from "@tracerbench/spawn-chrome/types"; + +export function spawnChrome( + options?: Partial, +): ChromeWithPipeConnection { return attachPipeTransport(_spawnChrome(options)); } @@ -43,9 +50,7 @@ export async function spawnWithWebSocket( return Object.assign(process, { connection, close }); } -export function newProtocolConnection( - attach: AttachMessageTransport, -): ProtocolConnection { +export function newProtocolConnection(attach: AttachMessageTransport) { return _newProtocolConnection( attach, () => new EventEmitter(), @@ -61,30 +66,61 @@ export { default as findChrome } from "@tracerbench/find-chrome"; function attachPipeTransport

( process: P, -): P & { connection: ProtocolConnection } { +): P & { + connection: RootConnection; + close(timeout?: number, raceCancellation?: RaceCancellation): Promise; +} { const connection = newProtocolConnection(process.attach); - return Object.assign(process, { connection }); + return Object.assign(process, { close, connection }); + + async function close(timeout?: number, raceCancellation?: RaceCancellation) { + if (process.hasExited) { + return; + } + try { + const waitForExit = process.waitForExit(timeout, raceCancellation); + await Promise.race([waitForExit, connection.send("Browser.close")]); + // double check in case send() won the race which is most of the time + // sometimes chrome exits before send() gets a response + await waitForExit; + } catch (e) { + // if we closed then we dont really care what the error is + if (!process.hasExited) { + throw e; + } + } + } } export interface ChromeWithPipeConnection extends Chrome { /** * Connection to devtools protocol https://chromedevtools.github.io/devtools-protocol/ */ - connection: ProtocolConnection; + connection: RootConnection; + + /** + * Close browser. + */ + close(timeout?: number, raceCancellation?: RaceCancellation): Promise; } export interface ProcessWithPipeConnection extends Process { /** * Connection to devtools protocol https://chromedevtools.github.io/devtools-protocol/ */ - connection: ProtocolConnection; + connection: RootConnection; + + /** + * Close browser. + */ + close(timeout?: number, raceCancellation?: RaceCancellation): Promise; } export interface ProcessWithWebSocketConnection extends Process { /** * Connection to devtools protocol https://chromedevtools.github.io/devtools-protocol/ */ - connection: ProtocolConnection; + connection: RootConnection; /** * Closes the web socket. diff --git a/test/spawnChromeTest.js b/test/spawnChromeTest.js index b7be214..1a571b0 100644 --- a/test/spawnChromeTest.js +++ b/test/spawnChromeTest.js @@ -13,24 +13,29 @@ QUnit.module("spawnChrome", () => { assert.ok(false, `browser error ${err.stack}`); }); + const browserVersion = await browser.send("Browser.getVersion"); + + assert.ok(browserVersion.protocolVersion); + assert.ok(browserVersion.product); + assert.ok(browserVersion.userAgent); + assert.ok(browserVersion.jsVersion); + + await browser.send("Security.setIgnoreCertificateErrors", { + ignore: true, + }); + const { targetId } = await browser.send("Target.createTarget", { url: "about:blank", }); await browser.send("Target.activateTarget", { targetId }); - const page = browser.connection( - await browser.send("Target.attachToTarget", { - targetId, - flatten: true, - }), - ); - - assert.ok(page, "session present"); + const page = await browser.attachToTarget(targetId); - if (page === undefined) { - return; - } + assert.equal(page.targetId, targetId); + assert.ok(page.sessionId); + assert.ok(page.targetInfo.type, "page"); + assert.ok(page.targetInfo.url, "about:blank"); page.on("error", err => { assert.ok(false, `target connection error ${err.stack}`); @@ -52,9 +57,7 @@ QUnit.module("spawnChrome", () => { await browser.send("Target.closeTarget", { targetId }); - await browser.send("Browser.close"); - - await chrome.waitForExit(); + await chrome.close(); } finally { await chrome.dispose(); }