From a0cfed5044a94de03e144708a5ff04d06458e802 Mon Sep 17 00:00:00 2001 From: Chris Breiding Date: Thu, 2 Nov 2023 13:55:13 -0400 Subject: [PATCH] fix: "bypass" proxying network requests from extra browser tabs/windows (#28188) Co-authored-by: Ryan Manuel Co-authored-by: Matt Schile --- cli/CHANGELOG.md | 3 +- packages/proxy/lib/http/index.ts | 7 +- packages/proxy/lib/http/request-middleware.ts | 24 +- .../proxy/lib/http/response-middleware.ts | 22 ++ packages/proxy/lib/types.ts | 1 + .../test/unit/http/request-middleware.spec.ts | 71 +++-- .../unit/http/response-middleware.spec.ts | 42 +++ .../server/lib/browsers/browser-cri-client.ts | 157 ++++++++++- .../unit/browsers/browser-cri-client_spec.ts | 247 ++++++++++++++++++ 9 files changed, 527 insertions(+), 47 deletions(-) diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 4cb0cd8cf5..f2843de97c 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -6,6 +6,7 @@ _Released 11/7/2023 (PENDING)_ **Bugfixes:** - Fixed an issue determining visibility when an element is hidden by an ancestor with a shared edge. Fixes [#27514](https://github.com/cypress-io/cypress/issues/27514). +- Fixed an issue where network requests made from tabs/windows other than the main Cypress tab would be delayed. Fixes [#28113](https://github.com/cypress-io/cypress/issues/28113). ## 13.4.0 @@ -83,7 +84,7 @@ _Released 09/12/2023_ **Bugfixes:** - Edge cases where `cy.intercept()` would not properly intercept and asset response bodies would not properly be captured for Test Replay have been addressed. Addressed in [#27771](https://github.com/cypress-io/cypress/pull/27771). -- Fixed an issue where `enter`, `keyup`, and `space` events were not triggering `click` events properly in some versions of Firefox. Addressed in [#27715](https://github.com/cypress-io/cypress/pull/27715). +- Fixed an issue where `enter`, `keyup`, and `space` events were not triggering `click` events properly in some versions of Firefox. Addressed in [#27715](https://github.com/cypress-io/cypress/pull/27715). - Fixed a regression in `13.0.0` where tests using Basic Authorization can potentially hang indefinitely on chromium browsers. Addressed in [#27781](https://github.com/cypress-io/cypress/pull/27781). - Fixed a regression in `13.0.0` where component tests using an intercept that matches all requests can potentially hang indefinitely. Addressed in [#27788](https://github.com/cypress-io/cypress/pull/27788). diff --git a/packages/proxy/lib/http/index.ts b/packages/proxy/lib/http/index.ts index 7549e508e6..a1957649ec 100644 --- a/packages/proxy/lib/http/index.ts +++ b/packages/proxy/lib/http/index.ts @@ -106,6 +106,7 @@ const READONLY_MIDDLEWARE_KEYS: (keyof HttpMiddlewareThis<{}>)[] = [ 'onResponse', 'onError', 'skipMiddleware', + 'onlyRunMiddleware', ] export type HttpMiddlewareThis = HttpMiddlewareCtx & ServerCtx & Readonly<{ @@ -119,6 +120,7 @@ export type HttpMiddlewareThis = HttpMiddlewareCtx & ServerCtx & Readonly< onResponse: (incomingRes: IncomingMessage, resStream: Readable) => void onError: (error: Error) => void skipMiddleware: (name: string) => void + onlyRunMiddleware: (names: string[]) => void }> export function _runStage (type: HttpStages, ctx: any, onError: Function) { @@ -220,9 +222,12 @@ export function _runStage (type: HttpStages, ctx: any, onError: Function) { _end() }, onError: _onError, - skipMiddleware: (name) => { + skipMiddleware: (name: string) => { ctx.middleware[type] = _.omit(ctx.middleware[type], name) }, + onlyRunMiddleware: (names: string[]) => { + ctx.middleware[type] = _.pick(ctx.middleware[type], names) + }, ...ctx, } diff --git a/packages/proxy/lib/http/request-middleware.ts b/packages/proxy/lib/http/request-middleware.ts index 08d4490a1c..4e7dcc39bc 100644 --- a/packages/proxy/lib/http/request-middleware.ts +++ b/packages/proxy/lib/http/request-middleware.ts @@ -10,6 +10,7 @@ import { doesTopNeedToBeSimulated } from './util/top-simulation' import type { HttpMiddleware } from './' import type { CypressIncomingRequest } from '../types' + // do not use a debug namespace in this file - use the per-request `this.debug` instead // available as cypress-verbose:proxy:http // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -31,15 +32,30 @@ const ExtractCypressMetadataHeaders: RequestMiddleware = function () { const span = telemetry.startSpan({ name: 'extract:cypress:metadata:headers', parentSpan: this.reqMiddlewareSpan, isVerbose }) this.req.isAUTFrame = !!this.req.headers['x-cypress-is-aut-frame'] - - span?.setAttributes({ - isAUTFrame: this.req.isAUTFrame, - }) + this.req.isFromExtraTarget = !!this.req.headers['x-cypress-is-from-extra-target'] if (this.req.headers['x-cypress-is-aut-frame']) { delete this.req.headers['x-cypress-is-aut-frame'] } + span?.setAttributes({ + isAUTFrame: this.req.isAUTFrame, + isFromExtraTarget: this.req.isFromExtraTarget, + }) + + // we only want to intercept requests from the main target and not ones from + // extra tabs or windows, so run the bare minimum request/response middleware + // to send the request/response directly through + if (this.req.isFromExtraTarget) { + this.debug('request for [%s %s] is from an extra target', this.req.method, this.req.proxiedUrl) + + delete this.req.headers['x-cypress-is-from-extra-target'] + + this.onlyRunMiddleware([ + 'SendRequestOutgoing', + ]) + } + span?.end() this.next() } diff --git a/packages/proxy/lib/http/response-middleware.ts b/packages/proxy/lib/http/response-middleware.ts index ef6d57e44e..9039927059 100644 --- a/packages/proxy/lib/http/response-middleware.ts +++ b/packages/proxy/lib/http/response-middleware.ts @@ -167,6 +167,27 @@ const LogResponse: ResponseMiddleware = function () { this.next() } +const FilterNonProxiedResponse: ResponseMiddleware = function () { + // if the request is from an extra target (i.e. not the main Cypress tab, but + // an extra tab/window), we want to skip any manipulation of the response and + // only run the middleware necessary to get it back to the browser + if (this.req.isFromExtraTarget) { + this.debug('response for [%s %s] is from extra target', this.req.method, this.req.proxiedUrl) + + this.onlyRunMiddleware([ + 'AttachPlainTextStreamFn', + 'PatchExpressSetHeader', + 'MaybeSendRedirectToClient', + 'CopyResponseStatusCode', + 'MaybeEndWithEmptyBody', + 'GzipBody', + 'SendResponseBodyToClient', + ]) + } + + this.next() +} + const AttachPlainTextStreamFn: ResponseMiddleware = function () { this.makeResStreamPlainText = function () { const span = telemetry.startSpan({ name: 'make:res:stream:plain:text', parentSpan: this.resMiddlewareSpan, isVerbose }) @@ -869,6 +890,7 @@ const SendResponseBodyToClient: ResponseMiddleware = function () { export default { LogResponse, + FilterNonProxiedResponse, AttachPlainTextStreamFn, InterceptResponse, PatchExpressSetHeader, diff --git a/packages/proxy/lib/types.ts b/packages/proxy/lib/types.ts index d27fa2b7f7..8ef5433ae3 100644 --- a/packages/proxy/lib/types.ts +++ b/packages/proxy/lib/types.ts @@ -18,6 +18,7 @@ export type CypressIncomingRequest = Request & { followRedirect?: boolean isAUTFrame: boolean credentialsLevel?: RequestCredentialLevel + isFromExtraTarget: boolean /** * Resource type from browserPreRequest. Copied to req so intercept matching can work. */ diff --git a/packages/proxy/test/unit/http/request-middleware.spec.ts b/packages/proxy/test/unit/http/request-middleware.spec.ts index 9025432306..3f4351667b 100644 --- a/packages/proxy/test/unit/http/request-middleware.spec.ts +++ b/packages/proxy/test/unit/http/request-middleware.spec.ts @@ -33,49 +33,66 @@ describe('http/request-middleware', () => { describe('ExtractCypressMetadataHeaders', () => { const { ExtractCypressMetadataHeaders } = RequestMiddleware - it('removes x-cypress-is-aut-frame header when it exists, sets in on the req', async () => { - const ctx = { + function prepareContext (headers = {}) { + return { getAUTUrl: sinon.stub().returns('http://localhost:8080'), + onlyRunMiddleware: sinon.stub(), remoteStates: { isPrimarySuperDomainOrigin: sinon.stub().returns(false), }, req: { - headers: { - 'x-cypress-is-aut-frame': 'true', - }, + headers, } as Partial, res: { on: (event, listener) => {}, off: (event, listener) => {}, }, } + } - await testMiddleware([ExtractCypressMetadataHeaders], ctx) - .then(() => { - expect(ctx.req.headers['x-cypress-is-aut-frame']).not.to.exist - expect(ctx.req.isAUTFrame).to.be.true + context('x-cypress-is-aut-frame', () => { + it('when it exists, removes header and sets in on the req', async () => { + const ctx = prepareContext({ + 'x-cypress-is-aut-frame': 'true', + }) + + await testMiddleware([ExtractCypressMetadataHeaders], ctx) + .then(() => { + expect(ctx.req.headers!['x-cypress-is-aut-frame']).not.to.exist + expect(ctx.req.isAUTFrame).to.be.true + }) + }) + + it('when it does not exist, sets in on the req', async () => { + const ctx = prepareContext() + + await testMiddleware([ExtractCypressMetadataHeaders], ctx).then(() => { + expect(ctx.req.headers!['x-cypress-is-aut-frame']).not.to.exist + expect(ctx.req.isAUTFrame).to.be.false + }) }) }) - it('removes x-cypress-is-aut-frame header when it does not exist, sets in on the req', async () => { - const ctx = { - getAUTUrl: sinon.stub().returns('http://localhost:8080'), - remoteStates: { - isPrimarySuperDomainOrigin: sinon.stub().returns(false), - }, - req: { - headers: {}, - } as Partial, - res: { - on: (event, listener) => {}, - off: (event, listener) => {}, - }, - } + context('x-cypress-is-from-extra-target', () => { + it('when it exists, sets in on the req and only runs necessary middleware', async () => { + const ctx = prepareContext({ + 'x-cypress-is-from-extra-target': 'true', + }) - await testMiddleware([ExtractCypressMetadataHeaders], ctx) - .then(() => { - expect(ctx.req.headers['x-cypress-is-aut-frame']).not.to.exist - expect(ctx.req.isAUTFrame).to.be.false + await testMiddleware([ExtractCypressMetadataHeaders], ctx) + + expect(ctx.req.headers!['x-cypress-is-from-extra-target']).not.to.exist + expect(ctx.req.isFromExtraTarget).to.be.true + expect(ctx.onlyRunMiddleware).to.be.calledWith(['SendRequestOutgoing']) + }) + + it('when it does not exist, removes header and sets in on the req', async () => { + const ctx = prepareContext() + + await testMiddleware([ExtractCypressMetadataHeaders], ctx) + + expect(ctx.req.headers!['x-cypress-is-from-extra-target']).not.to.exist + expect(ctx.req.isFromExtraTarget).to.be.false }) }) }) diff --git a/packages/proxy/test/unit/http/response-middleware.spec.ts b/packages/proxy/test/unit/http/response-middleware.spec.ts index 6adfa1ba47..afeb43492b 100644 --- a/packages/proxy/test/unit/http/response-middleware.spec.ts +++ b/packages/proxy/test/unit/http/response-middleware.spec.ts @@ -13,6 +13,7 @@ describe('http/response-middleware', function () { it('exports the members in the correct order', function () { expect(_.keys(ResponseMiddleware)).to.have.ordered.members([ 'LogResponse', + 'FilterNonProxiedResponse', 'AttachPlainTextStreamFn', 'InterceptResponse', 'PatchExpressSetHeader', @@ -99,6 +100,47 @@ describe('http/response-middleware', function () { }) }) + describe('FilterNonProxiedResponse', () => { + const { FilterNonProxiedResponse } = ResponseMiddleware + let ctx + + beforeEach(() => { + ctx = { + onlyRunMiddleware: sinon.stub(), + req: {}, + res: { + off: (event, listener) => {}, + }, + } + }) + + it('runs minimal subsequent middleware if request is from an extra target', () => { + ctx.req.isFromExtraTarget = true + + return testMiddleware([FilterNonProxiedResponse], ctx) + .then(() => { + expect(ctx.onlyRunMiddleware).to.be.calledWith([ + 'AttachPlainTextStreamFn', + 'PatchExpressSetHeader', + 'MaybeSendRedirectToClient', + 'CopyResponseStatusCode', + 'MaybeEndWithEmptyBody', + 'GzipBody', + 'SendResponseBodyToClient', + ]) + }) + }) + + it('runs all subsequent middleware if request is not from an extra target', () => { + ctx.req.isFromMainTarget = false + + return testMiddleware([FilterNonProxiedResponse], ctx) + .then(() => { + expect(ctx.onlyRunMiddleware).not.to.be.called + }) + }) + }) + describe('MaybeStripDocumentDomainFeaturePolicy', function () { const { MaybeStripDocumentDomainFeaturePolicy } = ResponseMiddleware let ctx diff --git a/packages/server/lib/browsers/browser-cri-client.ts b/packages/server/lib/browsers/browser-cri-client.ts index 3723cbfa71..9cc8599211 100644 --- a/packages/server/lib/browsers/browser-cri-client.ts +++ b/packages/server/lib/browsers/browser-cri-client.ts @@ -47,7 +47,11 @@ interface ManageTabsOptions { interface AttachedToTargetOptions { browserClient: CriClient + browserCriClient: BrowserCriClient + CriConstructor?: typeof CRI event: Protocol.Target.AttachedToTargetEvent + host: string + port: number protocolManager?: ProtocolManagerShape } @@ -159,6 +163,13 @@ const retryWithIncreasingDelay = async (retryable: () => Promise, browserN return retry() } +type TargetId = string + +interface ExtraTarget { + client: CRI.Client + targetInfo: Protocol.Target.TargetInfo +} + export class BrowserCriClient { private browserClient: CriClient private versionInfo: CRI.VersionResult @@ -177,6 +188,7 @@ export class BrowserCriClient { closed = false resettingBrowserTargets = false gracefulShutdown?: Boolean + extraTargetClients: Map = new Map() onClose: Function | null = null private constructor ({ browserClient, versionInfo, host, port, browserName, onAsynchronousError, protocolManager, fullyManageTabs }: BrowserCriClientOptions) { @@ -244,7 +256,7 @@ export class BrowserCriClient { ] browserClient.on('Target.attachedToTarget', async (event: Protocol.Target.AttachedToTargetEvent) => { - await this._onAttachToTarget({ browserClient, event, protocolManager }) + await this._onAttachToTarget({ browserClient, browserCriClient, event, host, port, protocolManager }) }) browserClient.on('Target.targetDestroyed', (event: Protocol.Target.TargetDestroyedEvent) => { @@ -255,22 +267,115 @@ export class BrowserCriClient { } static async _onAttachToTarget (options: AttachedToTargetOptions) { - const { browserClient, event, protocolManager } = options + const { browserClient, browserCriClient, CriConstructor, event, host, port, protocolManager } = options + const CreateCRI = CriConstructor || CRI + const { sessionId, targetInfo, waitingForDebugger } = event + let { targetId, url } = targetInfo - debug('Target.attachedToTarget %o', event.targetInfo) + debug('Target.attachedToTarget %o', targetInfo) try { - if (event.targetInfo.type !== 'page') { + // The basic approach here is we attach to targets and enable network traffic + // We must attach in a paused state so that we can enable network traffic before the target starts running. + if (targetInfo.type !== 'page') { await browserClient.send('Network.enable', protocolManager?.networkEnableOptions ?? DEFAULT_NETWORK_ENABLE_OPTIONS, event.sessionId) } - - if (event.waitingForDebugger) { - await browserClient.send('Runtime.runIfWaitingForDebugger', undefined, event.sessionId) - } } catch (error) { - // it's possible that the target was closed before we could enable network and continue, in that case, just ignore - debug('error attaching to target browser', error) + // it's possible that the target was closed before we could enable + // network and continue, in that case, just ignore + debug('error running Network.enable:', error) } + + if (!waitingForDebugger) { + debug('Not waiting for debugger (id: %s)', targetId) + + // a target created before we started listening won't be waiting + // for the debugger and is therefore not an extra target + return + } + + async function run () { + try { + await browserClient.send('Runtime.runIfWaitingForDebugger', undefined, sessionId) + } catch (error) { + // it's possible that the target was closed before we could enable + // network and continue, in that case, just ignore + debug('error running Runtime.runIfWaitingForDebugger:', error) + } + } + + // the url often isn't specified with this event, so we get it + // from Target.getTargets + if (!url) { + const { targetInfos } = await browserClient.send('Target.getTargets') + + const thisTarget = targetInfos.find((target) => target.targetId === targetId) + + if (thisTarget) { + url = thisTarget.url + } + } + + if ( + // if resetting browser targets, the first target attached to is the + // main Cypress tab, but hasn't been set as + // browserCriClient.currentlyAttachedTarget yet + browserCriClient.resettingBrowserTargets + // is the main Cypress tab + || targetId === browserCriClient.currentlyAttachedTarget?.targetId + // is not a tab/window, such as a service worker + || targetInfo.type !== 'page' + // is DevTools + || url.includes('devtools://') + // is the Launchpad + || url.includes('__launchpad') + // is chrome extension service worker + || url.includes('chrome-extension://') + ) { + debug('Not an extra target (id: %s)', targetId) + + // in these cases, we don't want to track the targets as extras. + // we're only interested in extra tabs or windows + return await run() + } + + debug('Connect as extra target (id: %s)', targetId) + + let extraTargetCriClient + + try { + extraTargetCriClient = await CreateCRI({ + host, + port, + target: targetId, + local: true, + useHostName: true, + }) + } catch (err: any) { + debug('Errored connecting to target (id: %s): %s', targetId, err?.stack || err) + + return await run() + } + + browserCriClient.addExtraTargetClient(targetInfo, extraTargetCriClient) + + await extraTargetCriClient.send('Fetch.enable') + + // we mark extra targets with this header, so that the proxy can recognize + // where they came from and run only the minimal middleware necessary + extraTargetCriClient.on('Fetch.requestPaused', async (params: Protocol.Fetch.RequestPausedEvent) => { + const details: Protocol.Fetch.ContinueRequestRequest = { + requestId: params.requestId, + headers: [{ name: 'X-Cypress-Is-From-Extra-Target', value: 'true' }], + } + + extraTargetCriClient.send('Fetch.continueRequest', details).catch((err) => { + // swallow this error so it doesn't crash Cypress + debug('continueRequest failed, url: %s, error: %s', params.request.url, err?.stack || err) + }) + }) + + await run() } static _onTargetDestroyed ({ browserClient, browserCriClient, browserName, event, onAsynchronousError }: TargetDestroyedOptions) { @@ -281,9 +386,17 @@ export class BrowserCriClient { resettingBrowserTargets: browserCriClient.resettingBrowserTargets, }) - // we may have gotten a delayed "Target.targetDestroyed" even for a page that we - // have already closed/disposed, so unless this matches our current target then bail - if (event.targetId !== browserCriClient.currentlyAttachedTarget?.targetId) { + const { targetId } = event + + if (targetId !== browserCriClient.currentlyAttachedTarget?.targetId) { + if (browserCriClient.hasExtraTargetClient(targetId)) { + debug('Close extra target client (id: %s)') + browserCriClient.getExtraTargetClient(targetId)!.client.close().catch(() => { }) + browserCriClient.removeExtraTargetClient(targetId) + } + + // we may have gotten a delayed "Target.targetDestroyed" event for a page that we + // have already closed/disposed, so unless this matches our current target then bail return } @@ -333,7 +446,7 @@ export class BrowserCriClient { errors.throwErr('BROWSER_PROCESS_CLOSED_UNEXPECTEDLY', browserName) }) .catch(Bluebird.TimeoutError, () => { - debug('browser websocket did not close, page was closed %o', { targetId: event.targetId }) + debug('browser websocket did not close, page was closed %o', { targetId }) // the browser websocket didn't close meaning // only the page was closed, not the browser errors.throwErr('BROWSER_PAGE_CLOSED_UNEXPECTEDLY', browserName) @@ -444,6 +557,22 @@ export class BrowserCriClient { this.resettingBrowserTargets = false } + addExtraTargetClient (targetInfo: Protocol.Target.TargetInfo, client: CRI.Client) { + this.extraTargetClients.set(targetInfo.targetId, { client, targetInfo }) + } + + hasExtraTargetClient (targetId: TargetId) { + return this.extraTargetClients.has(targetId) + } + + getExtraTargetClient (targetId: TargetId) { + return this.extraTargetClients.get(targetId) + } + + removeExtraTargetClient (targetId: TargetId) { + this.extraTargetClients.delete(targetId) + } + /** * Closes the browser client socket as well as the socket for the currently attached page target */ diff --git a/packages/server/test/unit/browsers/browser-cri-client_spec.ts b/packages/server/test/unit/browsers/browser-cri-client_spec.ts index 40fd09e09f..00bb9ccedc 100644 --- a/packages/server/test/unit/browsers/browser-cri-client_spec.ts +++ b/packages/server/test/unit/browsers/browser-cri-client_spec.ts @@ -5,6 +5,7 @@ import * as protocol from '../../../lib/browsers/protocol' import { stripAnsi } from '@packages/errors' import net from 'net' import { ProtocolManagerShape } from '@packages/types' +import type { Protocol } from 'devtools-protocol' const HOST = '127.0.0.1' const PORT = 50505 @@ -129,6 +130,252 @@ describe('lib/browsers/cri-client', function () { }) }) + context('._onAttachToTarget', () => { + let options: any + + beforeEach(() => { + options = { + browserClient: { + send: sinon.stub(), + }, + browserCriClient: { + addExtraTargetClient: sinon.stub(), + currentlyAttachedTarget: { + targetId: 'main-target-id', + }, + resettingBrowserTargets: false, + }, + CriConstructor: sinon.stub(), + event: { + sessionId: 'session-id', + targetInfo: { + targetId: 'target-id', + type: 'page', + url: 'http://the.url', + } as Protocol.Target.TargetInfo, + waitingForDebugger: true, + }, + host: 'localhost', + port: 1234, + } + }) + + it('is a noop if not waiting for debugger', async () => { + options.event.waitingForDebugger = false + + await BrowserCriClient._onAttachToTarget(options as any) + + expect(options.browserClient.send).not.to.be.called + }) + + it('gets url from Target.getTargets if not in event', async () => { + options.event.targetInfo.url = '' + + options.browserClient.send.withArgs('Target.getTargets').resolves({ + targetInfos: [{ + targetId: 'target-id', + url: 'devtools://some.devtools', + }], + }) + + options.browserClient.send.withArgs('Runtime.runIfWaitingForDebugger').resolves() + + await BrowserCriClient._onAttachToTarget(options as any) + + expect(options.browserClient.send).to.be.calledWith('Target.getTargets') + }) + + it('is a noop sending Runtime.runIfWaitingForDebugger if resetting browser targets', async () => { + options.browserCriClient.resettingBrowserTargets = true + options.browserClient.send.withArgs('Runtime.runIfWaitingForDebugger').resolves() + + await BrowserCriClient._onAttachToTarget(options as any) + + expect(options.CriConstructor).not.to.be.called + expect(options.browserClient.send).to.be.calledWith('Runtime.runIfWaitingForDebugger', undefined, 'session-id') + }) + + it('is a noop sending Runtime.runIfWaitingForDebugger if target is the main Cypress tab', async () => { + options.event.targetInfo.targetId = 'main-target-id' + options.browserClient.send.withArgs('Runtime.runIfWaitingForDebugger').resolves() + + await BrowserCriClient._onAttachToTarget(options as any) + + expect(options.CriConstructor).not.to.be.called + expect(options.browserClient.send).to.be.calledWith('Runtime.runIfWaitingForDebugger', undefined, 'session-id') + }) + + it('is a noop sending Runtime.runIfWaitingForDebugger if target is not a tab or window', async () => { + options.event.targetInfo.type = 'service_worker' + options.browserClient.send.withArgs('Runtime.runIfWaitingForDebugger').resolves() + + await BrowserCriClient._onAttachToTarget(options as any) + + expect(options.CriConstructor).not.to.be.called + expect(options.browserClient.send).to.be.calledWith('Runtime.runIfWaitingForDebugger', undefined, 'session-id') + }) + + it('is a noop sending Runtime.runIfWaitingForDebugger if target is DevTools', async () => { + options.event.targetInfo.url = 'devtools://dev.tools' + options.browserClient.send.withArgs('Runtime.runIfWaitingForDebugger').resolves() + + await BrowserCriClient._onAttachToTarget(options as any) + + expect(options.CriConstructor).not.to.be.called + expect(options.browserClient.send).to.be.calledWith('Runtime.runIfWaitingForDebugger', undefined, 'session-id') + }) + + it('is a noop sending Runtime.runIfWaitingForDebugger if target is the Launchpad', async () => { + options.event.targetInfo.url = 'http://localhost:1234/__launchpad' + options.browserClient.send.withArgs('Runtime.runIfWaitingForDebugger').resolves() + + await BrowserCriClient._onAttachToTarget(options as any) + + expect(options.CriConstructor).not.to.be.called + expect(options.browserClient.send).to.be.calledWith('Runtime.runIfWaitingForDebugger', undefined, 'session-id') + }) + + it('is a noop sending Runtime.runIfWaitingForDebugger if part of a chrome extension', async () => { + options.event.targetInfo.url = 'chrome-extension://some.extension' + options.browserClient.send.withArgs('Runtime.runIfWaitingForDebugger').resolves() + + await BrowserCriClient._onAttachToTarget(options as any) + + expect(options.CriConstructor).not.to.be.called + expect(options.browserClient.send).to.be.calledWith('Runtime.runIfWaitingForDebugger', undefined, 'session-id') + }) + + it('is a noop sending Runtime.runIfWaitingForDebugger if connecting to target errors', async () => { + options.CriConstructor.rejects(new Error('failed to connect')) + options.browserClient.send.withArgs('Runtime.runIfWaitingForDebugger').resolves() + + await BrowserCriClient._onAttachToTarget(options as any) + + expect(options.CriConstructor).to.be.called + expect(options.browserCriClient.addExtraTargetClient).not.to.be.called + expect(options.browserClient.send).to.be.calledWith('Runtime.runIfWaitingForDebugger', undefined, 'session-id') + }) + + it('connects to target and sends Fetch.enable', async () => { + const criClient = { + send: sinon.stub(), + on: sinon.stub(), + } + + options.CriConstructor.returns(criClient) + options.browserClient.send.withArgs('Fetch.enable').resolves() + options.browserClient.send.withArgs('Runtime.runIfWaitingForDebugger').resolves() + + await BrowserCriClient._onAttachToTarget(options as any) + + expect(options.CriConstructor).to.be.called + expect(options.browserCriClient.addExtraTargetClient).to.be.calledWith(options.event.targetInfo, criClient) + expect(criClient.send).to.be.calledWith('Fetch.enable') + expect(criClient.on).to.be.calledWith('Fetch.requestPaused', sinon.match.func) + expect(options.browserClient.send).to.be.calledWith('Runtime.runIfWaitingForDebugger', undefined, 'session-id') + }) + + it('adds X-Cypress-Is-From-Extra-Target header to requests from extra target', async () => { + const criClient = { + send: sinon.stub(), + on: sinon.stub(), + } + + options.CriConstructor.returns(criClient) + options.browserClient.send.withArgs('Fetch.enable').resolves() + options.browserClient.send.withArgs('Runtime.runIfWaitingForDebugger').resolves() + criClient.send.withArgs('Fetch.continueRequest').resolves() + + await BrowserCriClient._onAttachToTarget(options as any) + await criClient.on.lastCall.args[1]({ requestId: 'request-id' }) + + expect(criClient.send).to.be.calledWith('Fetch.continueRequest', { + requestId: 'request-id', + headers: [{ name: 'X-Cypress-Is-From-Extra-Target', value: 'true' }], + }) + }) + + it('ignores any errors from continuing request', async () => { + const criClient = { + send: sinon.stub(), + on: sinon.stub(), + } + + options.CriConstructor.returns(criClient) + options.browserClient.send.withArgs('Fetch.enable').resolves() + options.browserClient.send.withArgs('Runtime.runIfWaitingForDebugger').resolves() + criClient.send.withArgs('Fetch.continueRequest').rejects(new Error('continuing request failed')) + + await BrowserCriClient._onAttachToTarget(options as any) + await criClient.on.lastCall.args[1]({ requestId: 'request-id', request: { url: '' } }) + // error is caught or else the test would fail + }) + }) + + context('._onTargetDestroyed', () => { + describe('when not the currently attached target', () => { + let options: any + + beforeEach(() => { + options = { + browserCriClient: { + hasExtraTargetClient: sinon.stub().returns(true), + getExtraTargetClient: sinon.stub(), + removeExtraTargetClient: sinon.stub(), + currentlyAttachedTarget: { + targetId: 'main-target-id', + close: sinon.stub().resolves(), + }, + resettingBrowserTargets: false, + }, + event: { + targetId: 'target-id', + }, + } + }) + + it('is noop if target is not currently tracked', () => { + options.browserCriClient.hasExtraTargetClient.returns(false) + + BrowserCriClient._onTargetDestroyed(options as any) + + expect(options.browserCriClient.getExtraTargetClient).not.to.be.called + expect(options.browserCriClient.currentlyAttachedTarget.close).not.to.be.called + }) + + it('closes the extra target client', () => { + const client = { close: sinon.stub().resolves() } + + options.browserCriClient.getExtraTargetClient.returns({ client }) + + BrowserCriClient._onTargetDestroyed(options as any) + + expect(client.close).to.be.called + }) + + it('ignores errors closing the extra target client', () => { + const client = { close: sinon.stub().rejects(new Error('closing failed')) } + + options.browserCriClient.getExtraTargetClient.returns({ client }) + + BrowserCriClient._onTargetDestroyed(options as any) + + expect(options.browserCriClient.removeExtraTargetClient).to.be.calledWith('target-id') + // error is caught or else the test would fail + }) + + it('removes the extra target client from the tracker', () => { + const client = { close: sinon.stub().resolves() } + + options.browserCriClient.getExtraTargetClient.returns({ client }) + + BrowserCriClient._onTargetDestroyed(options as any) + + expect(options.browserCriClient.removeExtraTargetClient).to.be.calledWith('target-id') + }) + }) + }) + context('#ensureMinimumProtocolVersion', function () { function withProtocolVersion (actual, test) { return getClient()