UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

511 lines (453 loc) 15 kB
'use strict' const { getTestSuitePath } = require('../../../dd-trace/src/plugins/util/test') const { channel } = require('../helpers/instrument') const shimmer = require('../../../datadog-shimmer') // test channels const testStartCh = channel('ci:mocha:test:start') const testFinishCh = channel('ci:mocha:test:finish') // after a test has failed, we'll publish to this channel const testRetryCh = channel('ci:mocha:test:retry') const errorCh = channel('ci:mocha:test:error') const skipCh = channel('ci:mocha:test:skip') const testFnCh = channel('ci:mocha:test:fn') const isModifiedCh = channel('ci:mocha:test:is-modified') // suite channels const testSuiteErrorCh = channel('ci:mocha:test-suite:error') const BREAKPOINT_HIT_GRACE_PERIOD_MS = 200 const testToContext = new WeakMap() const originalFns = new WeakMap() const testToStartLine = new WeakMap() const testFileToSuiteCtx = new Map() const wrappedFunctions = new WeakSet() const newTests = {} const testsAttemptToFix = new Set() const testsQuarantined = new Set() const testsStatuses = new Map() function getAfterEachHooks (testOrHook) { const hooks = [] while (testOrHook.parent) { if (testOrHook.parent._afterEach) { hooks.push(...testOrHook.parent._afterEach) } testOrHook = testOrHook.parent } return hooks } function getTestProperties (test, testManagementTests) { const testSuite = getTestSuitePath(test.file, process.cwd()) const testName = test.fullTitle() const { attempt_to_fix: isAttemptToFix, disabled: isDisabled, quarantined: isQuarantined } = testManagementTests?.mocha?.suites?.[testSuite]?.tests?.[testName]?.properties || {} return { isAttemptToFix, isDisabled, isQuarantined } } function isNewTest (test, knownTests) { if (!knownTests?.mocha) { // invalid response, so we won't consider it as new return false } const testSuite = getTestSuitePath(test.file, process.cwd()) const testName = test.fullTitle() const testsForSuite = knownTests.mocha?.[testSuite] || [] return !testsForSuite.includes(testName) } function retryTest (test, numRetries, tags) { const suite = test.parent for (let retryIndex = 0; retryIndex < numRetries; retryIndex++) { const clonedTest = test.clone() suite.addTest(clonedTest) tags.forEach(tag => { if (tag) { clonedTest[tag] = true } }) } } function getSuitesByTestFile (root) { const suitesByTestFile = {} function getSuites (suite) { if (suite.file) { if (suitesByTestFile[suite.file]) { suitesByTestFile[suite.file].push(suite) } else { suitesByTestFile[suite.file] = [suite] } } suite.suites.forEach(suite => { getSuites(suite) }) } getSuites(root) const numSuitesByTestFile = Object.keys(suitesByTestFile).reduce((acc, testFile) => { acc[testFile] = suitesByTestFile[testFile].length return acc }, {}) return { suitesByTestFile, numSuitesByTestFile } } function isMochaRetry (test) { return test._currentRetry !== undefined && test._currentRetry !== 0 } function getIsLastRetry (test) { return test._currentRetry === test._retries } function getTestFullName (test) { return `mocha.${getTestSuitePath(test.file, process.cwd())}.${test.fullTitle()}` } function getTestStatus (test) { if (test.isPending()) { return 'skip' } if (test.isFailed() || test.timedOut) { return 'fail' } return 'pass' } function getTestToContextKey (test) { if (!test.fn) { return test } if (!wrappedFunctions.has(test.fn)) { return test.fn } const originalFn = originalFns.get(test.fn) return originalFn } function getTestContext (test) { const key = getTestToContextKey(test) return testToContext.get(key) } function runnableWrapper (RunnablePackage, libraryConfig) { shimmer.wrap(RunnablePackage.prototype, 'run', run => function () { if (!testFinishCh.hasSubscribers) { return run.apply(this, arguments) } // Flaky test retries does not work in parallel mode if (libraryConfig?.isFlakyTestRetriesEnabled) { this.retries(libraryConfig?.flakyTestRetriesCount) } // The reason why the wrapping logic is here is because we need to cover // `afterEach` and `beforeEach` hooks as well. // It can't be done in `getOnTestHandler` because it's only called for tests. const isBeforeEach = this.parent._beforeEach.includes(this) const isAfterEach = this.parent._afterEach.includes(this) const isTestHook = isBeforeEach || isAfterEach // we restore the original user defined function if (wrappedFunctions.has(this.fn)) { const originalFn = originalFns.get(this.fn) this.fn = originalFn wrappedFunctions.delete(this.fn) } if (isTestHook || this.type === 'test') { const test = isTestHook ? this.ctx.currentTest : this const ctx = getTestContext(test) if (ctx) { // we bind the test fn to the correct context const newFn = shimmer.wrapFunction(this.fn, originalFn => function () { return testFnCh.runStores(ctx, () => originalFn.apply(this, arguments)) }) // we store the original function, not to lose it originalFns.set(newFn, this.fn) this.fn = newFn wrappedFunctions.add(this.fn) } } return run.apply(this, arguments) }) return RunnablePackage } function getOnTestHandler (isMain) { return function (test) { const testStartLine = testToStartLine.get(test) // This may be a retry. If this is the case, `test.fn` is already wrapped, // so we need to restore it. if (wrappedFunctions.has(test.fn)) { const originalFn = originalFns.get(test.fn) test.fn = originalFn wrappedFunctions.delete(test.fn) } const { file: testSuiteAbsolutePath, title, _ddIsNew: isNew, _ddIsEfdRetry: isEfdRetry, _ddIsAttemptToFix: isAttemptToFix, _ddIsDisabled: isDisabled, _ddIsQuarantined: isQuarantined, _ddIsModified: isModified } = test const testInfo = { testName: test.fullTitle(), testSuiteAbsolutePath, title, testStartLine } if (!isMain) { testInfo.isParallel = true } testInfo.isNew = isNew testInfo.isEfdRetry = isEfdRetry testInfo.isAttemptToFix = isAttemptToFix testInfo.isDisabled = isDisabled testInfo.isQuarantined = isQuarantined testInfo.isModified = isModified // We want to store the result of the new tests if (isNew) { const testFullName = getTestFullName(test) if (newTests[testFullName]) { newTests[testFullName].push(test) } else { newTests[testFullName] = [test] } } if (!isAttemptToFix && isDisabled) { test.pending = true } const ctx = testInfo testToContext.set(test.fn, ctx) testStartCh.runStores(ctx, () => {}) } } function getOnTestEndHandler (config) { return async function (test) { const ctx = getTestContext(test) const status = getTestStatus(test) // After finishing it might take a bit for the snapshot to be handled. // This means that tests retried with DI are BREAKPOINT_HIT_GRACE_PERIOD_MS slower at least. if (test._ddShouldWaitForHitProbe || test._retriedTest?._ddShouldWaitForHitProbe) { await new Promise((resolve) => { setTimeout(() => { resolve() }, BREAKPOINT_HIT_GRACE_PERIOD_MS) }) } let hasFailedAllRetries = false let attemptToFixPassed = false let attemptToFixFailed = false const testName = getTestFullName(test) if (testsStatuses.get(testName)) { testsStatuses.get(testName).push(status) } else { testsStatuses.set(testName, [status]) } const testStatuses = testsStatuses.get(testName) const isLastAttempt = testStatuses.length === config.testManagementAttemptToFixRetries + 1 if (test._ddIsAttemptToFix && isLastAttempt) { if (testStatuses.includes('fail')) { attemptToFixFailed = true } if (testStatuses.every(status => status === 'fail')) { hasFailedAllRetries = true } else if (testStatuses.every(status => status === 'pass')) { attemptToFixPassed = true } } const isAttemptToFixRetry = test._ddIsAttemptToFix && testStatuses.length > 1 const isAtrRetry = config.isFlakyTestRetriesEnabled && !test._ddIsAttemptToFix && !test._ddIsEfdRetry // if there are afterEach to be run, we don't finish the test yet if (ctx && !getAfterEachHooks(test).length) { testFinishCh.publish({ status, hasBeenRetried: isMochaRetry(test), isLastRetry: getIsLastRetry(test), hasFailedAllRetries, attemptToFixPassed, attemptToFixFailed, isAttemptToFixRetry, isAtrRetry, ...ctx.currentStore }) } } } function getOnHookEndHandler () { return function (hook) { const test = hook.ctx.currentTest const afterEachHooks = getAfterEachHooks(hook) if (test && afterEachHooks.includes(hook)) { // only if it's an afterEach const isLastAfterEach = afterEachHooks.indexOf(hook) === afterEachHooks.length - 1 if (isLastAfterEach) { const status = getTestStatus(test) const ctx = getTestContext(test) if (ctx) { testFinishCh.publish({ status, hasBeenRetried: isMochaRetry(test), isLastRetry: getIsLastRetry(test), ...ctx.currentStore }) } } } } } function getOnFailHandler (isMain) { return function (testOrHook, err) { const testFile = testOrHook.file let test = testOrHook const isHook = testOrHook.type === 'hook' if (isHook && testOrHook.ctx) { test = testOrHook.ctx.currentTest } let testContext if (test) { testContext = getTestContext(test) } if (testContext) { if (isHook) { err.message = `${testOrHook.fullTitle()}: ${err.message}` testContext.err = err errorCh.runStores(testContext, () => {}) // if it's a hook and it has failed, 'test end' will not be called testFinishCh.publish({ status: 'fail', hasBeenRetried: isMochaRetry(test), ...testContext.currentStore }) } else { testContext.err = err errorCh.runStores(testContext, () => {}) } } if (isMain) { const testSuiteContext = testFileToSuiteCtx.get(testFile) if (testSuiteContext) { // we propagate the error to the suite const testSuiteError = new Error( `"${testOrHook.parent.fullTitle()}" failed with message "${err.message}"` ) testSuiteError.stack = err.stack testSuiteContext.error = testSuiteError testSuiteErrorCh.runStores(testSuiteContext, () => {}) } } } } function getOnTestRetryHandler (config) { return function (test, err) { const ctx = getTestContext(test) if (ctx) { const isFirstAttempt = test._currentRetry === 0 const willBeRetried = test._currentRetry < test._retries const isAtrRetry = !isFirstAttempt && config.isFlakyTestRetriesEnabled && !test._ddIsAttemptToFix && !test._ddIsEfdRetry testRetryCh.publish({ isFirstAttempt, err, willBeRetried, test, isAtrRetry, ...ctx.currentStore }) } const key = getTestToContextKey(test) testToContext.delete(key) } } function getOnPendingHandler () { return function (test) { const testStartLine = testToStartLine.get(test) const { file: testSuiteAbsolutePath, title } = test const testInfo = { testName: test.fullTitle(), testSuiteAbsolutePath, title, testStartLine } const ctx = getTestContext(test) if (ctx) { skipCh.publish(testInfo) } else { // if there is no context, the test has been skipped through `test.skip` // or the parent suite is skipped const testCtx = testInfo if (test.fn) { testToContext.set(test.fn, testCtx) } else { testToContext.set(test, testCtx) } skipCh.runStores(testCtx, () => {}) } } } // Hook to add retries to tests if Test Management or EFD is enabled function getRunTestsWrapper (runTests, config) { return function (suite) { if (config.isTestManagementTestsEnabled) { suite.tests.forEach((test) => { const { isAttemptToFix, isDisabled, isQuarantined } = getTestProperties(test, config.testManagementTests) if (isAttemptToFix && !test.isPending()) { test._ddIsAttemptToFix = true test._ddIsDisabled = isDisabled test._ddIsQuarantined = isQuarantined // This is needed to know afterwards which ones have been retried to ignore its result testsAttemptToFix.add(test) retryTest( test, config.testManagementAttemptToFixRetries, ['_ddIsAttemptToFix', isDisabled && '_ddIsDisabled', isQuarantined && '_ddIsQuarantined'] ) } else if (isDisabled) { test._ddIsDisabled = true } else if (isQuarantined) { testsQuarantined.add(test) test._ddIsQuarantined = true } }) } if (config.isImpactedTestsEnabled) { suite.tests.forEach((test) => { isModifiedCh.publish({ modifiedFiles: config.modifiedFiles, file: suite.file, onDone: (isModified) => { if (isModified) { test._ddIsModified = true if (!test.isPending() && !test._ddIsAttemptToFix && config.isEarlyFlakeDetectionEnabled) { retryTest( test, config.earlyFlakeDetectionNumRetries, ['_ddIsModified', '_ddIsEfdRetry'] ) } } } }) }) } if (config.isKnownTestsEnabled) { // by the time we reach `this.on('test')`, it is too late. We need to add retries here suite.tests.forEach(test => { if (!test.isPending() && isNewTest(test, config.knownTests)) { test._ddIsNew = true if (config.isEarlyFlakeDetectionEnabled && !test._ddIsAttemptToFix && !test._ddIsModified) { retryTest( test, config.earlyFlakeDetectionNumRetries, ['_ddIsNew', '_ddIsEfdRetry'] ) } } }) } return runTests.apply(this, arguments) } } module.exports = { isNewTest, getTestProperties, getSuitesByTestFile, isMochaRetry, getTestFullName, getTestStatus, runnableWrapper, testToContext, originalFns, getTestContext, testToStartLine, getOnTestHandler, getOnTestEndHandler, getOnTestRetryHandler, getOnHookEndHandler, getOnFailHandler, getOnPendingHandler, testFileToSuiteCtx, getRunTestsWrapper, newTests, testsQuarantined, testsAttemptToFix, testsStatuses }