chore: remove the electron extension API and opt for CDP methods to leverage Fetch.Enable/Paused (#27205)

* chore: refactor electron to use CDP instead of extension [run ci]

* chore: refactor pause methods to CDPAutomation from electron and chrome
[run ci]
This commit is contained in:
Bill Glesias
2023-07-12 11:02:43 -04:00
committed by GitHub
parent a0024e57ab
commit 32cfa50f0e
5 changed files with 302 additions and 332 deletions
@@ -12,6 +12,7 @@ import type { ResourceType, BrowserPreRequest, BrowserResponseReceived } from '@
import type { WriteVideoFrame } from '@packages/types'
import type { Automation } from '../automation'
import { cookieMatches, CyCookie, CyCookieFilter } from '../automation/util'
import type { CriClient } from './cri-client'
export type CdpCommand = keyof ProtocolMapping.Commands
@@ -141,6 +142,9 @@ export const normalizeResourceType = (resourceType: string | undefined): Resourc
type SendDebuggerCommand = <T extends CdpCommand>(message: T, data?: any) => Promise<ProtocolMapping.Commands[T]['returnType']>
type SendCloseCommand = (shouldKeepTabOpen: boolean) => Promise<any> | void
type OnFn = <T extends CdpEvent>(eventName: T, cb: (data: ProtocolMapping.Events[T][0]) => void) => void
interface HasFrame {
frame: Protocol.Page.Frame
}
// the intersection of what's valid in CDP and what's valid in FFCDP
// Firefox: https://searchfox.org/mozilla-central/rev/98a9257ca2847fad9a19631ac76199474516b31e/remote/cdp/domains/parent/Network.jsm#22
@@ -153,6 +157,9 @@ const ffToStandardResourceTypeMap: { [ff: string]: ResourceType } = {
}
export class CdpAutomation {
private frameTree: any
private gettingFrameTree: any
private constructor (private sendDebuggerCommandFn: SendDebuggerCommand, private onFn: OnFn, private sendCloseCommandFn: SendCloseCommand, private automation: Automation) {
onFn('Network.requestWillBeSent', this.onNetworkRequestWillBeSent)
onFn('Network.responseReceived', this.onResponseReceived)
@@ -268,6 +275,130 @@ export class CdpAutomation {
})
}
// eslint-disable-next-line @cypress/dev/arrow-body-multiline-braces
private _updateFrameTree = (client: CriClient, eventName) => async () => {
debugVerbose(`update frame tree for ${eventName}`)
this.gettingFrameTree = new Promise<void>(async (resolve) => {
try {
this.frameTree = (await client.send('Page.getFrameTree')).frameTree
debugVerbose('frame tree updated')
} catch (err) {
debugVerbose('failed to update frame tree:', err.stack)
} finally {
this.gettingFrameTree = null
resolve()
}
})
}
private _continueRequest = (client, params, headers?) => {
const details: Protocol.Fetch.ContinueRequestRequest = {
requestId: params.requestId,
}
if (headers && headers.length) {
// headers are received as an object but need to be an array
// to modify them
const currentHeaders = _.map(params.request.headers, (value, name) => ({ name, value }))
details.headers = [
...currentHeaders,
...headers,
]
}
debugVerbose('continueRequest: %o', details)
client.send('Fetch.continueRequest', details).catch((err) => {
// swallow this error so it doesn't crash Cypress.
// an "Invalid InterceptionId" error can randomly happen in the driver tests
// when testing the redirection loop limit, when a redirect request happens
// to be sent after the test has moved on. this shouldn't crash Cypress, in
// any case, and likely wouldn't happen for standard user tests, since they
// will properly fail and not move on like the driver tests
debugVerbose('continueRequest failed, url: %s, error: %s', params.request.url, err?.stack || err)
})
}
private _isAUTFrame = async (frameId: string) => {
debugVerbose('need frame tree')
// the request could come in while in the middle of getting the frame tree,
// which is asynchronous, so wait for it to be fetched
if (this.gettingFrameTree) {
debugVerbose('awaiting frame tree')
await this.gettingFrameTree
}
const frame = _.find(this.frameTree?.childFrames || [], ({ frame }) => {
return frame?.name?.startsWith('Your project:')
}) as HasFrame | undefined
if (frame) {
return frame.frame.id === frameId
}
return false
}
_handlePausedRequests = async (client) => {
// NOTE: only supported in chromium based browsers
await client.send('Fetch.enable')
// adds a header to the request to mark it as a request for the AUT frame
// itself, so the proxy can utilize that for injection purposes
client.on('Fetch.requestPaused', async (params: Protocol.Fetch.RequestPausedEvent) => {
const addedHeaders: {
name: string
value: string
}[] = []
/**
* Unlike the the web extension or Electrons's onBeforeSendHeaders, CDP can discern the difference
* between fetch or xhr resource types. Because of this, we set X-Cypress-Is-XHR-Or-Fetch to either
* 'xhr' or 'fetch' with CDP so the middleware can assume correct defaults in case credential/resourceTypes
* are not sent to the server.
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-ResourceType
*/
if (params.resourceType === 'XHR' || params.resourceType === 'Fetch') {
debugVerbose('add X-Cypress-Is-XHR-Or-Fetch header to: %s', params.request.url)
addedHeaders.push({
name: 'X-Cypress-Is-XHR-Or-Fetch',
value: params.resourceType.toLowerCase(),
})
}
if (
// is a script, stylesheet, image, etc
params.resourceType !== 'Document'
|| !(await this._isAUTFrame(params.frameId))
) {
return this._continueRequest(client, params, addedHeaders)
}
debugVerbose('add X-Cypress-Is-AUT-Frame header to: %s', params.request.url)
addedHeaders.push({
name: 'X-Cypress-Is-AUT-Frame',
value: 'true',
})
return this._continueRequest(client, params, addedHeaders)
})
}
// we can't get the frame tree during the Fetch.requestPaused event, because
// the CDP is tied up during that event and can't be utilized. so we maintain
// a reference to it that's updated when it's likely to have been changed
_listenForFrameTreeChanges = (client) => {
debugVerbose('listen for frame tree changes')
client.on('Page.frameAttached', this._updateFrameTree(client, 'Page.frameAttached'))
client.on('Page.frameDetached', this._updateFrameTree(client, 'Page.frameDetached'))
}
onRequest = (message, data) => {
let setCookie
+11 -141
View File
@@ -8,7 +8,6 @@ import path from 'path'
import extension from '@packages/extension'
import mime from 'mime'
import { launch } from '@packages/launcher'
import type { Protocol } from 'devtools-protocol'
import appData from '../util/app_data'
import { fs } from '../util/fs'
@@ -310,142 +309,7 @@ const _handleDownloads = async function (client, downloadsFolder: string, automa
})
}
let frameTree
let gettingFrameTree
const onReconnect = (client: CriClient) => {
// if the client disconnects (e.g. due to a computer sleeping), update
// the frame tree on reconnect in cases there were changes while
// the client was disconnected
return _updateFrameTree(client, 'onReconnect')()
}
// eslint-disable-next-line @cypress/dev/arrow-body-multiline-braces
const _updateFrameTree = (client: CriClient, eventName) => async () => {
debug(`update frame tree for ${eventName}`)
gettingFrameTree = new Promise<void>(async (resolve) => {
try {
frameTree = (await client.send('Page.getFrameTree')).frameTree
debug('frame tree updated')
} catch (err) {
debug('failed to update frame tree:', err.stack)
} finally {
gettingFrameTree = null
resolve()
}
})
}
// we can't get the frame tree during the Fetch.requestPaused event, because
// the CDP is tied up during that event and can't be utilized. so we maintain
// a reference to it that's updated when it's likely to have been changed
const _listenForFrameTreeChanges = (client) => {
debug('listen for frame tree changes')
client.on('Page.frameAttached', _updateFrameTree(client, 'Page.frameAttached'))
client.on('Page.frameDetached', _updateFrameTree(client, 'Page.frameDetached'))
}
const _continueRequest = (client, params, headers?) => {
const details: Protocol.Fetch.ContinueRequestRequest = {
requestId: params.requestId,
}
if (headers && headers.length) {
// headers are received as an object but need to be an array
// to modify them
const currentHeaders = _.map(params.request.headers, (value, name) => ({ name, value }))
details.headers = [
...currentHeaders,
...headers,
]
}
debug('continueRequest: %o', details)
client.send('Fetch.continueRequest', details).catch((err) => {
// swallow this error so it doesn't crash Cypress.
// an "Invalid InterceptionId" error can randomly happen in the driver tests
// when testing the redirection loop limit, when a redirect request happens
// to be sent after the test has moved on. this shouldn't crash Cypress, in
// any case, and likely wouldn't happen for standard user tests, since they
// will properly fail and not move on like the driver tests
debug('continueRequest failed, url: %s, error: %s', params.request.url, err?.stack || err)
})
}
interface HasFrame {
frame: Protocol.Page.Frame
}
const _isAUTFrame = async (frameId: string) => {
debug('need frame tree')
// the request could come in while in the middle of getting the frame tree,
// which is asynchronous, so wait for it to be fetched
if (gettingFrameTree) {
debug('awaiting frame tree')
await gettingFrameTree
}
const frame = _.find(frameTree?.childFrames || [], ({ frame }) => {
return frame?.name?.startsWith('Your project:')
}) as HasFrame | undefined
if (frame) {
return frame.frame.id === frameId
}
return false
}
const _handlePausedRequests = async (client) => {
await client.send('Fetch.enable')
// adds a header to the request to mark it as a request for the AUT frame
// itself, so the proxy can utilize that for injection purposes
client.on('Fetch.requestPaused', async (params: Protocol.Fetch.RequestPausedEvent) => {
const addedHeaders: {
name: string
value: string
}[] = []
/**
* Unlike the the web extension or Electrons's onBeforeSendHeaders, CDP can discern the difference
* between fetch or xhr resource types. Because of this, we set X-Cypress-Is-XHR-Or-Fetch to either
* 'xhr' or 'fetch' with CDP so the middleware can assume correct defaults in case credential/resourceTypes
* are not sent to the server.
* @see https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-ResourceType
*/
if (params.resourceType === 'XHR' || params.resourceType === 'Fetch') {
debug('add X-Cypress-Is-XHR-Or-Fetch header to: %s', params.request.url)
addedHeaders.push({
name: 'X-Cypress-Is-XHR-Or-Fetch',
value: params.resourceType.toLowerCase(),
})
}
if (
// is a script, stylesheet, image, etc
params.resourceType !== 'Document'
|| !(await _isAUTFrame(params.frameId))
) {
return _continueRequest(client, params, addedHeaders)
}
debug('add X-Cypress-Is-AUT-Frame header to: %s', params.request.url)
addedHeaders.push({
name: 'X-Cypress-Is-AUT-Frame',
value: 'true',
})
return _continueRequest(client, params, addedHeaders)
})
}
let onReconnect: (client: CriClient) => Promise<void> = async () => undefined
const _setAutomation = async (client: CriClient, automation: Automation, resetBrowserTargets: (shouldKeepTabOpen: boolean) => Promise<void>, options: BrowserLaunchOpts) => {
const cdpAutomation = await CdpAutomation.create(client.send, client.on, resetBrowserTargets, automation)
@@ -472,8 +336,6 @@ export = {
_handleDownloads,
_handlePausedRequests,
_setAutomation,
_getChromePreferences,
@@ -636,6 +498,14 @@ export = {
const cdpAutomation = await this._setAutomation(pageCriClient, automation, browserCriClient.resetBrowserTargets, options)
onReconnect = (client: CriClient) => {
// if the client disconnects (e.g. due to a computer sleeping), update
// the frame tree on reconnect in cases there were changes while
// the client was disconnected
// @ts-expect-error
return cdpAutomation._updateFrameTree(client, 'onReconnect')()
}
await pageCriClient.send('Page.enable')
await options['onInitializeNewBrowserTab']?.()
@@ -647,8 +517,8 @@ export = {
await this._navigateUsingCRI(pageCriClient, url)
await this._handlePausedRequests(pageCriClient)
_listenForFrameTreeChanges(pageCriClient)
await cdpAutomation._handlePausedRequests(pageCriClient)
cdpAutomation._listenForFrameTreeChanges(pageCriClient)
return cdpAutomation
},
+5 -46
View File
@@ -283,6 +283,8 @@ export = {
this._clearCache(win.webContents),
])
await browserCriClient?.currentlyAttachedTarget?.send('Page.enable')
await Promise.all([
videoApi && recordVideo(cdpAutomation, videoApi),
this._handleDownloads(win, options.downloadsFolder, automation),
@@ -292,7 +294,9 @@ export = {
await this._enableDebugger()
await win.loadURL(url)
this._listenToOnBeforeHeaders(win)
await cdpAutomation._handlePausedRequests(browserCriClient?.currentlyAttachedTarget)
cdpAutomation._listenForFrameTreeChanges(browserCriClient?.currentlyAttachedTarget)
return win
},
@@ -334,51 +338,6 @@ export = {
})
},
_listenToOnBeforeHeaders (win: BrowserWindow) {
// true if the frame only has a single parent, false otherwise
const isFirstLevelIFrame = (frame) => (!!frame?.parent && !frame.parent.parent)
// adds a header to the request to mark it as a request for the AUT frame
// itself, so the proxy can utilize that for injection purposes
win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => {
const requestModifications = {
requestHeaders: {
...details.requestHeaders,
/**
* Unlike CDP, Electrons's onBeforeSendHeaders resourceType cannot discern the difference
* between fetch or xhr resource types, but classifies both as 'xhr'. Because of this,
* we set X-Cypress-Is-XHR-Or-Fetch to true if the request is made with 'xhr' or 'fetch' so the
* middleware doesn't incorrectly assume which request type is being sent
* @see https://www.electronjs.org/docs/latest/api/web-request#webrequestonbeforesendheadersfilter-listener
*/
...(details.resourceType === 'xhr') ? {
'X-Cypress-Is-XHR-Or-Fetch': 'true',
} : {},
},
}
if (
// isn't an iframe
details.resourceType !== 'subFrame'
// the top-level frame or a nested frame
|| !isFirstLevelIFrame(details.frame)
// is the spec frame, not the AUT
|| details.url.includes('__cypress')
) {
cb(requestModifications)
return
}
cb({
requestHeaders: {
...requestModifications.requestHeaders,
'X-Cypress-Is-AUT-Frame': 'true',
},
})
})
},
_getPartition (options) {
if (options.isTextTerminal) {
// create dynamic persisted run