fix: "bypass" proxying network requests from extra browser tabs/windows (#28188)

Co-authored-by: Ryan Manuel <ryanm@cypress.io>
Co-authored-by: Matt Schile <mschile@cypress.io>
This commit is contained in:
Chris Breiding
2023-11-02 13:55:13 -04:00
committed by GitHub
parent becb893f07
commit a0cfed5044
9 changed files with 527 additions and 47 deletions

View File

@@ -106,6 +106,7 @@ const READONLY_MIDDLEWARE_KEYS: (keyof HttpMiddlewareThis<{}>)[] = [
'onResponse',
'onError',
'skipMiddleware',
'onlyRunMiddleware',
]
export type HttpMiddlewareThis<T> = HttpMiddlewareCtx<T> & ServerCtx & Readonly<{
@@ -119,6 +120,7 @@ export type HttpMiddlewareThis<T> = HttpMiddlewareCtx<T> & 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,
}

View File

@@ -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()
}

View File

@@ -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,

View File

@@ -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.
*/

View File

@@ -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<CypressIncomingRequest>,
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<CypressIncomingRequest>,
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
})
})
})

View File

@@ -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

View File

@@ -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 <T>(retryable: () => Promise<T>, 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<TargetId, ExtraTarget> = 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
*/

View File

@@ -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()