@revoloo/cypress6
Version:
Cypress.io end to end testing tool
1,665 lines (1,369 loc) • 45.9 kB
JavaScript
/* eslint-disable no-console, @cypress/dev/arrow-body-multiline-braces */
const _ = require('lodash')
const { app } = require('electron')
const la = require('lazy-ass')
const pkg = require('@packages/root')
const path = require('path')
const chalk = require('chalk')
const human = require('human-interval')
const debug = require('debug')('cypress:server:run')
const Promise = require('bluebird')
const logSymbols = require('log-symbols')
const recordMode = require('./record')
const errors = require('../errors')
const { ProjectBase } = require('../project-base')
const Reporter = require('../reporter')
const browserUtils = require('../browsers')
const openProject = require('../open_project')
const videoCapture = require('../video_capture')
const { fs } = require('../util/fs')
const runEvents = require('../plugins/run_events')
const env = require('../util/env')
const trash = require('../util/trash')
const random = require('../util/random')
const system = require('../util/system')
const duration = require('../util/duration')
const newlines = require('../util/newlines')
const terminal = require('../util/terminal')
const specsUtil = require('../util/specs')
const humanTime = require('../util/human_time')
const settings = require('../util/settings')
const chromePolicyCheck = require('../util/chrome_policy_check')
const experiments = require('../experiments')
const objUtils = require('../util/obj_utils')
const DELAY_TO_LET_VIDEO_FINISH_MS = 1000
const color = (val, c) => {
return chalk[c](val)
}
const gray = (val) => {
return color(val, 'gray')
}
const colorIf = function (val, c) {
if (val === 0 || val == null) {
val = '-'
c = 'gray'
}
return color(val, c)
}
const getSymbol = function (num) {
if (num) {
return logSymbols.error
}
return logSymbols.success
}
const getWidth = (table, index) => {
// get the true width of a table's column,
// based off of calculated table options for that column
const columnWidth = table.options.colWidths[index]
if (columnWidth) {
return columnWidth - (table.options.style['padding-left'] + table.options.style['padding-right'])
}
}
const formatBrowser = (browser) => {
// TODO: finish browser
return _.compact([
browser.displayName,
browser.majorVersion,
browser.isHeadless && gray('(headless)'),
]).join(' ')
}
const formatFooterSummary = (results) => {
const { totalFailed, runs } = results
const isCanceled = _.some(results.runs, { skippedSpec: true })
// pass or fail color
const c = isCanceled ? 'magenta' : totalFailed ? 'red' : 'green'
const phrase = (() => {
if (isCanceled) {
return 'The run was canceled'
}
// if we have any specs failing...
if (!totalFailed) {
return 'All specs passed!'
}
// number of specs
const total = runs.length
const failingRuns = _.filter(runs, 'stats.failures').length
const percent = Math.round((failingRuns / total) * 100)
return `${failingRuns} of ${total} failed (${percent}%)`
})()
return [
isCanceled ? '-' : formatSymbolSummary(totalFailed),
color(phrase, c),
gray(duration.format(results.totalDuration)),
colorIf(results.totalTests, 'reset'),
colorIf(results.totalPassed, 'green'),
colorIf(totalFailed, 'red'),
colorIf(results.totalPending, 'cyan'),
colorIf(results.totalSkipped, 'blue'),
]
}
const formatSymbolSummary = (failures) => {
return getSymbol(failures)
}
const formatPath = (name, n, colour = 'reset') => {
if (!name) return ''
const fakeCwdPath = env.get('FAKE_CWD_PATH')
if (fakeCwdPath && env.get('CYPRESS_INTERNAL_ENV') === 'test') {
// if we're testing within Cypress, we want to strip out
// the current working directory before calculating the stdout tables
// this will keep our snapshots consistent everytime we run
const cwdPath = process.cwd()
name = name
.split(cwdPath)
.join(fakeCwdPath)
}
// add newLines at each n char and colorize the path
if (n) {
let nameWithNewLines = newlines.addNewlineAtEveryNChar(name, n)
return `${color(nameWithNewLines, colour)}`
}
return `${color(name, colour)}`
}
const formatNodeVersion = ({ resolvedNodeVersion, resolvedNodePath }, width) => {
debug('formatting Node version. %o', { version: resolvedNodeVersion, path: resolvedNodePath })
if (resolvedNodePath) {
return formatPath(`v${resolvedNodeVersion} (${resolvedNodePath})`, width)
}
}
const formatRecordParams = function (runUrl, parallel, group, tag) {
if (runUrl) {
if (!group) {
group = false
}
if (!tag) {
tag = false
}
return `Tag: ${tag}, Group: ${group}, Parallel: ${Boolean(parallel)}`
}
}
const displayRunStarting = function (options = {}) {
const { browser, config, group, parallel, runUrl, specPattern, specs, tag } = options
console.log('')
terminal.divider('=')
console.log('')
terminal.header('Run Starting', {
color: ['reset'],
})
console.log('')
const experimental = experiments.getExperimentsFromResolved(config.resolved)
const enabledExperiments = _.pickBy(experimental, _.property('enabled'))
const hasExperiments = !_.isEmpty(enabledExperiments)
// if we show Node Version, then increase 1st column width
// to include wider 'Node Version:'.
// Without Node version, need to account for possible "Experiments" label
const colWidths = config.resolvedNodePath ? [16, 84] : (
hasExperiments ? [14, 86] : [12, 88]
)
const table = terminal.table({
colWidths,
type: 'outsideBorder',
})
const formatSpecPattern = () => {
// foo.spec.js, bar.spec.js, baz.spec.js
// also inserts newlines at col width
if (specPattern) {
return formatPath(specPattern.join(', '), getWidth(table, 1))
}
}
const formatSpecs = (specs) => {
// 25 found: (foo.spec.js, bar.spec.js, baz.spec.js)
const names = _.map(specs, 'name')
const specsTruncated = _.truncate(names.join(', '), { length: 250 })
const stringifiedSpecs = [
`${names.length} found `,
'(',
specsTruncated,
')',
]
.join('')
return formatPath(stringifiedSpecs, getWidth(table, 1))
}
const data = _
.chain([
[gray('Cypress:'), pkg.version],
[gray('Browser:'), formatBrowser(browser)],
[gray('Node Version:'), formatNodeVersion(config, getWidth(table, 1))],
[gray('Specs:'), formatSpecs(specs)],
[gray('Searched:'), formatSpecPattern(specPattern)],
[gray('Params:'), formatRecordParams(runUrl, parallel, group, tag)],
[gray('Run URL:'), runUrl ? formatPath(runUrl, getWidth(table, 1)) : ''],
[gray('Experiments:'), hasExperiments ? experiments.formatExperiments(enabledExperiments) : ''],
])
.filter(_.property(1))
.value()
table.push(...data)
const heading = table.toString()
console.log(heading)
console.log('')
return heading
}
const displaySpecHeader = function (name, curr, total, estimated) {
console.log('')
const PADDING = 2
const table = terminal.table({
colWidths: [10, 70, 20],
colAligns: ['left', 'left', 'right'],
type: 'pageDivider',
style: {
'padding-left': PADDING,
'padding-right': 0,
},
})
table.push(['', ''])
table.push([
'Running:',
`${formatPath(name, getWidth(table, 1), 'gray')}`,
gray(`(${curr} of ${total})`),
])
console.log(table.toString())
if (estimated) {
const estimatedLabel = `${' '.repeat(PADDING)}Estimated:`
return console.log(estimatedLabel, gray(humanTime.long(estimated)))
}
}
const collectTestResults = (obj = {}, estimated) => {
return {
name: _.get(obj, 'spec.name'),
tests: _.get(obj, 'stats.tests'),
passes: _.get(obj, 'stats.passes'),
pending: _.get(obj, 'stats.pending'),
failures: _.get(obj, 'stats.failures'),
skipped: _.get(obj, 'stats.skipped'),
duration: humanTime.long(_.get(obj, 'stats.wallClockDuration')),
estimated: estimated && humanTime.long(estimated),
screenshots: obj.screenshots && obj.screenshots.length,
video: Boolean(obj.video),
}
}
const renderSummaryTable = (runUrl) => {
return function (results) {
const { runs } = results
console.log('')
terminal.divider('=')
console.log('')
terminal.header('Run Finished', {
color: ['reset'],
})
if (runs && runs.length) {
const colAligns = ['left', 'left', 'right', 'right', 'right', 'right', 'right', 'right']
const colWidths = [3, 41, 11, 9, 9, 9, 9, 9]
const table1 = terminal.table({
colAligns,
colWidths,
type: 'noBorder',
head: [
'',
gray('Spec'),
'',
gray('Tests'),
gray('Passing'),
gray('Failing'),
gray('Pending'),
gray('Skipped'),
],
})
const table2 = terminal.table({
colAligns,
colWidths,
type: 'border',
})
const table3 = terminal.table({
colAligns,
colWidths,
type: 'noBorder',
head: formatFooterSummary(results),
})
_.each(runs, (run) => {
const { spec, stats } = run
const ms = duration.format(stats.wallClockDuration || 0)
const formattedSpec = formatPath(spec.name, getWidth(table2, 1))
if (run.skippedSpec) {
return table2.push([
'-',
formattedSpec, color('SKIPPED', 'gray'),
'-', '-', '-', '-', '-',
])
}
return table2.push([
formatSymbolSummary(stats.failures),
formattedSpec,
color(ms, 'gray'),
colorIf(stats.tests, 'reset'),
colorIf(stats.passes, 'green'),
colorIf(stats.failures, 'red'),
colorIf(stats.pending, 'cyan'),
colorIf(stats.skipped, 'blue'),
])
})
console.log('')
console.log('')
console.log(terminal.renderTables(table1, table2, table3))
console.log('')
if (runUrl) {
console.log('')
const table4 = terminal.table({
colWidths: [100],
type: 'pageDivider',
style: {
'padding-left': 2,
},
})
table4.push(['', ''])
table4.push([`Recorded Run: ${formatPath(runUrl, getWidth(table4, 0), 'gray')}`])
console.log(terminal.renderTables(table4))
console.log('')
}
}
}
}
const iterateThroughSpecs = function (options = {}) {
const { specs, runEachSpec, beforeSpecRun, afterSpecRun, config } = options
const serial = () => {
return Promise.mapSeries(specs, runEachSpec)
}
const ranSpecs = []
const parallelAndSerialWithRecord = (runs) => {
return beforeSpecRun()
.then(({ spec, claimedInstances, totalInstances, estimated, shouldFallbackToOfflineOrder }) => {
// if (!parallel) {
// // NOTE: if we receive the old API which always sends {spec: null},
// // that would instantly end the run with a 0 exit code if we act like parallel mode.
// // so instead we check length of ran specs just to make sure we have run all the specs.
// // However, this means the api can't end a run early for us without some other logic being added.
// if (shouldFallbackToOfflineOrder) {
// spec = _.without(specs, ...ranSpecs)[0]?.relative
// }
// }
// no more specs to run?
if (!spec) {
// then we're done!
return runs
}
// find the actual spec object amongst
// our specs array since the API sends us
// the relative name
spec = _.find(specs, { relative: spec })
ranSpecs.push(spec)
return runEachSpec(
spec,
claimedInstances - 1,
totalInstances,
estimated,
)
.tap((results) => {
runs.push(results)
return afterSpecRun(spec, results, config)
})
.then(() => {
// // no need to make an extra request if we know we've run all the specs
// if (!parallel && ranSpecs.length === specs.length) {
// return runs
// }
// recurse
return parallelAndSerialWithRecord(runs)
})
})
}
if (beforeSpecRun) {
// if we are running in parallel
// then ask the server for the next spec
return parallelAndSerialWithRecord([])
}
// else iterate in serial
return serial()
}
const getProjectId = Promise.method((project, id) => {
if (id == null) {
id = env.get('CYPRESS_PROJECT_ID')
}
// if we have an ID just use it
if (id) {
return id
}
return project.getProjectId()
.catch(() => {
// no id no problem
return null
})
})
const getDefaultBrowserOptsByFamily = (browser, project, writeVideoFrame, onError) => {
la(browserUtils.isBrowserFamily(browser.family), 'invalid browser family in', browser)
if (browser.name === 'electron') {
return getElectronProps(browser.isHeaded, writeVideoFrame, onError)
}
if (browser.family === 'chromium') {
return getChromeProps(writeVideoFrame)
}
if (browser.family === 'firefox') {
return getFirefoxProps(project, writeVideoFrame)
}
return {}
}
const getFirefoxProps = (project, writeVideoFrame) => {
debug('setting Firefox properties')
return _
.chain({})
.tap((props) => {
if (writeVideoFrame) {
const onScreencastFrame = (data) => {
writeVideoFrame(data)
}
project.on('capture:video:frames', onScreencastFrame)
props.onScreencastFrame = true
}
})
.value()
}
const getCdpVideoPropSetter = (writeVideoFrame) => {
if (!writeVideoFrame) {
return _.noop
}
return (props) => {
props.onScreencastFrame = (e) => {
// https://chromedevtools.github.io/devtools-protocol/tot/Page#event-screencastFrame
writeVideoFrame(Buffer.from(e.data, 'base64'))
}
}
}
const getChromeProps = (writeVideoFrame) => {
const shouldWriteVideo = Boolean(writeVideoFrame)
debug('setting Chrome properties %o', { shouldWriteVideo })
return _
.chain({})
.tap(getCdpVideoPropSetter(writeVideoFrame))
.value()
}
const getElectronProps = (isHeaded, writeVideoFrame, onError) => {
return _
.chain({
width: 1280,
height: 720,
show: isHeaded,
onCrashed () {
const err = errors.get('RENDERER_CRASHED')
errors.log(err)
onError(err)
},
onNewWindow (e, url, frameName, disposition, options) {
// 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
options.show = false
},
})
.tap(getCdpVideoPropSetter(writeVideoFrame))
.value()
}
const sumByProp = (runs, prop) => {
return _.sumBy(runs, prop) || 0
}
const getRun = (run, prop) => {
return _.get(run, prop)
}
const writeOutput = (outputPath, results) => {
return Promise.try(() => {
if (!outputPath) {
return
}
debug('saving output results %o', { outputPath })
return fs.outputJsonAsync(outputPath, results)
})
}
const onWarning = (err) => {
console.log(chalk.yellow(err.message))
}
const openProjectCreate = (projectRoot, socketId, args) => {
// now open the project to boot the server
// putting our web client app in headless mode
// - NO display server logs (via morgan)
// - YES display reporter results (via mocha reporter)
const options = {
socketId,
morgan: false,
report: true,
isTextTerminal: args.isTextTerminal,
// pass the list of browsers we have detected when opening a project
// to give user's plugins file a chance to change it
browsers: args.browsers,
onWarning,
onError: args.onError,
}
return openProject
.create(projectRoot, args, options)
.catch({ portInUse: true }, (err) => {
// TODO: this needs to move to call exitEarly
// so we record the failure in CI
return errors.throw('PORT_IN_USE_LONG', err.port)
})
}
const createAndOpenProject = function (socketId, options) {
const { projectRoot, projectId } = options
return ProjectBase
.ensureExists(projectRoot, options)
.then(() => {
// open this project without
// adding it to the global cache
return openProjectCreate(projectRoot, socketId, options)
})
.call('getProject')
.then((project) => {
return Promise.props({
project,
config: project.getConfig(),
projectId: getProjectId(project, projectId),
})
})
}
const removeOldProfiles = (browser) => {
return browserUtils.removeOldProfiles(browser)
.catch((err) => {
// dont make removing old browsers profiles break the build
return errors.warning('CANNOT_REMOVE_OLD_BROWSER_PROFILES', err.stack)
})
}
const trashAssets = Promise.method((config = {}) => {
if (config.trashAssetsBeforeRuns !== true) {
return
}
return Promise.all([
trash.folder(config.videosFolder),
trash.folder(config.screenshotsFolder),
trash.folder(config.downloadsFolder),
])
.catch((err) => {
// dont make trashing assets fail the build
return errors.warning('CANNOT_TRASH_ASSETS', err.stack)
})
})
const createVideoRecording = function (videoName, options = {}) {
const outputDir = path.dirname(videoName)
const onError = _.once((err) => {
// catch video recording failures and log them out
// but don't let this affect the run at all
return errors.warning('VIDEO_RECORDING_FAILED', err.stack)
})
return fs
.ensureDirAsync(outputDir)
.catch(onError)
.then(() => {
return videoCapture
.start(videoName, _.extend({}, options, { onError }))
})
}
const getVideoRecordingDelay = function (startedVideoCapture) {
if (startedVideoCapture) {
return DELAY_TO_LET_VIDEO_FINISH_MS
}
return 0
}
const maybeStartVideoRecording = Promise.method(function (options = {}) {
const { spec, browser, video, videosFolder } = options
debug(`video recording has been ${video ? 'enabled' : 'disabled'}. video: %s`, video)
// bail if we've been told not to capture
// a video recording
if (!video) {
return
}
// make sure we have a videosFolder
if (!videosFolder) {
throw new Error('Missing videoFolder for recording')
}
const videoPath = (suffix) => {
return path.join(videosFolder, spec.name + suffix)
}
const videoName = videoPath('.mp4')
const compressedVideoName = videoPath('-compressed.mp4')
return this.createVideoRecording(videoName, { webmInput: browser.family === 'firefox' })
.then((props = {}) => {
return {
videoName,
compressedVideoName,
endVideoCapture: props.endVideoCapture,
writeVideoFrame: props.writeVideoFrame,
startedVideoCapture: props.startedVideoCapture,
}
})
})
const warnVideoRecordingFailed = (err) => {
// log that post processing was attempted
// but failed and dont let this change the run exit code
errors.warning('VIDEO_POST_PROCESSING_FAILED', err.stack)
}
module.exports = {
collectTestResults,
getProjectId,
writeOutput,
openProjectCreate,
createVideoRecording,
getVideoRecordingDelay,
maybeStartVideoRecording,
getChromeProps,
getElectronProps,
displayRunStarting,
exitEarly (err) {
debug('set early exit error: %s', err.stack)
this.earlyExitErr = err
},
displayResults (obj = {}, estimated) {
const results = collectTestResults(obj, estimated)
const c = results.failures ? 'red' : 'green'
console.log('')
terminal.header('Results', {
color: [c],
})
const table = terminal.table({
colWidths: [14, 86],
type: 'outsideBorder',
})
const data = _.chain([
['Tests:', results.tests],
['Passing:', results.passes],
['Failing:', results.failures],
['Pending:', results.pending],
['Skipped:', results.skipped],
['Screenshots:', results.screenshots],
['Video:', results.video],
['Duration:', results.duration],
estimated ? ['Estimated:', results.estimated] : undefined,
['Spec Ran:', formatPath(results.name, getWidth(table, 1), c)],
])
.compact()
.map((arr) => {
const [key, val] = arr
return [color(key, 'gray'), color(val, c)]
})
.value()
table.push(...data)
console.log('')
console.log(table.toString())
console.log('')
},
displayScreenshots (screenshots = []) {
console.log('')
terminal.header('Screenshots', { color: ['yellow'] })
console.log('')
const table = terminal.table({
colWidths: [3, 82, 15],
colAligns: ['left', 'left', 'right'],
type: 'noBorder',
style: {
'padding-right': 0,
},
chars: {
'left': ' ',
'right': '',
},
})
screenshots.forEach((screenshot) => {
const dimensions = gray(`(${screenshot.width}x${screenshot.height})`)
table.push([
'-',
formatPath(`${screenshot.path}`, getWidth(table, 1)),
gray(dimensions),
])
})
console.log(table.toString())
console.log('')
},
async postProcessRecording (name, cname, videoCompression, shouldUploadVideo, quiet, ffmpegChaptersConfig) {
debug('ending the video recording %o', { name, videoCompression, shouldUploadVideo })
// once this ended promises resolves
// then begin processing the file
// dont process anything if videoCompress is off
// or we've been told not to upload the video
if (videoCompression === false || shouldUploadVideo === false) {
return
}
function continueProcessing (onProgress = undefined) {
return videoCapture.process(name, cname, videoCompression, ffmpegChaptersConfig, onProgress)
}
if (quiet) {
return continueProcessing()
}
console.log('')
terminal.header('Video', {
color: ['cyan'],
})
console.log('')
const table = terminal.table({
colWidths: [3, 21, 76],
colAligns: ['left', 'left', 'left'],
type: 'noBorder',
style: {
'padding-right': 0,
},
chars: {
'left': ' ',
'right': '',
},
})
table.push([
gray('-'),
gray('Started processing:'),
chalk.cyan(`Compressing to ${videoCompression} CRF`),
])
console.log(table.toString())
const started = Date.now()
let progress = Date.now()
const throttle = env.get('VIDEO_COMPRESSION_THROTTLE') || human('10 seconds')
const onProgress = function (float) {
if (float === 1) {
const finished = Date.now() - started
const dur = `(${humanTime.long(finished)})`
const table = terminal.table({
colWidths: [3, 21, 61, 15],
colAligns: ['left', 'left', 'left', 'right'],
type: 'noBorder',
style: {
'padding-right': 0,
},
chars: {
'left': ' ',
'right': '',
},
})
table.push([
gray('-'),
gray('Finished processing:'),
`${formatPath(name, getWidth(table, 2), 'cyan')}`,
gray(dur),
])
console.log(table.toString())
console.log('')
}
if (Date.now() - progress > throttle) {
// bump up the progress so we dont
// continuously get notifications
progress += throttle
const percentage = `${Math.ceil(float * 100)}%`
console.log(' Compression progress: ', chalk.cyan(percentage))
}
}
return continueProcessing(onProgress)
},
launchBrowser (options = {}) {
const { browser, spec, writeVideoFrame, setScreenshotMetadata, project, screenshots, projectRoot, onError } = options
const browserOpts = getDefaultBrowserOptsByFamily(browser, project, writeVideoFrame, onError)
browserOpts.automationMiddleware = {
onBeforeRequest (message, data) {
if (message === 'take:screenshot') {
return setScreenshotMetadata(data)
}
},
onAfterResponse: (message, data, resp) => {
if (message === 'take:screenshot' && resp) {
const existingScreenshot = _.findIndex(screenshots, { path: resp.path })
if (existingScreenshot !== -1) {
// NOTE: saving screenshots to the same path will overwrite the previous one
// so we shouldn't report more screenshots than exist on disk.
// this happens when cy.screenshot is used in a retried test
screenshots.splice(existingScreenshot, 1, this.screenshotMetadata(data, resp))
} else {
screenshots.push(this.screenshotMetadata(data, resp))
}
}
return resp
},
}
const warnings = {}
browserOpts.projectRoot = projectRoot
browserOpts.onWarning = (err) => {
const { message } = err
// if this warning has already been
// seen for this browser launch then
// suppress it
if (warnings[message]) {
return
}
warnings[message] = err
return project.onWarning
}
return openProject.launch(browser, spec, browserOpts)
},
navigateToNextSpec (spec) {
return openProject.changeUrlToSpec(spec)
},
listenForProjectEnd (project, exit) {
return new Promise((resolve, reject) => {
if (exit === false) {
resolve = () => {
console.log('not exiting due to options.exit being false')
}
}
const onEarlyExit = function (err) {
if (err.isFatalApiErr) {
return reject(err)
}
console.log('')
errors.log(err)
// probably should say we ended
// early too: (Ended Early: true)
// in the stats
const obj = {
error: errors.stripAnsi(err.message),
stats: {
failures: 1,
tests: 0,
passes: 0,
pending: 0,
suites: 0,
skipped: 0,
wallClockDuration: 0,
wallClockStartedAt: new Date().toJSON(),
wallClockEndedAt: new Date().toJSON(),
},
}
return resolve(obj)
}
const onEnd = (obj) => {
return resolve(obj)
}
// when our project fires its end event
// resolve the promise
project.once('end', onEnd)
// if we already received a reason to exit early, go ahead and do it
if (this.earlyExitErr) {
return onEarlyExit(this.earlyExitErr)
}
// otherwise override exitEarly so we exit as soon as there is a reason
this.exitEarly = (err) => {
onEarlyExit(err)
}
})
},
/**
* In CT mode, browser do not relaunch.
* In browser laucnh is where we wire the new video
* recording callback.
* This has the effect of always hitting the first specs
* video callback.
*
* This allows us, if we need to, to call a different callback
* in the same browser
*/
writeVideoFrameCallback () {
if (this.currentWriteVideoFrameCallback) {
return this.currentWriteVideoFrameCallback(...arguments)
}
},
waitForBrowserToConnect (options = {}, shouldLaunchBrowser = true) {
const { project, socketId, timeout, onError, writeVideoFrame, spec } = options
const browserTimeout = process.env.CYPRESS_INTERNAL_BROWSER_CONNECT_TIMEOUT || timeout || 60000
let attempts = 0
// short circuit current browser callback so that we
// can rewire it without relaunching the browser
this.currentWriteVideoFrameCallback = writeVideoFrame
options.writeVideoFrame = this.writeVideoFrameCallback.bind(this)
// without this the run mode is only setting new spec
// path for next spec in launch browser.
// we need it to run on every spec even in single browser mode
this.currentSetScreenshotMetadata = (data) => {
data.specName = spec.name
return data
}
options.setScreenshotMetadata = (data) => {
return this.currentSetScreenshotMetadata(data)
}
const wait = () => {
debug('waiting for socket to connect and browser to launch...')
if (!shouldLaunchBrowser) {
// If we do not launch the browser,
// we tell it that we are ready
// to receive the next spec
return this.navigateToNextSpec(options.spec)
.tap(() => {
debug('navigated to next spec')
})
}
return Promise.join(
this.waitForSocketConnection(project, socketId)
.tap(() => {
debug('socket connected', { socketId })
}),
this.launchBrowser(options)
.tap(() => {
debug('browser launched')
}),
)
.timeout(browserTimeout)
.catch(Promise.TimeoutError, (err) => {
attempts += 1
console.log('')
// always first close the open browsers
// before retrying or dieing
return openProject.closeBrowser()
.then(() => {
if (attempts === 1 || attempts === 2) {
// try again up to 3 attempts
const word = attempts === 1 ? 'Retrying...' : 'Retrying again...'
errors.warning('TESTS_DID_NOT_START_RETRYING', word)
return wait()
}
err = errors.get('TESTS_DID_NOT_START_FAILED')
errors.log(err)
onError(err)
})
})
}
return wait()
},
waitForSocketConnection (project, id) {
debug('waiting for socket connection... %o', { id })
return new Promise((resolve, reject) => {
const fn = function (socketId) {
debug('got socket connection %o', { id: socketId })
if (socketId === id) {
// remove the event listener if we've connected
project.removeListener('socket:connected', fn)
// resolve the promise
return resolve()
}
}
// when a socket connects verify this
// is the one that matches our id!
return project.on('socket:connected', fn)
})
},
waitForTestsToFinishRunning (options = {}) {
const { project, screenshots, startedVideoCapture, endVideoCapture, videoName, compressedVideoName, videoCompression, videoUploadOnPasses, exit, spec, estimated, quiet, config, runAllSpecsInSameBrowserSession } = options
// https://github.com/cypress-io/cypress/issues/2370
// delay 1 second if we're recording a video to give
// the browser padding to render the final frames
// to avoid chopping off the end of the video
const delay = this.getVideoRecordingDelay(startedVideoCapture)
return this.listenForProjectEnd(project, exit, runAllSpecsInSameBrowserSession)
.delay(delay)
.then(async (results) => {
_.defaults(results, {
error: null,
hooks: null,
tests: null,
video: null,
screenshots: null,
reporterStats: null,
})
// dashboard told us to skip this spec
const skippedSpec = results.skippedSpec
if (startedVideoCapture) {
results.video = videoName
}
if (screenshots) {
results.screenshots = screenshots
}
results.spec = spec
const { tests, stats } = results
const attempts = _.flatMap(tests, (test) => test.attempts)
// if we have a video recording
if (startedVideoCapture && tests && tests.length) {
// always set the video timestamp on tests
Reporter.setVideoTimestamp(startedVideoCapture, attempts)
}
let videoCaptureFailed = false
if (endVideoCapture) {
await endVideoCapture()
.tapCatch(() => videoCaptureFailed = true)
.catch(warnVideoRecordingFailed)
}
await runEvents.execute('after:spec', config, spec, results)
const videoExists = videoName ? await fs.pathExists(videoName) : false
if (startedVideoCapture && !videoExists) {
// the video file no longer exists at the path where we expect it,
// likely because the user deleted it in the after:spec event
errors.warning('VIDEO_DOESNT_EXIST', videoName)
results.video = null
}
const hasFailingTests = _.get(stats, 'failures') > 0
// we should upload the video if we upload on passes (by default)
// or if we have any failures and have started the video
const shouldUploadVideo = !skippedSpec && videoUploadOnPasses === true || Boolean((startedVideoCapture && hasFailingTests))
results.shouldUploadVideo = shouldUploadVideo
if (!quiet && !skippedSpec) {
this.displayResults(results, estimated)
if (screenshots && screenshots.length) {
this.displayScreenshots(screenshots)
}
}
if (!runAllSpecsInSameBrowserSession) {
// always close the browser now as opposed to letting
// it exit naturally with the parent process due to
// electron bug in windows
debug('attempting to close the browser')
await openProject.closeBrowser()
}
if (videoExists && !skippedSpec && endVideoCapture && !videoCaptureFailed) {
const ffmpegChaptersConfig = videoCapture.generateFfmpegChaptersConfig(results.tests)
await this.postProcessRecording(
videoName,
compressedVideoName,
videoCompression,
shouldUploadVideo,
quiet,
ffmpegChaptersConfig,
)
.catch(warnVideoRecordingFailed)
}
return results
})
},
screenshotMetadata (data, resp) {
return {
screenshotId: random.id(),
name: data.name || null,
testId: data.testId,
testAttemptIndex: data.testAttemptIndex,
takenAt: resp.takenAt,
path: resp.path,
height: resp.dimensions.height,
width: resp.dimensions.width,
}
},
runSpecs (options = {}) {
_.defaults(options, {
// only non-Electron browsers run headed by default
headed: options.browser.name !== 'electron',
})
const { config, browser, sys, headed, outputPath, specs, specPattern, beforeSpecRun, afterSpecRun, runUrl, parallel, group, tag, runAllSpecsInSameBrowserSession } = options
const isHeadless = !headed
browser.isHeadless = isHeadless
browser.isHeaded = !isHeadless
const results = {
startedTestsAt: null,
endedTestsAt: null,
totalDuration: null,
totalSuites: null,
totalTests: null,
totalFailed: null,
totalPassed: null,
totalPending: null,
totalSkipped: null,
runs: null,
browserPath: browser.path,
browserName: browser.name,
browserVersion: browser.version,
osName: sys.osName,
osVersion: sys.osVersion,
cypressVersion: pkg.version,
runUrl,
config,
}
if (!options.quiet) {
displayRunStarting({
config,
specs,
group,
tag,
runUrl,
browser,
parallel,
specPattern,
})
}
let firstSpec = true
const runEachSpec = (spec, index, length, estimated) => {
if (!options.quiet) {
displaySpecHeader(spec.name, index + 1, length, estimated)
}
return this.runSpec(config, spec, options, estimated, firstSpec)
.tap(() => {
firstSpec = false
})
.get('results')
.tap((results) => {
return debug('spec results %o', results)
})
}
const beforeRunDetails = {
browser,
config,
cypressVersion: pkg.version,
group,
parallel,
runUrl,
specs,
specPattern,
system: _.pick(sys, 'osName', 'osVersion'),
tag,
}
return runEvents.execute('before:run', config, beforeRunDetails)
.then(() => {
return iterateThroughSpecs({
specs,
config,
parallel,
runEachSpec,
afterSpecRun,
beforeSpecRun,
})
})
.then((runs = []) => {
results.status = 'finished'
results.startedTestsAt = getRun(_.first(runs), 'stats.wallClockStartedAt')
results.endedTestsAt = getRun(_.last(runs), 'stats.wallClockEndedAt')
results.totalDuration = sumByProp(runs, 'stats.wallClockDuration')
results.totalSuites = sumByProp(runs, 'stats.suites')
results.totalTests = sumByProp(runs, 'stats.tests')
results.totalPassed = sumByProp(runs, 'stats.passes')
results.totalPending = sumByProp(runs, 'stats.pending')
results.totalFailed = sumByProp(runs, 'stats.failures')
results.totalSkipped = sumByProp(runs, 'stats.skipped')
results.runs = runs
debug('final results of all runs: %o', results)
const { each, remapKeys, remove, renameKey, setValue } = objUtils
// Remap results for module API/after:run to remove private props and
// rename props to make more user-friendly
const moduleAPIResults = remapKeys(results, {
runs: each((run) => ({
tests: each((test) => ({
attempts: each((attempt, i) => ({
timings: remove,
failedFromHookId: remove,
wallClockDuration: renameKey('duration'),
wallClockStartedAt: renameKey('startedAt'),
wallClockEndedAt: renameKey('endedAt'),
screenshots: setValue(
_(run.screenshots)
.filter({ testId: test.testId, testAttemptIndex: i })
.map((screenshot) => _.omit(screenshot,
['screenshotId', 'testId', 'testAttemptIndex']))
.value(),
),
})),
testId: remove,
})),
hooks: each({
hookId: remove,
}),
stats: {
wallClockDuration: renameKey('duration'),
wallClockStartedAt: renameKey('startedAt'),
wallClockEndedAt: renameKey('endedAt'),
},
screenshots: remove,
})),
})
return Promise.try(() => {
return runAllSpecsInSameBrowserSession && openProject.closeBrowser()
})
.then(() => {
return runEvents.execute('after:run', config, moduleAPIResults)
})
.then(() => {
return writeOutput(outputPath, moduleAPIResults)
})
.return(results)
})
},
runSpec (config, spec = {}, options = {}, estimated, firstSpec) {
const { project, browser, onError } = options
const { isHeadless } = browser
debug('about to run spec %o', {
spec,
isHeadless,
browser,
})
if (browser.family !== 'chromium' && !options.config.chromeWebSecurity) {
console.log()
errors.warning('CHROME_WEB_SECURITY_NOT_SUPPORTED', browser.family)
}
const screenshots = []
return runEvents.execute('before:spec', config, spec)
.then(() => {
// we know we're done running headlessly
// when the renderer has connected and
// finishes running all of the tests.
// we're using an event emitter interface
// to gracefully handle this in promise land
return this.maybeStartVideoRecording({
spec,
browser,
video: options.video,
videosFolder: options.videosFolder,
})
})
.then((videoRecordProps = {}) => {
return Promise.props({
results: this.waitForTestsToFinishRunning({
spec,
config,
project,
estimated,
screenshots,
videoName: videoRecordProps.videoName,
compressedVideoName: videoRecordProps.compressedVideoName,
endVideoCapture: videoRecordProps.endVideoCapture,
startedVideoCapture: videoRecordProps.startedVideoCapture,
exit: options.exit,
videoCompression: options.videoCompression,
videoUploadOnPasses: options.videoUploadOnPasses,
quiet: options.quiet,
runAllSpecsInSameBrowserSession: options.runAllSpecsInSameBrowserSession,
}),
connection: this.waitForBrowserToConnect({
spec,
project,
browser,
screenshots,
onError,
writeVideoFrame: videoRecordProps.writeVideoFrame,
socketId: options.socketId,
webSecurity: options.webSecurity,
projectRoot: options.projectRoot,
}, !options.runAllSpecsInSameBrowserSession || firstSpec),
})
})
},
findSpecs (config, specPattern) {
return specsUtil
.find(config, specPattern)
.tap((specs = []) => {
if (debug.enabled) {
const names = _.map(specs, 'name')
return debug(
'found \'%d\' specs using spec pattern \'%s\': %o',
names.length,
specPattern,
names,
)
}
})
},
ready (options = {}) {
debug('run mode ready with options %o', options)
_.defaults(options, {
isTextTerminal: true,
browser: 'electron',
quiet: false,
})
const socketId = random.id()
const { projectRoot, record, key, ciBuildId, parallel, group, browser: browserName, tag } = options
// this needs to be a closure over `this.exitEarly` and not a reference
// because `this.exitEarly` gets overwritten in `this.listenForProjectEnd`
const onError = options.onError = (err) => {
this.exitEarly(err)
}
// alias and coerce to null
let specPattern = options.spec || null
// ensure the project exists
// and open up the project
return browserUtils.getAllBrowsersWith()
.then((browsers) => {
debug('found all system browsers %o', browsers)
options.browsers = browsers
return createAndOpenProject(socketId, options)
.then(({ project, projectId, config }) => {
debug('project created and opened with config %o', config)
// if we have a project id and a key but record hasnt been given
recordMode.warnIfProjectIdButNoRecordOption(projectId, options)
recordMode.throwIfRecordParamsWithoutRecording(record, ciBuildId, parallel, group, tag)
if (record) {
recordMode.throwIfNoProjectId(projectId, settings.configFile(options))
recordMode.throwIfIncorrectCiBuildIdUsage(ciBuildId, parallel, group)
recordMode.throwIfIndeterminateCiBuildId(ciBuildId, parallel, group)
}
// user code might have modified list of allowed browsers
// but be defensive about it
const userBrowsers = _.get(config, 'resolved.browsers.value', browsers)
// all these operations are independent and should be run in parallel to
// speed the initial booting time
return Promise.all([
system.info(),
browserUtils.ensureAndGetByNameOrPath(browserName, false, userBrowsers).tap(removeOldProfiles),
this.findSpecs(config, specPattern),
trashAssets(config),
])
.spread((sys = {}, browser = {}, specs = []) => {
// return only what is return to the specPattern
if (specPattern) {
specPattern = specsUtil.getPatternRelativeToProjectRoot(specPattern, projectRoot)
}
if (!specs.length) {
// did we use the spec pattern?
if (specPattern) {
errors.throw('NO_SPECS_FOUND', projectRoot, specPattern)
} else {
// else we looked in the integration folder
errors.throw('NO_SPECS_FOUND', config.integrationFolder, specPattern)
}
}
if (browser.family === 'chromium') {
chromePolicyCheck.run(onWarning)
}
if (options.componentTesting) {
specs = specs.filter((spec) => {
return spec.specType === 'component'
})
}
const runAllSpecs = ({ beforeSpecRun, afterSpecRun, runUrl, parallel }) => {
return this.runSpecs({
beforeSpecRun,
afterSpecRun,
projectRoot,
specPattern,
socketId,
parallel,
onError,
browser,
project,
runUrl,
group,
config,
specs,
sys,
tag,
videosFolder: config.videosFolder,
video: config.video,
videoCompression: config.videoCompression,
videoUploadOnPasses: config.videoUploadOnPasses,
exit: options.exit,
headed: options.headed,
quiet: options.quiet,
outputPath: options.outputPath,
runAllSpecsInSameBrowserSession: options.runAllSpecsInSameBrowserSession,
})
.tap((runSpecs) => {
if (!options.quiet) {
renderSummaryTable(runUrl)(runSpecs)
}
})
}
if (record) {
const { projectName } = config
return recordMode.createRunAndRecordSpecs({
tag,
key,
sys,
specs,
group,
config,
browser,
parallel,
ciBuildId,
project,
projectId,
projectRoot,
projectName,
specPattern,
runAllSpecs,
onError,
})
}
// not recording, can't be parallel
return runAllSpecs({
parallel: false,
})
})
})
})
},
async run (options) {
// electron >= 5.0.0 will exit the app if all browserwindows are closed,
// this is obviously undesirable in run mode
// https://github.com/cypress-io/cypress/pull/4720#issuecomment-514316695
app.on('window-all-closed', () => {
debug('all BrowserWindows closed, not exiting')
})
await app.whenReady()
return this.ready(options)
},
}