Files
cypress/packages/server/lib/reporter.js
T
Bill Glesias 201e9f366e feat: experimental retries (#27930)
* 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 commit c428443079.

* Revert "chore: add burnInTestAction capability (#27768)"

This reverts commit ae3df1a505.

* 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 commit bb5046c91e.

* 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 commit b459aba882.

* 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>
2023-10-26 14:06:14 -04:00

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