diff --git a/src/browser/cdp/domains/css.ts b/src/browser/cdp/domains/css.ts index c391d319b..5ecbed415 100644 --- a/src/browser/cdp/domains/css.ts +++ b/src/browser/cdp/domains/css.ts @@ -2,13 +2,21 @@ import { CDPConnection } from "../connection"; import { CDPEventEmitter } from "../emitter"; import type { CDPCSSStyleSheetHeader, CDPSessionId, CDPStyleSheetId } from "../types"; +export interface CSSRuleUsage { + styleSheetId: CDPStyleSheetId; + startOffset: number; + endOffset: number; + used: boolean; +} + interface StopRuleUsageTrackingResponse { - ruleUsage: Array<{ - styleSheetId: CDPStyleSheetId; - startOffset: number; - endOffset: number; - used: boolean; - }>; + ruleUsage: CSSRuleUsage[]; +} + +interface TakeCoverageDeltaResponse { + coverage: CSSRuleUsage[]; + /** Monotonically increasing time, in seconds. */ + timestamp: number; } export interface CssEvents { @@ -63,4 +71,9 @@ export class CDPCss extends CDPEventEmitter { async stopRuleUsageTracking(sessionId: CDPSessionId): Promise { return this._connection.request("CSS.stopRuleUsageTracking", { sessionId }); } + + /** @link https://chromedevtools.github.io/devtools-protocol/tot/CSS/#method-takeCoverageDelta */ + async takeCoverageDelta(sessionId: CDPSessionId): Promise { + return this._connection.request("CSS.takeCoverageDelta", { sessionId }); + } } diff --git a/src/browser/cdp/domains/debugger.ts b/src/browser/cdp/domains/debugger.ts index 0f2db8dd4..a5157f8f1 100644 --- a/src/browser/cdp/domains/debugger.ts +++ b/src/browser/cdp/domains/debugger.ts @@ -46,7 +46,7 @@ interface GetScriptSourceResponse { export interface DebuggerEvents { paused: { - callFrames: CDPDebuggerCallFrame; + callFrames: CDPDebuggerCallFrame[]; /** Location of console.profileEnd(). */ reason: CDPDebuggerPausedReason; /** Object containing break-specific auxiliary properties. */ diff --git a/src/browser/cdp/domains/fetch.ts b/src/browser/cdp/domains/fetch.ts index 2d5d2ca69..b4bb5a6ec 100644 --- a/src/browser/cdp/domains/fetch.ts +++ b/src/browser/cdp/domains/fetch.ts @@ -1,13 +1,215 @@ import { CDPEventEmitter } from "../emitter"; +import { CDPConnection } from "../connection"; +import type { + CDPFrameId, + CDPNetworkErrorReason, + CDPNetworkRequest, + CDPNetworkResourceType, + CDPSessionId, +} from "../types"; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Fetch/#type-RequestId */ +type FetchRequestId = string; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Fetch/#type-RequestStage */ +type FetchRequestStage = "Request" | "Response"; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Fetch/#type-HeaderEntry */ +interface HeaderEntry { + name: string; + value: string; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Fetch/#type-AuthChallenge */ +interface AuthChallenge { + /** Source of the authentication challenge. */ + source?: "Server" | "Proxy"; + /** Origin that issued the authentication challenge. */ + origin: string; + /** The authentication scheme used, such as basic or digest. */ + scheme: string; + /** The realm of the challenge. May be empty. */ + realm: string; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Fetch/#type-AuthChallengeResponse */ +interface AuthChallengeResponse { + /** + * The decision on what to do in response to the authorization challenge. + * Default means deferring to the default behavior of the net stack, which will likely either + * the Cancel authentication or display a popup dialog box. + */ + response: "Default" | "CancelAuth" | "ProvideCredentials"; + /** The username to provide, possibly empty. Should only be set if response is ProvideCredentials. */ + username?: string; + /** The password to provide, possibly empty. Should only be set if response is ProvideCredentials. */ + password?: string; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Fetch/#type-RequestPattern */ +interface RequestPattern { + /** Wildcards ('*' -> zero or more, '?' -> exactly one) are allowed. Escape character is backslash. Omitting is equivalent to "*". */ + urlPattern?: string; + /** If set, only requests for matching resource types will be intercepted. */ + resourceType?: CDPNetworkResourceType; + /** Stage at which to begin intercepting requests. Default is Request. */ + requestStage?: FetchRequestStage; +} + +interface EnableRequest { + /** If specified, only requests matching any of these patterns will produce fetchRequested event and will be paused until clients response. If not set, all requests will be affected. */ + patterns?: RequestPattern[]; + /** If true, authRequired events will be issued and requests will be paused expecting a call to continueWithAuth. */ + handleAuthRequests?: boolean; +} + +interface FailRequestRequest { + /** An id the client received in requestPaused event. */ + requestId: FetchRequestId; + /** Causes the request to fail with the given reason. */ + reason: CDPNetworkErrorReason; +} + +interface FulfillRequestRequest { + /** An id the client received in requestPaused event. */ + requestId: FetchRequestId; + /** An HTTP response code. */ + responseCode: number; + /** Response headers. */ + responseHeaders?: HeaderEntry[]; + /** Alternative way of specifying response headers as a \0-separated series of name: value pairs. Prefer the above method unless you need to represent some non-UTF8 values that can't be transmitted over the protocol as text. (Encoded as a base64 string when passed over JSON) */ + binaryResponseHeaders?: string; + /** A response body. If absent, original response body will be used if the request is intercepted at the response stage and empty body will be used if the request is intercepted at the request stage. (Encoded as a base64 string when passed over JSON) */ + body?: string; + /** A textual representation of responseCode. If absent, a standard phrase matching responseCode is used. */ + responsePhrase?: string; +} + +interface ContinueRequestRequest { + /** An id the client received in requestPaused event. */ + requestId: FetchRequestId; + /** If set, the request url will be modified in a way that's not observable by page. */ + url?: string; + /** If set, the request method will be overridden. */ + method?: string; + /** If set, overrides the post data in the request. (Encoded as a base64 string when passed over JSON) */ + postData?: string; + /** If set, overrides the request headers. Note that the overrides do not extend to subsequent redirect hops, if a redirect happens. */ + headers?: HeaderEntry[]; +} + +interface ContinueWithAuthRequest { + /** An id the client received in authRequired event. */ + requestId: FetchRequestId; + /** Response to with an authChallenge. */ + authChallengeResponse: AuthChallengeResponse; +} + +interface GetResponseBodyResponse { + /** Response body. */ + body: string; + /** True, if content was sent as base64. */ + base64Encoded: boolean; +} + +interface TakeResponseBodyAsStreamResponse { + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/IO/#type-StreamHandle */ + stream: string; +} export interface FetchEvents { - authRequired: Record; - requestPaused: Record; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Fetch/#event-requestPaused */ + requestPaused: { + /** Each request the page makes will have a unique id. */ + requestId: FetchRequestId; + /** The details of the request. */ + request: CDPNetworkRequest; + /** The id of the frame that initiated the request. */ + frameId: CDPFrameId; + /** How the requested resource will be used. */ + resourceType: CDPNetworkResourceType; + /** Response error if intercepted at response stage. */ + responseErrorReason?: CDPNetworkErrorReason; + /** Response code if intercepted at response stage. */ + responseStatusCode?: number; + /** Response status text if intercepted at response stage. */ + responseStatusText?: string; + /** Response headers if intercepted at the response stage. */ + responseHeaders?: HeaderEntry[]; + /** If the intercepted request had a corresponding Network.requestWillBeSent event fired for it, then this networkId will be the same as the requestId present in the requestWillBeSent event. */ + networkId?: FetchRequestId; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Fetch/#event-authRequired */ + authRequired: { + /** Each request the page makes will have a unique id. */ + requestId: FetchRequestId; + /** The details of the request. */ + request: CDPNetworkRequest; + /** The id of the frame that initiated the request. */ + frameId: CDPFrameId; + /** How the requested resource will be used. */ + resourceType: CDPNetworkResourceType; + /** Details of the Authorization Challenge encountered. If this is set, client should respond with continueRequest that contains AuthChallengeResponse. */ + authChallenge: AuthChallenge; + }; } /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Fetch/ */ export class CDFetch extends CDPEventEmitter { - public constructor() { + private readonly _connection: CDPConnection; + + public constructor(connection: CDPConnection) { super(); + + this._connection = connection; + } + + /** + * @param sessionId result of "Target.attachToTarget" + * @link https://chromedevtools.github.io/devtools-protocol/1-3/Fetch/#method-enable + */ + async enable(sessionId: CDPSessionId, params?: EnableRequest): Promise { + return this._connection.request("Fetch.enable", { sessionId, params }); + } + + /** + * @param sessionId result of "Target.attachToTarget" + * @link https://chromedevtools.github.io/devtools-protocol/1-3/Fetch/#method-disable + */ + async disable(sessionId: CDPSessionId): Promise { + return this._connection.request("Fetch.disable", { sessionId }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Fetch/#method-failRequest */ + async failRequest(sessionId: CDPSessionId, params: FailRequestRequest): Promise { + return this._connection.request("Fetch.failRequest", { sessionId, params }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Fetch/#method-fulfillRequest */ + async fulfillRequest(sessionId: CDPSessionId, params: FulfillRequestRequest): Promise { + return this._connection.request("Fetch.fulfillRequest", { sessionId, params }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Fetch/#method-continueRequest */ + async continueRequest(sessionId: CDPSessionId, params: ContinueRequestRequest): Promise { + return this._connection.request("Fetch.continueRequest", { sessionId, params }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Fetch/#method-continueWithAuth */ + async continueWithAuth(sessionId: CDPSessionId, params: ContinueWithAuthRequest): Promise { + return this._connection.request("Fetch.continueWithAuth", { sessionId, params }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Fetch/#method-getResponseBody */ + async getResponseBody(sessionId: CDPSessionId, requestId: FetchRequestId): Promise { + return this._connection.request("Fetch.getResponseBody", { sessionId, params: { requestId } }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Fetch/#method-takeResponseBodyAsStream */ + async takeResponseBodyAsStream( + sessionId: CDPSessionId, + requestId: FetchRequestId, + ): Promise { + return this._connection.request("Fetch.takeResponseBodyAsStream", { sessionId, params: { requestId } }); } } diff --git a/src/browser/cdp/domains/network.ts b/src/browser/cdp/domains/network.ts index d64027e36..3ed947d54 100644 --- a/src/browser/cdp/domains/network.ts +++ b/src/browser/cdp/domains/network.ts @@ -1,28 +1,362 @@ import { CDPEventEmitter } from "../emitter"; +import { CDPConnection } from "../connection"; +import type { + CDPFrameId, + CDPNetworkCookie, + CDPNetworkCookieParam, + CDPNetworkHeaders, + CDPNetworkInitiator, + CDPNetworkLoaderId, + CDPNetworkMonotonicTime, + CDPNetworkRequest, + CDPNetworkRequestId, + CDPNetworkResourceType, + CDPNetworkResponse, + CDPNetworkTimeSinceEpoch, + CDPNetworkWebSocketFrame, + CDPNetworkWebSocketRequest, + CDPNetworkWebSocketResponse, + CDPSessionId, +} from "../types"; + +interface DeleteCookiesRequest { + /** Name of the cookies to remove. */ + name: string; + /** If specified, deletes all the cookies with the given name where domain and path match provided URL. */ + url?: string; + /** If specified, deletes only cookies with the exact domain. */ + domain?: string; + /** If specified, deletes only cookies with the exact path. */ + path?: string; +} + +interface GetCookiesRequest { + /** The list of URLs for which applicable cookies will be fetched. If not specified, it's assumed to be set to the list of URLs of the pages in the current context. */ + urls?: string[]; +} + +interface GetCookiesResponse { + /** Array of cookie objects. */ + cookies: CDPNetworkCookie[]; +} + +interface GetResponseBodyResponse { + /** Response body. */ + body: string; + /** True, if content was sent as base64. */ + base64Encoded: boolean; +} + +interface GetRequestPostDataResponse { + /** Request body string, omitting files from multipart requests. */ + postData: string; +} + +interface SetCookieRequest { + /** Cookie name. */ + name: string; + /** Cookie value. */ + value: string; + /** The request-URI to associate with the setting of the cookie. This value can affect the default domain, path, source port, and source scheme values of the created cookie. */ + url?: string; + /** Cookie domain. */ + domain?: string; + /** Cookie path. */ + path?: string; + /** True if cookie is secure. */ + secure?: boolean; + /** True if cookie is http-only. */ + httpOnly?: boolean; + /** Cookie SameSite type. */ + sameSite?: "Strict" | "Lax" | "None"; + /** Cookie expiration date, session cookie if not set. */ + expires?: CDPNetworkTimeSinceEpoch; + /** True if cookie is SameParty. */ + sameParty?: boolean; +} + +interface EnableRequest { + maxPostDataSize?: number; +} export interface NetworkEvents { - dataReceived: Record; - eventSourceMessageReceived: Record; - loadingFailed: Record; - loadingFinished: Record; - requestServedFromCache: Record; - requestWillBeSent: Record; - responseReceived: Record; - webSocketClosed: Record; - webSocketCreated: Record; - webSocketFrameError: Record; - webSocketFrameReceived: Record; - webSocketFrameSent: Record; - webSocketHandshakeResponseReceived: Record; - webSocketWillSendHandshakeRequest: Record; - webTransportClosed: Record; - webTransportConnectionEstablished: Record; - webTransportCreated: Record; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-dataReceived */ + dataReceived: { + /** Request identifier. */ + requestId: CDPNetworkRequestId; + /** Timestamp. */ + timestamp: CDPNetworkMonotonicTime; + /** Data chunk length. */ + dataLength: number; + /** Actual bytes received (might be less than dataLength for compressed encodings). */ + encodedDataLength: number; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-eventSourceMessageReceived */ + eventSourceMessageReceived: { + /** Request identifier. */ + requestId: CDPNetworkRequestId; + /** Timestamp. */ + timestamp: CDPNetworkMonotonicTime; + /** Message type. */ + eventName: string; + /** Message identifier. */ + eventId: string; + /** Message content. */ + data: string; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-loadingFailed */ + loadingFailed: { + /** Request identifier. */ + requestId: CDPNetworkRequestId; + /** Timestamp. */ + timestamp: CDPNetworkMonotonicTime; + /** Resource type. */ + type: CDPNetworkResourceType; + /** Error message. */ + errorText: string; + /** True if loading was canceled. */ + canceled?: boolean; + /** The reason why loading was blocked, if any. */ + blockedReason?: string; + /** The reason why loading was blocked by CORS, if any. */ + corsErrorStatus?: { + corsError: string; + failedParameter: string; + }; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-loadingFinished */ + loadingFinished: { + /** Request identifier. */ + requestId: CDPNetworkRequestId; + /** Timestamp. */ + timestamp: CDPNetworkMonotonicTime; + /** Total number of bytes received for this request. */ + encodedDataLength: number; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-requestServedFromCache */ + requestServedFromCache: { + /** Request identifier. */ + requestId: CDPNetworkRequestId; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-requestWillBeSent */ + requestWillBeSent: { + /** Request identifier. */ + requestId: CDPNetworkRequestId; + /** Loader identifier. Empty string if the request is fetched from worker. */ + loaderId: CDPNetworkLoaderId; + /** URL of the document this request is loaded for. */ + documentURL: string; + /** Request data. */ + request: CDPNetworkRequest; + /** Timestamp. */ + timestamp: CDPNetworkMonotonicTime; + /** Timestamp. */ + wallTime: CDPNetworkTimeSinceEpoch; + /** Request initiator. */ + initiator: CDPNetworkInitiator; + /** Redirect response data. */ + redirectResponse?: CDPNetworkResponse; + /** Type of this resource. */ + type?: CDPNetworkResourceType; + /** Frame identifier. */ + frameId?: CDPFrameId; + /** Whether the request is initiated by a user gesture. Defaults to false. */ + hasUserGesture?: boolean; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-responseReceived */ + responseReceived: { + /** Request identifier. */ + requestId: CDPNetworkRequestId; + /** Loader identifier. Empty string if the request is fetched from worker. */ + loaderId: CDPNetworkLoaderId; + /** Timestamp. */ + timestamp: CDPNetworkMonotonicTime; + /** Resource type. */ + type: CDPNetworkResourceType; + /** Response data. */ + response: CDPNetworkResponse; + /** Frame identifier. */ + frameId?: CDPFrameId; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-webSocketClosed */ + webSocketClosed: { + /** Request identifier. */ + requestId: CDPNetworkRequestId; + /** Timestamp. */ + timestamp: CDPNetworkMonotonicTime; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-webSocketCreated */ + webSocketCreated: { + /** Request identifier. */ + requestId: CDPNetworkRequestId; + /** WebSocket request URL. */ + url: string; + /** Request initiator. */ + initiator?: CDPNetworkInitiator; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-webSocketFrameError */ + webSocketFrameError: { + /** Request identifier. */ + requestId: CDPNetworkRequestId; + /** Timestamp. */ + timestamp: CDPNetworkMonotonicTime; + /** WebSocket error message. */ + errorMessage: string; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-webSocketFrameReceived */ + webSocketFrameReceived: { + /** Request identifier. */ + requestId: CDPNetworkRequestId; + /** Timestamp. */ + timestamp: CDPNetworkMonotonicTime; + /** WebSocket response data. */ + response: CDPNetworkWebSocketFrame; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-webSocketFrameSent */ + webSocketFrameSent: { + /** Request identifier. */ + requestId: CDPNetworkRequestId; + /** Timestamp. */ + timestamp: CDPNetworkMonotonicTime; + /** WebSocket response data. */ + response: CDPNetworkWebSocketFrame; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-webSocketHandshakeResponseReceived */ + webSocketHandshakeResponseReceived: { + /** Request identifier. */ + requestId: CDPNetworkRequestId; + /** Timestamp. */ + timestamp: CDPNetworkMonotonicTime; + /** WebSocket response data. */ + response: CDPNetworkWebSocketResponse; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-webSocketWillSendHandshakeRequest */ + webSocketWillSendHandshakeRequest: { + /** Request identifier. */ + requestId: CDPNetworkRequestId; + /** Timestamp. */ + timestamp: CDPNetworkMonotonicTime; + /** UTC Timestamp. */ + wallTime: CDPNetworkTimeSinceEpoch; + /** WebSocket request data. */ + request: CDPNetworkWebSocketRequest; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-webTransportCreated */ + webTransportCreated: { + /** WebTransport identifier. */ + transportId: CDPNetworkRequestId; + /** WebTransport request URL. */ + url: string; + /** Timestamp. */ + timestamp: CDPNetworkMonotonicTime; + /** Request initiator. */ + initiator?: CDPNetworkInitiator; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-webTransportConnectionEstablished */ + webTransportConnectionEstablished: { + /** WebTransport identifier. */ + transportId: CDPNetworkRequestId; + /** Timestamp. */ + timestamp: CDPNetworkMonotonicTime; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-webTransportClosed */ + webTransportClosed: { + /** WebTransport identifier. */ + transportId: CDPNetworkRequestId; + /** Timestamp. */ + timestamp: CDPNetworkMonotonicTime; + }; } /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/ */ export class CDPNetwork extends CDPEventEmitter { - public constructor() { + private readonly _connection: CDPConnection; + + public constructor(connection: CDPConnection) { super(); + + this._connection = connection; + } + + /** + * @param sessionId result of "Target.attachToTarget" + * @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-enable + */ + async enable(sessionId: CDPSessionId, params?: EnableRequest): Promise { + return this._connection.request("Network.enable", { sessionId, params }); + } + + /** + * @param sessionId result of "Target.attachToTarget" + * @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-disable + */ + async disable(sessionId: CDPSessionId): Promise { + return this._connection.request("Network.disable", { sessionId }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-clearBrowserCache */ + async clearBrowserCache(sessionId: CDPSessionId): Promise { + return this._connection.request("Network.clearBrowserCache", { sessionId }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-clearBrowserCookies */ + async clearBrowserCookies(sessionId: CDPSessionId): Promise { + return this._connection.request("Network.clearBrowserCookies", { sessionId }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-deleteCookies */ + async deleteCookies(sessionId: CDPSessionId, params: DeleteCookiesRequest): Promise { + return this._connection.request("Network.deleteCookies", { sessionId, params }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-getCookies */ + async getCookies(sessionId: CDPSessionId, params?: GetCookiesRequest): Promise { + return this._connection.request("Network.getCookies", { sessionId, params }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-getResponseBody */ + async getResponseBody(sessionId: CDPSessionId, requestId: CDPNetworkRequestId): Promise { + return this._connection.request("Network.getResponseBody", { sessionId, params: { requestId } }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-getRequestPostData */ + async getRequestPostData( + sessionId: CDPSessionId, + requestId: CDPNetworkRequestId, + ): Promise { + return this._connection.request("Network.getRequestPostData", { sessionId, params: { requestId } }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-setBypassServiceWorker */ + async setBypassServiceWorker(sessionId: CDPSessionId, bypass: boolean): Promise { + return this._connection.request("Network.setBypassServiceWorker", { sessionId, params: { bypass } }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-setCacheDisabled */ + async setCacheDisabled(sessionId: CDPSessionId, cacheDisabled: boolean): Promise { + return this._connection.request("Network.setCacheDisabled", { sessionId, params: { cacheDisabled } }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-setCookie */ + async setCookie(sessionId: CDPSessionId, params: SetCookieRequest): Promise<{ success: boolean }> { + return this._connection.request("Network.setCookie", { sessionId, params }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-setCookies */ + async setCookies(sessionId: CDPSessionId, cookies: CDPNetworkCookieParam[]): Promise { + return this._connection.request("Network.setCookies", { sessionId, params: { cookies } }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-setExtraHTTPHeaders */ + async setExtraHTTPHeaders(sessionId: CDPSessionId, headers: CDPNetworkHeaders): Promise { + return this._connection.request("Network.setExtraHTTPHeaders", { sessionId, params: { headers } }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-setUserAgentOverride */ + async setUserAgentOverride( + sessionId: CDPSessionId, + params: { userAgent: string; acceptLanguage?: string; platform?: string }, + ): Promise { + return this._connection.request("Network.setUserAgentOverride", { sessionId, params }); } } diff --git a/src/browser/cdp/domains/page.ts b/src/browser/cdp/domains/page.ts new file mode 100644 index 000000000..d021e41f6 --- /dev/null +++ b/src/browser/cdp/domains/page.ts @@ -0,0 +1,504 @@ +import { CDPEventEmitter } from "../emitter"; +import { CDPConnection } from "../connection"; +import type { + CDPExecutionContextId, + CDPFrameId, + CDPNetworkLoaderId, + CDPNetworkMonotonicTime, + CDPSessionId, +} from "../types"; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#type-ScriptIdentifier */ +type PageScriptIdentifier = string; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#type-TransitionType */ +type PageTransitionType = + | "link" + | "typed" + | "address_bar" + | "auto_bookmark" + | "auto_subframe" + | "manual_subframe" + | "generated" + | "auto_toplevel" + | "form_submit" + | "reload" + | "keyword" + | "keyword_generated" + | "other"; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#type-DialogType */ +type PageDialogType = "alert" | "confirm" | "prompt" | "beforeunload"; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#type-Frame */ +interface PageFrame { + /** Frame unique identifier. */ + id: CDPFrameId; + /** Parent frame identifier. */ + parentId?: CDPFrameId; + /** Identifier of the loader associated with this frame. */ + loaderId: CDPNetworkLoaderId; + /** Frame's name as specified in the tag. */ + name?: string; + /** Frame document's URL without fragment. */ + url: string; + /** Frame document's security origin. */ + securityOrigin: string; + /** Frame document's mimeType as determined by the browser. */ + mimeType: string; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#type-FrameTree */ +interface PageFrameTree { + /** Frame information for this tree item. */ + frame: PageFrame; + /** Child frames. */ + childFrames?: PageFrameTree[]; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#type-NavigationEntry */ +interface PageNavigationEntry { + /** Unique id of the navigation history entry. */ + id: number; + /** URL of the navigation history entry. */ + url: string; + /** URL that the user typed in the url bar. */ + userTypedURL: string; + /** Title of the navigation history entry. */ + title: string; + /** Transition type. */ + transitionType: PageTransitionType; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#type-LayoutViewport */ +interface PageLayoutViewport { + /** Horizontal offset relative to the document (CSS pixels). */ + pageX: number; + /** Vertical offset relative to the document (CSS pixels). */ + pageY: number; + /** Width (CSS pixels), excludes scrollbar if present. */ + clientWidth: number; + /** Height (CSS pixels), excludes scrollbar if present. */ + clientHeight: number; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#type-VisualViewport */ +interface PageVisualViewport { + /** Horizontal offset relative to the layout viewport (CSS pixels). */ + offsetX: number; + /** Vertical offset relative to the layout viewport (CSS pixels). */ + offsetY: number; + /** Horizontal offset relative to the document (CSS pixels). */ + pageX: number; + /** Vertical offset relative to the document (CSS pixels). */ + pageY: number; + /** Width (CSS pixels), excludes scrollbar if present. */ + clientWidth: number; + /** Height (CSS pixels), excludes scrollbar if present. */ + clientHeight: number; + /** Scale relative to the ideal viewport (size at width=device-width). */ + scale: number; + /** Page zoom factor (CSS to device independent pixels ratio). */ + zoom?: number; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#type-Viewport */ +interface PageViewport { + /** X offset in device independent pixels (dip). */ + x: number; + /** Y offset in device independent pixels (dip). */ + y: number; + /** Rectangle width in device independent pixels (dip). */ + width: number; + /** Rectangle height in device independent pixels (dip). */ + height: number; + /** Page scale factor. */ + scale: number; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#type-AppManifestError */ +interface PageAppManifestError { + /** Error message. */ + message: string; + /** If critical, this is a non-recoverable parse error. */ + critical: number; + /** Error line. */ + line: number; + /** Error column. */ + column: number; +} + +interface AddScriptToEvaluateOnNewDocumentRequest { + source: string; +} + +interface AddScriptToEvaluateOnNewDocumentResponse { + /** Identifier of the added script. */ + identifier: PageScriptIdentifier; +} + +interface CaptureScreenshotRequest { + /** Image compression format (defaults to png). */ + format?: "jpeg" | "png" | "webp"; + /** Compression quality from range [0..100] (jpeg only). */ + quality?: number; + /** Capture the screenshot of a given region only. */ + clip?: PageViewport; +} + +interface CaptureScreenshotResponse { + /** Base64-encoded image data. (Encoded as a base64 string when passed over JSON) */ + data: string; +} + +interface CreateIsolatedWorldRequest { + /** Id of the frame in which the isolated world should be created. */ + frameId: CDPFrameId; + /** An optional name which is reported in the Execution Context. */ + worldName?: string; +} + +interface CreateIsolatedWorldResponse { + /** Execution context of the isolated world. */ + executionContextId: CDPExecutionContextId; +} + +interface GetAppManifestResponse { + /** Manifest location. */ + url: string; + errors: PageAppManifestError[]; + /** Manifest content. */ + data?: string; +} + +interface GetFrameTreeResponse { + /** Present frame tree structure. */ + frameTree: PageFrameTree; +} + +interface GetLayoutMetricsResponse { + /** Deprecated metrics relating to the layout viewport. Is in device pixels. Use `cssLayoutViewport` instead. */ + layoutViewport: PageLayoutViewport; + /** Deprecated metrics relating to the visual viewport. Is in device pixels. Use `cssVisualViewport` instead. */ + visualViewport: PageVisualViewport; + /** Deprecated size of scrollable area. Is in DP. Use `cssContentSize` instead. */ + contentSize: { x: number; y: number; width: number; height: number }; + /** Metrics relating to the layout viewport in CSS pixels. */ + cssLayoutViewport: PageLayoutViewport; + /** Metrics relating to the visual viewport in CSS pixels. */ + cssVisualViewport: PageVisualViewport; + /** Size of scrollable area in CSS pixels. */ + cssContentSize: { x: number; y: number; width: number; height: number }; +} + +interface GetNavigationHistoryResponse { + /** Index of the current navigation history entry. */ + currentIndex: number; + /** Array of navigation history entries. */ + entries: PageNavigationEntry[]; +} + +interface HandleJavaScriptDialogRequest { + /** Whether to accept or dismiss the dialog. */ + accept: boolean; + /** The text to enter into the dialog prompt before accepting. Used only if this is a prompt dialog. */ + promptText?: string; +} + +interface NavigateRequest { + /** URL to navigate the page to. */ + url: string; + /** Referrer URL. */ + referrer?: string; + /** Intended transition type. */ + transitionType?: PageTransitionType; + /** Frame id to navigate, if not specified navigates the top level frame. */ + frameId?: CDPFrameId; +} + +interface NavigateResponse { + /** Frame id that has navigated (or failed to navigate). */ + frameId: CDPFrameId; + /** Loader identifier. This is omitted in case of same-document navigation, as the previously committed loaderId would not change. */ + loaderId?: CDPNetworkLoaderId; + /** User friendly error message, present if and only if navigation has failed. */ + errorText?: string; +} + +interface PrintToPDFRequest { + /** Paper orientation. Defaults to false. */ + landscape?: boolean; + /** Display header and footer. Defaults to false. */ + displayHeaderFooter?: boolean; + /** Print background graphics. Defaults to false. */ + printBackground?: boolean; + /** Scale of the webpage rendering. Defaults to 1. */ + scale?: number; + /** Paper width in inches. Defaults to 8.5 inches. */ + paperWidth?: number; + /** Paper height in inches. Defaults to 11 inches. */ + paperHeight?: number; + /** Top margin in inches. Defaults to 1cm (~0.4 inches). */ + marginTop?: number; + /** Bottom margin in inches. Defaults to 1cm (~0.4 inches). */ + marginBottom?: number; + /** Left margin in inches. Defaults to 1cm (~0.4 inches). */ + marginLeft?: number; + /** Right margin in inches. Defaults to 1cm (~0.4 inches). */ + marginRight?: number; + /** Paper ranges to print, one based, e.g., '1-5, 8, 11-13'. Pages are printed in the document order, not in the order specified, and no more than once. Defaults to empty string, which implies the entire document is printed. */ + pageRanges?: string; + /** HTML template for the print header. Should be valid HTML markup. */ + headerTemplate?: string; + /** HTML template for the print footer. Should use the same format as the headerTemplate. */ + footerTemplate?: string; + /** Whether or not to prefer page size as defined by css. Defaults to false, in which case the content will be scaled to fit the paper size. */ + preferCSSPageSize?: boolean; +} + +interface PrintToPDFResponse { + /** Base64-encoded pdf data. (Encoded as a base64 string when passed over JSON) */ + data: string; +} + +interface ReloadRequest { + /** If true, browser cache is ignored (as if the user pressed Shift+refresh). */ + ignoreCache?: boolean; + /** If set, the script will be injected into all frames of the inspected page after reload. Argument will be ignored if reloading dataURL origin. */ + scriptToEvaluateOnLoad?: string; +} + +interface SetDocumentContentRequest { + /** Frame id to set HTML for. */ + frameId: CDPFrameId; + /** HTML content to set. */ + html: string; +} + +export interface PageEvents { + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#event-domContentEventFired */ + domContentEventFired: { + timestamp: CDPNetworkMonotonicTime; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#event-fileChooserOpened */ + fileChooserOpened: { + /** Input mode. */ + mode: "selectSingle" | "selectMultiple"; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#event-frameAttached */ + frameAttached: { + /** Id of the frame that has been attached. */ + frameId: CDPFrameId; + /** Parent frame identifier. */ + parentFrameId: CDPFrameId; + /** JavaScript stack trace of when frame was attached, only set if frame initiated from script. */ + stack?: Record; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#event-frameDetached */ + frameDetached: { + /** Id of the frame that has been detached. */ + frameId: CDPFrameId; + reason: "remove" | "swap"; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#event-frameNavigated */ + frameNavigated: { + /** Frame object. */ + frame: PageFrame; + type: PageTransitionType; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#event-interstitialHidden */ + interstitialHidden: Record; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#event-interstitialShown */ + interstitialShown: Record; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#event-javascriptDialogClosed */ + javascriptDialogClosed: { + /** Whether dialog was confirmed. */ + result: boolean; + /** User input in case of prompt. */ + userInput: string; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#event-javascriptDialogOpening */ + javascriptDialogOpening: { + /** Frame url. */ + url: string; + /** Message that will be displayed by the dialog. */ + message: string; + /** Dialog type. */ + type: PageDialogType; + /** True iff browser is capable showing or acting on the given dialog. When browser has no dialog handler for given target, calling alert while Page domain is engaged will stall the page execution. Execution can be resumed via calling Page.handleJavaScriptDialog. */ + hasBrowserHandler: boolean; + /** Default dialog prompt. */ + defaultPrompt?: string; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#event-lifecycleEvent */ + lifecycleEvent: { + /** Id of the frame. */ + frameId: CDPFrameId; + /** Loader identifier. Empty string if the request is fetched from worker. */ + loaderId: CDPNetworkLoaderId; + name: string; + timestamp: CDPNetworkMonotonicTime; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#event-loadEventFired */ + loadEventFired: { + timestamp: CDPNetworkMonotonicTime; + }; + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#event-windowOpen */ + windowOpen: { + /** The URL for the new window. */ + url: string; + /** Window name. */ + windowName: string; + /** An array of enabled window features. */ + windowFeatures: string[]; + /** Whether or not it was triggered by user gesture. */ + userGesture: boolean; + }; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/ */ +export class CDPPage extends CDPEventEmitter { + private readonly _connection: CDPConnection; + + public constructor(connection: CDPConnection) { + super(); + + this._connection = connection; + } + + /** + * @param sessionId result of "Target.attachToTarget" + * @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-enable + */ + async enable(sessionId: CDPSessionId): Promise { + return this._connection.request("Page.enable", { sessionId }); + } + + /** + * @param sessionId result of "Target.attachToTarget" + * @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-disable + */ + async disable(sessionId: CDPSessionId): Promise { + return this._connection.request("Page.disable", { sessionId }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-addScriptToEvaluateOnNewDocument */ + async addScriptToEvaluateOnNewDocument( + sessionId: CDPSessionId, + params: AddScriptToEvaluateOnNewDocumentRequest, + ): Promise { + return this._connection.request("Page.addScriptToEvaluateOnNewDocument", { sessionId, params }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-removeScriptToEvaluateOnNewDocument */ + async removeScriptToEvaluateOnNewDocument( + sessionId: CDPSessionId, + identifier: PageScriptIdentifier, + ): Promise { + return this._connection.request("Page.removeScriptToEvaluateOnNewDocument", { + sessionId, + params: { identifier }, + }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-bringToFront */ + async bringToFront(sessionId: CDPSessionId): Promise { + return this._connection.request("Page.bringToFront", { sessionId }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-captureScreenshot */ + async captureScreenshot( + sessionId: CDPSessionId, + params?: CaptureScreenshotRequest, + ): Promise { + return this._connection.request("Page.captureScreenshot", { sessionId, params }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-close */ + async close(sessionId: CDPSessionId): Promise { + return this._connection.request("Page.close", { sessionId }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-createIsolatedWorld */ + async createIsolatedWorld( + sessionId: CDPSessionId, + params: CreateIsolatedWorldRequest, + ): Promise { + return this._connection.request("Page.createIsolatedWorld", { sessionId, params }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-getAppManifest */ + async getAppManifest(sessionId: CDPSessionId): Promise { + return this._connection.request("Page.getAppManifest", { sessionId }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-getFrameTree */ + async getFrameTree(sessionId: CDPSessionId): Promise { + return this._connection.request("Page.getFrameTree", { sessionId }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-getLayoutMetrics */ + async getLayoutMetrics(sessionId: CDPSessionId): Promise { + return this._connection.request("Page.getLayoutMetrics", { sessionId }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-getNavigationHistory */ + async getNavigationHistory(sessionId: CDPSessionId): Promise { + return this._connection.request("Page.getNavigationHistory", { sessionId }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-resetNavigationHistory */ + async resetNavigationHistory(sessionId: CDPSessionId): Promise { + return this._connection.request("Page.resetNavigationHistory", { sessionId }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-handleJavaScriptDialog */ + async handleJavaScriptDialog(sessionId: CDPSessionId, params: HandleJavaScriptDialogRequest): Promise { + return this._connection.request("Page.handleJavaScriptDialog", { sessionId, params }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-navigate */ + async navigate(sessionId: CDPSessionId, params: NavigateRequest): Promise { + return this._connection.request("Page.navigate", { sessionId, params }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-navigateToHistoryEntry */ + async navigateToHistoryEntry(sessionId: CDPSessionId, entryId: number): Promise { + return this._connection.request("Page.navigateToHistoryEntry", { sessionId, params: { entryId } }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-printToPDF */ + async printToPDF(sessionId: CDPSessionId, params?: PrintToPDFRequest): Promise { + return this._connection.request("Page.printToPDF", { sessionId, params }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-reload */ + async reload(sessionId: CDPSessionId, params?: ReloadRequest): Promise { + return this._connection.request("Page.reload", { sessionId, params }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-setBypassCSP */ + async setBypassCSP(sessionId: CDPSessionId, enabled: boolean): Promise { + return this._connection.request("Page.setBypassCSP", { sessionId, params: { enabled } }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-setDocumentContent */ + async setDocumentContent(sessionId: CDPSessionId, params: SetDocumentContentRequest): Promise { + return this._connection.request("Page.setDocumentContent", { sessionId, params }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-setInterceptFileChooserDialog */ + async setInterceptFileChooserDialog(sessionId: CDPSessionId, enabled: boolean): Promise { + return this._connection.request("Page.setInterceptFileChooserDialog", { sessionId, params: { enabled } }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-setLifecycleEventsEnabled */ + async setLifecycleEventsEnabled(sessionId: CDPSessionId, enabled: boolean): Promise { + return this._connection.request("Page.setLifecycleEventsEnabled", { sessionId, params: { enabled } }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-stopLoading */ + async stopLoading(sessionId: CDPSessionId): Promise { + return this._connection.request("Page.stopLoading", { sessionId }); + } +} diff --git a/src/browser/cdp/emitter.ts b/src/browser/cdp/emitter.ts index 808ba7622..3d3e8a15b 100644 --- a/src/browser/cdp/emitter.ts +++ b/src/browser/cdp/emitter.ts @@ -1,5 +1,6 @@ import * as logger from "../../utils/logger"; import { EventEmitter } from "events"; +import { CDPSessionId } from "./types"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyFunc = (...args: any) => unknown; @@ -7,15 +8,18 @@ type AnyFunc = (...args: any) => unknown; export class CDPEventEmitter extends EventEmitter { private _callbackMap: Map = new Map(); - on(event: U, listener: (params: Events[U]) => void | Promise): this { + on( + event: U, + listener: (params: Events[U], sessionId?: CDPSessionId) => void | Promise, + ): this { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const eventListenerWithErrorBoundary = (params: Events[U]): void | Promise => { + const eventListenerWithErrorBoundary = (params: Events[U], sessionId?: CDPSessionId): void | Promise => { const logError = (e: unknown): void => { logger.error(`Catched unhandled error in CDP "${event}" handler: ${(e && (e as Error).stack) || e}`); }; try { - const result = listener(params); + const result = listener(params, sessionId); return result instanceof Promise ? result.catch(logError) : result; } catch (e) { diff --git a/src/browser/cdp/index.ts b/src/browser/cdp/index.ts index ae430f096..f9b7d55d0 100644 --- a/src/browser/cdp/index.ts +++ b/src/browser/cdp/index.ts @@ -9,6 +9,7 @@ import { CDPDom } from "./domains/dom"; import { CDPCss } from "./domains/css"; import { CDPNetwork } from "./domains/network"; import { CDFetch } from "./domains/fetch"; +import { CDPPage } from "./domains/page"; export class CDP { private readonly _connection: CDPConnection; @@ -20,6 +21,7 @@ export class CDP { public readonly css: CDPCss; public readonly network: CDPNetwork; public readonly fetch: CDFetch; + public readonly page: CDPPage; static async create(browser: Browser): Promise { // "isChrome" is "true" when automationProtocol is "devtools" @@ -42,8 +44,9 @@ export class CDP { this.runtime = new CDPRuntime(connection); this.dom = new CDPDom(connection); this.css = new CDPCss(connection); - this.network = new CDPNetwork(); - this.fetch = new CDFetch(); + this.network = new CDPNetwork(connection); + this.fetch = new CDFetch(connection); + this.page = new CDPPage(connection); this._connection.onEventMessage = this._onEventMessage.bind(this); } @@ -67,28 +70,31 @@ export class CDP { switch (domain) { case "Target": - this.target.emit(method, cdpEventMessage.params); + this.target.emit(method, cdpEventMessage.params, cdpEventMessage.sessionId); break; case "Profiler": - this.profiler.emit(method, cdpEventMessage.params); + this.profiler.emit(method, cdpEventMessage.params, cdpEventMessage.sessionId); break; case "Debugger": - this.debugger.emit(method, cdpEventMessage.params); + this.debugger.emit(method, cdpEventMessage.params, cdpEventMessage.sessionId); break; case "Runtime": - this.runtime.emit(method, cdpEventMessage.params); + this.runtime.emit(method, cdpEventMessage.params, cdpEventMessage.sessionId); break; case "DOM": - this.dom.emit(method, cdpEventMessage.params); + this.dom.emit(method, cdpEventMessage.params, cdpEventMessage.sessionId); break; case "CSS": - this.css.emit(method, cdpEventMessage.params); + this.css.emit(method, cdpEventMessage.params, cdpEventMessage.sessionId); break; case "Network": - this.network.emit(method, cdpEventMessage.params); + this.network.emit(method, cdpEventMessage.params, cdpEventMessage.sessionId); break; case "Fetch": - this.fetch.emit(method, cdpEventMessage.params); + this.fetch.emit(method, cdpEventMessage.params, cdpEventMessage.sessionId); + break; + case "Page": + this.page.emit(method, cdpEventMessage.params, cdpEventMessage.sessionId); break; } } diff --git a/src/browser/cdp/selectivity/css-selectivity.ts b/src/browser/cdp/selectivity/css-selectivity.ts index 6e947370a..2fad215c4 100644 --- a/src/browser/cdp/selectivity/css-selectivity.ts +++ b/src/browser/cdp/selectivity/css-selectivity.ts @@ -13,16 +13,19 @@ import { CacheType, getCachedSelectivityFile, hasCachedSelectivityFile, setCache import { debugSelectivity } from "./debug"; import type { CDP } from ".."; import type { CDPStyleSheetId, CDPSessionId } from "../types"; -import type { CssEvents } from "../domains/css"; +import type { CssEvents, CSSRuleUsage } from "../domains/css"; import type { SelectivityAssetState } from "./types"; export class CSSSelectivity { private readonly _cdp: CDP; private readonly _sessionId: CDPSessionId; private readonly _sourceRoot: string; - private _cssOnStyleSheetAddedFn: ((params: CssEvents["styleSheetAdded"]) => void) | null = null; + private _cssOnStyleSheetAddedFn: + | ((params: CssEvents["styleSheetAdded"], cdpSessionId?: CDPSessionId) => void) + | null = null; private _stylesSourceMap: Record = {}; private _styleSheetIdToSourceMapUrl: Record = {}; + private _coverageResult: CSSRuleUsage[] = []; constructor(cdp: CDP, sessionId: CDPSessionId, sourceRoot = "") { this._cdp = cdp; @@ -30,8 +33,11 @@ export class CSSSelectivity { this._sourceRoot = sourceRoot; } - private _processStyle({ header: { styleSheetId, sourceURL, sourceMapURL } }: CssEvents["styleSheetAdded"]): void { - if (!this._sessionId) { + private _processStyle( + { header: { styleSheetId, sourceURL, sourceMapURL } }: CssEvents["styleSheetAdded"], + cdpSessionId?: CDPSessionId, + ): void { + if (!this._sessionId || cdpSessionId !== this._sessionId) { return; } @@ -79,18 +85,80 @@ export class CSSSelectivity { } } + // If we haven't got "styleSheetAdded" event for the script, pull up styles + source map manually + private _ensureStylesAreLoading(ruleUsage: CSSRuleUsage[]): void { + ruleUsage.forEach(({ styleSheetId }) => { + if (Object.hasOwn(this._stylesSourceMap, styleSheetId)) { + return; + } + + const scriptSourcePromise = this._cdp.css + .getStyleSheetText(this._sessionId, styleSheetId) + .then(res => res.text) + .catch((err: Error) => err); + + this._stylesSourceMap[styleSheetId] ||= scriptSourcePromise.then(sourceCode => { + if (sourceCode instanceof Error) { + return sourceCode; + } + + const sourceMapsStartIndex = sourceCode.lastIndexOf(CSS_SOURCE_MAP_URL_COMMENT); + const sourceMapsEndIndex = sourceCode.indexOf("*/", sourceMapsStartIndex); + + // Source maps are not generated for this source file + if (sourceMapsStartIndex === -1) { + return null; + } + + const sourceMapURL = + sourceMapsEndIndex === -1 + ? sourceCode.slice(sourceMapsStartIndex + CSS_SOURCE_MAP_URL_COMMENT.length) + : sourceCode.slice( + sourceMapsStartIndex + CSS_SOURCE_MAP_URL_COMMENT.length, + sourceMapsEndIndex, + ); + + // If we encounter css stylesheet, that was not reported by "styleSheetAdded" + // We can only get sourcemaps if they are inlined + // Otherwise, we can't resolve actual sourcemaps url because we dont know css styles url itself. + if (!isDataProtocol(sourceMapURL)) { + return new Error( + [ + `Missed stylesheet url for stylesheet id ${styleSheetId}.`, + "Looks like Chrome Devtools 'styleSheetAdded' event was lost", + "It could happen due to network instability", + "Switching to inline sourcemaps for CSS will help at the cost of increased RAM usage", + ].join("\n"), + ); + } + + return fetchTextWithBrowserFallback(sourceMapURL, this._cdp.runtime, this._sessionId).catch( + (err: Error) => err, + ); + }); + }); + } + + private async _waitForLoadingStyles(): Promise { + await Promise.allSettled(Object.values(this._stylesSourceMap)); + } + async start(): Promise { const cssOnStyleSheetAdded = (this._cssOnStyleSheetAddedFn = this._processStyle.bind(this)); this._cdp.css.on("styleSheetAdded", cssOnStyleSheetAdded); - await Promise.all([ - this._cdp.target.setAutoAttach(this._sessionId, { autoAttach: true, waitForDebuggerOnStart: false }), - this._cdp.dom - .enable(this._sessionId) - .then(() => this._cdp.css.enable(this._sessionId)) - .then(() => this._cdp.css.startRuleUsageTracking(this._sessionId)), - ]); + await this._cdp.css.startRuleUsageTracking(this._sessionId); + } + + async takeCoverageSnapshot(): Promise { + const coveragePart = await this._cdp.css.takeCoverageDelta(this._sessionId); + + this._ensureStylesAreLoading(coveragePart.coverage); + + await this._waitForLoadingStyles(); + + this._coverageResult.push(...coveragePart.coverage); } /** @param drop only performs cleanup without providing actual deps. Should be "true" if test is failed */ @@ -100,62 +168,13 @@ export class CSSSelectivity { return null; } - const coverage = await this._cdp.css.stopRuleUsageTracking(this._sessionId); - - // If we haven't got "styleSheetAdded" event for the script, pull up styles + source map manually - coverage.ruleUsage.forEach(({ styleSheetId }) => { - if (Object.hasOwn(this._stylesSourceMap, styleSheetId)) { - return; - } - - const scriptSourcePromise = this._cdp.css - .getStyleSheetText(this._sessionId, styleSheetId) - .then(res => res.text) - .catch((err: Error) => err); - - this._stylesSourceMap[styleSheetId] ||= scriptSourcePromise.then(sourceCode => { - if (sourceCode instanceof Error) { - return sourceCode; - } + const coverageLastPart = await this._cdp.css.stopRuleUsageTracking(this._sessionId); + const coverageStyles = [...this._coverageResult, ...coverageLastPart.ruleUsage]; - const sourceMapsStartIndex = sourceCode.lastIndexOf(CSS_SOURCE_MAP_URL_COMMENT); - const sourceMapsEndIndex = sourceCode.indexOf("*/", sourceMapsStartIndex); - - // Source maps are not generated for this source file - if (sourceMapsStartIndex === -1) { - return null; - } - - const sourceMapURL = - sourceMapsEndIndex === -1 - ? sourceCode.slice(sourceMapsStartIndex + CSS_SOURCE_MAP_URL_COMMENT.length) - : sourceCode.slice( - sourceMapsStartIndex + CSS_SOURCE_MAP_URL_COMMENT.length, - sourceMapsEndIndex, - ); - - // If we encounter css stylesheet, that was not reported by "styleSheetAdded" - // We can only get sourcemaps if they are inlined - // Otherwise, we can't resolve actual sourcemaps url because we dont know css styles url itself. - if (!isDataProtocol(sourceMapURL)) { - return new Error( - [ - `Missed stylesheet url for stylesheet id ${styleSheetId}.`, - "Looks like Chrome Devtools 'styleSheetAdded' event was lost", - "It could happen due to network instability", - "Switching to inline sourcemaps for CSS will help at the cost of increased RAM usage", - ].join("\n"), - ); - } - - return fetchTextWithBrowserFallback(sourceMapURL, this._cdp.runtime, this._sessionId).catch( - (err: Error) => err, - ); - }); - }); + this._ensureStylesAreLoading(coverageLastPart.ruleUsage); const totalDependingSourceFiles = new Set(); - const grouppedByStyleSheetCoverage = groupBy(coverage.ruleUsage, "styleSheetId"); + const grouppedByStyleSheetCoverage = groupBy(coverageStyles, "styleSheetId"); const styleSheetIds = Object.keys(grouppedByStyleSheetCoverage); await Promise.all( diff --git a/src/browser/cdp/selectivity/index.ts b/src/browser/cdp/selectivity/index.ts index 0916c597b..e34670b16 100644 --- a/src/browser/cdp/selectivity/index.ts +++ b/src/browser/cdp/selectivity/index.ts @@ -15,6 +15,8 @@ import { MasterEvents } from "../../../events"; import { selectivityShouldRead, selectivityShouldWrite } from "./modes"; import { debugSelectivity } from "./debug"; import { getUsedDumpsTracker } from "./used-dumps-tracker"; +import { DebuggerEvents } from "../domains/debugger"; +import { CDPSessionId } from "../types"; type StopSelectivityFn = (test: Test, shouldWrite: boolean) => Promise; @@ -129,6 +131,9 @@ export const clearUnusedSelectivityDumps = async (config: Config): Promise } }; +const testplaneCoverageBreakScriptName = "__testplane_cdp_coverage_snapshot_pause"; +const scriptToEvaluateOnNewDocument = `window.addEventListener("beforeunload", function ${testplaneCoverageBreakScriptName}() {debugger;});`; + export const startSelectivity = async (browser: ExistingBrowser): Promise => { const { enabled, compression, sourceRoot, testDependenciesPath, mapDependencyRelativePath } = browser.config.selectivity; @@ -147,9 +152,9 @@ export const startSelectivity = async (browser: ExistingBrowser): Promise handle.includes(t.targetId))?.targetId; if (!cdpTargetId) { @@ -162,10 +167,18 @@ export const startSelectivity = async (browser: ExistingBrowser): Promise r.sessionId); + const sessionId = await cdp.target.attachToTarget(cdpTargetId).then(r => r.sessionId); + + const cssSelectivity = new CSSSelectivity(cdp, sessionId, sourceRoot); + const jsSelectivity = new JSSelectivity(cdp, sessionId, sourceRoot); - const cssSelectivity = new CSSSelectivity(browser.cdp, sessionId, sourceRoot); - const jsSelectivity = new JSSelectivity(browser.cdp, sessionId, sourceRoot); + await Promise.all([ + cdp.dom.enable(sessionId).then(() => cdp.css.enable(sessionId)), + cdp.target.setAutoAttach(sessionId, { autoAttach: true, waitForDebuggerOnStart: false }), + cdp.debugger.enable(sessionId), + cdp.page.enable(sessionId), + cdp.profiler.enable(sessionId), + ]); await Promise.allSettled([cssSelectivity.start(), jsSelectivity.start()]).then(async ([css, js]) => { if (css.status === "rejected" || js.status === "rejected") { @@ -178,14 +191,58 @@ export const startSelectivity = async (browser: ExistingBrowser): Promise = Promise.resolve(); + let isSelectivityStopped = false; + + const debuggerPausedFn = ({ callFrames }: DebuggerEvents["paused"], eventCdpSessionId?: CDPSessionId): void => { + if (eventCdpSessionId !== sessionId) { + return; + } + + if (callFrames[0]?.functionName !== testplaneCoverageBreakScriptName || isSelectivityStopped) { + cdp.debugger.resume(sessionId).catch(() => {}); + return; + } + + pageSwitchPromise = pageSwitchPromise.finally(() => + Promise.all([cssSelectivity.takeCoverageSnapshot(), jsSelectivity.takeCoverageSnapshot()]) + .catch(err => { + console.error("Selectivity: couldn't take snapshot while navigating:", err); + }) + .then(() => { + cdp.debugger.resume(sessionId).catch(() => {}); + }), + ); + }; + + cdp.debugger.on("paused", debuggerPausedFn); + + await cdp.page.addScriptToEvaluateOnNewDocument(sessionId, { source: scriptToEvaluateOnNewDocument }); + /** @param drop only performs cleanup without writing anything. Should be "true" if test is failed */ return async function stopSelectivity(test: Test, drop: boolean): Promise { - const [cssDependencies, jsDependencies] = await Promise.all([ + isSelectivityStopped = true; + + await pageSwitchPromise; + + const [cssDependenciesPromise, jsDependenciesPromise] = await Promise.allSettled([ cssSelectivity.stop(drop), jsSelectivity.stop(drop), ]); - cdpTaget.detachFromTarget(sessionId).catch(() => {}); + cdp.debugger.off("paused", debuggerPausedFn); + cdp.target.detachFromTarget(sessionId).catch(() => {}); + + if (jsDependenciesPromise.status === "rejected") { + throw jsDependenciesPromise.reason; + } + + if (cssDependenciesPromise.status === "rejected") { + throw cssDependenciesPromise.reason; + } + + const cssDependencies = cssDependenciesPromise.value; + const jsDependencies = jsDependenciesPromise.value; if (drop || (!cssDependencies?.size && !jsDependencies?.size)) { return; diff --git a/src/browser/cdp/selectivity/js-selectivity.ts b/src/browser/cdp/selectivity/js-selectivity.ts index caf627d05..8761572dc 100644 --- a/src/browser/cdp/selectivity/js-selectivity.ts +++ b/src/browser/cdp/selectivity/js-selectivity.ts @@ -6,7 +6,7 @@ import { CacheType, getCachedSelectivityFile, hasCachedSelectivityFile, setCache import { debugSelectivity } from "./debug"; import type { CDP } from ".."; import type { DebuggerEvents } from "../domains/debugger"; -import type { CDPRuntimeScriptId, CDPSessionId } from "../types"; +import type { CDPRuntimeScriptId, CDPScriptCoverage, CDPSessionId } from "../types"; import type { SelectivityAssetState } from "./types"; const SOURCE_CODE_EXTENSIONS = [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"]; @@ -19,12 +19,14 @@ export class JSSelectivity { private readonly _cdp: CDP; private readonly _sessionId: CDPSessionId; private readonly _sourceRoot: string; - private _debuggerOnPausedFn: (() => void) | null = null; - private _debuggerOnScriptParsedFn: ((params: DebuggerEvents["scriptParsed"]) => void) | null = null; + private _debuggerOnScriptParsedFn: + | ((params: DebuggerEvents["scriptParsed"], cdpSessionId?: CDPSessionId) => void) + | null = null; private _scriptsSource: Record = {}; private _scriptsSourceMap: Record = {}; private _scriptIdToSourceUrl: Record = {}; private _scriptIdToSourceMapUrl: Record = {}; + private _coverageResult: CDPScriptCoverage[] = []; constructor(cdp: CDP, sessionId: CDPSessionId, sourceRoot = "") { this._cdp = cdp; @@ -32,8 +34,11 @@ export class JSSelectivity { this._sourceRoot = sourceRoot; } - private _processScript({ scriptId, url, sourceMapURL }: DebuggerEvents["scriptParsed"]): void { - if (!this._sessionId) { + private _processScript( + { scriptId, url, sourceMapURL }: DebuggerEvents["scriptParsed"], + cdpSessionId?: CDPSessionId, + ): void { + if (!this._sessionId || cdpSessionId !== this._sessionId) { return; } @@ -100,114 +105,125 @@ export class JSSelectivity { } } - async start(): Promise { - const debuggerOnPaused = (this._debuggerOnPausedFn = async (): Promise => { - return this._cdp.debugger.resume(this._sessionId).catch(() => {}); - }); + // If we haven't got "scriptParsed" event for the script, pull up source code + source map manually + private _ensureScriptsAreLoading(coverage: CDPScriptCoverage[]): void { + coverage.forEach(({ scriptId, url }) => { + const fixedUrl = url || this._scriptIdToSourceUrl[scriptId]; + + // Was processed with "this._processScript" or anonymous + if ( + (Object.hasOwn(this._scriptsSource, scriptId) && Object.hasOwn(this._scriptsSourceMap, scriptId)) || + !fixedUrl + ) { + return; + } - const debuggerOnScriptParsedFn = (this._debuggerOnScriptParsedFn = this._processScript.bind(this)); - const sessionId = this._sessionId; + // Not dropping sources to fs the end of test (when "stop" is called) because we use it immediately + const scriptSourcePromise = this._cdp.debugger + .getScriptSource(this._sessionId, scriptId) + .then(({ scriptSource }) => { + setCachedSelectivityFile(CacheType.Asset, fixedUrl, scriptSource).catch(() => {}); + return scriptSource; + }) + .catch((err: Error) => err); + + this._scriptIdToSourceUrl[scriptId] ||= url; + this._scriptsSource[scriptId] ||= scriptSourcePromise; + this._scriptsSourceMap[scriptId] ||= scriptSourcePromise.then(async sourceCode => { + if (sourceCode instanceof Error) { + return sourceCode; + } - this._cdp.debugger.on("paused", debuggerOnPaused); - this._cdp.debugger.on("scriptParsed", debuggerOnScriptParsedFn); + const sourceMapsStartIndex = sourceCode.lastIndexOf(JS_SOURCE_MAP_URL_COMMENT); + const sourceMapsEndIndex = sourceCode.indexOf("\n", sourceMapsStartIndex); - await Promise.all([ - this._cdp.target.setAutoAttach(sessionId, { autoAttach: true, waitForDebuggerOnStart: false }), - this._cdp.debugger.enable(sessionId), - this._cdp.profiler.enable(sessionId).then(() => - this._cdp.profiler.startPreciseCoverage(sessionId, { - callCount: false, - detailed: false, - allowTriggeredUpdates: false, - }), - ), - ]); - } + // Source maps are not generated for this source file + if (sourceMapsStartIndex === -1) { + return null; + } - /** @param drop only performs cleanup without providing actual deps. Should be "true" if test is failed */ - async stop(drop?: boolean): Promise | null> { - try { - if (drop) { - return null; - } + const sourceMapURL = + sourceMapsEndIndex === -1 + ? sourceCode.slice(sourceMapsStartIndex + JS_SOURCE_MAP_URL_COMMENT.length) + : sourceCode.slice(sourceMapsStartIndex + JS_SOURCE_MAP_URL_COMMENT.length, sourceMapsEndIndex); - const coverage = await this._cdp.profiler.takePreciseCoverage(this._sessionId); + if (isDataProtocol(sourceMapURL)) { + return fetchTextWithBrowserFallback(sourceMapURL, this._cdp.runtime, this._sessionId).catch( + (err: Error) => err, + ); + } - // If we haven't got "scriptParsed" event for the script, pull up source code + source map manually - coverage.result.forEach(({ scriptId, url }) => { - const fixedUrl = url || this._scriptIdToSourceUrl[scriptId]; + const resolvedSourceMapUrl = urlResolve(fixedUrl, sourceMapURL); - // Was processed with "this._processScript" or anonymous - if ((this._scriptsSource[scriptId] && this._scriptsSourceMap[scriptId]) || !fixedUrl) { - return; - } + this._scriptIdToSourceMapUrl[scriptId] ||= resolvedSourceMapUrl; - // Not dropping sources to fs the end of test (when "stop" is called) because we use it immediately - const scriptSourcePromise = this._cdp.debugger - .getScriptSource(this._sessionId, scriptId) - .then(({ scriptSource }) => { - setCachedSelectivityFile(CacheType.Asset, fixedUrl, scriptSource).catch(() => {}); - return scriptSource; - }) - .catch((err: Error) => err); - - this._scriptIdToSourceUrl[scriptId] ||= url; - this._scriptsSource[scriptId] ||= scriptSourcePromise; - this._scriptsSourceMap[scriptId] ||= scriptSourcePromise.then(async sourceCode => { - if (sourceCode instanceof Error) { - return sourceCode; + try { + const cachedSourceMaps = await getCachedSelectivityFile(CacheType.Asset, resolvedSourceMapUrl); + + if (cachedSourceMaps) { + return cachedSourceMaps; } - const sourceMapsStartIndex = sourceCode.lastIndexOf(JS_SOURCE_MAP_URL_COMMENT); - const sourceMapsEndIndex = sourceCode.indexOf("\n", sourceMapsStartIndex); + const sourceMap = await fetchTextWithBrowserFallback( + resolvedSourceMapUrl, + this._cdp.runtime, + this._sessionId, + ); - // Source maps are not generated for this source file - if (sourceMapsStartIndex === -1) { - return null; - } + setCachedSelectivityFile(CacheType.Asset, resolvedSourceMapUrl, sourceMap).catch(() => {}); - const sourceMapURL = - sourceMapsEndIndex === -1 - ? sourceCode.slice(sourceMapsStartIndex + JS_SOURCE_MAP_URL_COMMENT.length) - : sourceCode.slice( - sourceMapsStartIndex + JS_SOURCE_MAP_URL_COMMENT.length, - sourceMapsEndIndex, - ); - - if (isDataProtocol(sourceMapURL)) { - return fetchTextWithBrowserFallback(sourceMapURL, this._cdp.runtime, this._sessionId).catch( - (err: Error) => err, - ); - } + return sourceMap; + } catch (err) { + return err as Error; + } + }); + }); + } - const resolvedSourceMapUrl = urlResolve(fixedUrl, sourceMapURL); + private async _waitForLoadingScripts(): Promise { + await Promise.all([ + Promise.allSettled(Object.values(this._scriptsSource)), + Promise.allSettled(Object.values(this._scriptsSourceMap)), + ]); + } - this._scriptIdToSourceMapUrl[scriptId] ||= resolvedSourceMapUrl; + async start(): Promise { + const debuggerOnScriptParsedFn = (this._debuggerOnScriptParsedFn = this._processScript.bind(this)); + const sessionId = this._sessionId; - try { - const cachedSourceMaps = await getCachedSelectivityFile(CacheType.Asset, resolvedSourceMapUrl); + this._cdp.debugger.on("scriptParsed", debuggerOnScriptParsedFn); - if (cachedSourceMaps) { - return cachedSourceMaps; - } + await this._cdp.profiler.startPreciseCoverage(sessionId, { + callCount: false, + detailed: false, + allowTriggeredUpdates: false, + }); + } - const sourceMap = await fetchTextWithBrowserFallback( - resolvedSourceMapUrl, - this._cdp.runtime, - this._sessionId, - ); + async takeCoverageSnapshot(): Promise { + const coveragePart = await this._cdp.profiler.takePreciseCoverage(this._sessionId); - setCachedSelectivityFile(CacheType.Asset, resolvedSourceMapUrl, sourceMap).catch(() => {}); + this._ensureScriptsAreLoading(coveragePart.result); - return sourceMap; - } catch (err) { - return err as Error; - } - }); - }); + await this._waitForLoadingScripts(); + + this._coverageResult.push(...coveragePart.result); + } + + /** @param drop only performs cleanup without providing actual deps. Should be "true" if test is failed */ + async stop(drop?: boolean): Promise | null> { + try { + if (drop) { + return null; + } + + const coverageLastPart = await this._cdp.profiler.takePreciseCoverage(this._sessionId); + const coverageScripts = [...this._coverageResult, ...coverageLastPart.result]; + + this._ensureScriptsAreLoading(coverageLastPart.result); const totalDependingSourceFiles = new Set(); - const grouppedByScriptCoverage = groupBy(coverage.result, "scriptId"); + const grouppedByScriptCoverage = groupBy(coverageScripts, "scriptId"); const scriptIds = Object.keys(grouppedByScriptCoverage); await Promise.all( @@ -271,7 +287,6 @@ export class JSSelectivity { return totalDependingSourceFiles; } finally { - this._debuggerOnPausedFn && this._cdp.debugger.off("paused", this._debuggerOnPausedFn); this._debuggerOnScriptParsedFn && this._cdp.debugger.off("scriptParsed", this._debuggerOnScriptParsedFn); } } diff --git a/src/browser/cdp/types.ts b/src/browser/cdp/types.ts index 7b771134f..81ab760e6 100644 --- a/src/browser/cdp/types.ts +++ b/src/browser/cdp/types.ts @@ -67,6 +67,7 @@ export interface CDPSuccessResponse> { export interface CDPEvent> { method: `${Domain}.${MethodName}`; params: T; + sessionId?: CDPSessionId; } export type CDPResponse> = CDPErrorResponse | CDPSuccessResponse; @@ -246,3 +247,278 @@ export interface CDPCSSStyleSheetHeader { /** Column offset of the end of the stylesheet within the resource (zero based). */ endColumn: number; } + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-RequestId */ +export type CDPNetworkRequestId = string; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-LoaderId */ +export type CDPNetworkLoaderId = string; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-TimeSinceEpoch */ +export type CDPNetworkTimeSinceEpoch = number; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-MonotonicTime */ +export type CDPNetworkMonotonicTime = number; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-Headers */ +export type CDPNetworkHeaders = Record; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-ResourcePriority */ +export type CDPNetworkResourcePriority = "VeryLow" | "Low" | "Medium" | "High" | "VeryHigh"; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-ErrorReason */ +export type CDPNetworkErrorReason = + | "Failed" + | "Aborted" + | "TimedOut" + | "AccessDenied" + | "ConnectionClosed" + | "ConnectionReset" + | "ConnectionRefused" + | "ConnectionAborted" + | "ConnectionFailed" + | "NameNotResolved" + | "InternetDisconnected" + | "AddressUnreachable" + | "BlockedByClient" + | "BlockedByResponse"; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-ResourceType */ +export type CDPNetworkResourceType = + | "Document" + | "Stylesheet" + | "Image" + | "Media" + | "Font" + | "Script" + | "TextTrack" + | "XHR" + | "Fetch" + | "Prefetch" + | "EventSource" + | "WebSocket" + | "Manifest" + | "SignedExchange" + | "Ping" + | "CSPViolationReport" + | "Preflight" + | "Other"; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-ConnectionType */ +export type CDPNetworkConnectionType = + | "none" + | "cellular2g" + | "cellular3g" + | "cellular4g" + | "bluetooth" + | "ethernet" + | "wifi" + | "wimax" + | "other"; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-CookieSameSite */ +export type CDPNetworkCookieSameSite = "Strict" | "Lax" | "None"; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-CookiePriority */ +export type CDPNetworkCookiePriority = "Low" | "Medium" | "High"; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-CookieSourceScheme */ +export type CDPNetworkCookieSourceScheme = "Unset" | "NonSecure" | "Secure"; + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-ResourceTiming */ +export interface CDPNetworkResourceTiming { + /** Timing's requestTime is a baseline in seconds, while the other numbers are ticks in milliseconds relatively to this requestTime. */ + requestTime: number; + /** Started resolving proxy. */ + proxyStart: number; + /** Finished resolving proxy. */ + proxyEnd: number; + /** Started DNS address resolve. */ + dnsStart: number; + /** Finished DNS address resolve. */ + dnsEnd: number; + /** Started connecting to the remote host. */ + connectStart: number; + /** Connected to the remote host. */ + connectEnd: number; + /** Started SSL handshake. */ + sslStart: number; + /** Finished SSL handshake. */ + sslEnd: number; + /** Started sending request. */ + sendStart: number; + /** Finished sending request. */ + sendEnd: number; + /** Time the server started pushing request. */ + pushStart: number; + /** Time the server finished pushing request. */ + pushEnd: number; + /** Started receiving response headers. */ + receiveHeadersStart: number; + /** Finished receiving response headers. */ + receiveHeadersEnd: number; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-Request */ +export interface CDPNetworkRequest { + /** Request URL (without fragment). */ + url: string; + /** Fragment of the requested URL starting with hash, if present. */ + urlFragment?: string; + /** HTTP request method. */ + method: string; + /** HTTP request headers. */ + headers: CDPNetworkHeaders; + /** HTTP POST request data. */ + postData?: string; + /** True when the request has POST data. */ + hasPostData?: boolean; + /** The mixed content type of the request. */ + mixedContentType?: "blockable" | "optionally-blockable" | "none"; + /** Priority of the resource request at the time request is sent. */ + initialPriority: CDPNetworkResourcePriority; + /** The referrer policy of the request, as defined in https://www.w3.org/TR/referrer-policy/ */ + referrerPolicy: string; + /** Whether is loaded via link preload. */ + isLinkPreload?: boolean; + /** Whether the request is same-site. */ + isSameSite?: boolean; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-Response */ +export interface CDPNetworkResponse { + /** Response URL. This URL can be different from CachedResource.url in case of redirect. */ + url: string; + /** HTTP response status code. */ + status: number; + /** HTTP response status text. */ + statusText: string; + /** HTTP response headers. */ + headers: CDPNetworkHeaders; + /** Resource mimeType as determined by the browser. */ + mimeType: string; + /** Refined HTTP request headers that were actually transmitted over the network. */ + requestHeaders?: CDPNetworkHeaders; + /** Specifies whether physical connection was actually reused for this request. */ + connectionReused: boolean; + /** Physical connection id that was actually used for this request. */ + connectionId: number; + /** Remote IP address. */ + remoteIPAddress?: string; + /** Remote port. */ + remotePort?: number; + /** Specifies that the request was served from the disk cache. */ + fromDiskCache?: boolean; + /** Specifies that the request was served from the ServiceWorker. */ + fromServiceWorker?: boolean; + /** Specifies that the request was served from the prefetch cache. */ + fromPrefetchCache?: boolean; + /** Total number of bytes received for this request so far. */ + encodedDataLength: number; + /** Timing information for the given request. */ + timing?: CDPNetworkResourceTiming; + /** Protocol used to fetch this request. */ + protocol?: string; + /** Security state of the request resource. */ + securityState: "unknown" | "neutral" | "insecure" | "secure" | "info" | "insecure-broken"; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-WebSocketRequest */ +export interface CDPNetworkWebSocketRequest { + /** HTTP request headers. */ + headers: CDPNetworkHeaders; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-WebSocketResponse */ +export interface CDPNetworkWebSocketResponse { + /** HTTP response status code. */ + status: number; + /** HTTP response status text. */ + statusText: string; + /** HTTP response headers. */ + headers: CDPNetworkHeaders; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-WebSocketFrame */ +export interface CDPNetworkWebSocketFrame { + /** WebSocket message opcode. */ + opcode: number; + /** WebSocket message mask. */ + mask: boolean; + /** WebSocket message payload data. If the opcode is 1, this is a text message and payloadData is a UTF-8 string. If the opcode isn't 1, then payloadData is a base64 encoded string representing binary data. */ + payloadData: string; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-Initiator */ +export interface CDPNetworkInitiator { + /** Type of this initiator. */ + type: "parser" | "script" | "preload" | "SignedExchange" | "preflight" | "other"; + /** Initiator URL, set for Parser type or for Script type (when script is importing module) or for SignedExchange type. */ + url?: string; + /** Initiator line number, set for Parser type or for Script type (when script is importing module) (0-based). */ + lineNumber?: number; + /** Initiator column number, set for Parser type or for Script type (when script is importing module) (0-based). */ + columnNumber?: number; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-Cookie */ +export interface CDPNetworkCookie { + /** Cookie name. */ + name: string; + /** Cookie value. */ + value: string; + /** Cookie domain. */ + domain: string; + /** Cookie path. */ + path: string; + /** Cookie expiration date as the number of seconds since the UNIX epoch. */ + expires: number; + /** Cookie size. */ + size: number; + /** True if cookie is http-only. */ + httpOnly: boolean; + /** True if cookie is secure. */ + secure: boolean; + /** True in case of session cookie. */ + session: boolean; + /** Cookie SameSite type. */ + sameSite?: CDPNetworkCookieSameSite; + /** Cookie Priority. */ + priority: CDPNetworkCookiePriority; + /** True if cookie is SameParty. */ + sameParty: boolean; + /** Cookie source scheme type. */ + sourceScheme: CDPNetworkCookieSourceScheme; + /** Cookie source port. Valid values are {-1, [1, 65535]}, -1 indicates an unspecified port. */ + sourcePort: number; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-CookieParam */ +export interface CDPNetworkCookieParam { + /** Cookie name. */ + name: string; + /** Cookie value. */ + value: string; + /** The request-URI to associate with the setting of the cookie. This value can affect the default domain, path, source port, and source scheme values of the created cookie. */ + url?: string; + /** Cookie domain. */ + domain?: string; + /** Cookie path. */ + path?: string; + /** True if cookie is secure. */ + secure?: boolean; + /** True if cookie is http-only. */ + httpOnly?: boolean; + /** Cookie SameSite type. */ + sameSite?: CDPNetworkCookieSameSite; + /** Cookie expiration date, session cookie if not set. */ + expires?: CDPNetworkTimeSinceEpoch; + /** Cookie Priority. */ + priority?: CDPNetworkCookiePriority; + /** True if cookie is SameParty. */ + sameParty?: boolean; + /** Cookie source scheme type. */ + sourceScheme?: CDPNetworkCookieSourceScheme; + /** Cookie source port. Valid values are {-1, [1, 65535]}, -1 indicates an unspecified port. */ + sourcePort?: number; +} diff --git a/test/src/browser/cdp/selectivity/css-selectivity.ts b/test/src/browser/cdp/selectivity/css-selectivity.ts index a7fa329f4..d680f6a7f 100644 --- a/test/src/browser/cdp/selectivity/css-selectivity.ts +++ b/test/src/browser/cdp/selectivity/css-selectivity.ts @@ -11,6 +11,7 @@ describe("CDP/Selectivity/CSSSelectivity", () => { enable: SinonStub; startRuleUsageTracking: SinonStub; stopRuleUsageTracking: SinonStub; + takeCoverageDelta: SinonStub; getStyleSheetText: SinonStub; on: SinonStub; off: SinonStub; @@ -71,6 +72,7 @@ describe("CDP/Selectivity/CSSSelectivity", () => { stopRuleUsageTracking: sandbox.stub().resolves({ ruleUsage: [{ styleSheetId: "stylesheet-123", startOffset: 0, endOffset: 100, used: true }], }), + takeCoverageDelta: sandbox.stub().resolves({ coverage: [] }), getStyleSheetText: sandbox.stub().resolves({ text: "mock css" }), on: sandbox.stub(), off: sandbox.stub(), @@ -143,12 +145,6 @@ describe("CDP/Selectivity/CSSSelectivity", () => { await cssSelectivity.start(); - assert.calledWith(cdpMock.target.setAutoAttach, sessionId, { - autoAttach: true, - waitForDebuggerOnStart: false, - }); - assert.calledWith(cdpMock.dom.enable, sessionId); - assert.calledWith(cdpMock.css.enable, sessionId); assert.calledWith(cdpMock.css.startRuleUsageTracking, sessionId); assert.calledOnceWith(cdpMock.css.on, "styleSheetAdded"); }); @@ -164,7 +160,7 @@ describe("CDP/Selectivity/CSSSelectivity", () => { const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; - styleSheetAddedHandler(styleSheetEvent); + styleSheetAddedHandler(styleSheetEvent, sessionId); await hasCachedSelectivityFileStubResult; await fetchTextWithBrowserFallbackStubResult; @@ -182,7 +178,7 @@ describe("CDP/Selectivity/CSSSelectivity", () => { const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; - styleSheetAddedHandler(styleSheetEvent); + styleSheetAddedHandler(styleSheetEvent, sessionId); await hasCachedSelectivityFileStubResult; @@ -197,7 +193,10 @@ describe("CDP/Selectivity/CSSSelectivity", () => { const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; - styleSheetAddedHandler({ ...styleSheetEvent, header: { ...styleSheetEvent.header, sourceMapURL } }); + styleSheetAddedHandler( + { ...styleSheetEvent, header: { ...styleSheetEvent.header, sourceMapURL } }, + sessionId, + ); assert.neverCalledWith(hasCachedSelectivityFileStub, CacheType.Asset, sourceMapURL); assert.neverCalledWith(getCachedSelectivityFileStub, CacheType.Asset, sourceMapURL); @@ -231,7 +230,7 @@ describe("CDP/Selectivity/CSSSelectivity", () => { }, }; - styleSheetAddedHandler(styleSheetEvent); + styleSheetAddedHandler(styleSheetEvent, sessionId); assert.notCalled(fetchTextWithBrowserFallbackStub); }); @@ -269,6 +268,78 @@ describe("CDP/Selectivity/CSSSelectivity", () => { }); }); + describe("takeCoverageSnapshot", () => { + it("should call takeCoverageDelta with sessionId", async () => { + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); + + await cssSelectivity.start(); + await cssSelectivity.takeCoverageSnapshot(); + + assert.calledOnceWith(cdpMock.css.takeCoverageDelta, sessionId); + }); + + it("should fetch styles for unknown stylesheets", async () => { + const cssWithSourceMap = `.test { color: red; } +/*# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozfQ==*/`; + + cdpMock.css.takeCoverageDelta.resolves({ + coverage: [{ styleSheetId: "stylesheet-999", startOffset: 0, endOffset: 50, used: true }], + }); + cdpMock.css.getStyleSheetText.resolves({ text: cssWithSourceMap }); + + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); + + await cssSelectivity.start(); + await cssSelectivity.takeCoverageSnapshot(); + + assert.calledWith(cdpMock.css.getStyleSheetText, sessionId, "stylesheet-999"); + }); + + it("should not re-fetch already known stylesheets", async () => { + cdpMock.css.takeCoverageDelta.resolves({ + coverage: [{ styleSheetId: "stylesheet-123", startOffset: 0, endOffset: 50, used: true }], + }); + + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); + + await cssSelectivity.start(); + + const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; + styleSheetAddedHandler(styleSheetEvent, sessionId); + + await cssSelectivity.takeCoverageSnapshot(); + + assert.notCalled(cdpMock.css.getStyleSheetText); + }); + + it("should accumulate coverage results used by stop()", async () => { + const mockSourceMap = { + sources: ["src/styles.css"], + sourceRoot: "/root", + }; + + cdpMock.css.takeCoverageDelta.resolves({ + coverage: [{ styleSheetId: "stylesheet-123", startOffset: 0, endOffset: 50, used: true }], + }); + cdpMock.css.stopRuleUsageTracking.resolves({ + ruleUsage: [{ styleSheetId: "stylesheet-123", startOffset: 50, endOffset: 100, used: true }], + }); + patchSourceMapSourcesStub.returns(mockSourceMap); + + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); + + await cssSelectivity.start(); + + const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; + styleSheetAddedHandler(styleSheetEvent, sessionId); + + await cssSelectivity.takeCoverageSnapshot(); + const result = await cssSelectivity.stop(); + + assert.deepEqual(Array.from(result || []).sort(), ["/root/src/styles.css"]); + }); + }); + describe("stop", () => { it("should return empty array when drop is true", async () => { const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); @@ -303,25 +374,28 @@ describe("CDP/Selectivity/CSSSelectivity", () => { await cssSelectivity.start(); const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; - styleSheetAddedHandler({ - header: { - styleSheetId: "stylesheet-123", - frameId: "frame-123", - sourceURL: "http://example.com/styles.css", - sourceMapURL: "styles.css.map", - origin: "regular" as const, - title: "styles.css", - disabled: false, - isInline: false, - isMutable: false, - isConstructed: false, - startLine: 0, - startColumn: 0, - length: 100, - endLine: 10, - endColumn: 0, + styleSheetAddedHandler( + { + header: { + styleSheetId: "stylesheet-123", + frameId: "frame-123", + sourceURL: "http://example.com/styles.css", + sourceMapURL: "styles.css.map", + origin: "regular" as const, + title: "styles.css", + disabled: false, + isInline: false, + isMutable: false, + isConstructed: false, + startLine: 0, + startColumn: 0, + length: 100, + endLine: 10, + endColumn: 0, + }, }, - }); + sessionId, + ); const result = await cssSelectivity.stop(); @@ -393,7 +467,7 @@ describe("CDP/Selectivity/CSSSelectivity", () => { await cssSelectivity.start(); const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; - styleSheetAddedHandler(styleSheetEvent); + styleSheetAddedHandler(styleSheetEvent, sessionId); await cssSelectivity.stop(); @@ -422,7 +496,7 @@ describe("CDP/Selectivity/CSSSelectivity", () => { await cssSelectivity.start(); const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; - styleSheetAddedHandler(styleSheetEvent); + styleSheetAddedHandler(styleSheetEvent, sessionId); const result = await cssSelectivity.stop(); @@ -483,25 +557,28 @@ describe("CDP/Selectivity/CSSSelectivity", () => { await cssSelectivity.start(); const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; - styleSheetAddedHandler({ - header: { - styleSheetId: "stylesheet-123", - frameId: "frame-123", - sourceURL: "http://example.com/styles.css", - sourceMapURL: "styles.css.map", - origin: "regular" as const, - title: "styles.css", - disabled: false, - isInline: false, - isMutable: false, - isConstructed: false, - startLine: 0, - startColumn: 0, - length: 100, - endLine: 10, - endColumn: 0, + styleSheetAddedHandler( + { + header: { + styleSheetId: "stylesheet-123", + frameId: "frame-123", + sourceURL: "http://example.com/styles.css", + sourceMapURL: "styles.css.map", + origin: "regular" as const, + title: "styles.css", + disabled: false, + isInline: false, + isMutable: false, + isConstructed: false, + startLine: 0, + startColumn: 0, + length: 100, + endLine: 10, + endColumn: 0, + }, }, - }); + sessionId, + ); const error: Error & { cause: Error } = await cssSelectivity.stop().catch(err => err); @@ -527,13 +604,16 @@ describe("CDP/Selectivity/CSSSelectivity", () => { await cssSelectivity.start(); const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; - styleSheetAddedHandler({ - header: { - styleSheetId: "stylesheet-123", - sourceURL: "http://example.com/styles.css", - sourceMapURL: "styles.css.map", + styleSheetAddedHandler( + { + header: { + styleSheetId: "stylesheet-123", + sourceURL: "http://example.com/styles.css", + sourceMapURL: "styles.css.map", + }, }, - }); + sessionId, + ); const result = await cssSelectivity.stop(); @@ -566,21 +646,27 @@ describe("CDP/Selectivity/CSSSelectivity", () => { await cssSelectivity.start(); const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; - styleSheetAddedHandler({ - header: { - styleSheetId: "stylesheet-123", - sourceURL: "http://example.com/styles1.css", - sourceMapURL: "styles1.css.map", + styleSheetAddedHandler( + { + header: { + styleSheetId: "stylesheet-123", + sourceURL: "http://example.com/styles1.css", + sourceMapURL: "styles1.css.map", + }, }, - }); + sessionId, + ); - styleSheetAddedHandler({ - header: { - styleSheetId: "stylesheet-456", - sourceURL: "http://example.com/styles2.css", - sourceMapURL: "styles2.css.map", + styleSheetAddedHandler( + { + header: { + styleSheetId: "stylesheet-456", + sourceURL: "http://example.com/styles2.css", + sourceMapURL: "styles2.css.map", + }, }, - }); + sessionId, + ); const result = await cssSelectivity.stop(); diff --git a/test/src/browser/cdp/selectivity/index.ts b/test/src/browser/cdp/selectivity/index.ts index 62e5ac7cd..e5e23ba22 100644 --- a/test/src/browser/cdp/selectivity/index.ts +++ b/test/src/browser/cdp/selectivity/index.ts @@ -28,8 +28,8 @@ describe("CDP/Selectivity", () => { constants: { R_OK: number; W_OK: number }; }; - let cssSelectivityMock: { start: SinonStub; stop: SinonStub }; - let jsSelectivityMock: { start: SinonStub; stop: SinonStub }; + let cssSelectivityMock: { start: SinonStub; stop: SinonStub; takeCoverageSnapshot: SinonStub }; + let jsSelectivityMock: { start: SinonStub; stop: SinonStub; takeCoverageSnapshot: SinonStub }; let testDependenciesWriterMock: { saveFor: SinonStub }; let hashWriterMock: { addTestDependencyHashes: SinonStub; addPatternDependencyHash: SinonStub; save: SinonStub }; let hashReaderMock: { patternHasChanged: SinonStub; getTestChangedDeps: SinonStub }; @@ -48,7 +48,17 @@ describe("CDP/Selectivity", () => { }; publicAPI: { isChromium: boolean; getWindowHandle: SinonStub }; cdp: { - target: { getTargets: SinonStub; attachToTarget: SinonStub; detachFromTarget: SinonStub }; + target: { + getTargets: SinonStub; + attachToTarget: SinonStub; + detachFromTarget: SinonStub; + setAutoAttach: SinonStub; + }; + dom: { enable: SinonStub }; + css: { enable: SinonStub }; + debugger: { enable: SinonStub; on: SinonStub; off: SinonStub; resume: SinonStub }; + page: { enable: SinonStub; addScriptToEvaluateOnNewDocument: SinonStub }; + profiler: { enable: SinonStub }; } | null; }; @@ -56,10 +66,12 @@ describe("CDP/Selectivity", () => { cssSelectivityMock = { start: sandbox.stub().resolves(), stop: sandbox.stub().resolves(new Set(["src/styles.css"])), + takeCoverageSnapshot: sandbox.stub().resolves(), }; jsSelectivityMock = { start: sandbox.stub().resolves(), stop: sandbox.stub().resolves(new Set(["src/app.js"])), + takeCoverageSnapshot: sandbox.stub().resolves(), }; testDependenciesWriterMock = { saveFor: sandbox.stub().resolves(), @@ -127,7 +139,21 @@ describe("CDP/Selectivity", () => { }), attachToTarget: sandbox.stub().resolves({ sessionId: "session-123" }), detachFromTarget: sandbox.stub().resolves(), + setAutoAttach: sandbox.stub().resolves(), + }, + dom: { enable: sandbox.stub().resolves() }, + css: { enable: sandbox.stub().resolves() }, + debugger: { + enable: sandbox.stub().resolves(), + on: sandbox.stub(), + off: sandbox.stub(), + resume: sandbox.stub().resolves(), }, + page: { + enable: sandbox.stub().resolves(), + addScriptToEvaluateOnNewDocument: sandbox.stub().resolves(), + }, + profiler: { enable: sandbox.stub().resolves() }, }, }; @@ -218,6 +244,77 @@ describe("CDP/Selectivity", () => { assert.isFunction(stopFn); }); + it("should enable CDP domains before starting selectivity", async () => { + await startSelectivity(browserMock as unknown as ExistingBrowser); + + assert.calledWith(browserMock.cdp!.dom.enable, "session-123"); + assert.calledWith(browserMock.cdp!.css.enable, "session-123"); + assert.calledWith(browserMock.cdp!.target.setAutoAttach, "session-123", { + autoAttach: true, + waitForDebuggerOnStart: false, + }); + assert.calledWith(browserMock.cdp!.debugger.enable, "session-123"); + assert.calledWith(browserMock.cdp!.page.enable, "session-123"); + assert.calledWith(browserMock.cdp!.profiler.enable, "session-123"); + }); + + it("should register debugger paused handler and add beforeunload script", async () => { + await startSelectivity(browserMock as unknown as ExistingBrowser); + + assert.calledOnceWith(browserMock.cdp!.debugger.on, "paused"); + assert.calledOnceWith(browserMock.cdp!.page.addScriptToEvaluateOnNewDocument, "session-123", { + source: sinon.match.string, + }); + assert.include( + browserMock.cdp!.page.addScriptToEvaluateOnNewDocument.args[0][1].source, + 'window.addEventListener("beforeunload", function', + ); + }); + + it("should take coverage snapshots when debugger pauses on beforeunload handler", async () => { + await startSelectivity(browserMock as unknown as ExistingBrowser); + + const pausedHandler = browserMock.cdp!.debugger.on.getCall(0).args[1]; + + pausedHandler({ callFrames: [{ functionName: "__testplane_cdp_coverage_snapshot_pause" }] }, "session-123"); + + // Need to let the promise chain resolve + await new Promise(resolve => setTimeout(resolve, 10)); + + assert.calledOnce(cssSelectivityMock.takeCoverageSnapshot); + assert.calledOnce(jsSelectivityMock.takeCoverageSnapshot); + assert.calledWith(browserMock.cdp!.debugger.resume, "session-123"); + }); + + it("should ignore debugger paused events from different sessions", async () => { + await startSelectivity(browserMock as unknown as ExistingBrowser); + + const pausedHandler = browserMock.cdp!.debugger.on.getCall(0).args[1]; + + pausedHandler( + { callFrames: [{ functionName: "__testplane_cdp_coverage_snapshot_pause" }] }, + "different-session", + ); + + await new Promise(resolve => setTimeout(resolve, 10)); + + assert.notCalled(cssSelectivityMock.takeCoverageSnapshot); + assert.notCalled(jsSelectivityMock.takeCoverageSnapshot); + }); + + it("should ignore debugger paused events with non-matching function name", async () => { + await startSelectivity(browserMock as unknown as ExistingBrowser); + + const pausedHandler = browserMock.cdp!.debugger.on.getCall(0).args[1]; + + pausedHandler({ callFrames: [{ functionName: "someOtherFunction" }] }, "session-123"); + + await new Promise(resolve => setTimeout(resolve, 10)); + + assert.notCalled(cssSelectivityMock.takeCoverageSnapshot); + assert.notCalled(jsSelectivityMock.takeCoverageSnapshot); + }); + it("should handle window handle containing target ID", async () => { browserMock.publicAPI.getWindowHandle.resolves("CDwindow-target-123-suffix"); browserMock.cdp!.target.getTargets.resolves({ diff --git a/test/src/browser/cdp/selectivity/js-selectivity.ts b/test/src/browser/cdp/selectivity/js-selectivity.ts index 4dcb70de2..ef9345b0f 100644 --- a/test/src/browser/cdp/selectivity/js-selectivity.ts +++ b/test/src/browser/cdp/selectivity/js-selectivity.ts @@ -109,30 +109,12 @@ describe("CDP/Selectivity/JSSelectivity", () => { await jsSelectivity.start(); - assert.calledWith(cdpMock.target.setAutoAttach, sessionId, { - autoAttach: true, - waitForDebuggerOnStart: false, - }); - assert.calledWith(cdpMock.debugger.enable, sessionId); - assert.calledWith(cdpMock.profiler.enable, sessionId); assert.calledWith(cdpMock.profiler.startPreciseCoverage, sessionId, { callCount: false, detailed: false, allowTriggeredUpdates: false, }); - assert.calledTwice(cdpMock.debugger.on); // paused and scriptParsed events - }); - - it("should handle debugger paused events", async () => { - const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); - - await jsSelectivity.start(); - - const pausedHandler = cdpMock.debugger.on.getCall(0).args[1]; - - await pausedHandler({}); - - assert.calledWith(cdpMock.debugger.resume, sessionId); + assert.calledOnce(cdpMock.debugger.on); // scriptParsed event only }); it("should handle scriptParsed events when there is no cache", async () => { @@ -144,7 +126,7 @@ describe("CDP/Selectivity/JSSelectivity", () => { await jsSelectivity.start(); - const scriptParsedHandler = cdpMock.debugger.on.getCall(1).args[1]; + const scriptParsedHandler = cdpMock.debugger.on.getCall(0).args[1]; const scriptParsedEvent = { scriptId: "script-123", @@ -152,7 +134,7 @@ describe("CDP/Selectivity/JSSelectivity", () => { sourceMapURL: "app.js.map", }; - scriptParsedHandler(scriptParsedEvent); + scriptParsedHandler(scriptParsedEvent, sessionId); await hasCachedSelectivityFileStubResult; await fetchTextWithBrowserFallbackStubResult; @@ -171,7 +153,7 @@ describe("CDP/Selectivity/JSSelectivity", () => { await jsSelectivity.start(); - const scriptParsedHandler = cdpMock.debugger.on.getCall(1).args[1]; + const scriptParsedHandler = cdpMock.debugger.on.getCall(0).args[1]; const scriptParsedEvent = { scriptId: "script-123", @@ -179,7 +161,7 @@ describe("CDP/Selectivity/JSSelectivity", () => { sourceMapURL: "app.js.map", }; - scriptParsedHandler(scriptParsedEvent); + scriptParsedHandler(scriptParsedEvent, sessionId); await hasCachedSelectivityFileStubResult; @@ -199,7 +181,7 @@ describe("CDP/Selectivity/JSSelectivity", () => { await jsSelectivity.start(); - const scriptParsedHandler = cdpMock.debugger.on.getCall(1).args[1]; + const scriptParsedHandler = cdpMock.debugger.on.getCall(0).args[1]; const sourceMapURL = "data:application/json;base64,eyJ2ZXJzaW9uIjozfQ=="; const scriptParsedEvent = { @@ -208,7 +190,7 @@ describe("CDP/Selectivity/JSSelectivity", () => { sourceMapURL: "data:application/json;base64,eyJ2ZXJzaW9uIjozfQ==", }; - scriptParsedHandler(scriptParsedEvent); + scriptParsedHandler(scriptParsedEvent, sessionId); await hasCachedSelectivityFileStubResult; @@ -221,7 +203,7 @@ describe("CDP/Selectivity/JSSelectivity", () => { await jsSelectivity.start(); - const scriptParsedHandler = cdpMock.debugger.on.getCall(1).args[1]; + const scriptParsedHandler = cdpMock.debugger.on.getCall(0).args[1]; const scriptParsedEvent = { scriptId: "script-123", @@ -229,13 +211,139 @@ describe("CDP/Selectivity/JSSelectivity", () => { sourceMapURL: "", }; - scriptParsedHandler(scriptParsedEvent); + scriptParsedHandler(scriptParsedEvent, sessionId); assert.notCalled(cdpMock.debugger.getScriptSource); assert.notCalled(fetchTextWithBrowserFallbackStub); }); }); + describe("takeCoverageSnapshot", () => { + it("should call takePreciseCoverage with sessionId", async () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); + + await jsSelectivity.start(); + await jsSelectivity.takeCoverageSnapshot(); + + assert.calledWith(cdpMock.profiler.takePreciseCoverage, sessionId); + }); + + it("should fetch sources for unknown scripts", async () => { + cdpMock.profiler.takePreciseCoverage.resolves({ + timestamp: 100500, + result: [ + { + scriptId: "script-999", + url: "http://example.com/bundle.js", + functions: [ + { + functionName: "bar", + isBlockCoverage: false, + ranges: [{ startOffset: 0, endOffset: 30, count: 1 }], + }, + ], + }, + ], + }); + cdpMock.debugger.getScriptSource.resolves({ + scriptSource: "mock source\n//# sourceMappingURL=bundle.js.map", + }); + + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); + + await jsSelectivity.start(); + await jsSelectivity.takeCoverageSnapshot(); + + assert.calledWith(cdpMock.debugger.getScriptSource, sessionId, "script-999"); + }); + + it("should not re-fetch already known scripts", async () => { + cdpMock.profiler.takePreciseCoverage.resolves({ + timestamp: 100500, + result: [ + { + scriptId: "script-123", + url: "http://example.com/app.js", + functions: [ + { + functionName: "foo", + isBlockCoverage: false, + ranges: [{ startOffset: 0, endOffset: 30, count: 1 }], + }, + ], + }, + ], + }); + + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); + + await jsSelectivity.start(); + + const scriptParsedHandler = cdpMock.debugger.on.getCall(0).args[1]; + scriptParsedHandler( + { scriptId: "script-123", url: "http://example.com/app.js", sourceMapURL: "app.js.map" }, + sessionId, + ); + + await jsSelectivity.takeCoverageSnapshot(); + + // getScriptSource is called by _processScript (from scriptParsed), not by _ensureScriptsAreLoading + assert.calledOnce(cdpMock.debugger.getScriptSource); + }); + + it("should accumulate coverage results used by stop()", async () => { + cdpMock.profiler.takePreciseCoverage + .onFirstCall() + .resolves({ + timestamp: 100500, + result: [ + { + scriptId: "script-123", + url: "http://example.com/app.js", + functions: [ + { + functionName: "foo", + isBlockCoverage: false, + ranges: [{ startOffset: 0, endOffset: 30, count: 1 }], + }, + ], + }, + ], + }) + .onSecondCall() + .resolves({ + timestamp: 100600, + result: [ + { + scriptId: "script-123", + url: "http://example.com/app.js", + functions: [ + { + functionName: "bar", + isBlockCoverage: false, + ranges: [{ startOffset: 30, endOffset: 60, count: 1 }], + }, + ], + }, + ], + }); + cdpMock.debugger.getScriptSource.resolves({ + scriptSource: "mock source\n//# sourceMappingURL=app.js.map", + }); + extractSourceFilesDepsStub.returns(new Set(["src/app.js"])); + + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); + + await jsSelectivity.start(); + await jsSelectivity.takeCoverageSnapshot(); + const result = await jsSelectivity.stop(); + + assert.deepEqual(Array.from(result || []), ["src/app.js"]); + // takePreciseCoverage is called once by takeCoverageSnapshot and once by stop + assert.calledTwice(cdpMock.profiler.takePreciseCoverage); + }); + }); + describe("stop", () => { it("should return empty array when drop is true", async () => { const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); @@ -244,7 +352,7 @@ describe("CDP/Selectivity/JSSelectivity", () => { const result = await jsSelectivity.stop(true); assert.deepEqual(Array.from(result || []).sort(), []); - assert.calledTwice(cdpMock.debugger.off); // Remove both event listeners + assert.calledOnce(cdpMock.debugger.off); // Remove scriptParsed event listener only }); it("should process coverage and return dependencies", async () => { @@ -359,7 +467,7 @@ describe("CDP/Selectivity/JSSelectivity", () => { await jsSelectivity.start(); - const scriptParsedHandler = cdpMock.debugger.on.getCall(1).args[1]; + const scriptParsedHandler = cdpMock.debugger.on.getCall(0).args[1]; const sourceMapURL = "data:application/json;base64,eyJ2ZXJzaW9uIjozfQ=="; const scriptParsedEvent = { @@ -368,7 +476,7 @@ describe("CDP/Selectivity/JSSelectivity", () => { sourceMapURL: sourceMapURL, }; - scriptParsedHandler(scriptParsedEvent); + scriptParsedHandler(scriptParsedEvent, sessionId); await jsSelectivity.stop();