UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

1,385 lines (1,254 loc) 54.6 kB
'use strict' // Capture real timers at module load, before any test can install fake timers. const { performance } = require('perf_hooks') const dateNow = Date.now const satisfies = require('../../../vendor/dist/semifies') const { TEST_STATUS, TEST_IS_RUM_ACTIVE, TEST_CODE_OWNERS, getTestEnvironmentMetadata, CI_APP_ORIGIN, getTestParentSpan, getCodeOwnersFileEntries, getCodeOwnersForFilename, getTestCommonTags, getTestSessionCommonTags, getTestModuleCommonTags, getTestSuiteCommonTags, TEST_SUITE_ID, TEST_MODULE_ID, TEST_SESSION_ID, TEST_COMMAND, TEST_MODULE, TEST_SOURCE_START, finishAllTraceSpans, getCoveredFilenamesFromCoverage, getTestSuitePath, addIntelligentTestRunnerSpanTags, TEST_SKIPPED_BY_ITR, TEST_ITR_UNSKIPPABLE, TEST_ITR_FORCED_RUN, TEST_ITR_SKIPPING_ENABLED, ITR_CORRELATION_ID, TEST_SOURCE_FILE, TEST_IS_NEW, TEST_IS_RETRY, TEST_EARLY_FLAKE_ENABLED, TEST_EARLY_FLAKE_ABORT_REASON, getTestSessionName, TEST_SESSION_NAME, TEST_LEVEL_EVENT_TYPES, TEST_RETRY_REASON, DD_TEST_IS_USER_PROVIDED_SERVICE, TEST_MANAGEMENT_IS_QUARANTINED, TEST_MANAGEMENT_ENABLED, TEST_MANAGEMENT_IS_DISABLED, TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX, TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED, TEST_HAS_FAILED_ALL_RETRIES, getLibraryCapabilitiesTags, TEST_RETRY_REASON_TYPES, getPullRequestDiff, getModifiedFilesFromDiff, getSessionRequestErrorTags, DD_CI_LIBRARY_CONFIGURATION_ERROR_SETTINGS, DD_CI_LIBRARY_CONFIGURATION_ERROR_KNOWN_TESTS, DD_CI_LIBRARY_CONFIGURATION_ERROR_SKIPPABLE_TESTS, DD_CI_LIBRARY_CONFIGURATION_ERROR_TEST_MANAGEMENT_TESTS, getSessionItrSkippingEnabledTags, TEST_IS_MODIFIED, TEST_HAS_DYNAMIC_NAME, getIsFaultyEarlyFlakeDetection, DYNAMIC_NAME_RE, recordAttemptToFixExecution, logAttemptToFixTestExecution, logTestOptimizationSummary, getEfdRetryCount, getMaxEfdRetryCount, getPullRequestBaseBranch, TEST_FINAL_STATUS, getTestOptimizationRequestResults, } = require('../../dd-trace/src/plugins/util/test') const { isMarkedAsUnskippable } = require('../../datadog-plugin-jest/src/util') const { ORIGIN_KEY, COMPONENT } = require('../../dd-trace/src/constants') const getConfig = require('../../dd-trace/src/config') const { appClosing: appClosingTelemetry } = require('../../dd-trace/src/telemetry') const log = require('../../dd-trace/src/log') const { TELEMETRY_EVENT_CREATED, TELEMETRY_EVENT_FINISHED, TELEMETRY_ITR_FORCED_TO_RUN, TELEMETRY_CODE_COVERAGE_EMPTY, TELEMETRY_ITR_UNSKIPPABLE, TELEMETRY_CODE_COVERAGE_NUM_FILES, incrementCountMetric, distributionMetric, TELEMETRY_ITR_SKIPPED, TELEMETRY_TEST_SESSION, } = require('../../dd-trace/src/ci-visibility/telemetry') const { GIT_REPOSITORY_URL, GIT_COMMIT_SHA, GIT_BRANCH, CI_PROVIDER_NAME, CI_WORKSPACE_PATH, GIT_COMMIT_MESSAGE, GIT_TAG, GIT_PULL_REQUEST_BASE_BRANCH_SHA, GIT_COMMIT_HEAD_SHA, GIT_PULL_REQUEST_BASE_BRANCH, GIT_COMMIT_HEAD_MESSAGE, } = require('../../dd-trace/src/plugins/util/tags') const { OS_VERSION, OS_PLATFORM, OS_ARCHITECTURE, RUNTIME_NAME, RUNTIME_VERSION, } = require('../../dd-trace/src/plugins/util/env') const { DD_MAJOR } = require('../../../version') const { resolveOriginalSourceFile, resolveSourceLineForTest, shouldTrustInvocationDetailsLine, } = require('./source-map-utils') const TEST_FRAMEWORK_NAME = 'cypress' let hasWarnedDeprecatedCypressVersion = false const CYPRESS_STATUS_TO_TEST_STATUS = { passed: 'pass', failed: 'fail', pending: 'skip', skipped: 'skip', } function getSessionStatus (summary) { if (summary.totalFailed !== undefined && summary.totalFailed > 0) { return 'fail' } if (summary.totalSkipped !== undefined && summary.totalSkipped === summary.totalTests) { return 'skip' } return 'pass' } function getCypressVersion (details) { if (details?.cypressVersion) { return details.cypressVersion } if (details?.config?.version) { return details.config.version } return '' } function warnDeprecatedCypressVersion (version) { if (DD_MAJOR >= 6 || hasWarnedDeprecatedCypressVersion || !version || !satisfies(version, '<12.0.0')) { return } hasWarnedDeprecatedCypressVersion = true // console.warn does not seem to work reliably in Cypress, so use console.log instead. // eslint-disable-next-line no-console console.log( 'WARNING: dd-trace support for Cypress<12.0.0 is deprecated' + ' and will not be supported in dd-trace v6. Please upgrade Cypress to >=12.0.0.' ) } function getRootDir (details) { if (details?.config) { return details.config.projectRoot || details.config.repoRoot || process.cwd() } return process.cwd() } function getCypressCommand (details) { if (!details) { return TEST_FRAMEWORK_NAME } return `${TEST_FRAMEWORK_NAME} ${details.specPattern || ''}` } function getIsTestIsolationEnabled (cypressConfig) { if (!cypressConfig) { // If we can't read testIsolation config parameter, we default to allowing retries return true } return cypressConfig.testIsolation === undefined ? true : cypressConfig.testIsolation } function getLibraryConfiguration (tracer, testConfiguration) { return new Promise(resolve => { if (!tracer._tracer._exporter?.getLibraryConfiguration) { return resolve({ err: new Error('Test Optimization was not initialized correctly') }) } tracer._tracer._exporter.getLibraryConfiguration(testConfiguration, (err, libraryConfig) => { resolve({ err, libraryConfig }) }) }) } function getSkippableTests (tracer, testConfiguration) { return new Promise(resolve => { if (!tracer._tracer._exporter?.getSkippableSuites) { return resolve({ err: new Error('Test Optimization was not initialized correctly') }) } tracer._tracer._exporter.getSkippableSuites(testConfiguration, (err, skippableTests, correlationId) => { resolve({ err, skippableTests, correlationId, }) }) }) } function getKnownTests (tracer, testConfiguration) { return new Promise(resolve => { if (!tracer._tracer._exporter?.getKnownTests) { return resolve({ err: new Error('Test Optimization was not initialized correctly') }) } tracer._tracer._exporter.getKnownTests(testConfiguration, (err, knownTests) => { resolve({ err, knownTests, }) }) }) } function getTestManagementTests (tracer, testConfiguration) { return new Promise(resolve => { if (!tracer._tracer._exporter?.getTestManagementTests) { return resolve({ err: new Error('Test Optimization was not initialized correctly') }) } tracer._tracer._exporter.getTestManagementTests(testConfiguration, (err, testManagementTests) => { resolve({ err, testManagementTests, }) }) }) } function getModifiedFiles (testEnvironmentMetadata) { const { [GIT_PULL_REQUEST_BASE_BRANCH]: pullRequestBaseBranch, [GIT_PULL_REQUEST_BASE_BRANCH_SHA]: pullRequestBaseBranchSha, [GIT_COMMIT_HEAD_SHA]: commitHeadSha, } = testEnvironmentMetadata const baseBranchSha = pullRequestBaseBranchSha || getPullRequestBaseBranch(pullRequestBaseBranch) if (baseBranchSha) { const diff = getPullRequestDiff(baseBranchSha, commitHeadSha) const modifiedFiles = getModifiedFilesFromDiff(diff) if (modifiedFiles) { return modifiedFiles } } throw new Error('Modified tests could not be retrieved') } function getSuiteStatus (suiteStats) { if (!suiteStats) { return 'skip' } if (suiteStats.failures !== undefined && suiteStats.failures > 0) { return 'fail' } if (suiteStats.tests !== undefined && (suiteStats.tests === suiteStats.pending || suiteStats.tests === suiteStats.skipped)) { return 'skip' } return 'pass' } function getMatchingCypressTest (cypressTests, testName, attemptIndex, testStatus, preferIndexedMatch = false) { let matchingTestByIndex let matchingTestByStatus let matchingTestIndex = 0 for (const cypressTest of cypressTests) { if (cypressTest.title.join(' ') !== testName) { continue } if (matchingTestIndex === attemptIndex) { matchingTestByIndex = cypressTest } matchingTestIndex++ if (!matchingTestByStatus && CYPRESS_STATUS_TO_TEST_STATUS[cypressTest.state] === testStatus) { matchingTestByStatus = cypressTest } } return preferIndexedMatch ? matchingTestByIndex || matchingTestByStatus : matchingTestByStatus || matchingTestByIndex } function isCypressHookFailure (cypressTest) { return CYPRESS_STATUS_TO_TEST_STATUS[cypressTest.state] === 'fail' && /\bhook\b/.test(String(cypressTest.displayError || '')) } const FINAL_STATUS_RETRY_KIND = { none: 'none', atr: 'atr', efd: 'efd', atf: 'atf', } function getFinalStatusRetryKind ({ finishedTest, finishedTestAttempts, flakyTestRetriesCount }) { // Infer retry kind from the executions we actually saw so ATR enabled with // a retry count of 0 is still treated as a single final execution. if (finishedTest.isAttemptToFix) { return FINAL_STATUS_RETRY_KIND.atf } if (finishedTestAttempts.some(testAttempt => testAttempt.isEfdRetry)) { return FINAL_STATUS_RETRY_KIND.efd } if (finishedTestAttempts.length > 1 && flakyTestRetriesCount > 0) { return FINAL_STATUS_RETRY_KIND.atr } return FINAL_STATUS_RETRY_KIND.none } function getFinalStatus ({ status, retryKind, hasFailedAllRetries, hasPassedAllAtfRetries, isQuarantined, isDisabled, }) { // If the test is quarantined or disabled, its final status is skip unless attempt-to-fix takes precedence. if (status === 'skip' || (retryKind !== FINAL_STATUS_RETRY_KIND.atf && (isQuarantined || isDisabled))) { return 'skip' } switch (retryKind) { case FINAL_STATUS_RETRY_KIND.atr: case FINAL_STATUS_RETRY_KIND.efd: // These modes report the aggregate result across attempts. return hasFailedAllRetries ? 'fail' : 'pass' case FINAL_STATUS_RETRY_KIND.atf: // Attempt-to-fix only passes if every execution passed. return hasPassedAllAtfRetries ? 'pass' : 'fail' default: return status } } class CypressPlugin { _isInit = false testEnvironmentMetadata = getTestEnvironmentMetadata(TEST_FRAMEWORK_NAME) finishedTestsByFile = {} testStatuses = {} hasLibraryConfiguration = false isTestsSkipped = false isSuitesSkippingEnabled = false isCodeCoverageEnabled = false isFlakyTestRetriesEnabled = false flakyTestRetriesCount = 0 isEarlyFlakeDetectionEnabled = false isEarlyFlakeDetectionFaulty = false isKnownTestsEnabled = false earlyFlakeDetectionNumRetries = 0 earlyFlakeDetectionSlowTestRetries = {} efdRetryCountByTest = {} efdSlowAbortedTests = {} earlyFlakeDetectionFaultyThreshold = 0 testsToSkip = [] skippedTests = [] hasForcedToRunSuites = false hasUnskippableSuites = false unskippableSuites = [] knownTests = [] isTestManagementTestsEnabled = false testManagementAttemptToFixRetries = 0 isImpactedTestsEnabled = false modifiedFiles = [] newTestsWithDynamicNames = new Set() attemptToFixExecutions = new Map() loggedAttemptToFixTests = new Set() constructor () { const { [GIT_REPOSITORY_URL]: repositoryUrl, [GIT_COMMIT_SHA]: sha, [OS_VERSION]: osVersion, [OS_PLATFORM]: osPlatform, [OS_ARCHITECTURE]: osArchitecture, [RUNTIME_NAME]: runtimeName, [RUNTIME_VERSION]: runtimeVersion, [GIT_BRANCH]: branch, [CI_PROVIDER_NAME]: ciProviderName, [CI_WORKSPACE_PATH]: repositoryRoot, [GIT_COMMIT_MESSAGE]: commitMessage, [GIT_TAG]: tag, [GIT_PULL_REQUEST_BASE_BRANCH_SHA]: pullRequestBaseSha, [GIT_COMMIT_HEAD_SHA]: commitHeadSha, [GIT_COMMIT_HEAD_MESSAGE]: commitHeadMessage, } = this.testEnvironmentMetadata this.repositoryRoot = repositoryRoot || process.cwd() this.ciProviderName = ciProviderName this.codeOwnersEntries = getCodeOwnersFileEntries(repositoryRoot) this.testConfiguration = { repositoryUrl, sha, osVersion, osPlatform, osArchitecture, runtimeName, runtimeVersion, branch, testLevel: 'test', commitMessage, tag, pullRequestBaseSha, commitHeadSha, commitHeadMessage, } } /** * Resets state that is scoped to a single Cypress run so the singleton plugin * can be reused safely across multiple programmatic cypress.run() calls. * * @returns {void} */ resetRunState () { this._isInit = false this.finishedTestsByFile = {} this.testStatuses = {} this.hasLibraryConfiguration = false this.isTestsSkipped = false this.isSuitesSkippingEnabled = false this.isCodeCoverageEnabled = false this.isFlakyTestRetriesEnabled = false this.flakyTestRetriesCount = 0 this.isEarlyFlakeDetectionEnabled = false this.isEarlyFlakeDetectionFaulty = false this.isKnownTestsEnabled = false this.earlyFlakeDetectionNumRetries = 0 this.earlyFlakeDetectionSlowTestRetries = {} this.efdRetryCountByTest = {} this.efdSlowAbortedTests = {} this.earlyFlakeDetectionFaultyThreshold = 0 this.testsToSkip = [] this.skippedTests = [] this.hasForcedToRunSuites = false this.hasUnskippableSuites = false this.unskippableSuites = [] this.knownTests = [] this.knownTestsByTestSuite = undefined this.isTestManagementTestsEnabled = false this.testManagementAttemptToFixRetries = 0 this.testManagementTests = undefined this.isImpactedTestsEnabled = false this.modifiedFiles = [] this.attemptToFixExecutions = new Map() this.loggedAttemptToFixTests = new Set() this.activeTestSpan = null this.testSuiteSpan = null this.testModuleSpan = null this.testSessionSpan = null this.command = undefined this.frameworkVersion = undefined this.rootDir = undefined this.itrCorrelationId = undefined this.isTestIsolationEnabled = undefined this.rumFlushWaitMillis = undefined this._pendingRequestErrorTags = [] this.libraryConfigurationPromise = undefined this._timeOrigin = 0 this._perfOrigin = 0 } /** * Returns the current time in the same coordinate system used by span * start/finish. Captured at session span creation so it shares the same * epoch as the trace without reaching into span internals. * * @returns {number} */ _now () { return this._timeOrigin + performance.now() - this._perfOrigin } // Init function returns a promise that resolves with the Cypress configuration // Depending on the received configuration, the Cypress configuration can be modified: // for example, to enable retries for failed tests. init (tracer, cypressConfig) { this.resetRunState() this._isInit = true this.tracer = tracer this.cypressConfig = cypressConfig warnDeprecatedCypressVersion(cypressConfig.version) this.isTestIsolationEnabled = getIsTestIsolationEnabled(cypressConfig) this.rumFlushWaitMillis = getConfig().DD_CIVISIBILITY_RUM_FLUSH_WAIT_MILLIS if (!this.isTestIsolationEnabled) { log.warn('Test isolation is disabled, retries will not be enabled') } // we have to do it here because the tracer is not initialized in the constructor this.testEnvironmentMetadata[DD_TEST_IS_USER_PROVIDED_SERVICE] = tracer._tracer._config.isServiceUserProvided ? 'true' : 'false' this._pendingRequestErrorTags = [] this.libraryConfigurationPromise = getLibraryConfiguration(this.tracer, this.testConfiguration) .then((libraryConfigurationResponse) => { if (libraryConfigurationResponse.err) { log.error('Cypress plugin library config response error', libraryConfigurationResponse.err) this._pendingRequestErrorTags.push({ tag: DD_CI_LIBRARY_CONFIGURATION_ERROR_SETTINGS, value: 'true', }) } else { this.hasLibraryConfiguration = true const { libraryConfig: { isSuitesSkippingEnabled, isCodeCoverageEnabled, isEarlyFlakeDetectionEnabled, earlyFlakeDetectionNumRetries, earlyFlakeDetectionSlowTestRetries, earlyFlakeDetectionFaultyThreshold, isFlakyTestRetriesEnabled, flakyTestRetriesCount, isKnownTestsEnabled, isTestManagementEnabled, testManagementAttemptToFixRetries, isImpactedTestsEnabled, }, } = libraryConfigurationResponse this.isSuitesSkippingEnabled = isSuitesSkippingEnabled this.isCodeCoverageEnabled = isCodeCoverageEnabled this.isEarlyFlakeDetectionEnabled = isEarlyFlakeDetectionEnabled this.earlyFlakeDetectionNumRetries = earlyFlakeDetectionNumRetries this.earlyFlakeDetectionSlowTestRetries = earlyFlakeDetectionSlowTestRetries ?? {} this.earlyFlakeDetectionFaultyThreshold = earlyFlakeDetectionFaultyThreshold this.isKnownTestsEnabled = isKnownTestsEnabled if (isFlakyTestRetriesEnabled && this.isTestIsolationEnabled) { this.isFlakyTestRetriesEnabled = true this.flakyTestRetriesCount = flakyTestRetriesCount ?? 0 this.cypressConfig.retries.runMode = this.flakyTestRetriesCount } else { this.flakyTestRetriesCount = 0 } this.isTestManagementTestsEnabled = isTestManagementEnabled this.testManagementAttemptToFixRetries = testManagementAttemptToFixRetries this.isImpactedTestsEnabled = isImpactedTestsEnabled } return this.cypressConfig }) return this.libraryConfigurationPromise } getIsTestModified (testSuiteAbsolutePath) { const relativeTestSuitePath = getTestSuitePath(testSuiteAbsolutePath, this.repositoryRoot) if (!this.modifiedFiles) { return false } const lines = this.modifiedFiles[relativeTestSuitePath] if (!lines) { return false } return lines.length > 0 } getTestSuiteProperties (testSuite) { return this.testManagementTests?.cypress?.suites?.[testSuite]?.tests || {} } getTestProperties (testSuite, testName) { const { attempt_to_fix: isAttemptToFix, disabled: isDisabled, quarantined: isQuarantined } = this.getTestSuiteProperties(testSuite)?.[testName]?.properties || {} return { isAttemptToFix, isDisabled, isQuarantined } } /** * Returns how many EFD retries must be scheduled before the first duration is known. * * @returns {number} */ getConfiguredEfdRetryCount () { const { earlyFlakeDetectionSlowTestRetries } = this if (!earlyFlakeDetectionSlowTestRetries || !Object.keys(earlyFlakeDetectionSlowTestRetries).length) { return this.earlyFlakeDetectionNumRetries } return getMaxEfdRetryCount(earlyFlakeDetectionSlowTestRetries) } /** * Returns the selected EFD retry count for a test, or the scheduling count if it has not run yet. * * @param {string} testSuite * @param {string} testName * @returns {number} */ getEfdRetryCountForTest (testSuite, testName) { const testSuiteRetries = this.efdRetryCountByTest[testSuite] if (!testSuiteRetries || testSuiteRetries[testName] === undefined) { return this.getConfiguredEfdRetryCount() } return testSuiteRetries[testName] } /** * Stores the selected EFD retry count for a test after its first execution duration is known. * * @param {string} testSuite * @param {string} testName * @param {number | undefined} duration * @returns {number} */ setEfdRetryCountForTest (testSuite, testName, duration) { if (!this.efdRetryCountByTest[testSuite]) { this.efdRetryCountByTest[testSuite] = {} } const retryCount = getEfdRetryCount(duration ?? 0, this.earlyFlakeDetectionSlowTestRetries) this.efdRetryCountByTest[testSuite][testName] = retryCount if (retryCount === 0) { if (!this.efdSlowAbortedTests[testSuite]) { this.efdSlowAbortedTests[testSuite] = {} } this.efdSlowAbortedTests[testSuite][testName] = true } return retryCount } /** * Returns whether an EFD retry clone is beyond the selected retry count and should be discarded. * * @param {string} testSuite * @param {string} testName * @param {number} efdRetryIndex * @returns {boolean} */ shouldSkipEfdRetry (testSuite, testName, efdRetryIndex) { const testSuiteRetries = this.efdRetryCountByTest[testSuite] return testSuiteRetries?.[testName] !== undefined && efdRetryIndex > testSuiteRetries[testName] } getTestSuiteSpan ({ testSuite, testSuiteAbsolutePath }) { const testSuiteSpanMetadata = { ...getTestSuiteCommonTags(this.command, this.frameworkVersion, testSuite, TEST_FRAMEWORK_NAME), ...this.getSessionRequestErrorTags(), ...this.getSessionItrSkippingEnabledTags(), } this.ciVisEvent(TELEMETRY_EVENT_CREATED, 'suite') if (testSuiteAbsolutePath) { const resolvedSuiteAbsolutePath = resolveOriginalSourceFile(testSuiteAbsolutePath) || testSuiteAbsolutePath const testSourceFile = getTestSuitePath(resolvedSuiteAbsolutePath, this.repositoryRoot) testSuiteSpanMetadata[TEST_SOURCE_FILE] = testSourceFile testSuiteSpanMetadata[TEST_SOURCE_START] = 1 const codeOwners = this.getTestCodeOwners({ testSuite, testSourceFile }) if (codeOwners) { testSuiteSpanMetadata[TEST_CODE_OWNERS] = codeOwners } } return this.tracer.startSpan(`${TEST_FRAMEWORK_NAME}.test_suite`, { childOf: this.testModuleSpan, tags: { [COMPONENT]: TEST_FRAMEWORK_NAME, ...this.testEnvironmentMetadata, ...testSuiteSpanMetadata, }, integrationName: TEST_FRAMEWORK_NAME, }) } getTestSpan ({ testName, testSuite, isUnskippable, isForcedToRun, testSourceFile, isDisabled, isQuarantined }) { const testSuiteTags = { [TEST_COMMAND]: this.command, [TEST_MODULE]: TEST_FRAMEWORK_NAME, } if (this.testSuiteSpan) { testSuiteTags[TEST_SUITE_ID] = this.testSuiteSpan.context().toSpanId() } if (this.testSessionSpan && this.testModuleSpan) { testSuiteTags[TEST_SESSION_ID] = this.testSessionSpan.context().toTraceId() testSuiteTags[TEST_MODULE_ID] = this.testModuleSpan.context().toSpanId() Object.assign(testSuiteTags, this.getSessionRequestErrorTags()) // If testSuiteSpan couldn't be created, we'll use the testModuleSpan as the parent if (!this.testSuiteSpan) { testSuiteTags[TEST_SUITE_ID] = this.testModuleSpan.context().toSpanId() } } const childOf = getTestParentSpan(this.tracer) const { resource, ...testSpanMetadata } = getTestCommonTags(testName, testSuite, this.cypressConfig.version, TEST_FRAMEWORK_NAME) if (testSourceFile) { testSpanMetadata[TEST_SOURCE_FILE] = testSourceFile } Object.assign(testSpanMetadata, this.getSessionItrSkippingEnabledTags()) const codeOwners = this.getTestCodeOwners({ testSuite, testSourceFile }) if (codeOwners) { testSpanMetadata[TEST_CODE_OWNERS] = codeOwners } if (isUnskippable) { this.hasUnskippableSuites = true incrementCountMetric(TELEMETRY_ITR_UNSKIPPABLE, { testLevel: 'suite' }) testSpanMetadata[TEST_ITR_UNSKIPPABLE] = 'true' } if (isForcedToRun) { this.hasForcedToRunSuites = true incrementCountMetric(TELEMETRY_ITR_FORCED_TO_RUN, { testLevel: 'suite' }) testSpanMetadata[TEST_ITR_FORCED_RUN] = 'true' } if (isDisabled) { testSpanMetadata[TEST_MANAGEMENT_IS_DISABLED] = 'true' } if (isQuarantined) { testSpanMetadata[TEST_MANAGEMENT_IS_QUARANTINED] = 'true' } this.ciVisEvent(TELEMETRY_EVENT_CREATED, 'test', { hasCodeOwners: !!codeOwners }) return this.tracer.startSpan(`${TEST_FRAMEWORK_NAME}.test`, { childOf, tags: { [COMPONENT]: TEST_FRAMEWORK_NAME, [ORIGIN_KEY]: CI_APP_ORIGIN, ...testSpanMetadata, ...this.testEnvironmentMetadata, ...testSuiteTags, }, integrationName: TEST_FRAMEWORK_NAME, }) } /** * Returns request error tags from the test session span for propagation to test spans. * @returns {Record<string, string>} */ getSessionRequestErrorTags () { return getSessionRequestErrorTags(this.testSessionSpan) } /** * Returns ITR skipping-enabled tags from the test session span for propagation to child events. * * @returns {Record<string, string>} */ getSessionItrSkippingEnabledTags () { return getSessionItrSkippingEnabledTags(this.testSessionSpan) } ciVisEvent (name, testLevel, tags = {}) { incrementCountMetric(name, { testLevel, testFramework: 'cypress', isUnsupportedCIProvider: !this.ciProviderName, ...tags, }) } async beforeRun (details) { // We need to make sure that the plugin is initialized before running the tests // This is for the case where the user has not returned the promise from the init function await this.libraryConfigurationPromise this.command = getCypressCommand(details) this.frameworkVersion = getCypressVersion(details) this.rootDir = getRootDir(details) const { knownTestsResponse, testManagementTestsResponse, skippableSuitesResponse: skippableTestsRequestResponse, } = await getTestOptimizationRequestResults({ isKnownTestsEnabled: this.isKnownTestsEnabled, isTestManagementTestsEnabled: this.isTestManagementTestsEnabled, isSuitesSkippingEnabled: this.isSuitesSkippingEnabled, getKnownTests: () => getKnownTests(this.tracer, this.testConfiguration), getTestManagementTests: () => getTestManagementTests(this.tracer, this.testConfiguration), getSkippableSuites: () => getSkippableTests(this.tracer, this.testConfiguration), }) if (this.isKnownTestsEnabled) { const currentKnownTestsResponse = knownTestsResponse || await getKnownTests(this.tracer, this.testConfiguration) if (currentKnownTestsResponse.err) { log.error('Cypress known tests response error', currentKnownTestsResponse.err) this._pendingRequestErrorTags.push({ tag: DD_CI_LIBRARY_CONFIGURATION_ERROR_KNOWN_TESTS, value: 'true' }) this.isEarlyFlakeDetectionEnabled = false this.isKnownTestsEnabled = false } else { if (currentKnownTestsResponse.knownTests?.[TEST_FRAMEWORK_NAME]) { this.knownTestsByTestSuite = currentKnownTestsResponse.knownTests[TEST_FRAMEWORK_NAME] } else { this.isEarlyFlakeDetectionEnabled = false this.isKnownTestsEnabled = false } } } if (this.isKnownTestsEnabled && details.specs) { const testSuites = details.specs.map(({ relative }) => relative) const isFaulty = getIsFaultyEarlyFlakeDetection( testSuites, this.knownTestsByTestSuite, this.earlyFlakeDetectionFaultyThreshold ) if (isFaulty) { log.warn('New test detection is disabled because the number of new test files is too high.') this.isEarlyFlakeDetectionEnabled = false this.isEarlyFlakeDetectionFaulty = true this.isKnownTestsEnabled = false } } if (this.isSuitesSkippingEnabled) { const skippableTestsResponse = skippableTestsRequestResponse || await getSkippableTests(this.tracer, this.testConfiguration) if (skippableTestsResponse.err) { log.error('Cypress skippable tests response error', skippableTestsResponse.err) this._pendingRequestErrorTags.push({ tag: DD_CI_LIBRARY_CONFIGURATION_ERROR_SKIPPABLE_TESTS, value: 'true' }) } else { const { skippableTests, correlationId } = skippableTestsResponse this.testsToSkip = skippableTests || [] this.itrCorrelationId = correlationId incrementCountMetric(TELEMETRY_ITR_SKIPPED, { testLevel: 'test' }, this.testsToSkip.length) } } if (this.isTestManagementTestsEnabled) { const currentTestManagementTestsResponse = testManagementTestsResponse || await getTestManagementTests(this.tracer, this.testConfiguration) if (currentTestManagementTestsResponse.err) { log.error('Cypress test management tests response error', currentTestManagementTestsResponse.err) this._pendingRequestErrorTags.push({ tag: DD_CI_LIBRARY_CONFIGURATION_ERROR_TEST_MANAGEMENT_TESTS, value: 'true', }) this.isTestManagementTestsEnabled = false } else { this.testManagementTests = currentTestManagementTestsResponse.testManagementTests } } if (this.isImpactedTestsEnabled) { try { this.modifiedFiles = getModifiedFiles(this.testEnvironmentMetadata) } catch (error) { log.error(error) this.isImpactedTestsEnabled = false } } // `details.specs` are test files if (details.specs) { for (const { absolute, relative } of details.specs) { const isUnskippableSuite = isMarkedAsUnskippable({ path: absolute }) if (isUnskippableSuite) { this.unskippableSuites.push(relative) } } } const childOf = getTestParentSpan(this.tracer) const testSessionSpanMetadata = getTestSessionCommonTags(this.command, this.frameworkVersion, TEST_FRAMEWORK_NAME) const testModuleSpanMetadata = getTestModuleCommonTags(this.command, this.frameworkVersion, TEST_FRAMEWORK_NAME) if (this.isEarlyFlakeDetectionEnabled) { testSessionSpanMetadata[TEST_EARLY_FLAKE_ENABLED] = 'true' } if (this.isEarlyFlakeDetectionFaulty) { testSessionSpanMetadata[TEST_EARLY_FLAKE_ABORT_REASON] = 'faulty' } const trimmedCommand = DD_MAJOR < 6 ? this.command : 'cypress run' const testSessionName = getTestSessionName( this.tracer._tracer._config, trimmedCommand, this.testEnvironmentMetadata ) if (this.tracer._tracer._exporter?.addMetadataTags) { const metadataTags = {} for (const testLevel of TEST_LEVEL_EVENT_TYPES) { metadataTags[testLevel] = { [TEST_SESSION_NAME]: testSessionName, } } const libraryCapabilitiesTags = getLibraryCapabilitiesTags(this.constructor.id, this.frameworkVersion) metadataTags.test = { ...metadataTags.test, ...libraryCapabilitiesTags, } this.tracer._tracer._exporter.addMetadataTags(metadataTags) } // Capture time references that match what startSpan records internally // (trace.startTime = Date.now(), trace.ticks = performance.now()). // This lets _now() produce values in the same coordinate system as // span._startTime without accessing span internals. this._timeOrigin = dateNow() this._perfOrigin = performance.now() this.testSessionSpan = this.tracer.startSpan(`${TEST_FRAMEWORK_NAME}.test_session`, { childOf, tags: { [COMPONENT]: TEST_FRAMEWORK_NAME, ...this.testEnvironmentMetadata, ...testSessionSpanMetadata, }, integrationName: TEST_FRAMEWORK_NAME, }) for (const { tag, value } of this._pendingRequestErrorTags) { this.testSessionSpan.setTag(tag, value) } this._pendingRequestErrorTags = [] this.ciVisEvent(TELEMETRY_EVENT_CREATED, 'session') const sessionRequestErrorTags = getSessionRequestErrorTags(this.testSessionSpan) this.testModuleSpan = this.tracer.startSpan(`${TEST_FRAMEWORK_NAME}.test_module`, { childOf: this.testSessionSpan, tags: { [COMPONENT]: TEST_FRAMEWORK_NAME, ...this.testEnvironmentMetadata, ...testModuleSpanMetadata, ...sessionRequestErrorTags, }, integrationName: TEST_FRAMEWORK_NAME, }) if (this.hasLibraryConfiguration) { const skippingEnabled = this.isSuitesSkippingEnabled ? 'true' : 'false' this.testSessionSpan.setTag(TEST_ITR_SKIPPING_ENABLED, skippingEnabled) this.testModuleSpan.setTag(TEST_ITR_SKIPPING_ENABLED, skippingEnabled) } this.ciVisEvent(TELEMETRY_EVENT_CREATED, 'module') return details } afterRun (suiteStats) { if (!this._isInit) { log.warn('Attemping to call afterRun without initializating the plugin first') return } if (this.testSessionSpan && this.testModuleSpan) { const testStatus = getSessionStatus(suiteStats) this.testModuleSpan.setTag(TEST_STATUS, testStatus) this.testSessionSpan.setTag(TEST_STATUS, testStatus) addIntelligentTestRunnerSpanTags( this.testSessionSpan, this.testModuleSpan, { isSuitesSkipped: this.isTestsSkipped, isSuitesSkippingEnabled: this.isSuitesSkippingEnabled, isCodeCoverageEnabled: this.isCodeCoverageEnabled, skippingType: 'test', skippingCount: this.skippedTests.length, hasForcedToRunSuites: this.hasForcedToRunSuites, hasUnskippableSuites: this.hasUnskippableSuites, } ) if (this.isTestManagementTestsEnabled) { this.testSessionSpan.setTag(TEST_MANAGEMENT_ENABLED, 'true') } logTestOptimizationSummary({ attemptToFixExecutions: this.attemptToFixExecutions, newTestsWithDynamicNames: this.newTestsWithDynamicNames, }) this.testModuleSpan.finish() this.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'module') this.testSessionSpan.finish() this.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'session') incrementCountMetric(TELEMETRY_TEST_SESSION, { provider: this.ciProviderName, autoInjected: !!getConfig().DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER, }) finishAllTraceSpans(this.testSessionSpan) } return new Promise(resolve => { const finishAfterRun = () => { this._isInit = false appClosingTelemetry() resolve(null) } const exporter = this.tracer._tracer._exporter if (!exporter) { finishAfterRun() return } if (exporter.flush) { exporter.flush(() => { finishAfterRun() }) } else if (exporter._writer) { exporter._writer.flush(() => { finishAfterRun() }) } else { finishAfterRun() } }) } afterSpec (spec, results) { const { tests, stats } = results || {} const cypressTests = tests || [] const finishedTests = this.finishedTestsByFile[spec.relative] || [] if (!this.testSuiteSpan) { // dd:testSuiteStart hasn't been triggered for whatever reason // We will create the test suite span on the spot if that's the case log.warn('There was an error creating the test suite event.') this.testSuiteSpan = this.getTestSuiteSpan({ testSuite: spec.relative, testSuiteAbsolutePath: spec.absolute, }) } // Get tests that didn't go through `dd:afterEach` // and create a skipped test span for each of them for (const { title } of cypressTests) { const cypressTestName = title.join(' ') const isTestFinished = finishedTests.find(({ testName }) => cypressTestName === testName) if (isTestFinished) { continue } const isSkippedByItr = this.testsToSkip.find(test => cypressTestName === test.name && spec.relative === test.suite ) const testSourceFile = spec.absolute && this.repositoryRoot ? getTestSuitePath(spec.absolute, this.repositoryRoot) : spec.relative const skippedTestSpan = this.getTestSpan({ testName: cypressTestName, testSuite: spec.relative, testSourceFile }) skippedTestSpan.setTag(TEST_FINAL_STATUS, 'skip') skippedTestSpan.setTag(TEST_STATUS, 'skip') if (isSkippedByItr) { skippedTestSpan.setTag(TEST_SKIPPED_BY_ITR, 'true') } if (this.itrCorrelationId) { skippedTestSpan.setTag(ITR_CORRELATION_ID, this.itrCorrelationId) } const { isDisabled, isQuarantined } = this.getTestProperties(spec.relative, cypressTestName) if (isDisabled) { skippedTestSpan.setTag(TEST_MANAGEMENT_IS_DISABLED, 'true') } else if (isQuarantined) { skippedTestSpan.setTag(TEST_MANAGEMENT_IS_QUARANTINED, 'true') } skippedTestSpan.finish() } // Make sure that reported test statuses are the same as Cypress reports. // This is not always the case, such as when an `after` hook fails: // Cypress will report the last run test as failed, but we don't know that yet at `dd:afterEach` let latestError const finishedTestsByTestName = finishedTests.reduce((acc, finishedTest) => { if (!acc[finishedTest.testName]) { acc[finishedTest.testName] = [] } acc[finishedTest.testName].push(finishedTest) return acc }, {}) for (const [testName, finishedTestAttempts] of Object.entries(finishedTestsByTestName)) { for (const [attemptIndex, finishedTest] of finishedTestAttempts.entries()) { // We can check if this is the last attempt regardless of the retry mechanism const isLastAttempt = attemptIndex === finishedTestAttempts.length - 1 const isDatadogManagedAttempt = finishedTest.isEfdManagedTest || finishedTest.isAttemptToFix const cypressTest = isDatadogManagedAttempt ? getMatchingCypressTest(cypressTests, testName, attemptIndex, finishedTest.testStatus, isLastAttempt) || cypressTests.find(test => test.title.join(' ') === testName) : cypressTests.find(test => test.title.join(' ') === testName) if (!cypressTest) { continue } // finishedTests can include multiple tests with the same name if they have been retried // by early flake detection. Cypress is unaware of this so .attempts does not necessarily have // the same length as `finishedTestAttempts` const shouldUseCapturedStatus = isDatadogManagedAttempt && !(isLastAttempt && isCypressHookFailure(cypressTest)) let cypressTestStatus = shouldUseCapturedStatus ? finishedTest.testStatus : CYPRESS_STATUS_TO_TEST_STATUS[cypressTest.state] if (!finishedTest.isEfdManagedTest && !finishedTest.isAttemptToFix && cypressTest.attempts && cypressTest.attempts[attemptIndex]) { cypressTestStatus = CYPRESS_STATUS_TO_TEST_STATUS[cypressTest.attempts[attemptIndex].state] const isAtrRetry = attemptIndex > 0 && this.isFlakyTestRetriesEnabled && !finishedTest.isAttemptToFix && !finishedTest.isEfdRetry if (attemptIndex > 0) { finishedTest.testSpan.setTag(TEST_IS_RETRY, 'true') if (finishedTest.isEfdRetry) { finishedTest.testSpan.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.efd) } else if (isAtrRetry) { finishedTest.testSpan.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.atr) } else { finishedTest.testSpan.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.ext) } } } if (finishedTest.isEfdManagedTest && finishedTest.testStatus !== 'skip' && cypressTestStatus === 'skip') { cypressTestStatus = finishedTest.testStatus } if (cypressTest.displayError) { latestError = new Error(cypressTest.displayError) } // Update test status - but NOT for non-ATF quarantined tests where we intentionally // report 'fail' to Datadog even though Cypress sees it as 'pass' const isQuarantinedTest = finishedTest.testSpan?.context()?._tags?.[TEST_MANAGEMENT_IS_QUARANTINED] === 'true' if (cypressTestStatus !== finishedTest.testStatus && (!isQuarantinedTest || finishedTest.isAttemptToFix)) { finishedTest.testSpan.setTag(TEST_STATUS, cypressTestStatus) finishedTest.testSpan.setTag('error', latestError) if (finishedTest.isAttemptToFix && cypressTestStatus === 'fail') { finishedTest.testSpan.setTag(TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED, 'false') } } if (this.itrCorrelationId) { finishedTest.testSpan.setTag(ITR_CORRELATION_ID, this.itrCorrelationId) } const resolvedSpecAbsolutePath = spec.absolute ? resolveOriginalSourceFile(spec.absolute) || spec.absolute : spec.absolute const testSourceFile = resolvedSpecAbsolutePath && this.repositoryRoot ? getTestSuitePath(resolvedSpecAbsolutePath, this.repositoryRoot) : spec.relative if (testSourceFile) { finishedTest.testSpan.setTag(TEST_SOURCE_FILE, testSourceFile) } const codeOwners = this.getTestCodeOwners({ testSuite: spec.relative, testSourceFile }) if (codeOwners) { finishedTest.testSpan.setTag(TEST_CODE_OWNERS, codeOwners) } if (isLastAttempt) { const testSpanTags = finishedTest.testSpan.context()._tags const retryKind = getFinalStatusRetryKind({ finishedTest, finishedTestAttempts, flakyTestRetriesCount: this.flakyTestRetriesCount, }) const hasFailedAllRetries = testSpanTags[TEST_HAS_FAILED_ALL_RETRIES] === 'true' const hasPassedAllAtfRetries = testSpanTags[TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED] === 'true' const isQuarantined = testSpanTags[TEST_MANAGEMENT_IS_QUARANTINED] === 'true' const isDisabled = testSpanTags[TEST_MANAGEMENT_IS_DISABLED] === 'true' const finalStatus = getFinalStatus({ status: cypressTestStatus, retryKind, hasFailedAllRetries, hasPassedAllAtfRetries, isQuarantined, isDisabled, }) if (finalStatus) { finishedTest.testSpan.setTag(TEST_FINAL_STATUS, finalStatus) } } finishedTest.testSpan.finish(finishedTest.finishTime) } } if (this.testSuiteSpan) { const status = getSuiteStatus(stats) this.testSuiteSpan.setTag(TEST_STATUS, status) if (latestError) { this.testSuiteSpan.setTag('error', latestError) } this.testSuiteSpan.finish() this.testSuiteSpan = null this.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'suite') } } getTasks () { return { 'dd:testSuiteStart': ({ testSuite, testSuiteAbsolutePath }) => { const suitePayload = { isEarlyFlakeDetectionEnabled: this.isEarlyFlakeDetectionEnabled, knownTestsForSuite: this.knownTestsByTestSuite?.[testSuite] || [], earlyFlakeDetectionNumRetries: this.getConfiguredEfdRetryCount(), earlyFlakeDetectionSlowTestRetries: this.earlyFlakeDetectionSlowTestRetries, isKnownTestsEnabled: this.isKnownTestsEnabled, isTestManagementEnabled: this.isTestManagementTestsEnabled, testManagementAttemptToFixRetries: this.testManagementAttemptToFixRetries, testManagementTests: this.getTestSuiteProperties(testSuite), isImpactedTestsEnabled: this.isImpactedTestsEnabled, isModifiedTest: this.getIsTestModified(testSuiteAbsolutePath), repositoryRoot: this.repositoryRoot, isTestIsolationEnabled: this.isTestIsolationEnabled, rumFlushWaitMillis: this.rumFlushWaitMillis, } this.testSuiteSpan ||= this.getTestSuiteSpan({ testSuite, testSuiteAbsolutePath }) return suitePayload }, 'dd:beforeEach': (test) => { const { testName, testSuite, isEfdRetry, efdRetryIndex } = test if (isEfdRetry && this.shouldSkipEfdRetry(testSuite, testName, efdRetryIndex)) { return { shouldSkip: true, shouldDiscard: true } } const shouldSkip = this.testsToSkip.some(test => { return testName === test.name && testSuite === test.suite }) const isUnskippable = this.unskippableSuites.includes(testSuite) const isForcedToRun = shouldSkip && isUnskippable const { isAttemptToFix, isDisabled, isQuarantined } = this.getTestProperties(testSuite, testName) // skip test if (shouldSkip && !isUnskippable) { this.skippedTests.push(test) this.isTestsSkipped = true return { shouldSkip: true } } if (isAttemptToFix) { logAttemptToFixTestExecution(testSuite, testName, this.loggedAttemptToFixTests) } // For disabled tests (not attemptToFix), skip them if (!isAttemptToFix && isDisabled) { return { shouldSkip: true } } // Quarantined tests (not attemptToFix) run normally but their failures are caught // by Cypress.on('fail') in support.js and suppressed, so Cypress sees them as passed if (!this.activeTestSpan) { this.activeTestSpan = this.getTestSpan({ testName, testSuite, isUnskippable, isForcedToRun, isDisabled, isQuarantined, }) } return this.activeTestSpan ? { traceId: this.activeTestSpan.context().toTraceId() } : {} }, 'dd:afterEach': ({ test, coverage }) => { if (!this.activeTestSpan) { log.warn('There is no active test span in dd:afterEach handler') return null } const { state, error, isRUMActive, testSourceLine, testSourceStack, testSuite, testSuiteAbsolutePath, testName, testItTitle, isNew, isEfdRetry, isAttemptToFix, isModified, duration, isQuarantined: isQuarantinedFromSupport, isDisabled: isDisabledFromSupport, } = test if (coverage && this.isCodeCoverageEnabled && this.tracer._tracer._exporter?.exportCoverage) { const coverageFiles = getCoveredFilenamesFromCoverage(coverage) const relativeCoverageFiles = [...coverageFiles, testSuiteAbsolutePath].map( file => getTestSuitePath(file, this.repositoryRoot || this.rootDir) ) if (!relativeCoverageFiles.length) { incrementCountMetric(TELEMETRY_CODE_COVERAGE_EMPTY) } distributionMetric(TELEMETRY_CODE_COVERAGE_NUM_FILES, {}, relativeCoverageFiles.length) const { _traceId, _spanId } = this.testSuiteSpan.context() const formattedCoverage = { sessionId: _traceId, suiteId: _spanId, testId: this.activeTestSpan.context()._spanId, files: relativeCoverageFiles, } this.tracer._tracer._exporter.exportCoverage(formattedCoverage) } const isEfdManagedTest = (isNew || isModified) && this.isEarlyFlakeDetectionEnabled && !isAttemptToFix let testStatus = CYPRESS_STATUS_TO_TEST_STATUS[state] let didAbortSlowEfdRetries = false if (isEfdManagedTest && !isEfdRetry && this.efdRetryCountByTest[testSuite]?.[testName] === undefined) { const retryCount = this.setEfdRetryCountForTest(testSuite, testName, duration) if (retryCount === 0) { didAbortSlowEfdRetries = true this.activeTestSpan.setTag(TEST_EARLY_FLAKE_ABORT_REASON, 'slow') } } if (didAbortSlowEfdRetries && testStatus === 'skip' && !error && duration > 0) { testStatus = 'pass' } this.activeTestSpan.setTag(TEST_STATUS, testStatus) // Save the test status to know if it has passed all retries if (this.testStatuses[testName]) { this.testStatuses[testName].push(testStatus) } else { this.testStatuses[testName] = [testStatus] } const testStatuses = this.testStatuses[testName] const activeSpanTags = this.activeTestSpan.context()._tags if (error) { this.activeTestSpan.setTag('error', error) } if (isRUMActive) { this.activeTestSpan.setTag(TEST_IS_RUM_ACTIVE, 'true') } // Source-line resolution strategy: // 1. If plain JS and no source map, trust invocationDetails.line directly. // 2. Otherwise, try invocationDetails.stack line mapped through source map. // 3. If that fails, scan generated file for it/test/specify declaration by test name. // 4. If declaration found: // - .ts file: use declaration line directly. // - .js file: map declaration line through source map. // 5. If all fail, keep original invocationDetails.line. if (testSourceLine) { let resolvedLine = testSourceLine if (testSuiteAbsolutePath && testItTitle) { // Use invocationDetails directly only for plain JS specs without source maps. // Otherwise, resolve from the test declaration in the spec and map via source map. const shouldTrustInvocationDetails = shouldTrustInvocationDetailsLine(testSuiteAbsolutePath, testSourceLine) if (!shouldTrustInvocationDetails) { resolvedLine = resolveSourceLineForTest( testSuiteAbsolutePath, testItTitle, testSourceStack ) ?? testSourceLine } } this.activeTestSpan.setTag(TEST_SOURCE_START, resolvedLine) } if (isNew) { this.activeTestSpan.setTag(TEST_IS_NEW, 'true') if (isEfdRetry) { this.activeTestSpan.setTag(TEST_IS_RETRY, 'true') this.activeTestSpan.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.efd) } if (DYNAMIC_NAME_RE.test(testName)) { this