@revoloo/cypress6
Version:
Cypress.io end to end testing tool
421 lines (336 loc) • 11.4 kB
JavaScript
const _ = require('lodash')
const EE = require('events')
const path = require('path')
const Bluebird = require('bluebird')
const debug = require('debug')('cypress:server:browsers:electron')
const menu = require('../gui/menu')
const Windows = require('../gui/windows')
const { CdpAutomation } = require('./cdp_automation')
const savedState = require('../saved_state')
const utils = require('./utils')
const errors = require('../errors')
// 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 = 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 = function (win, options) {
const sendCommand = Bluebird.method((...args) => {
return tryToCall(win, () => {
return win.webContents.debugger.sendCommand
.apply(win.webContents.debugger, args)
})
})
const automation = CdpAutomation(sendCommand)
if (!options.onScreencastFrame) {
// 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
automation.onRequest = _.wrap(automation.onRequest, async (fn, message, data) => {
if (message !== 'take:screenshot') {
return fn(message, data)
}
await sendCommand('Page.startScreencast')
const ret = await fn(message, data)
await sendCommand('Page.stopScreencast')
return ret
})
}
return automation
}
const _installExtensions = function (win, extensionPaths = [], options) {
Windows.removeAllExtensions(win)
return Bluebird.map(extensionPaths, (extensionPath) => {
try {
return Windows.installExtension(win, extensionPath)
} catch (error) {
return options.onWarning(errors.get('EXTENSION_NOT_LOADED', 'Electron', extensionPath))
}
})
}
const _maybeRecordVideo = function (webContents, options) {
return async () => {
const { onScreencastFrame } = options
debug('maybe recording video %o', { onScreencastFrame })
if (!onScreencastFrame) {
return
}
webContents.debugger.on('message', (event, method, params) => {
if (method === 'Page.screencastFrame') {
onScreencastFrame(params)
webContents.debugger.sendCommand('Page.screencastFrameAck', { sessionId: params.sessionId })
}
})
await webContents.debugger.sendCommand('Page.startScreencast', {
format: 'jpeg',
})
}
}
module.exports = {
_defaultOptions (projectRoot, state, options, automation) {
const _this = this
const defaults = {
x: state.browserX,
y: state.browserY,
width: state.browserWidth || 1280,
height: state.browserHeight || 720,
devTools: state.isBrowserDevToolsOpen,
minWidth: 100,
minHeight: 100,
contextMenu: true,
partition: this._getPartition(options),
trackState: {
width: 'browserWidth',
height: 'browserHeight',
x: 'browserX',
y: 'browserY',
devTools: 'isBrowserDevToolsOpen',
},
webPreferences: {
sandbox: true,
},
onFocus () {
if (options.show) {
return menu.set({ withDevTools: true })
}
},
onNewWindow (e, url) {
const _win = this
return _this._launchChild(e, url, _win, projectRoot, state, options, automation)
.then((child) => {
// 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) {
instance.pid.push(child.webContents.getOSProcessId())
}
})
})
},
}
return _.defaultsDeep({}, options, defaults)
},
_getAutomation,
_render (url, projectRoot, automation, options = {}) {
const win = Windows.create(projectRoot, options)
automation.use(_getAutomation(win, options))
return this._launch(win, url, automation, options)
.tap(_maybeRecordVideo(win.webContents, options))
},
_launchChild (e, url, parent, projectRoot, state, options, automation) {
e.preventDefault()
const [parentX, parentY] = parent.getPosition()
options = this._defaultOptions(projectRoot, state, options)
_.extend(options, {
x: parentX + 100,
y: parentY + 100,
trackState: false,
})
const win = Windows.create(projectRoot, options)
// 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, options)
},
_launch (win, url, automation, options) {
if (options.show) {
menu.set({ withDevTools: true })
}
ELECTRON_DEBUG_EVENTS.forEach((e) => {
win.on(e, () => {
debug('%s fired on the BrowserWindow %o', e, { browserWindowUrl: url })
})
})
return Bluebird.try(() => {
return this._attachDebugger(win.webContents)
})
.then(() => {
let ua
ua = options.userAgent
if (ua) {
this._setUserAgent(win.webContents, ua)
}
const setProxy = () => {
let ps
ps = options.proxyServer
if (ps) {
return this._setProxy(win.webContents, ps)
}
}
return Bluebird.join(
setProxy(),
this._clearCache(win.webContents),
)
})
.then(() => {
return win.loadURL(url)
})
.then(() => {
// enabling can only happen once the window has loaded
return this._enableDebugger(win.webContents)
})
.then(() => {
return this._handleDownloads(win.webContents, options.downloadsFolder, automation)
})
.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 = function (message, data) {
debug('debugger: sending %s with params %o', message, data)
return originalSendCommand.call(webContents.debugger, message, data)
.then((res) => {
let debugRes = res
if (debug.enabled && (_.get(debugRes, 'data.length') > 100)) {
debugRes = _.clone(debugRes)
debugRes.data = `${debugRes.data.slice(0, 100)} [truncated]`
}
debug('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) {
debug('debugger: enable Console and Network')
return webContents.debugger.sendCommand('Console.enable')
},
_handleDownloads (webContents, dir, automation) {
webContents.session.on('will-download', (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(),
})
})
})
return webContents.debugger.sendCommand('Page.setDownloadBehavior', {
behavior: 'allow',
downloadPath: dir,
})
},
_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()
},
_setUserAgent (webContents, userAgent) {
debug('setting user agent to:', userAgent)
// set both because why not
webContents.userAgent = 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>',
})
},
open (browser, url, options = {}, automation) {
const { projectRoot, isTextTerminal } = options
debug('open %o', { browser, url })
return savedState.create(projectRoot, isTextTerminal)
.then((state) => {
return state.get()
}).then((state) => {
debug('received saved state %o', state)
// get our electron default options
// TODO: this is bad, don't mutate the options object
options = this._defaultOptions(projectRoot, state, options, automation)
// get the GUI window defaults now
options = Windows.defaults(options)
debug('browser window options %o', _.omitBy(options, _.isFunction))
const defaultLaunchOptions = utils.getDefaultLaunchOptions({
preferences: options,
})
return utils.executeBeforeBrowserLaunch(browser, defaultLaunchOptions, options)
}).then((launchOptions) => {
const { preferences } = launchOptions
debug('launching browser window to url: %s', url)
return this._render(url, projectRoot, automation, preferences)
.then(async (win) => {
await _installExtensions(win, launchOptions.extensions, options)
// 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')
})
instance = _.extend(events, {
pid: [tryToCall(win, () => {
return win.webContents.getOSProcessId()
})],
browserWindow: win,
kill () {
return tryToCall(win, 'destroy')
},
removeAllListeners () {
return tryToCall(win, 'removeAllListeners')
},
})
return instance
})
})
},
}