dd-trace
Version:
Datadog APM tracing client for JavaScript
633 lines (576 loc) • 20.7 kB
JavaScript
'use strict'
const { basename } = require('node:path')
const { storage } = require('../../datadog-core')
const id = require('../../dd-trace/src/id')
const CiPlugin = require('../../dd-trace/src/plugins/ci_plugin')
const getConfig = require('../../dd-trace/src/config')
const {
SCREENSHOT_UPLOAD_RESULT_ERROR,
SCREENSHOT_UPLOAD_RESULT_UPLOADED,
getScreenshotCapturedAtMs,
getScreenshotUploadResult,
getScreenshotUploadTag,
} = require('../../dd-trace/src/ci-visibility/test-screenshot')
const {
finishAllTraceSpans,
getTestSuiteCommonTags,
getTestSuitePath,
isModifiedTest,
setRumTestCorrelation,
TEST_BROWSER_NAME,
TEST_CODE_OWNERS,
TEST_EARLY_FLAKE_ABORT_REASON,
TEST_EARLY_FLAKE_ENABLED,
TEST_FRAMEWORK_VERSION,
TEST_HAS_FAILED_ALL_RETRIES,
TEST_IS_MODIFIED,
TEST_IS_NEW,
TEST_IS_RETRY,
TEST_IS_RUM_ACTIVE,
TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED,
TEST_MANAGEMENT_ENABLED,
TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX,
TEST_MANAGEMENT_IS_DISABLED,
TEST_MANAGEMENT_IS_QUARANTINED,
TEST_MODULE_ID,
TEST_MODULE,
TEST_NAME,
TEST_PARAMETERS,
TEST_RETRY_REASON_TYPES,
TEST_RETRY_REASON,
TEST_SESSION_ID,
TEST_SOURCE_FILE,
TEST_SOURCE_START,
TEST_STATUS,
TEST_SUITE_ID,
TEST_SUITE,
TEST_HAS_DYNAMIC_NAME,
DYNAMIC_NAME_RE,
TEST_FINAL_STATUS,
} = require('../../dd-trace/src/plugins/util/test')
const { RESOURCE_NAME } = require('../../../ext/tags')
const { COMPONENT } = require('../../dd-trace/src/constants')
const {
TELEMETRY_EVENT_CREATED,
TELEMETRY_EVENT_FINISHED,
TELEMETRY_TEST_SESSION,
} = require('../../dd-trace/src/ci-visibility/telemetry')
const { appClosing: appClosingTelemetry } = require('../../dd-trace/src/telemetry')
const log = require('../../dd-trace/src/log')
const PLAYWRIGHT_FAILURE_SCREENSHOT_RE = /^test-failed-\d+\.png$/
/**
* Returns whether an attachment is an automatic Playwright failure screenshot.
*
* @param {object} attachment - Playwright test attachment
* @returns {boolean}
*/
function isPlaywrightFailureScreenshot (attachment) {
return attachment?.name === 'screenshot' &&
attachment.contentType === 'image/png' &&
typeof attachment.path === 'string' &&
PLAYWRIGHT_FAILURE_SCREENSHOT_RE.test(basename(attachment.path))
}
class PlaywrightPlugin extends CiPlugin {
static id = 'playwright'
constructor (...args) {
super(...args)
this._testSuiteSpansByTestSuiteAbsolutePath = new Map()
this.numFailedTests = 0
this.numFailedSuites = 0
this.pendingTestFinishes = 0
this.finishSession = undefined
this.addSub('ci:playwright:test:is-modified', ({
filePath,
modifiedFiles,
onDone,
}) => {
const testSuite = getTestSuitePath(filePath, this.repositoryRoot)
const isModified = isModifiedTest(testSuite, 0, 0, modifiedFiles, this.constructor.id)
onDone(isModified)
})
this.addSub('ci:playwright:session:start', ({ isFailureScreenshotEnabled }) => {
if (!getConfig().testOptimization.DD_TEST_FAILURE_SCREENSHOTS_ENABLED) return
if (!isFailureScreenshotEnabled) {
log.warn(
'%s %s',
'DD_TEST_FAILURE_SCREENSHOTS_ENABLED is true, but Playwright screenshot capture is disabled.',
'Set Playwright use.screenshot to "only-on-failure", "on-first-failure", or "on".'
)
}
})
this.addSub('ci:playwright:session:configuration', ({ isFailureScreenshotEnabled }) => {
if (!getConfig().testOptimization.DD_TEST_FAILURE_SCREENSHOTS_ENABLED || !isFailureScreenshotEnabled) return
if (!this.tracer._exporter?.canUploadTestScreenshots?.()) {
log.warn(
'%s %s',
'DD_TEST_FAILURE_SCREENSHOTS_ENABLED is true, but Playwright screenshot upload is not supported',
'by the active Test Optimization transport.'
)
}
})
this.addSub('ci:playwright:session:finish', ({
status,
isEarlyFlakeDetectionEnabled,
isEarlyFlakeDetectionFaulty,
isTestManagementTestsEnabled,
onDone,
}) => {
const finishSession = () => {
this.testModuleSpan.setTag(TEST_STATUS, status)
this.testSessionSpan.setTag(TEST_STATUS, status)
if (isEarlyFlakeDetectionEnabled) {
this.testSessionSpan.setTag(TEST_EARLY_FLAKE_ENABLED, 'true')
}
if (isEarlyFlakeDetectionFaulty) {
this.testSessionSpan.setTag(TEST_EARLY_FLAKE_ABORT_REASON, 'faulty')
}
if (status === 'fail' && this.numFailedSuites > 0) {
let errorMessage = `Test suites failed: ${this.numFailedSuites}.`
if (this.numFailedTests > 0) {
errorMessage += ` Tests failed: ${this.numFailedTests}`
}
const error = new Error(errorMessage)
this.testModuleSpan.setTag('error', error)
this.testSessionSpan.setTag('error', error)
}
if (isTestManagementTestsEnabled) {
this.testSessionSpan.setTag(TEST_MANAGEMENT_ENABLED, 'true')
}
this.testModuleSpan.finish()
this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'module')
this.testSessionSpan.finish()
this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'session')
finishAllTraceSpans(this.testSessionSpan)
this.telemetry.count(TELEMETRY_TEST_SESSION, {
provider: this.ciProviderName,
autoInjected: !!this._tracerConfig.testOptimization.DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER,
})
appClosingTelemetry()
this.tracer._exporter.flush(onDone)
this.numFailedTests = 0
this.numFailedSuites = 0
this.finishSession = undefined
}
if (this.pendingTestFinishes > 0) {
this.finishSession = finishSession
} else {
finishSession()
}
})
this.addBind('ci:playwright:test-suite:start', (ctx) => {
const { testSuiteAbsolutePath, testSourceFileAbsolutePath } = ctx
const store = storage('legacy').getStore()
const testSuite = getTestSuitePath(testSuiteAbsolutePath, this.rootDir)
const testSourceFile = getTestSuitePath(
testSourceFileAbsolutePath || testSuiteAbsolutePath,
this.repositoryRoot
)
const testSuiteMetadata = {
...getTestSuiteCommonTags(
this.command,
this.frameworkVersion,
testSuite,
'playwright'
),
...this.getSessionRequestErrorTags(),
}
if (testSourceFile) {
testSuiteMetadata[TEST_SOURCE_FILE] = testSourceFile
testSuiteMetadata[TEST_SOURCE_START] = 1
}
const codeOwners = this.getCodeOwners(testSuiteMetadata)
if (codeOwners) {
testSuiteMetadata[TEST_CODE_OWNERS] = codeOwners
}
const testSuiteSpan = this.tracer.startSpan('playwright.test_suite', {
childOf: this.testModuleSpan,
tags: {
[COMPONENT]: this.constructor.id,
...this.testEnvironmentMetadata,
...testSuiteMetadata,
},
})
this.telemetry.ciVisEvent(TELEMETRY_EVENT_CREATED, 'suite')
ctx.parentStore = store
ctx.currentStore = { ...store, testSuiteSpan }
this._testSuiteSpansByTestSuiteAbsolutePath.set(testSuiteAbsolutePath, testSuiteSpan)
return ctx.currentStore
})
this.addSub('ci:playwright:test-suite:finish', ({ testSuiteSpan, status, error }) => {
if (!testSuiteSpan) return
if (error) {
testSuiteSpan.setTag('error', error)
testSuiteSpan.setTag(TEST_STATUS, 'fail')
} else {
testSuiteSpan.setTag(TEST_STATUS, status)
}
if (status === 'fail' || error) {
this.numFailedSuites++
}
testSuiteSpan.finish()
this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'suite')
})
this.addSub('ci:playwright:test:page-goto', (ctx) => {
const activeSpan = storage('legacy').getStore()?.span
if (!setRumTestCorrelation(ctx, activeSpan)) {
log.error('ci:playwright:test:page-goto: test span not found')
}
})
this.addSub('ci:playwright:worker:report', ({ serializedTraces, screenshots }) => {
const traces = JSON.parse(serializedTraces)
const formattedTraces = []
let formattedTestSpan
for (const trace of traces) {
const formattedTrace = []
for (const span of trace) {
const formattedSpan = {
...span,
span_id: id(span.span_id),
trace_id: id(span.trace_id),
parent_id: id(span.parent_id),
}
if (span.meta[TEST_IS_NEW] === 'true' && DYNAMIC_NAME_RE.test(span.meta[TEST_NAME] || '')) {
formattedSpan.meta[TEST_HAS_DYNAMIC_NAME] = 'true'
}
if (span.name === 'playwright.test') {
formattedTestSpan = formattedSpan
// TODO: remove this comment
// TODO: Let's pass rootDir, repositoryRoot, command, session id and module id as env vars
// so we don't need this re-serialization logic. This can be passed just once, since they're unique
// for a test session. They can be passed the same way `DD_PLAYWRIGHT_WORKER` is passed.
formattedSpan.meta[TEST_SESSION_ID] = this.testSessionSpan.context().toTraceId()
formattedSpan.meta[TEST_MODULE_ID] = this.testModuleSpan.context().toSpanId()
Object.assign(formattedSpan.meta, this.getSessionRequestErrorTags())
formattedSpan.meta[TEST_FRAMEWORK_VERSION] = this.frameworkVersion
formattedSpan.meta[TEST_MODULE] = this.constructor.id
// MISSING _trace.startTime and _trace.ticks - because by now the suite is already serialized
const testSuite = this._testSuiteSpansByTestSuiteAbsolutePath.get(
formattedSpan.meta.test_suite_absolute_path
)
if (testSuite) {
formattedSpan.meta[TEST_SUITE_ID] = testSuite.context().toSpanId()
}
// test_suite_absolute_path is just a hack because in the worker we don't have rootDir and repositoryRoot
// but if we pass those the same way we pass `DD_PLAYWRIGHT_WORKER` this is not necessary
const testSuitePath = getTestSuitePath(formattedSpan.meta.test_suite_absolute_path, this.rootDir)
const testSourceAbsolutePath = formattedSpan.meta.test_source_absolute_path ||
formattedSpan.meta.test_suite_absolute_path
const testSourceFile = getTestSuitePath(testSourceAbsolutePath, this.repositoryRoot)
// we need to rewrite this because this.rootDir and this.repositoryRoot are not available in the worker
formattedSpan.meta[TEST_SUITE] = testSuitePath
formattedSpan.meta[TEST_SOURCE_FILE] = testSourceFile
formattedSpan.resource = `${testSuitePath}.${formattedSpan.meta[TEST_NAME]}`
delete formattedSpan.meta.test_suite_absolute_path
delete formattedSpan.meta.test_source_absolute_path
}
formattedTrace.push(formattedSpan)
}
formattedTraces.push(formattedTrace)
}
const exportTraces = () => {
for (const trace of formattedTraces) {
this.tracer._exporter.export(trace)
}
}
if (!formattedTestSpan || !screenshots) {
exportTraces()
return
}
this.pendingTestFinishes++
const uploadStarted = this.uploadTestScreenshots({
screenshots,
traceId: formattedTestSpan.trace_id.toString(10),
}, (screenshotUploadResult) => {
const screenshotUploadTag = getScreenshotUploadTag(screenshotUploadResult)
if (screenshotUploadTag) {
formattedTestSpan.meta[screenshotUploadTag] = 'true'
}
exportTraces()
this.pendingTestFinishes--
if (this.pendingTestFinishes === 0 && this.finishSession) {
this.finishSession()
}
})
if (uploadStarted) return
this.pendingTestFinishes--
exportTraces()
})
this.addBind('ci:playwright:test:start', (ctx) => {
const {
testName,
testSuiteAbsolutePath,
testSourceFileAbsolutePath,
testSourceLine,
browserName,
isDisabled,
} = ctx
const store = storage('legacy').getStore()
const testSuite = getTestSuitePath(testSuiteAbsolutePath, this.rootDir)
const testSourceFile = getTestSuitePath(
testSourceFileAbsolutePath || testSuiteAbsolutePath,
this.repositoryRoot
)
const span = this.startTestSpan(
testName,
testSuiteAbsolutePath,
testSuite,
testSourceFile,
testSourceLine,
browserName,
testSourceFileAbsolutePath
)
if (isDisabled) {
span.setTag(TEST_MANAGEMENT_IS_DISABLED, 'true')
}
ctx.parentStore = store
ctx.currentStore = { ...store, span }
return ctx.currentStore
})
this.addSub('ci:playwright:test:finish', ({
span,
testStatus,
steps,
error,
extraTags,
isNew,
hasDynamicName,
isEfdRetry,
isRetry,
isAttemptToFix,
isDisabled,
isQuarantined,
isAttemptToFixRetry,
hasFailedAllRetries,
hasPassedAttemptToFixRetries,
hasFailedAttemptToFixRetries,
isAtrRetry,
isModified,
finalStatus,
earlyFlakeAbortReason,
onDone,
}) => {
if (!span) {
onDone?.()
return
}
const isRUMActive = span.context().getTag(TEST_IS_RUM_ACTIVE)
span.setTag(TEST_STATUS, testStatus)
if (error) {
span.setTag('error', error)
}
if (extraTags) {
span.addTags(extraTags)
}
if (isNew) {
span.setTag(TEST_IS_NEW, 'true')
if (isEfdRetry) {
span.setTag(TEST_IS_RETRY, 'true')
span.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.efd)
}
}
if (hasDynamicName) {
span.setTag(TEST_HAS_DYNAMIC_NAME, 'true')
}
if (isRetry) {
span.setTag(TEST_IS_RETRY, 'true')
if (isAtrRetry) {
span.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.atr)
} else {
span.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.ext)
}
}
if (hasFailedAllRetries) {
span.setTag(TEST_HAS_FAILED_ALL_RETRIES, 'true')
}
if (isAttemptToFix) {
span.setTag(TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX, 'true')
}
if (isAttemptToFixRetry) {
span.setTag(TEST_IS_RETRY, 'true')
span.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.atf)
}
if (hasPassedAttemptToFixRetries) {
span.setTag(TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED, 'true')
} else if (hasFailedAttemptToFixRetries) {
span.setTag(TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED, 'false')
}
if (isDisabled) {
span.setTag(TEST_MANAGEMENT_IS_DISABLED, 'true')
}
if (isQuarantined) {
span.setTag(TEST_MANAGEMENT_IS_QUARANTINED, 'true')
}
if (isModified) {
span.setTag(TEST_IS_MODIFIED, 'true')
if (isEfdRetry) {
span.setTag(TEST_IS_RETRY, 'true')
span.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.efd)
}
}
if (finalStatus) {
span.setTag(TEST_FINAL_STATUS, finalStatus)
}
if (earlyFlakeAbortReason) {
span.setTag(TEST_EARLY_FLAKE_ABORT_REASON, earlyFlakeAbortReason)
}
for (const step of steps) {
const stepStartTime = step.startTime.getTime()
const stepSpan = this.tracer.startSpan('playwright.step', {
childOf: span,
startTime: stepStartTime,
tags: {
[COMPONENT]: this.constructor.id,
'playwright.step': step.title,
[RESOURCE_NAME]: step.title,
},
})
if (step.error) {
stepSpan.setTag('error', step.error)
}
let stepDuration = step.duration
if (stepDuration <= 0 || Number.isNaN(stepDuration)) {
stepDuration = 0
}
stepSpan.finish(stepStartTime + stepDuration)
}
if (finalStatus === 'fail') {
this.numFailedTests++
}
this.telemetry.ciVisEvent(
TELEMETRY_EVENT_FINISHED,
'test',
{
hasCodeOwners: !!span.context().getTag(TEST_CODE_OWNERS),
isNew,
isRum: isRUMActive,
browserDriver: 'playwright',
isQuarantined,
isDisabled,
isModified,
}
)
span.finish()
finishAllTraceSpans(span)
if (this._tracerConfig.DD_PLAYWRIGHT_WORKER) {
this.tracer._exporter.flush(onDone)
}
})
this.addSub('ci:playwright:test:skip', ({
testName,
testSuiteAbsolutePath,
testSourceFileAbsolutePath,
testSourceLine,
browserName,
isNew,
isDisabled,
isModified,
isQuarantined,
}) => {
const testSuite = getTestSuitePath(testSuiteAbsolutePath, this.rootDir)
const testSourceFile = getTestSuitePath(
testSourceFileAbsolutePath || testSuiteAbsolutePath,
this.repositoryRoot
)
const span = this.startTestSpan(
testName,
testSuiteAbsolutePath,
testSuite,
testSourceFile,
testSourceLine,
browserName,
testSourceFileAbsolutePath
)
span.setTag(TEST_STATUS, 'skip')
span.setTag(TEST_FINAL_STATUS, 'skip')
if (isNew) {
span.setTag(TEST_IS_NEW, 'true')
}
if (isDisabled) {
span.setTag(TEST_MANAGEMENT_IS_DISABLED, 'true')
}
if (isModified) {
span.setTag(TEST_IS_MODIFIED, 'true')
}
if (isQuarantined) {
span.setTag(TEST_MANAGEMENT_IS_QUARANTINED, 'true')
}
span.finish()
})
}
/**
* Uploads automatic failure screenshots for a Playwright test attempt.
*
* @param {object} options - Upload options
* @param {Array<object>} options.screenshots - Playwright test attachments
* @param {string} options.traceId - Test trace id used as the screenshot key
* @param {(result: string|undefined) => void} onDone - Completion callback
* @returns {boolean} Whether at least one upload was started
*/
uploadTestScreenshots ({ screenshots, traceId }, onDone) {
const exporter = this.tracer?._exporter
if (!Array.isArray(screenshots) || !screenshots.length ||
!exporter?.canUploadTestScreenshots?.() ||
!exporter.uploadTestScreenshot) {
return false
}
const screenshotPaths = new Set()
for (const screenshot of screenshots) {
if (isPlaywrightFailureScreenshot(screenshot)) {
screenshotPaths.add(screenshot.path)
}
}
if (!screenshotPaths.size) return false
const uploadResults = new Array(screenshotPaths.size)
let pendingUploads = screenshotPaths.size
let index = 0
for (const filePath of screenshotPaths) {
const resultIndex = index++
exporter.uploadTestScreenshot({
filePath,
traceId,
idempotencyKey: `${traceId}:${basename(filePath)}`,
capturedAtMs: getScreenshotCapturedAtMs(filePath, filePath),
}, (error, uploaded = true) => {
if (uploaded) {
uploadResults[resultIndex] = error
? SCREENSHOT_UPLOAD_RESULT_ERROR
: SCREENSHOT_UPLOAD_RESULT_UPLOADED
}
pendingUploads--
if (pendingUploads === 0) {
onDone(getScreenshotUploadResult(uploadResults))
}
})
}
return true
}
// TODO: this runs both in worker and main process (main process: skipped tests that do not go through _runTest)
startTestSpan (
testName,
testSuiteAbsolutePath,
testSuite,
testSourceFile,
testSourceLine,
browserName,
testSourceFileAbsolutePath
) {
const testSuiteSpan = this._testSuiteSpansByTestSuiteAbsolutePath.get(testSuiteAbsolutePath)
const extraTags = {
[TEST_SOURCE_START]: testSourceLine,
}
if (testSourceFile) {
extraTags[TEST_SOURCE_FILE] = testSourceFile
}
if (browserName) {
// Added as parameter too because it should affect the test fingerprint
extraTags[TEST_PARAMETERS] = JSON.stringify({ arguments: { browser: browserName }, metadata: {} })
extraTags[TEST_BROWSER_NAME] = browserName
}
extraTags.test_suite_absolute_path = testSuiteAbsolutePath
if (testSourceFileAbsolutePath) {
extraTags.test_source_absolute_path = testSourceFileAbsolutePath
}
return super.startTestSpan(testName, testSuite, testSuiteSpan, extraTags)
}
}
module.exports = PlaywrightPlugin