mirror of
https://github.com/cypress-io/cypress.git
synced 2026-05-03 05:20:38 -05:00
201e9f366e
* chore: set up feature/test-burn-in feature branch * feat: add burnIn Configuration option (currently a no-op) (#27377) * feat: add the burnIn Configuration to the config package. Option currently is a no-op * chore: make burn in experimental * chore: set experimentalBurnIn to false by default * feat: add new experimental retries configuration (#27412) * feat: implement the experimental retries configuration options to pair with test burn in * [run ci] * fix cache invalidation [run ci] * fix snapshot added in v13 for module api to include test burn in experimentalflag * chore: fix merge conflict * chore: add burnInTestAction capability (#27768) * add burnInTestAction capability * feat: add burn in capability for cloud * chore: fix snapshot for record_spec * feat: implement experimental retries (#27826) * chore: format the retries/runner snapshot files to make diff easier * feat: implement experimentalRetries strategies 'detect-flake-and-pass-on-threshold' and 'detect-flake-but-always-fail'. This should not be a breaking change, though it does modify mocha and the test object even when the experiment is not configured. This is to exercise the system and make sure things still work as expected even when we go GA. Test updates will follow in following commits. * chore: update snapshots from system tests and cy-in-cy tests that now have the cypress test metadata property _cypressTestStatusInfo. tests have been added in the fail-with-[before|after]each specs to visually see the suite being skipped when developing. * chore: add cy-in-cy tests to verify reporter behavior for pass/fail tests, as well as new mocha snapshots to verify attempts. New tests were needed for this as the 'retries' option in testConfigOverrides currently is and will be invalid for experiment and will function as an override. tests run in the cy-in-cy tests are using globally configured experimentalRetries for the given tested project, which showcases the different behavior between attempts/retries and pass/fail status. * chore: add unit test like driver test to verify the test object in mocha is decorated/handled properly in calculateTestStatus * chore: add sanity system tests to verify console reporter output for experimental retries logic. Currently there is a bug in the reporter where the logged status doesnt wait for the aftereach to complete, which impacts the total exitCode and printed status. * fix: aftereach console output. make sure to fail the test in the appropriate spot in runner.ts and not prematurely, which in turn updates the snapshots for cy-in-cy as the fail event comes later." * chore: address comments from code review * fix: make sure hook failures print outer status + attempts when the error is the hook itself. * chore: improve types within calculateTestStatus inside mocha.ts * Revert "feat: add burnIn Configuration option (currently a no-op) (#27377)" This reverts commitc428443079. * Revert "chore: add burnInTestAction capability (#27768)" This reverts commitae3df1a505. * chore: run snapshot and binary jobs against experimental retries feature branch * chore: add changelog entry (wip) * Revert "fix snapshot added in v13 for module api to include test burn in experimentalflag" This reverts commitbb5046c91e. * Fix system tests * Clear CircleCI cache * Normalize retries config for test execution * Fixed some unit tests * update snapshots for newer test metadata * Fix cy-in-cy snapshots * update snapshots * bump cache version * chore: ensure legacy retry overrides work; reject exp. retries overrides (#28045) * update changelog * flip if statement in experimental retries option validation * refactor invalid experimental retry override for more useful error msg * revert testConfigOverrides snapshot * update snapshots for test override sys test * Update packages/config/src/validation.ts Co-authored-by: Chris Breiding <chrisbreiding@users.noreply.github.com> * succinct changelog entry; links to docs for details * testConfigOverride system test snapshots * Update .github/workflows/update_v8_snapshot_cache.yml Co-authored-by: Ryan Manuel <ryanm@cypress.io> * Update cli/CHANGELOG.md Co-authored-by: Ryan Manuel <ryanm@cypress.io> * Update packages/driver/src/cypress.ts Co-authored-by: Ryan Manuel <ryanm@cypress.io> * updating cache-version * improve typescript usage when appending experimental retry options to experiments in Experimenets.vue * Revert "improve typescript usage when appending experimental retry options to experiments in Experimenets.vue" This reverts commitb459aba882. * refactor test config override validation for experimental retry subkeys * account for error throw differences in browsers in system tests * bump circle cache * bump circle cache again --------- Co-authored-by: astone123 <adams@cypress.io> Co-authored-by: mabela416 <mabel@cypress.io> Co-authored-by: Muaz Othman <muaz@cypress.io> Co-authored-by: Muaz Othman <muazweb@gmail.com> Co-authored-by: Cacie Prins <cacie@cypress.io> Co-authored-by: Cacie Prins <cacieprins@users.noreply.github.com> Co-authored-by: Chris Breiding <chrisbreiding@users.noreply.github.com> Co-authored-by: Ryan Manuel <ryanm@cypress.io> Co-authored-by: Matthew Schile <mschile@cypress.io>
731 lines
23 KiB
JavaScript
731 lines
23 KiB
JavaScript
const _ = require('lodash')
|
|
const path = require('path')
|
|
const { stackUtils } = require('@packages/errors')
|
|
// mocha-* is used to allow us to have later versions of mocha specified in devDependencies
|
|
// and prevents accidentally upgrading this one
|
|
// TODO: look into upgrading this to version in driver
|
|
const Mocha = require('mocha-7.0.1')
|
|
const mochaReporters = require('mocha-7.0.1/lib/reporters')
|
|
const mochaCreateStatsCollector = require('mocha-7.0.1/lib/stats-collector')
|
|
const mochaColor = mochaReporters.Base.color
|
|
const mochaSymbols = mochaReporters.Base.symbols
|
|
|
|
const debug = require('debug')('cypress:server:reporter')
|
|
const Promise = require('bluebird')
|
|
const { overrideRequire } = require('./override_require')
|
|
|
|
// override calls to `require('mocha*')` when to always resolve with a mocha we control
|
|
// otherwise mocha will be resolved from project's node_modules and might not work with our code
|
|
const customReporterMochaPath = path.dirname(require.resolve('mocha-7.0.1'))
|
|
|
|
const buildAttemptMessage = (currentRetry, totalRetries) => {
|
|
return `(Attempt ${currentRetry} of ${totalRetries})`
|
|
}
|
|
|
|
// Used for experimentalRetries, where we want to display the passed/failed
|
|
// status of an attempt in the console
|
|
const getIconStatus = (status) => {
|
|
let overallStatusSymbol
|
|
let overallStatusSymbolColor
|
|
let overallStatusColor
|
|
|
|
switch (status) {
|
|
case 'passed':
|
|
overallStatusSymbol = mochaSymbols.ok
|
|
overallStatusSymbolColor = 'checkmark'
|
|
overallStatusColor = 'checkmark'
|
|
break
|
|
case 'failed':
|
|
overallStatusSymbol = mochaSymbols.err
|
|
overallStatusSymbolColor = 'bright fail'
|
|
overallStatusColor = 'error message'
|
|
break
|
|
default:
|
|
overallStatusSymbol = undefined
|
|
overallStatusSymbolColor = undefined
|
|
overallStatusColor = 'medium'
|
|
}
|
|
|
|
return {
|
|
overallStatusSymbol,
|
|
overallStatusSymbolColor,
|
|
overallStatusColor,
|
|
}
|
|
}
|
|
|
|
overrideRequire((depPath, _load) => {
|
|
if ((depPath === 'mocha') || depPath.startsWith('mocha/')) {
|
|
return _load(depPath.replace('mocha', customReporterMochaPath))
|
|
}
|
|
})
|
|
|
|
// if Mocha.Suite.prototype.titlePath
|
|
// throw new Error('Mocha.Suite.prototype.titlePath already exists. Please remove the monkeypatch code.')
|
|
|
|
// Mocha.Suite.prototype.titlePath = ->
|
|
// result = []
|
|
|
|
// if @parent
|
|
// result = result.concat(@parent.titlePath())
|
|
|
|
// if !@root
|
|
// result.push(@title)
|
|
|
|
// return result
|
|
|
|
// Mocha.Runnable.prototype.titlePath = ->
|
|
// @parent.titlePath().concat([@title])
|
|
|
|
const getTitlePath = function (runnable, titles = []) {
|
|
// `originalTitle` is a Mocha Hook concept used to associated the
|
|
// hook to the test that executed it
|
|
if (runnable.originalTitle) {
|
|
runnable.title = runnable.originalTitle
|
|
}
|
|
|
|
if (runnable.title) {
|
|
// sanitize the title which may have been altered by a suite-/
|
|
// test-level browser skip to ensure the original title is used
|
|
const BROWSER_SKIP_TITLE = ' (skipped due to browser)'
|
|
|
|
titles.unshift(runnable.title.replace(BROWSER_SKIP_TITLE, ''))
|
|
}
|
|
|
|
if (runnable.parent) {
|
|
return getTitlePath(runnable.parent, titles)
|
|
}
|
|
|
|
return titles
|
|
}
|
|
|
|
const createSuite = function (obj, parent) {
|
|
const suite = new Mocha.Suite(obj.title, {})
|
|
|
|
if (parent) {
|
|
suite.parent = parent
|
|
}
|
|
|
|
suite.file = obj.file
|
|
suite.root = !!obj.root
|
|
|
|
return suite
|
|
}
|
|
|
|
const createRunnable = function (obj, parent) {
|
|
let fn
|
|
const { body } = obj
|
|
|
|
if (body) {
|
|
fn = function () {}
|
|
fn.toString = () => {
|
|
return body
|
|
}
|
|
}
|
|
|
|
const runnable = new Mocha.Test(obj.title, fn)
|
|
|
|
runnable.timedOut = obj.timedOut
|
|
runnable.async = obj.async
|
|
runnable.sync = obj.sync
|
|
runnable.duration = obj.duration
|
|
runnable.state = obj.state != null ? obj.state : 'skipped' // skipped by default
|
|
runnable._retries = obj._retries
|
|
// shouldn't need to set _currentRetry, but we'll do it anyways
|
|
runnable._currentRetry = obj._currentRetry
|
|
|
|
if (runnable.body == null) {
|
|
runnable.body = body
|
|
}
|
|
|
|
if (parent) {
|
|
runnable.parent = parent
|
|
}
|
|
|
|
return runnable
|
|
}
|
|
|
|
const mochaProps = {
|
|
'currentRetry': '_currentRetry',
|
|
'retries': '_retries',
|
|
}
|
|
|
|
const toMochaProps = (testProps) => {
|
|
return _.each(mochaProps, (val, key) => {
|
|
if (testProps.hasOwnProperty(key)) {
|
|
testProps[val] = testProps[key]
|
|
|
|
return delete testProps[key]
|
|
}
|
|
})
|
|
}
|
|
|
|
const toAttemptProps = (runnable) => {
|
|
return _.pick(runnable, [
|
|
'err',
|
|
'state',
|
|
'timings',
|
|
'failedFromHookId',
|
|
'wallClockStartedAt',
|
|
'wallClockDuration',
|
|
])
|
|
}
|
|
|
|
const mergeRunnable = (eventName) => {
|
|
return (function (testProps, runnables) {
|
|
toMochaProps(testProps)
|
|
|
|
const runnable = runnables[testProps.id]
|
|
|
|
if (eventName === 'test:before:run') {
|
|
if (testProps._currentRetry > runnable._currentRetry) {
|
|
debug('test retried:', testProps.title)
|
|
const prevAttempts = runnable.prevAttempts || []
|
|
|
|
delete runnable.prevAttempts
|
|
const prevAttempt = toAttemptProps(runnable)
|
|
|
|
delete runnable.failedFromHookId
|
|
delete runnable.err
|
|
delete runnable.hookName
|
|
testProps.prevAttempts = prevAttempts.concat([prevAttempt])
|
|
}
|
|
}
|
|
|
|
return [_.extend(runnable, testProps)]
|
|
})
|
|
}
|
|
|
|
const safelyMergeRunnable = function (hookProps, runnables) {
|
|
const { hookId, title, hookName, body, type } = hookProps
|
|
|
|
if (!runnables[hookId]) {
|
|
runnables[hookId] = {
|
|
hookId,
|
|
type,
|
|
title,
|
|
body,
|
|
hookName,
|
|
}
|
|
}
|
|
|
|
return [_.extend({}, runnables[hookProps.id], hookProps)]
|
|
}
|
|
|
|
const mergeErr = function (runnable, runnables, stats) {
|
|
// this will always be a test because
|
|
// we reset hook id's to match tests
|
|
let test = runnables[runnable.id]
|
|
|
|
test.err = runnable.err
|
|
test.state = 'failed'
|
|
|
|
if (runnable.type === 'hook') {
|
|
test.failedFromHookId = runnable.hookId
|
|
}
|
|
|
|
// dont mutate the test, and merge in the runnable title
|
|
// in the case its a hook so that we emit the right 'fail'
|
|
// event for reporters
|
|
|
|
// However, we need to propagate the cypressTestStatusInfo to the reporter in order to print the test information correctly
|
|
// in the terminal, as well as updating the test state as it may have changed in the client-side runner.
|
|
test = _.extend({}, test, { title: runnable.title, _cypressTestStatusInfo: runnable._cypressTestStatusInfo, state: runnable.state })
|
|
|
|
return [test, test.err]
|
|
}
|
|
|
|
const setDate = function (obj, runnables, stats) {
|
|
let e; let s
|
|
|
|
s = obj.start
|
|
|
|
if (s) {
|
|
stats.wallClockStartedAt = new Date(s)
|
|
}
|
|
|
|
e = obj.end
|
|
|
|
if (e) {
|
|
stats.wallClockEndedAt = new Date(e)
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
const orNull = function (prop) {
|
|
if (prop == null) return null
|
|
|
|
return prop
|
|
}
|
|
|
|
const events = {
|
|
'start': setDate,
|
|
'end': setDate,
|
|
'suite': mergeRunnable('suite'),
|
|
'suite end': mergeRunnable('suite end'),
|
|
'test': mergeRunnable('test'),
|
|
'test end': mergeRunnable('test end'),
|
|
'hook': safelyMergeRunnable,
|
|
'retry': true,
|
|
'hook end': safelyMergeRunnable,
|
|
'pass': mergeRunnable('pass'),
|
|
'pending': mergeRunnable('pending'),
|
|
'fail': mergeErr,
|
|
'test:after:run': mergeRunnable('test:after:run'), // our own custom event
|
|
'test:before:run': mergeRunnable('test:before:run'), // our own custom event
|
|
}
|
|
|
|
class Reporter {
|
|
constructor (reporterName = 'spec', reporterOptions = {}, projectRoot) {
|
|
if (!(this instanceof Reporter)) {
|
|
return new Reporter(reporterName)
|
|
}
|
|
|
|
this.reporterName = reporterName
|
|
this.projectRoot = projectRoot
|
|
this.reporterOptions = reporterOptions
|
|
this.normalizeTest = this.normalizeTest.bind(this)
|
|
}
|
|
|
|
setRunnables (rootRunnable, cypressConfig) {
|
|
if (!rootRunnable) {
|
|
rootRunnable = { title: '' }
|
|
}
|
|
|
|
// manage stats ourselves
|
|
this.stats = { suites: 0, tests: 0, passes: 0, pending: 0, skipped: 0, failures: 0 }
|
|
this.retriesConfig = cypressConfig ? cypressConfig.retries : {}
|
|
this.runnables = {}
|
|
rootRunnable = this._createRunnable(rootRunnable, 'suite')
|
|
const reporter = Reporter.loadReporter(this.reporterName, this.projectRoot)
|
|
|
|
this.mocha = new Mocha({ reporter })
|
|
this.mocha.suite = rootRunnable
|
|
this.runner = new Mocha.Runner(rootRunnable)
|
|
mochaCreateStatsCollector(this.runner)
|
|
|
|
this.reporter = new this.mocha._reporter(this.runner, {
|
|
reporterOptions: this.reporterOptions,
|
|
})
|
|
|
|
if (this.reporterName === 'spec') {
|
|
// Unfortunately the reporter doesn't expose its indentation logic, so we have to replicate it here
|
|
let indents = 0
|
|
|
|
this.runner.on('suite', function (suite) {
|
|
++indents
|
|
})
|
|
|
|
this.runner.on('suite end', function () {
|
|
--indents
|
|
})
|
|
|
|
const retriesConfig = this.retriesConfig
|
|
|
|
// Override the default reporter to always show test timing even for fast tests
|
|
// and display slow ones in yellow rather than red
|
|
this.runner._events.pass[2] = function (test) {
|
|
// can possibly be undefined if the test fails before being run, such as in before/beforeEach hooks
|
|
const cypressTestMetaData = test._cypressTestStatusInfo
|
|
|
|
const durationColor = test.speed === 'slow' ? 'medium' : 'fast'
|
|
|
|
let fmt
|
|
|
|
// Print the default if the experiment is not configured
|
|
if (!retriesConfig?.experimentalStrategy) {
|
|
fmt =
|
|
Array(indents).join(' ') +
|
|
mochaColor('checkmark', ` ${ mochaSymbols.ok}`) +
|
|
mochaColor('pass', ' %s') +
|
|
mochaColor(durationColor, ' (%dms)')
|
|
|
|
// Log: `✓ test title (300ms)` when a test passes
|
|
// eslint-disable-next-line no-console
|
|
console.log(fmt, test.title, test.duration)
|
|
} else {
|
|
// If there have been no retries and experimental retries is configured,
|
|
// DON'T decorate the last test in the console as an attempt.
|
|
if (cypressTestMetaData?.attempts > 1) {
|
|
const lastTestStatus = getIconStatus(test.state)
|
|
|
|
fmt =
|
|
Array(indents).join(' ') +
|
|
mochaColor(lastTestStatus.overallStatusColor, ` ${ lastTestStatus.overallStatusSymbol}${buildAttemptMessage(cypressTestMetaData.attempts, test.retries + 1)}`)
|
|
|
|
// or Log: `✓(Attempt 3 of 3) test title` when the overall outerStatus of a test has passed
|
|
// eslint-disable-next-line no-console
|
|
console.log(fmt, test.title)
|
|
}
|
|
|
|
const finalTestStatus = getIconStatus(cypressTestMetaData.outerStatus || test.state)
|
|
|
|
const finalMessaging =
|
|
Array(indents).join(' ') +
|
|
mochaColor(finalTestStatus.overallStatusSymbolColor, ` ${ finalTestStatus.overallStatusSymbol ? finalTestStatus.overallStatusSymbol : ''}`) +
|
|
mochaColor(finalTestStatus.overallStatusColor, ' %s') +
|
|
mochaColor(durationColor, ' (%dms)')
|
|
|
|
// Log: ✓`test title` when the overall outerStatus of a test has passed
|
|
// OR
|
|
// Log: ✖`test title` when the overall outerStatus of a test has failed
|
|
// eslint-disable-next-line no-console
|
|
console.log(finalMessaging, test.title, test.duration)
|
|
}
|
|
}
|
|
|
|
const originalFailPrint = this.runner._events.fail[2]
|
|
|
|
this.runner._events.fail[2] = function (test) {
|
|
// can possibly be undefined if the test fails before being run, such as in before/beforeEach hooks
|
|
const cypressTestMetaData = test._cypressTestStatusInfo
|
|
|
|
// print the default if the experiment is not configured
|
|
if (!retriesConfig?.experimentalStrategy) {
|
|
return originalFailPrint.call(this, test)
|
|
}
|
|
|
|
const durationColor = test.speed === 'slow' ? 'medium' : 'fast'
|
|
|
|
// If there have been no retries and experimental retries is configured,
|
|
// DON'T decorate the last test in the console as an attempt.
|
|
if (cypressTestMetaData?.attempts > 1) {
|
|
const lastTestStatus = getIconStatus(test.state)
|
|
|
|
const fmt =
|
|
Array(indents).join(' ') +
|
|
mochaColor(lastTestStatus.overallStatusColor, ` ${ lastTestStatus.overallStatusSymbol}${buildAttemptMessage(cypressTestMetaData.attempts, test.retries + 1)}`)
|
|
|
|
// Log: `✖(Attempt 3 of 3) test title (300ms)` when a test fails and none of the retries have passed
|
|
// eslint-disable-next-line no-console
|
|
console.log(fmt, test.title)
|
|
}
|
|
|
|
const finalTestStatus = getIconStatus(cypressTestMetaData?.outerStatus || test.state)
|
|
|
|
const finalMessaging =
|
|
Array(indents).join(' ') +
|
|
mochaColor(finalTestStatus.overallStatusSymbolColor, ` ${ finalTestStatus.overallStatusSymbol ? finalTestStatus.overallStatusSymbol : ''}`) +
|
|
mochaColor(finalTestStatus.overallStatusColor, ' %s') +
|
|
mochaColor(durationColor, ' (%dms)')
|
|
|
|
// Log: ✖`test title` when the overall outerStatus of a test has failed
|
|
// eslint-disable-next-line no-console
|
|
console.log(finalMessaging, test.title, test.duration)
|
|
}
|
|
|
|
this.runner.ignoreLeaks = true
|
|
}
|
|
}
|
|
|
|
_createRunnable (runnableProps, type, parent) {
|
|
const runnable = (() => {
|
|
switch (type) {
|
|
case 'suite':
|
|
// eslint-disable-next-line no-case-declarations
|
|
const suite = createSuite(runnableProps, parent)
|
|
|
|
suite.tests = _.map(runnableProps.tests, (testProps) => {
|
|
return this._createRunnable(testProps, 'test', suite)
|
|
})
|
|
|
|
suite.suites = _.map(runnableProps.suites, (suiteProps) => {
|
|
return this._createRunnable(suiteProps, 'suite', suite)
|
|
})
|
|
|
|
return suite
|
|
case 'test':
|
|
return createRunnable(runnableProps, parent)
|
|
default:
|
|
throw new Error(`Unknown runnable type: '${type}'`)
|
|
}
|
|
})()
|
|
|
|
runnable.id = runnableProps.id
|
|
|
|
this.runnables[runnableProps.id] = runnable
|
|
|
|
return runnable
|
|
}
|
|
|
|
emit (event, arg) {
|
|
// iI using GA retries, log the retry attempt as the status of the attempt is not used
|
|
if (event === 'retry' && this.reporterName === 'spec' && !this.retriesConfig?.experimentalStrategy) {
|
|
this._logRetry(arg)
|
|
}
|
|
|
|
// If using experimental retries, log the attempt after the test attempt runs to accurately represent the attempt pass/fail status
|
|
// We don't log the last attempt as this is handled by the main pass/fail handler defined above, and would ultimately log AFTER the test complete test status is reported
|
|
// from the mocha:pass/mocha:fail event
|
|
if (event === 'test:after:run' && this.reporterName === 'spec' && this.retriesConfig?.experimentalStrategy && !arg.final) {
|
|
this._logRetry(arg)
|
|
}
|
|
|
|
// ignore the event if we haven't accounted for it
|
|
if (!events[event]) return
|
|
|
|
const args = this.parseArgs(event, arg)
|
|
|
|
return this.runner && this.runner.emit.apply(this.runner, args)
|
|
}
|
|
|
|
parseArgs (event, arg) {
|
|
const normalizeArgs = events[event]
|
|
|
|
const args = _.isFunction(normalizeArgs)
|
|
? normalizeArgs.call(this, arg, this.runnables, this.stats)
|
|
: [arg]
|
|
|
|
// the normalizeArgs function needs the id property, but we want to remove
|
|
// and other private props before logging/emitting to mocha
|
|
args[0] = _.isPlainObject(args[0]) ? _.omit(args[0], ['id', 'hookId']) : args[0]
|
|
|
|
debug('got mocha event \'%s\' with args: %o', event, args)
|
|
|
|
return [event].concat(args)
|
|
}
|
|
|
|
_logRetry (test) {
|
|
const runnable = this.runnables[test.id]
|
|
|
|
// Merge the runnable with the updated test props to gain most recent status from the app runnable (in the case a passed test is retried).
|
|
_.extend(runnable, test)
|
|
const padding = ' '.repeat(runnable.titlePath().length)
|
|
|
|
// Don't display a pass/fail symbol if we don't know the status.
|
|
let mochaSymbolToDisplay = ''
|
|
let mochaColorScheme = 'medium'
|
|
|
|
// If experimental retries are configured, we need to print the pass/fail status of each attempt as it is no longer implied.
|
|
if (this.retriesConfig?.experimentalStrategy) {
|
|
switch (runnable.state) {
|
|
case 'passed':
|
|
mochaSymbolToDisplay = mochaColor('checkmark', mochaSymbols.ok)
|
|
mochaColorScheme = 'green'
|
|
break
|
|
case 'failed':
|
|
mochaSymbolToDisplay = mochaColor('bright fail', mochaSymbols.err)
|
|
mochaColorScheme = 'error message'
|
|
break
|
|
default:
|
|
}
|
|
}
|
|
|
|
const attemptMessage = mochaColor(mochaColorScheme, buildAttemptMessage(test.currentRetry + 1, test.retries + 1))
|
|
|
|
// Log: `(Attempt 1 of 2) test title` when a test attempts without experimentalRetries configured.
|
|
// OR
|
|
// Log: `✓(Attempt 1 of 2) test title` when a test attempt passes with experimentalRetries configured.
|
|
// OR
|
|
// Log: `✖(Attempt 1 of 2) test title` when a test attempt fails with experimentalRetries configured.
|
|
// eslint-disable-next-line no-console
|
|
return console.log(`${padding}${mochaSymbolToDisplay}${attemptMessage} ${test.title}`)
|
|
}
|
|
|
|
normalizeHook (hook = {}) {
|
|
return {
|
|
hookId: hook.hookId,
|
|
hookName: hook.hookName,
|
|
title: getTitlePath(hook),
|
|
body: hook.body,
|
|
}
|
|
}
|
|
|
|
normalizeTest (test = {}) {
|
|
let outerTest = _.clone(test)
|
|
|
|
// In the case tests were skipped or another case where they haven't run,
|
|
// test._cypressTestStatusInfo.outerStatus will be undefined. In this case,
|
|
// the test state reflects the outer state.
|
|
outerTest.state = test._cypressTestStatusInfo?.outerStatus || test.state
|
|
|
|
// If the outerStatus is failed, but the last test passed, look up the first error that occurred
|
|
// and use this as the test overall error. This same logic is applied in the mocha patch to report
|
|
// the error correctly to the Cypress browser reporter (see ./driver/patches/mocha+7.0.1.dev.patch)
|
|
if (test.state === 'passed' && outerTest.state === 'failed') {
|
|
outerTest.err = (test.prevAttempts || []).find((t) => t.state === 'failed')?.err
|
|
// Otherwise, if the test failed, set the error on the outer test similar to the state
|
|
// as we can assume that if the last test failed, the state could NEVER be passing.
|
|
} else if (test.state === 'failed') {
|
|
outerTest.err = test.err
|
|
}
|
|
|
|
const normalizedTest = {
|
|
testId: orNull(outerTest.id),
|
|
title: getTitlePath(outerTest),
|
|
state: orNull(outerTest.state),
|
|
body: orNull(outerTest.body),
|
|
displayError: orNull(outerTest.err && outerTest.err.stack),
|
|
attempts: _.map((outerTest.prevAttempts || []).concat([test]), (attempt) => {
|
|
const err = attempt.err && {
|
|
name: attempt.err.name,
|
|
message: attempt.err.message,
|
|
stack: attempt.err.stack && stackUtils.stackWithoutMessage(attempt.err.stack),
|
|
codeFrame: attempt.err.codeFrame,
|
|
}
|
|
|
|
return {
|
|
state: orNull(attempt.state),
|
|
error: orNull(err),
|
|
timings: orNull(attempt.timings),
|
|
failedFromHookId: orNull(attempt.failedFromHookId),
|
|
wallClockStartedAt: orNull(attempt.wallClockStartedAt && new Date(attempt.wallClockStartedAt)),
|
|
wallClockDuration: orNull(attempt.wallClockDuration),
|
|
videoTimestamp: null,
|
|
}
|
|
}),
|
|
}
|
|
|
|
return normalizedTest
|
|
}
|
|
|
|
end () {
|
|
if (this.reporter.done) {
|
|
const {
|
|
failures,
|
|
} = this.runner
|
|
|
|
return new Promise((resolve, reject) => {
|
|
return this.reporter.done(failures, resolve)
|
|
}).then(() => {
|
|
return this.results()
|
|
})
|
|
}
|
|
|
|
return this.results()
|
|
}
|
|
|
|
results () {
|
|
const tests = _
|
|
.chain(this.runnables)
|
|
.filter({ type: 'test' })
|
|
.map(this.normalizeTest)
|
|
.value()
|
|
|
|
const hooks = _
|
|
.chain(this.runnables)
|
|
.filter({ type: 'hook' })
|
|
.map(this.normalizeHook)
|
|
.value()
|
|
|
|
const suites = _
|
|
.chain(this.runnables)
|
|
.filter({ root: false }) // don't include root suite
|
|
.value()
|
|
|
|
// default to 0
|
|
this.stats.wallClockDuration = 0
|
|
|
|
const { wallClockStartedAt, wallClockEndedAt } = this.stats
|
|
|
|
if (wallClockStartedAt && wallClockEndedAt) {
|
|
this.stats.wallClockDuration = wallClockEndedAt - wallClockStartedAt
|
|
}
|
|
|
|
this.stats.suites = suites.length
|
|
this.stats.tests = tests.length
|
|
this.stats.passes = _.filter(tests, { state: 'passed' }).length
|
|
this.stats.pending = _.filter(tests, { state: 'pending' }).length
|
|
this.stats.skipped = _.filter(tests, { state: 'skipped' }).length
|
|
this.stats.failures = _.filter(tests, { state: 'failed' }).length
|
|
|
|
// return an object of results
|
|
return {
|
|
// this is our own stats object
|
|
stats: this.stats,
|
|
|
|
reporter: this.reporterName,
|
|
|
|
// this comes from the reporter, not us
|
|
reporterStats: this.runner.stats,
|
|
|
|
hooks,
|
|
|
|
tests,
|
|
}
|
|
}
|
|
|
|
static setVideoTimestamp (videoStart, tests = []) {
|
|
return _.map(tests, (test) => {
|
|
// if we have a wallClockStartedAt
|
|
let wcs
|
|
|
|
wcs = test.wallClockStartedAt
|
|
|
|
if (wcs) {
|
|
test.videoTimestamp = test.wallClockStartedAt - videoStart
|
|
}
|
|
|
|
return test
|
|
})
|
|
}
|
|
|
|
static create (reporterName, reporterOptions, projectRoot) {
|
|
return new Reporter(reporterName, reporterOptions, projectRoot)
|
|
}
|
|
|
|
static loadReporter (reporterName, projectRoot) {
|
|
let p
|
|
|
|
debug('trying to load reporter:', reporterName)
|
|
|
|
// Explicitly require this here (rather than dynamically) so that it gets included in the v8 snapshot
|
|
if (reporterName === 'teamcity') {
|
|
debug(`${reporterName} is built-in reporter`)
|
|
|
|
return require('mocha-teamcity-reporter')
|
|
}
|
|
|
|
// Explicitly require this here (rather than dynamically) so that it gets included in the v8 snapshot
|
|
if (reporterName === 'junit') {
|
|
debug(`${reporterName} is built-in reporter`)
|
|
|
|
return require('mocha-junit-reporter')
|
|
}
|
|
|
|
if (mochaReporters[reporterName]) {
|
|
debug(`${reporterName} is Mocha reporter`)
|
|
|
|
return reporterName
|
|
}
|
|
|
|
// it's likely a custom reporter
|
|
// that is local (./custom-reporter.js)
|
|
// or one installed by the user through npm
|
|
try {
|
|
p = path.resolve(projectRoot, reporterName)
|
|
|
|
// try local
|
|
debug('trying to require local reporter with path:', p)
|
|
|
|
// using path.resolve() here so we can just pass an
|
|
// absolute path as the reporterName which avoids
|
|
// joining projectRoot unnecessarily
|
|
return require(p)
|
|
} catch (err) {
|
|
if (err.code !== 'MODULE_NOT_FOUND') {
|
|
// bail early if the error wasn't MODULE_NOT_FOUND
|
|
// because that means theres something actually wrong
|
|
// with the found reporter
|
|
throw err
|
|
}
|
|
|
|
p = path.resolve(projectRoot, 'node_modules', reporterName)
|
|
|
|
// try npm. if this fails, we're out of options, so let it throw
|
|
debug('trying to require local reporter with path:', p)
|
|
|
|
return require(p)
|
|
}
|
|
}
|
|
|
|
static getSearchPathsForReporter (reporterName, projectRoot) {
|
|
return _.uniq([
|
|
path.resolve(projectRoot, reporterName),
|
|
path.resolve(projectRoot, 'node_modules', reporterName),
|
|
])
|
|
}
|
|
}
|
|
|
|
module.exports = Reporter
|