mirror of
https://github.com/cypress-io/cypress.git
synced 2026-04-30 12:01:06 -05:00
0c265638ce
* feat: add X-Cypress-Request header in extension * feat: add X-Cypress-Request header in CDP * feat: add X-Cypress-Request header in electron * feat: add ExtractRequestedWithAndCredentialsIfApplicable middleware stub to remove the newly added x-cypress-request header * chore: change defaultHeaders variable name to requestModifications to more accurately reflect usage * chore: condense ExtractIsAUTFrameHeader and ExtractRequestedWithAndCredentialsIfApplicable into ExtractCypressMetadataHeaders middleware * test: add anti assertion for x-cypress-request and remove setting request verbage (as it does nothing yet)
545 lines
16 KiB
TypeScript
545 lines
16 KiB
TypeScript
import _ from 'lodash'
|
|
import EE from 'events'
|
|
import path from 'path'
|
|
import Debug from 'debug'
|
|
import menu from '../gui/menu'
|
|
import * as Windows from '../gui/windows'
|
|
import { CdpAutomation, screencastOpts, CdpCommand, CdpEvent } from './cdp_automation'
|
|
import * as savedState from '../saved_state'
|
|
import utils from './utils'
|
|
import * as errors from '../errors'
|
|
import type { Browser, BrowserInstance } from './types'
|
|
import type { BrowserWindow, WebContents } from 'electron'
|
|
import type { Automation } from '../automation'
|
|
import type { BrowserLaunchOpts, Preferences, RunModeVideoApi } from '@packages/types'
|
|
|
|
// TODO: unmix these two types
|
|
type ElectronOpts = Windows.WindowOptions & BrowserLaunchOpts
|
|
|
|
const debug = Debug('cypress:server:browsers:electron')
|
|
const debugVerbose = Debug('cypress-verbose:server:browsers:electron')
|
|
|
|
// additional events that are nice to know about to be logged
|
|
// https://electronjs.org/docs/api/browser-window#instance-events
|
|
const ELECTRON_DEBUG_EVENTS = [
|
|
'close',
|
|
'responsive',
|
|
'session-end',
|
|
'unresponsive',
|
|
]
|
|
|
|
let instance: BrowserInstance | null = null
|
|
|
|
const tryToCall = function (win, method) {
|
|
try {
|
|
if (!win.isDestroyed()) {
|
|
if (_.isString(method)) {
|
|
return win[method]()
|
|
}
|
|
|
|
return method()
|
|
}
|
|
} catch (err) {
|
|
return debug('got error calling window method:', err.stack)
|
|
}
|
|
}
|
|
|
|
const _getAutomation = async function (win, options: BrowserLaunchOpts, parent) {
|
|
async function sendCommand (method: CdpCommand, data?: object) {
|
|
return tryToCall(win, () => {
|
|
return win.webContents.debugger.sendCommand
|
|
.call(win.webContents.debugger, method, data)
|
|
})
|
|
}
|
|
|
|
const on = (eventName: CdpEvent, cb) => {
|
|
win.webContents.debugger.on('message', (event, method, params) => {
|
|
if (method === eventName) {
|
|
cb(params)
|
|
}
|
|
})
|
|
}
|
|
|
|
const sendClose = () => {
|
|
win.destroy()
|
|
}
|
|
|
|
const automation = await CdpAutomation.create(sendCommand, on, sendClose, parent, options.experimentalSessionAndOrigin)
|
|
|
|
automation.onRequest = _.wrap(automation.onRequest, async (fn, message, data) => {
|
|
switch (message) {
|
|
case 'take:screenshot': {
|
|
// after upgrading to Electron 8, CDP screenshots can hang if a screencast is not also running
|
|
// workaround: start and stop screencasts between screenshots
|
|
// @see https://github.com/cypress-io/cypress/pull/6555#issuecomment-596747134
|
|
if (!options.videoApi) {
|
|
await sendCommand('Page.startScreencast', screencastOpts())
|
|
const ret = await fn(message, data)
|
|
|
|
await sendCommand('Page.stopScreencast')
|
|
|
|
return ret
|
|
}
|
|
|
|
return fn(message, data)
|
|
}
|
|
case 'focus:browser:window': {
|
|
win.show()
|
|
|
|
return
|
|
}
|
|
default: {
|
|
return fn(message, data)
|
|
}
|
|
}
|
|
})
|
|
|
|
return automation
|
|
}
|
|
|
|
function _installExtensions (win: BrowserWindow, extensionPaths: string[], options) {
|
|
Windows.removeAllExtensions(win)
|
|
|
|
return Promise.all(extensionPaths.map((extensionPath) => {
|
|
try {
|
|
return Windows.installExtension(win, extensionPath)
|
|
} catch (error) {
|
|
return options.onWarning(errors.get('EXTENSION_NOT_LOADED', 'Electron', extensionPath))
|
|
}
|
|
}))
|
|
}
|
|
|
|
async function recordVideo (cdpAutomation: CdpAutomation, videoApi: RunModeVideoApi) {
|
|
const { writeVideoFrame } = await videoApi.useFfmpegVideoController()
|
|
|
|
await cdpAutomation.startVideoRecording(writeVideoFrame)
|
|
}
|
|
|
|
export = {
|
|
_defaultOptions (projectRoot: string | undefined, state: Preferences, options: BrowserLaunchOpts, automation: Automation): ElectronOpts {
|
|
const _this = this
|
|
|
|
const defaults: Windows.WindowOptions = {
|
|
x: state.browserX || undefined,
|
|
y: state.browserY || undefined,
|
|
width: state.browserWidth || 1280,
|
|
height: state.browserHeight || 720,
|
|
minWidth: 100,
|
|
minHeight: 100,
|
|
devTools: state.isBrowserDevToolsOpen || undefined,
|
|
contextMenu: true,
|
|
partition: this._getPartition(options),
|
|
trackState: {
|
|
width: 'browserWidth',
|
|
height: 'browserHeight',
|
|
x: 'browserX',
|
|
y: 'browserY',
|
|
devTools: 'isBrowserDevToolsOpen',
|
|
},
|
|
webPreferences: {
|
|
sandbox: true,
|
|
},
|
|
show: !options.browser.isHeadless,
|
|
// prevents a tiny 1px padding around the window
|
|
// causing screenshots/videos to be off by 1px
|
|
resizable: !options.browser.isHeadless,
|
|
onCrashed () {
|
|
const err = errors.get('RENDERER_CRASHED')
|
|
|
|
errors.log(err)
|
|
|
|
if (!options.onError) throw new Error('Missing onError in onCrashed')
|
|
|
|
options.onError(err)
|
|
},
|
|
onFocus () {
|
|
if (!options.browser.isHeadless) {
|
|
return menu.set({ withInternalDevTools: true })
|
|
}
|
|
},
|
|
async onNewWindow (this: BrowserWindow, e, url) {
|
|
const _win = this
|
|
|
|
const child = await _this._launchChild(e, url, _win, projectRoot, state, options, automation)
|
|
|
|
// close child on parent close
|
|
_win.on('close', () => {
|
|
if (!child.isDestroyed()) {
|
|
child.destroy()
|
|
}
|
|
})
|
|
|
|
// add this pid to list of pids
|
|
tryToCall(child, () => {
|
|
if (instance && instance.pid) {
|
|
if (!instance.allPids) throw new Error('Missing allPids!')
|
|
|
|
instance.allPids.push(child.webContents.getOSProcessId())
|
|
}
|
|
})
|
|
},
|
|
}
|
|
|
|
return _.defaultsDeep({}, options, defaults)
|
|
},
|
|
|
|
_getAutomation,
|
|
|
|
async _render (url: string, automation: Automation, preferences, options: ElectronOpts) {
|
|
const win = Windows.create(options.projectRoot, preferences)
|
|
|
|
if (preferences.browser.isHeadless) {
|
|
// seemingly the only way to force headless to a certain screen size
|
|
// electron BrowserWindow constructor is not respecting width/height preferences
|
|
win.setSize(preferences.width, preferences.height)
|
|
} else if (options.isTextTerminal) {
|
|
// we maximize in headed mode as long as it's run mode
|
|
// this is consistent with chrome+firefox headed
|
|
win.maximize()
|
|
}
|
|
|
|
const launched = await this._launch(win, url, automation, preferences, options.videoApi)
|
|
|
|
automation.use(await _getAutomation(win, preferences, automation))
|
|
|
|
return launched
|
|
},
|
|
|
|
_launchChild (e, url, parent, projectRoot, state, options, automation) {
|
|
e.preventDefault()
|
|
|
|
const [parentX, parentY] = parent.getPosition()
|
|
|
|
const electronOptions = this._defaultOptions(projectRoot, state, options, automation)
|
|
|
|
_.extend(electronOptions, {
|
|
x: parentX + 100,
|
|
y: parentY + 100,
|
|
trackState: false,
|
|
// in run mode, force new windows to automatically open with show: false
|
|
// this prevents window.open inside of javascript client code to cause a new BrowserWindow instance to open
|
|
// https://github.com/cypress-io/cypress/issues/123
|
|
show: !options.isTextTerminal,
|
|
})
|
|
|
|
const win = Windows.create(projectRoot, electronOptions)
|
|
|
|
// needed by electron since we prevented default and are creating
|
|
// our own BrowserWindow (https://electron.atom.io/docs/api/web-contents/#event-new-window)
|
|
e.newGuest = win
|
|
|
|
return this._launch(win, url, automation, electronOptions)
|
|
},
|
|
|
|
async _launch (win: BrowserWindow, url: string, automation: Automation, options: ElectronOpts, videoApi?: RunModeVideoApi) {
|
|
if (options.show) {
|
|
menu.set({ withInternalDevTools: true })
|
|
}
|
|
|
|
ELECTRON_DEBUG_EVENTS.forEach((e) => {
|
|
// @ts-expect-error mapping strings to event names is failing typecheck
|
|
win.on(e, () => {
|
|
debug('%s fired on the BrowserWindow %o', e, { browserWindowUrl: url })
|
|
})
|
|
})
|
|
|
|
this._attachDebugger(win.webContents)
|
|
|
|
const ua = options.userAgent
|
|
|
|
if (ua) {
|
|
this._setUserAgent(win.webContents, ua)
|
|
// @see https://github.com/cypress-io/cypress/issues/22953
|
|
} else if (options.experimentalModifyObstructiveThirdPartyCode) {
|
|
const userAgent = this._getUserAgent(win.webContents)
|
|
// replace any obstructive electron user agents that contain electron or cypress references to appear more chrome-like
|
|
const modifiedNonObstructiveUserAgent = userAgent.replace(/Cypress.*?\s|[Ee]lectron.*?\s/g, '')
|
|
|
|
this._setUserAgent(win.webContents, modifiedNonObstructiveUserAgent)
|
|
}
|
|
|
|
const setProxy = () => {
|
|
let ps
|
|
|
|
ps = options.proxyServer
|
|
|
|
if (ps) {
|
|
return this._setProxy(win.webContents, ps)
|
|
}
|
|
}
|
|
|
|
await Promise.all([
|
|
setProxy(),
|
|
this._clearCache(win.webContents),
|
|
])
|
|
|
|
await win.loadURL('about:blank')
|
|
const cdpAutomation = await this._getAutomation(win, options, automation)
|
|
|
|
automation.use(cdpAutomation)
|
|
|
|
await Promise.all([
|
|
videoApi && recordVideo(cdpAutomation, videoApi),
|
|
this._handleDownloads(win, options.downloadsFolder, automation),
|
|
])
|
|
|
|
// enabling can only happen once the window has loaded
|
|
await this._enableDebugger(win.webContents)
|
|
|
|
await win.loadURL(url)
|
|
if (options.experimentalSessionAndOrigin) {
|
|
this._listenToOnBeforeHeaders(win)
|
|
}
|
|
|
|
return win
|
|
},
|
|
|
|
_attachDebugger (webContents) {
|
|
try {
|
|
webContents.debugger.attach('1.3')
|
|
debug('debugger attached')
|
|
} catch (err) {
|
|
debug('debugger attached failed %o', { err })
|
|
throw err
|
|
}
|
|
|
|
const originalSendCommand = webContents.debugger.sendCommand
|
|
|
|
webContents.debugger.sendCommand = async function (message, data) {
|
|
debugVerbose('debugger: sending %s with params %o', message, data)
|
|
|
|
try {
|
|
const res = await originalSendCommand.call(webContents.debugger, message, data)
|
|
|
|
let debugRes = res
|
|
|
|
if (debug.enabled && (_.get(debugRes, 'data.length') > 100)) {
|
|
debugRes = _.clone(debugRes)
|
|
debugRes.data = `${debugRes.data.slice(0, 100)} [truncated]`
|
|
}
|
|
|
|
debugVerbose('debugger: received response to %s: %o', message, debugRes)
|
|
|
|
return res
|
|
} catch (err) {
|
|
debug('debugger: received error on %s: %o', message, err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
webContents.debugger.sendCommand('Browser.getVersion')
|
|
|
|
webContents.debugger.on('detach', (event, reason) => {
|
|
debug('debugger detached due to %o', { reason })
|
|
})
|
|
|
|
webContents.debugger.on('message', (event, method, params) => {
|
|
if (method === 'Console.messageAdded') {
|
|
debug('console message: %o', params.message)
|
|
}
|
|
})
|
|
},
|
|
|
|
_enableDebugger (webContents: WebContents) {
|
|
debug('debugger: enable Console and Network')
|
|
|
|
return webContents.debugger.sendCommand('Console.enable')
|
|
},
|
|
|
|
_handleDownloads (win, dir, automation) {
|
|
const onWillDownload = (event, downloadItem) => {
|
|
const savePath = path.join(dir, downloadItem.getFilename())
|
|
|
|
automation.push('create:download', {
|
|
id: downloadItem.getETag(),
|
|
filePath: savePath,
|
|
mime: downloadItem.getMimeType(),
|
|
url: downloadItem.getURL(),
|
|
})
|
|
|
|
downloadItem.once('done', () => {
|
|
automation.push('complete:download', {
|
|
id: downloadItem.getETag(),
|
|
})
|
|
})
|
|
}
|
|
|
|
const { session } = win.webContents
|
|
|
|
session.on('will-download', onWillDownload)
|
|
|
|
// avoid adding redundant `will-download` handlers if session is reused for next spec
|
|
win.on('closed', () => session.removeListener('will-download', onWillDownload))
|
|
|
|
return win.webContents.debugger.sendCommand('Page.setDownloadBehavior', {
|
|
behavior: 'allow',
|
|
downloadPath: dir,
|
|
})
|
|
},
|
|
|
|
_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,
|
|
...(details.resourceType === 'xhr') ? {
|
|
'X-Cypress-Request': '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
|
|
// to enable parallelization
|
|
return `persist:run-${process.pid}`
|
|
}
|
|
|
|
// we're in interactive mode and always
|
|
// use the same session
|
|
return 'persist:interactive'
|
|
},
|
|
|
|
_clearCache (webContents) {
|
|
debug('clearing cache')
|
|
|
|
return webContents.session.clearCache()
|
|
},
|
|
|
|
_getUserAgent (webContents) {
|
|
const userAgent = webContents.session.getUserAgent()
|
|
|
|
debug('found user agent: %s', userAgent)
|
|
|
|
return userAgent
|
|
},
|
|
|
|
_setUserAgent (webContents, userAgent) {
|
|
debug('setting user agent to:', userAgent)
|
|
// set both because why not
|
|
webContents.userAgent = userAgent
|
|
|
|
// In addition to the session, also set the user-agent optimistically through CDP. @see https://github.com/cypress-io/cypress/issues/23597
|
|
webContents.debugger.sendCommand('Network.setUserAgentOverride', {
|
|
userAgent,
|
|
})
|
|
|
|
return webContents.session.setUserAgent(userAgent)
|
|
},
|
|
|
|
_setProxy (webContents, proxyServer) {
|
|
return webContents.session.setProxy({
|
|
proxyRules: proxyServer,
|
|
// this should really only be necessary when
|
|
// running Chromium versions >= 72
|
|
// https://github.com/cypress-io/cypress/issues/1872
|
|
proxyBypassRules: '<-loopback>',
|
|
})
|
|
},
|
|
|
|
async connectToNewSpec (browser: Browser, options: ElectronOpts, automation: Automation) {
|
|
if (!options.url) throw new Error('Missing url in connectToNewSpec')
|
|
|
|
await this.open(browser, options.url, options, automation)
|
|
},
|
|
|
|
connectToExisting () {
|
|
throw new Error('Attempting to connect to existing browser for Cypress in Cypress which is not yet implemented for electron')
|
|
},
|
|
|
|
async open (browser: Browser, url: string, options: BrowserLaunchOpts, automation: Automation) {
|
|
debug('open %o', { browser, url })
|
|
|
|
const State = await savedState.create(options.projectRoot, options.isTextTerminal)
|
|
const state = await State.get()
|
|
|
|
debug('received saved state %o', state)
|
|
|
|
// get our electron default options
|
|
const electronOptions: ElectronOpts = Windows.defaults(
|
|
this._defaultOptions(options.projectRoot, state, options, automation),
|
|
)
|
|
|
|
debug('browser window options %o', _.omitBy(electronOptions, _.isFunction))
|
|
|
|
const defaultLaunchOptions = utils.getDefaultLaunchOptions({
|
|
preferences: electronOptions,
|
|
})
|
|
|
|
const launchOptions = await utils.executeBeforeBrowserLaunch(browser, defaultLaunchOptions, electronOptions)
|
|
|
|
const { preferences } = launchOptions
|
|
|
|
debug('launching browser window to url: %s', url)
|
|
|
|
const win = await this._render(url, automation, preferences, electronOptions)
|
|
|
|
await _installExtensions(win, launchOptions.extensions, electronOptions)
|
|
|
|
// cause the webview to receive focus so that
|
|
// native browser focus + blur events fire correctly
|
|
// https://github.com/cypress-io/cypress/issues/1939
|
|
tryToCall(win, 'focusOnWebView')
|
|
|
|
const events = new EE()
|
|
|
|
win.once('closed', () => {
|
|
debug('closed event fired')
|
|
|
|
Windows.removeAllExtensions(win)
|
|
|
|
return events.emit('exit')
|
|
})
|
|
|
|
const mainPid: number = tryToCall(win, () => {
|
|
return win.webContents.getOSProcessId()
|
|
})
|
|
|
|
instance = _.extend(events, {
|
|
pid: mainPid,
|
|
allPids: [mainPid],
|
|
browserWindow: win,
|
|
kill (this: BrowserInstance) {
|
|
if (this.isProcessExit) {
|
|
// if the process is exiting, all BrowserWindows will be destroyed anyways
|
|
return
|
|
}
|
|
|
|
return tryToCall(win, 'destroy')
|
|
},
|
|
removeAllListeners () {
|
|
return tryToCall(win, 'removeAllListeners')
|
|
},
|
|
}) as BrowserInstance
|
|
|
|
return instance
|
|
},
|
|
}
|