@revoloo/cypress6
Version:
Cypress.io end to end testing tool
1,751 lines (1,363 loc) • 46.9 kB
JavaScript
/* eslint-disable prefer-rest-params */
/* globals Cypress */
const _ = require('lodash')
const moment = require('moment')
const Promise = require('bluebird')
const Pending = require('mocha/lib/pending')
const $Log = require('./log')
const $utils = require('./utils')
const $errUtils = require('./error_utils')
const $stackUtils = require('./stack_utils')
const { getResolvedTestConfigOverride } = require('../cy/testConfigOverrides')
const mochaCtxKeysRe = /^(_runnable|test)$/
const betweenQuotesRe = /\"(.+?)\"/
const HOOKS = 'beforeAll beforeEach afterEach afterAll'.split(' ')
const TEST_BEFORE_RUN_EVENT = 'runner:test:before:run'
const TEST_AFTER_RUN_EVENT = 'runner:test:after:run'
const RUNNABLE_LOGS = 'routes agents commands hooks'.split(' ')
const RUNNABLE_PROPS = '_testConfig id order title _titlePath root hookName hookId err state failedFromHookId body speed type duration wallClockStartedAt wallClockDuration timings file originalTitle invocationDetails final currentRetry retries'.split(' ')
const debug = require('debug')('cypress:driver:runner')
const fire = (event, runnable, Cypress) => {
debug('fire: %o', { event })
if (runnable._fired == null) {
runnable._fired = {}
}
runnable._fired[event] = true
// dont fire anything again if we are skipped
if (runnable._ALREADY_RAN) {
return
}
return Cypress.action(event, wrap(runnable), runnable)
}
const fired = (event, runnable) => {
return !!(runnable._fired && runnable._fired[event])
}
const testBeforeRunAsync = (test, Cypress) => {
return Promise.try(() => {
if (!fired('runner:test:before:run:async', test)) {
return fire('runner:test:before:run:async', test, Cypress)
}
})
}
const runnableAfterRunAsync = (runnable, Cypress) => {
runnable.clearTimeout()
return Promise.try(() => {
// NOTE: other events we do not fire more than once, but this needed to change for test-retries
return fire('runner:runnable:after:run:async', runnable, Cypress)
})
}
const testAfterRun = (test, Cypress) => {
test.clearTimeout()
if (!fired(TEST_AFTER_RUN_EVENT, test)) {
setWallClockDuration(test)
try {
fire(TEST_AFTER_RUN_EVENT, test, Cypress)
} catch (e) {
// if the test:after:run listener throws it's likely spec code
// Since the test status has already been emitted this can't affect the test status.
// Let's just log the error to console
// TODO: revist when we handle uncaught exceptions/rejections between tests
// eslint-disable-next-line no-console
console.error(e)
}
// perf loop only through
// a tests OWN properties and not
// inherited properties from its shared ctx
for (let key of Object.keys(test.ctx || {})) {
const value = test.ctx[key]
if (_.isObject(value) && !mochaCtxKeysRe.test(key)) {
// nuke any object properties that come from
// cy.as() aliases or anything set from 'this'
// so we aggressively perform GC and prevent obj
// ref's from building up
test.ctx[key] = undefined
}
}
// reset the fn to be empty function
// for GC to be aggressive and prevent
// closures from hold references
test.fn = () => {}
// prevent loop comprehension
return null
}
}
const setTestTimingsForHook = (test, hookName, obj) => {
if (test.timings == null) {
test.timings = {}
}
if (test.timings[hookName] == null) {
test.timings[hookName] = []
}
return test.timings[hookName].push(obj)
}
const setTestTimings = (test, name, obj) => {
if (test.timings == null) {
test.timings = {}
}
test.timings[name] = obj
}
const setWallClockDuration = (test) => {
return test.wallClockDuration = new Date() - test.wallClockStartedAt
}
// we need to optimize wrap by converting
// tests to an id-based object which prevents
// us from recursively iterating through every
// parent since we could just return the found test
const wrap = (runnable) => {
return $utils.reduceProps(runnable, RUNNABLE_PROPS)
}
const wrapAll = (runnable) => {
return _.extend(
{},
$utils.reduceProps(runnable, RUNNABLE_PROPS),
$utils.reduceProps(runnable, RUNNABLE_LOGS),
)
}
const condenseHooks = (runnable, getHookId) => {
runnable._condensedHooks = true
const hooks = _.compact(_.concat(
runnable._beforeAll,
runnable._beforeEach,
runnable._afterAll,
runnable._afterEach,
))
return _.map(hooks, (hook) => {
if (!hook.hookId) {
hook.hookId = getHookId()
}
if (!hook.hookName) {
hook.hookName = getHookName(hook)
}
return wrap(hook)
})
}
const getHookName = (hook) => {
// find the name of the hook by parsing its
// title and pulling out whats between the quotes
const name = hook.title.match(betweenQuotesRe)
return name && name[1]
}
const forceGc = (obj) => {
// aggressively forces GC by purging
// references to ctx, and removes callback
// functions for closures
for (let key of Object.keys(obj.ctx || {})) {
obj.ctx[key] = undefined
}
if (obj.fn) {
obj.fn = () => {}
}
}
const eachHookInSuite = (suite, fn) => {
for (let type of HOOKS) {
for (let hook of suite[`_${type}`]) {
fn(hook)
}
}
// prevent loop comprehension
return null
}
// iterates over a suite's tests (including nested suites)
// and will return as soon as the callback is true
const findTestInSuite = (suite, fn = _.identity) => {
for (const test of suite.tests) {
if (fn(test)) {
return test
}
}
for (const childSuite of suite.suites) {
const test = findTestInSuite(childSuite, fn)
if (test) {
return test
}
}
}
const findSuiteInSuite = (suite, fn = _.identity) => {
if (fn(suite)) {
return suite
}
for (const childSuite of suite.suites) {
const foundSuite = findSuiteInSuite(childSuite, fn)
if (foundSuite) {
return foundSuite
}
}
}
const suiteHasTest = (suite, testId) => {
return findTestInSuite(suite, (test) => test.id === testId)
}
const suiteHasSuite = (suite, suiteId) => {
return findSuiteInSuite(suite, (s) => s.id === suiteId)
}
// same as findTestInSuite but iterates backwards
const findLastTestInSuite = (suite, fn = _.identity) => {
for (let i = suite.suites.length - 1; i >= 0; i--) {
const test = findLastTestInSuite(suite.suites[i], fn)
if (test) {
return test
}
}
for (let i = suite.tests.length - 1; i >= 0; i--) {
const test = suite.tests[i]
if (fn(test)) {
return test
}
}
}
const getAllSiblingTests = (suite, getTestById) => {
const tests = []
suite.eachTest((testRunnable) => {
// iterate through each of our suites tests.
// this will iterate through all nested tests
// as well. and then we add it only if its
// in our filtered tests array
const test = getTestById(testRunnable.id)
if (test) {
return tests.push(test)
}
})
return tests
}
function getTestIndexFromId (id) {
return +id.slice(1)
}
const getTestFromHook = (hook) => {
// if theres already a currentTest use that
const test = hook.ctx.currentTest
if (test) {
return test
}
}
// we have to see if this is the last suite amongst
// its siblings. but first we have to filter out
// suites which dont have a filtered test in them
const isLastSuite = (suite, tests) => {
if (suite.root) {
return false
}
// grab all of the suites from our filtered tests
// including all of their ancestor suites!
const suites = _.reduce(tests, (memo, test) => {
let parent
while ((parent = test.parent)) {
memo.push(parent)
test = parent
}
return memo
}
, [])
// intersect them with our parent suites and see if the last one is us
return _
.chain(suites)
.uniq()
.intersection(suite.parent.suites)
.last()
.value() === suite
}
// we are the last test that will run in the suite
// if we're the last test in the tests array or
// if we failed from a hook and that hook was 'before'
// since then mocha skips the remaining tests in the suite
const lastTestThatWillRunInSuite = (test, tests) => {
return isLastTest(test, tests) || (test.failedFromHookId && (test.hookName === 'before all'))
}
const isLastTest = (test, tests) => {
return test.id === _.get(_.last(tests), 'id')
}
const isRootSuite = (suite) => {
return suite && suite.root
}
const overrideRunnerHook = (Cypress, _runner, getTestById, getTest, setTest, getTests) => {
// bail if our _runner doesnt have a hook.
// useful in tests
if (!_runner.hook) {
return
}
// monkey patch the hook event so we can wrap
// 'test:after:run' around all of
// the hooks surrounding a test runnable
// const _runnerHook = _runner.hook
_runner.hook = $utils.monkeypatchBefore(_runner.hook, function (name, fn) {
if (name !== 'afterAll' && name !== 'afterEach') {
return
}
const test = getTest()
const allTests = getTests()
let shouldFireTestAfterRun = _.noop
switch (name) {
case 'afterEach':
shouldFireTestAfterRun = () => {
// find all of the grep'd tests which share
// the same parent suite as our current test
const tests = getAllSiblingTests(test.parent, getTestById)
if (this.suite.root) {
_runner._shouldBufferSuiteEnd = true
// make sure this test isnt the last test overall but also
// isnt the last test in our filtered parent suite's tests array
if (test.final === false || (test !== _.last(allTests)) && (test !== _.last(tests))) {
return true
}
}
}
break
case 'afterAll':
shouldFireTestAfterRun = () => {
// find all of the filtered allTests which share
// the same parent suite as our current _test
// const t = getTest()
if (test) {
const siblings = getAllSiblingTests(test.parent, getTestById)
// 1. if we're the very last test in the entire allTests
// we wait until the root suite fires
// 2. else if we arent the last nested suite we fire if we're
// the last test that will run
if (
(isRootSuite(this.suite) && isLastTest(test, allTests)) ||
(!isLastSuite(this.suite, allTests) && lastTestThatWillRunInSuite(test, siblings))
) {
return true
}
}
}
break
default:
break
}
const newArgs = [name, $utils.monkeypatchBefore(fn,
function () {
if (!shouldFireTestAfterRun()) return
setTest(null)
if (test.final !== false) {
test.final = true
if (test.state === 'passed') {
Cypress.action('runner:pass', wrap(test))
}
Cypress.action('runner:test:end', wrap(test))
_runner._shouldBufferSuiteEnd = false
_runner._onTestAfterRun.map((fn) => {
return fn()
})
_runner._onTestAfterRun = []
}
testAfterRun(test, Cypress)
})]
return newArgs
})
}
const getTestResults = (tests) => {
return _.map(tests, (test) => {
const obj = _.pick(test, 'id', 'duration', 'state')
obj.title = test.originalTitle
// TODO FIX THIS!
if (!obj.state) {
obj.state = 'skipped'
}
return obj
})
}
const hasOnly = (suite) => {
return (
suite._onlyTests.length ||
suite._onlySuites.length ||
_.some(suite.suites, hasOnly)
)
}
const normalizeAll = (suite, initialTests = {}, setTestsById, setTests, onRunnable, onLogsById, getRunnableId, getHookId, getOnlyTestId, getOnlySuiteId, createEmptyOnlyTest) => {
let hasTests = false
// only loop until we find the first test
findTestInSuite(suite, (test) => {
return hasTests = true
})
// if we dont have any tests then bail
// unless we're using studio to add to the root suite
if (!hasTests && getOnlySuiteId() !== 'r1') {
return
}
// we are doing a super perf loop here where
// we hand back a normalized object but also
// create optimized lookups for the tests without
// traversing through it multiple times
const tests = {}
const normalizedSuite = normalize(suite, tests, initialTests, onRunnable, onLogsById, getRunnableId, getHookId, getOnlyTestId, getOnlySuiteId, createEmptyOnlyTest)
if (setTestsById) {
// use callback here to hand back
// the optimized tests
setTestsById(tests)
}
if (setTests) {
let i = 0
const testsArr = _.map(tests, (test) => {
test.order = i += 1
return test
})
// same pattern here
setTests(testsArr)
}
// generate the diff of the config after spec has been executed
// e.g. config changes via Cypress.config('...')
normalizedSuite.runtimeConfig = {}
_.map(Cypress.config(), (v, key) => {
if (_.isEqual(v, Cypress.originalConfig[key])) {
return null
}
normalizedSuite.runtimeConfig[key] = v
})
return normalizedSuite
}
const normalize = (runnable, tests, initialTests, onRunnable, onLogsById, getRunnableId, getHookId, getOnlyTestId, getOnlySuiteId, createEmptyOnlyTest) => {
const normalizeRunnable = (runnable) => {
if (!runnable.id) {
runnable.id = getRunnableId()
}
// tests have a type of 'test' whereas suites do not have a type property
if (runnable.type == null) {
runnable.type = 'suite'
}
if (onRunnable) {
onRunnable(runnable)
}
// if we have a runnable in the initial state
// then merge in existing properties into the runnable
const i = initialTests[runnable.id]
let prevAttempts
if (i) {
prevAttempts = []
if (i.prevAttempts) {
prevAttempts = _.map(i.prevAttempts, (test) => {
if (test) {
_.each(RUNNABLE_LOGS, (type) => {
return _.each(test[type], onLogsById)
})
}
// reduce this runnable down to its props
// and collections
return wrapAll(test)
})
}
_.each(RUNNABLE_LOGS, (type) => {
return _.each(i[type], onLogsById)
})
_.extend(runnable, i)
}
// merge all hooks into single array
runnable.hooks = condenseHooks(runnable, getHookId)
// reduce this runnable down to its props
// and collections
const wrappedRunnable = wrapAll(runnable)
if (runnable.type === 'test') {
const cfg = getResolvedTestConfigOverride(runnable)
if (_.size(cfg)) {
runnable._testConfig = cfg
wrappedRunnable._testConfig = cfg
}
wrappedRunnable._titlePath = runnable.titlePath()
}
if (prevAttempts) {
wrappedRunnable.prevAttempts = prevAttempts
}
return wrappedRunnable
}
const push = (test) => {
return tests[test.id] != null ? tests[test.id] : (tests[test.id] = test)
}
const onlyIdMode = () => {
return !!getOnlyTestId() || !!getOnlySuiteId()
}
const suiteHasOnlyId = (suite) => {
return suiteHasTest(suite, getOnlyTestId()) || suiteHasSuite(suite, getOnlySuiteId())
}
const normalizedRunnable = normalizeRunnable(runnable)
if (getOnlySuiteId() && runnable.id === getOnlySuiteId()) {
createEmptyOnlyTest(runnable)
}
if ((runnable.type !== 'suite') || !hasOnly(runnable)) {
if (runnable.type === 'test' && (!getOnlyTestId() || runnable.id === getOnlyTestId())) {
push(runnable)
}
const runnableTests = runnable.tests
const runnableSuites = runnable.suites
if (onlyIdMode()) {
runnable.tests = []
runnable._onlyTests = []
runnable.suites = []
runnable._onlySuites = []
runnable._afterAll = []
runnable._afterEach = []
}
// recursively iterate and normalize all other _runnables
_.each({ tests: runnableTests, suites: runnableSuites }, (_runnables, type) => {
if (runnable[type]) {
return normalizedRunnable[type] = _.compact(_.map(_runnables, (childRunnable) => {
const normalizedChild = normalize(childRunnable, tests, initialTests, onRunnable, onLogsById, getRunnableId, getHookId, getOnlyTestId, getOnlySuiteId, createEmptyOnlyTest)
if (type === 'tests' && onlyIdMode()) {
if (normalizedChild.id === getOnlyTestId()) {
runnable.tests = [childRunnable]
runnable._onlyTests = [childRunnable]
return normalizedChild
}
return null
}
if (type === 'suites' && onlyIdMode()) {
if (suiteHasOnlyId(childRunnable)) {
runnable.suites = [childRunnable]
runnable._onlySuites = [childRunnable]
return normalizedChild
}
return null
}
return normalizedChild
}))
}
})
return normalizedRunnable
}
// this follows how mocha filters onlys. its runner#filterOnly
// is pretty much the same minus the normalization part
const filterOnly = (normalizedSuite, suite) => {
if (suite._onlyTests.length) {
const suiteOnlyTests = suite._onlyTests
if (getOnlyTestId()) {
suite.tests = []
suite._onlyTests = []
suite._afterAll = []
suite._afterEach = []
} else {
suite.tests = suite._onlyTests
}
normalizedSuite.tests = _.compact(_.map(suiteOnlyTests, (test) => {
const normalizedTest = normalizeRunnable(test)
if (getOnlyTestId()) {
if (normalizedTest.id === getOnlyTestId()) {
suite.tests = [test]
suite._onlyTests = [test]
push(test)
return normalizedTest
}
return null
}
push(test)
return normalizedTest
}))
suite.suites = []
normalizedSuite.suites = []
} else {
suite.tests = []
normalizedSuite.tests = []
_.each(suite._onlySuites, (onlySuite) => {
const normalizedOnlySuite = normalizeRunnable(onlySuite)
if (hasOnly(onlySuite)) {
filterOnly(normalizedOnlySuite, onlySuite)
}
})
const suiteSuites = suite.suites
suite.suites = []
normalizedSuite.suites = _.compact(_.map(suiteSuites, (childSuite) => {
const normalizedChildSuite = normalize(childSuite, tests, initialTests, onRunnable, onLogsById, getRunnableId, getHookId, getOnlyTestId, getOnlySuiteId, createEmptyOnlyTest)
if ((suite._onlySuites.indexOf(childSuite) !== -1) || filterOnly(normalizedChildSuite, childSuite)) {
if (onlyIdMode()) {
if (suiteHasOnlyId(childSuite)) {
suite.suites.push(childSuite)
return normalizedChildSuite
}
return null
}
suite.suites.push(childSuite)
return normalizedChildSuite
}
}))
}
return suite.tests.length || suite.suites.length
}
filterOnly(normalizedRunnable, runnable)
return normalizedRunnable
}
const hookFailed = (hook, err, getTest, getTestFromHookOrFindTest) => {
// NOTE: sometimes mocha will fail a hook without having emitted on('hook')
// event, so this hook might not have currentTest set correctly
// in which case we need to lookup the test
const test = getTest() || getTestFromHookOrFindTest(hook)
setHookFailureProps(test, hook, err)
if (hook.alreadyEmittedMocha) {
test.alreadyEmittedMocha = true
} else {
return Cypress.action('runner:test:end', wrap(test))
}
}
const setHookFailureProps = (test, hook, err) => {
err = $errUtils.wrapErr(err)
const hookName = getHookName(hook)
test.err = err
test.state = 'failed'
test.duration = hook.duration // TODO: nope (?)
test.hookName = hookName // TODO: why are we doing this?
test.failedFromHookId = hook.hookId
}
function getTestFromRunnable (runnable) {
switch (runnable.type) {
case 'hook':
return getTestFromHook(runnable)
case 'test':
return runnable
default: null
}
}
const _runnerListeners = (_runner, Cypress, _emissions, getTestById, getTest, setTest, getTestFromHookOrFindTest) => {
_runner.on('start', () => {
return Cypress.action('runner:start', {
start: new Date(),
})
})
_runner.on('end', () => {
return Cypress.action('runner:end', {
end: new Date(),
})
})
_runner.on('suite', (suite) => {
if (_emissions.started[suite.id]) {
return
}
_emissions.started[suite.id] = true
return Cypress.action('runner:suite:start', wrap(suite))
})
_runner._shouldBufferSuiteEnd = false
_runner._onTestAfterRun = []
_runner.on('suite end', (suite) => {
const handleSuiteEnd = () => {
// cleanup our suite + its hooks
forceGc(suite)
eachHookInSuite(suite, forceGc)
if (_emissions.ended[suite.id]) {
return
}
_emissions.ended[suite.id] = true
Cypress.action('runner:suite:end', wrap(suite))
}
if (_runner._shouldBufferSuiteEnd) {
_runner._onTestAfterRun = _runner._onTestAfterRun.concat([handleSuiteEnd])
return
}
return handleSuiteEnd()
})
_runner.on('hook', (hook) => {
// mocha incorrectly sets currentTest on before/after all's.
// if there is a nested suite with a before, then
// currentTest will refer to the previous test run
// and not our current
// https://github.com/cypress-io/cypress/issues/1987
if ((hook.hookName === 'before all' || hook.hookName === 'after all') && hook.ctx.currentTest) {
delete hook.ctx.currentTest
}
let test = getTest()
// https://github.com/cypress-io/cypress/issues/9162
// In https://github.com/cypress-io/cypress/issues/8113, getTest() call was removed to handle skip() properly.
// But it caused tests to hang when there's a failure in before().
// That's why getTest() is revived and checks if the state is 'pending'.
if (!test || test.state === 'pending') {
// set the hook's id from the test because
// hooks do not have their own id, their
// commands need to grouped with the test
// and we can only associate them by this id
test = getTestFromHookOrFindTest(hook)
}
if (!test) {
// we couldn't find a test to run with this hook
// probably because the entire suite has already completed
// so return early and tell onRunnableRun to skip the test
return
}
hook.id = test.id
hook.ctx.currentTest = test
// make sure we set this test as the current now
// else its possible that our TEST_AFTER_RUN_EVENT
// will never fire if this failed in a before hook
setTest(test)
return Cypress.action('runner:hook:start', wrap(hook))
})
_runner.on('hook end', (hook) => {
return Cypress.action('runner:hook:end', wrap(hook))
})
_runner.on('test', (test) => {
setTest(test)
if (_emissions.started[test.id]) {
return
}
_emissions.started[test.id] = true
return Cypress.action('runner:test:start', wrap(test))
})
_runner.on('test end', (test) => {
if (_emissions.ended[test.id]) {
return
}
_emissions.ended[test.id] = true
// NOTE: we wait to send 'test end' until after hooks run
// return Cypress.action('runner:test:end', wrap(test))
})
// Ignore the 'pass' event since we emit our own
// _runner.on('pass', (test) => {
// return Cypress.action('runner:pass', wrap(test))
// })
/**
* Mocha retry event is only fired in Mocha version 6+
* https://github.com/mochajs/mocha/commit/2a76dd7589e4a1ed14dd2a33ab89f182e4c4a050
*/
_runner.on('retry', (test, err) => {
test.err = $errUtils.wrapErr(err)
Cypress.action('runner:retry', wrap(test), test.err)
})
// if a test is pending mocha will only
// emit the pending event instead of the test
// so we normalize the pending / test events
_runner.on('pending', function (test) {
// do nothing if our test is skipped
if (test._ALREADY_RAN) {
return
}
if (!fired(TEST_BEFORE_RUN_EVENT, test)) {
fire(TEST_BEFORE_RUN_EVENT, test, Cypress)
}
test.state = 'pending'
if (!test.alreadyEmittedMocha) {
// do not double emit this event
test.alreadyEmittedMocha = true
Cypress.action('runner:pending', wrap(test))
}
this.emit('test', test)
// if this is not the last test amongst its siblings
// then go ahead and fire its test:after:run event
// else this will not get called
const tests = getAllSiblingTests(test.parent, getTestById)
if (_.last(tests) !== test) {
test.final = true
return fire(TEST_AFTER_RUN_EVENT, test, Cypress)
}
})
_runner.on('fail', (runnable, err) => {
let hookName
const isHook = runnable.type === 'hook'
err.stack = $stackUtils.normalizedStack(err)
if (isHook) {
const parentTitle = runnable.parent.title
hookName = getHookName(runnable)
const test = getTest() || getTestFromHookOrFindTest(runnable)
// append a friendly message to the error indicating
// we're skipping the remaining tests in this suite
err = $errUtils.appendErrMsg(
err,
$errUtils.errByPath('uncaught.error_in_hook', {
parentTitle,
hookName,
retries: test._retries,
}).message,
)
}
// always set runnable err so we can tap into
// taking a screenshot on error
runnable.err = $errUtils.wrapErr(err)
if (!runnable.alreadyEmittedMocha) {
// do not double emit this event
runnable.alreadyEmittedMocha = true
Cypress.action('runner:fail', wrap(runnable), runnable.err)
}
// if we've already fired the test after run event
// it means that this runnable likely failed due to
// a double done(err) callback, and we need to fire
// this again!
if (fired(TEST_AFTER_RUN_EVENT, runnable)) {
fire(TEST_AFTER_RUN_EVENT, runnable, Cypress)
}
if (isHook) {
// if a hook fails (such as a before) then the test will never
// get run and we'll need to make sure we set the test so that
// the TEST_AFTER_RUN_EVENT fires correctly
return hookFailed(runnable, runnable.err, getTest, getTestFromHookOrFindTest)
}
})
}
const create = (specWindow, mocha, Cypress, cy) => {
let _runnableId = 0
let _hookId = 0
let _uncaughtFn = null
let _resumedAtTestIndex = null
const _runner = mocha.getRunner()
_runner.suite = mocha.getRootSuite()
function isNotAlreadyRunTest (test) {
return _resumedAtTestIndex == null || getTestIndexFromId(test.id) >= _resumedAtTestIndex
}
const getTestFromHookOrFindTest = (hook) => {
const test = getTestFromHook(hook)
if (test) {
return test
}
const suite = hook.parent
let foundTest
if (hook.hookName === 'after all') {
foundTest = findLastTestInSuite(suite, isNotAlreadyRunTest)
} else if (hook.hookName === 'before all') {
foundTest = findTestInSuite(suite, isNotAlreadyRunTest)
}
// if test has retried, we getTestById will give us the last attempt
foundTest = foundTest && getTestById(foundTest.id)
return foundTest
}
const onScriptError = (err) => {
// err will not be returned if cy can associate this
// uncaught exception to an existing runnable
if (!err) {
return true
}
const todoMsg = () => {
if (!Cypress.config('isTextTerminal')) {
return 'Check your console for the stack trace or click this message to see where it originated from.'
}
}
const appendMsg = _.chain([
'Cypress could not associate this error to any specific test.',
'We dynamically generated a new test to display this failure.',
todoMsg(),
])
.compact()
.join('\n\n')
.value()
err = $errUtils.appendErrMsg(err, appendMsg)
const throwErr = () => {
throw err
}
// we could not associate this error
// and shouldn't ever start our run
_uncaughtFn = throwErr
// return undefined so the browser does its default
// uncaught exception behavior (logging to console)
return undefined
}
specWindow.onerror = function () {
const err = cy.onSpecWindowUncaughtException.apply(cy, arguments)
return onScriptError(err)
}
// hold onto the _runnables for faster lookup later
let _test = null
let _tests = []
let _testsById = {}
const _testsQueue = []
const _testsQueueById = {}
// only used during normalization
const _runnables = []
const _logsById = {}
let _emissions = {
started: {},
ended: {},
}
let _startTime = null
let _onlyTestId = null
let _onlySuiteId = null
const getRunnableId = () => {
return `r${++_runnableId}`
}
const getHookId = () => {
return `h${++_hookId}`
}
const setTestsById = (tbid) => {
return _testsById = tbid
}
const setTests = (t) => {
return _tests = t
}
const getTests = () => {
return _tests
}
const onRunnable = (r) => {
// set defualt retries at onRunnable time instead of onRunnableRun
return _runnables.push(r)
}
const onLogsById = (l) => {
return _logsById[l.id] = l
}
const getTest = () => {
return _test
}
const setTest = (t) => {
return _test = t
}
const getTestById = (id) => {
// perf short circuit
if (!id) {
return
}
return _testsById[id]
}
const replaceTest = (runnable, id) => {
const testsQueueIndex = _.findIndex(_testsQueue, { id })
_testsQueue.splice(testsQueueIndex, 1, runnable)
_testsQueueById[id] = runnable
const testsIndex = _.findIndex(_tests, { id })
_tests.splice(testsIndex, 1, runnable)
_testsById[id] = runnable
}
const setOnlyTestId = (testId) => {
_onlyTestId = testId
}
const getOnlyTestId = () => _onlyTestId
const setOnlySuiteId = (suiteId) => {
_onlySuiteId = suiteId
}
const getOnlySuiteId = () => _onlySuiteId
overrideRunnerHook(Cypress, _runner, getTestById, getTest, setTest, getTests)
// this forces mocha to enqueue a duplicate test in the case of test retries
const replacePreviousAttemptWith = (test) => {
const prevAttempt = _testsById[test.id]
const prevAttempts = prevAttempt.prevAttempts || []
const newPrevAttempts = prevAttempts.concat([prevAttempt])
delete prevAttempt.prevAttempts
test.prevAttempts = newPrevAttempts
replaceTest(test, test.id)
}
const maybeHandleRetry = (runnable, err) => {
if (!err) return
const r = runnable
const isHook = r.type === 'hook'
const isTest = r.type === 'test'
const test = getTest() || getTestFromHook(runnable, getTestById)
const hookName = isHook && getHookName(r)
const isBeforeEachHook = isHook && !!hookName.match(/before each/)
const isAfterEachHook = isHook && !!hookName.match(/after each/)
const retryAbleRunnable = isTest || isBeforeEachHook || isAfterEachHook
const willRetry = (test._currentRetry < test._retries) && retryAbleRunnable
const fail = function () {
return err
}
const noFail = function () {
return
}
if (err) {
if (willRetry) {
test.state = 'failed'
test.final = false
}
if (willRetry && isBeforeEachHook) {
delete runnable.err
test._retriesBeforeEachFailedTestFn = test.fn
// this prevents afterEach hooks that exist at a deeper level than the failing one from running
// we will always skip remaining beforeEach hooks since they will always be same level or deeper
test._skipHooksWithLevelGreaterThan = runnable.titlePath().length
setHookFailureProps(test, runnable, err)
test.fn = function () {
throw err
}
return noFail()
}
if (willRetry && isAfterEachHook) {
// if we've already failed this attempt from an afterEach hook then we've already enqueud another attempt
// so return early
if (test._retriedFromAfterEachHook) {
return noFail()
}
setHookFailureProps(test, runnable, err)
const newTest = test.clone()
newTest._currentRetry = test._currentRetry + 1
test.parent.testsQueue.unshift(newTest)
// this prevents afterEach hooks that exist at a deeper (or same) level than the failing one from running
test._skipHooksWithLevelGreaterThan = runnable.titlePath().length - 1
test._retriedFromAfterEachHook = true
Cypress.action('runner:retry', wrap(test), test.err)
return noFail()
}
}
return fail()
}
const createEmptyOnlyTest = (suite) => {
const test = mocha.createTest('New Test', _.noop)
test.id = getRunnableId()
suite.addTest(test)
suite.appendOnlyTest(test)
test.invocationDetails = suite.invocationDetails
setOnlyTestId(test.id)
return test
}
return {
onScriptError,
setOnlyTestId,
setOnlySuiteId,
normalizeAll (tests) {
// if we have an uncaught error then slice out
// all of the tests and suites and just generate
// a single test since we received an uncaught
// error prior to processing any of mocha's tests
// which could have occurred in a separate support file
if (_uncaughtFn) {
_runner.suite.suites = []
_runner.suite.tests = []
// prevents .only on suite from hiding uncaught error
_runner.suite._onlySuites = []
// create a runnable to associate for the failure
mocha.createRootTest('An uncaught error was detected outside of a test', _uncaughtFn)
}
return normalizeAll(
_runner.suite,
tests,
setTestsById,
setTests,
onRunnable,
onLogsById,
getRunnableId,
getHookId,
getOnlyTestId,
getOnlySuiteId,
createEmptyOnlyTest,
)
},
run (fn) {
if (_startTime == null) {
_startTime = moment().toJSON()
}
_runnerListeners(_runner, Cypress, _emissions, getTestById, getTest, setTest, getTestFromHookOrFindTest)
return _runner.run((failures) => {
// if we happen to make it all the way through
// the run, then just set _runner.stopped to true here
_runner.stopped = true
// remove all the listeners
// so no more events fire
// since a test failure may 'leak' after a run completes
_runner.removeAllListeners()
// TODO this functions is not correctly
// synchronized with the 'end' event that
// we manage because of uncaught hook errors
if (fn) {
return fn(failures, getTestResults(_tests))
}
})
},
onRunnableRun (runnableRun, runnable, args) {
// extract out the next(fn) which mocha uses to
// move to the next runnable - this will be our async seam
const _next = args[0]
const test = getTest() || getTestFromRunnable(runnable)
// if there's no test, this is likely a rouge before/after hook
// that should not have run, so skip this runnable
if (!test || _runner.stopped) {
return _next()
}
// first time seeing a retried test
// that hasn't already replaced our test
if (test._currentRetry > 0 && _testsById[test.id] !== test) {
replacePreviousAttemptWith(test)
}
// closure for calculating the actual
// runtime of a runnables fn exection duration
// and also the run of the runnable:after:run:async event
let lifecycleStart
let wallClockStartedAt = null
let wallClockEnd = null
let fnDurationStart = null
let fnDurationEnd = null
let afterFnDurationStart = null
let afterFnDurationEnd = null
// when this is a hook, capture the real start
// date so we can calculate our test's duration
// including all of its hooks
wallClockStartedAt = new Date()
if (!test.wallClockStartedAt) {
// if we don't have lifecycle timings yet
lifecycleStart = wallClockStartedAt
}
if (test.wallClockStartedAt == null) {
test.wallClockStartedAt = wallClockStartedAt
}
// if this isnt a hook, then the name is 'test'
const hookName = runnable.type === 'hook' ? getHookName(runnable) : 'test'
// set hook id to hook id or test id
const hookId = runnable.type === 'hook' ? runnable.hookId : runnable.id
// if we haven't yet fired this event for this test
// that means that we need to reset the previous state
// of cy - since we now have a new 'test' and all of the
// associated _runnables will share this state
if (!fired(TEST_BEFORE_RUN_EVENT, test)) {
fire(TEST_BEFORE_RUN_EVENT, test, Cypress)
// this is the earliest we can set test._retries since test:before:run
// will load in testConfigOverrides (per test configuration)
const retries = Cypress.getTestRetries() ?? -1
test._retries = retries
}
const isHook = runnable.type === 'hook'
const isAfterEachHook = isHook && hookName.match(/after each/)
const isBeforeEachHook = isHook && hookName.match(/before each/)
// if we've been told to skip hooks at a certain nested level
// this happens if we're handling a runnable that is going to retry due to failing in a hook
const shouldSkipRunnable = test._skipHooksWithLevelGreaterThan != null
&& isHook
&& (isBeforeEachHook || isAfterEachHook && runnable.titlePath().length > test._skipHooksWithLevelGreaterThan)
if (shouldSkipRunnable) {
return _next.call(this)
}
const next = (err) => {
// now set the duration of the after runnable run async event
afterFnDurationEnd = (wallClockEnd = new Date())
switch (runnable.type) {
case 'hook':
// reset runnable duration to include lifecycle
// and afterFn timings purely for the mocha runner.
// this is what it 'feels' like to the user
runnable.duration = wallClockEnd - wallClockStartedAt
setTestTimingsForHook(test, hookName, {
hookId: runnable.hookId,
fnDuration: fnDurationEnd - fnDurationStart,
afterFnDuration: afterFnDurationEnd - afterFnDurationStart,
})
break
case 'test':
// if we are currently on a test then
// recalculate its duration to be based
// against that (purely for the mocha reporter)
test.duration = wallClockEnd - test.wallClockStartedAt
// but still preserve its actual function
// body duration for timings
setTestTimings(test, 'test', {
fnDuration: fnDurationEnd - fnDurationStart,
afterFnDuration: afterFnDurationEnd - afterFnDurationStart,
})
break
default:
break
}
return _next.call(runnable, err)
}
const onNext = (err) => {
// when done with the function set that to end
fnDurationEnd = new Date()
// and also set the afterFnDuration to this same date
afterFnDurationStart = fnDurationEnd
// attach error right now
// if we have one
if (err) {
if (err instanceof Pending) {
err.isPending = true
}
runnable.err = $errUtils.wrapErr(err)
} else {
// https://github.com/cypress-io/cypress/issues/9209
// Mocha reuses runnable object. Because of that, runnable.err isn't undefined even when err is undefined.
// It causes Cypress to take superfluous screenshots.
delete runnable.err
}
err = maybeHandleRetry(runnable, err)
return runnableAfterRunAsync(runnable, Cypress)
.then(() => {
// once we complete callback with the
// original err
next(err)
// return null here to signal to bluebird
// that we did not forget to return a promise
// because mocha internally does not return
// the test.run(fn)
return null
}).catch((err) => {
next(err)
// return null here to signal to bluebird
// that we did not forget to return a promise
// because mocha internally does not return
// the test.run(fn)
return null
})
}
// our runnable is about to run, so let cy know. this enables
// us to always have a correct runnable set even when we are
// running lifecycle events
// and also get back a function result handler that we use as
// an async seam
cy.setRunnable(runnable, hookId)
// TODO: handle promise timeouts here!
// whenever any runnable is about to run
// we figure out what test its associated to
// if its a hook, and then we fire the
// test:before:run:async action if its not
// been fired before for this test
return testBeforeRunAsync(test, Cypress)
.catch((err) => {
// TODO: if our async tasks fail
// then allow us to cause the test
// to fail here by blowing up its fn
// callback
const { fn } = runnable
const restore = () => {
return runnable.fn = fn
}
runnable.fn = () => {
restore()
throw err
}
}).finally(() => {
if (lifecycleStart) {
// capture how long the lifecycle took as part
// of the overall wallClockDuration of our test
setTestTimings(test, 'lifecycle', new Date() - lifecycleStart)
}
// capture the moment we're about to invoke
// the runnable's callback function
fnDurationStart = new Date()
// call the original method with our
// custom onNext function
return runnableRun.call(runnable, onNext)
})
},
getStartTime () {
return _startTime
},
setStartTime (iso) {
_startTime = iso
},
countByTestState (tests, state) {
const count = _.filter(tests, (test, key) => {
return test.state === state
})
return count.length
},
setNumLogs (num) {
return $Log.setCounter(num)
},
getEmissions () {
return _emissions
},
getTestsState () {
const id = _test != null ? _test.id : undefined
const tests = {}
// bail if we dont have a current test
if (!id) {
return {}
}
// search through all of the tests
// until we find the current test
// and break then
for (let testRunnable of _tests) {
if (testRunnable.id === id) {
break
} else {
const test = serializeTest(testRunnable)
test.prevAttempts = _.map(testRunnable.prevAttempts, serializeTest)
tests[test.id] = test
}
}
return tests
},
stop () {
if (_runner.stopped) {
return
}
_runner.stopped = true
// abort the run
_runner.abort()
// emit the final 'end' event
// since our reporter depends on this event
// and mocha may never fire this because our
// runnable may never finish
_runner.emit('end')
// remove all the listeners
// so no more events fire
_runner.removeAllListeners()
},
getDisplayPropsForLog: $Log.getDisplayProps,
getConsolePropsForLogById (logId) {
const attrs = _logsById[logId]
if (attrs) {
return $Log.getConsoleProps(attrs)
}
},
getSnapshotPropsForLogById (logId) {
const attrs = _logsById[logId]
if (attrs) {
return $Log.getSnapshotProps(attrs)
}
},
resumeAtTest (id, emissions = {}) {
_resumedAtTestIndex = getTestIndexFromId(id)
_emissions = emissions
for (let test of _tests) {
if (getTestIndexFromId(test.id) !== _resumedAtTestIndex) {
test._ALREADY_RAN = true
test.pending = true
} else {
// bail so we can stop now
return
}
}
},
getResumedAtTestIndex () {
return _resumedAtTestIndex
},
cleanupQueue (numTestsKeptInMemory) {
const cleanup = (queue) => {
if (queue.length > numTestsKeptInMemory) {
const test = queue.shift()
delete _testsQueueById[test.id]
_.each(RUNNABLE_LOGS, (logs) => {
return _.each(test[logs], (attrs) => {
// we know our attrs have been cleaned
// now, so lets store that
attrs._hasBeenCleanedUp = true
return $Log.reduceMemory(attrs)
})
})
return cleanup(queue)
}
}
return cleanup(_testsQueue)
},
addLog (attrs, isInteractive) {
// we dont need to hold a log reference
// to anything in memory when we're headless
// because you cannot inspect any logs
if (!isInteractive) {
return
}
let test = getTestById(attrs.testId)
// bail if for whatever reason we
// cannot associate this log to a test
if (!test) {
return
}
// if this test isnt in the current queue
// then go ahead and add it
if (!_testsQueueById[test.id]) {
_testsQueueById[test.id] = true
_testsQueue.push(test)
}
const existing = _logsById[attrs.id]
if (existing) {
// because log:state:changed may
// fire at a later time, its possible
// we've already cleaned up these attrs
// and in that case we don't want to do
// anything at all
if (existing._hasBeenCleanedUp) {
return
}
// mutate the existing object
return _.extend(existing, attrs)
}
_logsById[attrs.id] = attrs
const { testId, instrument } = attrs
test = getTestById(testId)
if (test) {
// pluralize the instrument
// as a property on the runnable
let name
const logs = test[name = `${instrument}s`] != null ? test[name] : (test[name] = [])
// else push it onto the logs
return logs.push(attrs)
}
},
}
}
const mixinLogs = (test) => {
_.each(RUNNABLE_LOGS, (type) => {
const logs = test[type]
if (logs) {
test[type] = _.map(logs, $Log.toSerializedJSON)
}
})
}
const serializeTest = (test) => {
const wrappedTest = wrapAll(test)
mixinLogs(wrappedTest)
return wrappedTest
}
module.exports = {
create,
}