Skip to content

Commit 5774881

Browse files
feat(cypress): upload failure screenshots
1 parent 65a9329 commit 5774881

8 files changed

Lines changed: 365 additions & 2 deletions

File tree

integration-tests/ci-visibility-intake.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,31 @@ class FakeCiVisIntake extends FakeAgent {
211211
})
212212
})
213213

214+
app.post([
215+
'/api/v2/ci/tests/screenshots',
216+
'/evp_proxy/:version/api/v2/ci/tests/screenshots',
217+
], upload.any(), (req, res) => {
218+
res.status(200).send('OK')
219+
220+
const screenshotFile = req.files.find(f => f.fieldname === 'screenshot')
221+
const eventFile = req.files.find(f => f.fieldname === 'event')
222+
223+
this.emit('message', {
224+
headers: req.headers,
225+
screenshotFile: screenshotFile && {
226+
name: screenshotFile.fieldname,
227+
filename: screenshotFile.originalname,
228+
type: screenshotFile.mimetype,
229+
content: screenshotFile.buffer,
230+
},
231+
eventFile: eventFile && {
232+
name: eventFile.fieldname,
233+
content: JSON.parse(eventFile.buffer.toString('utf8')),
234+
},
235+
url: req.url,
236+
})
237+
})
238+
214239
app.post([
215240
'/api/v2/libraries/tests/services/setting',
216241
'/evp_proxy/:version/api/v2/libraries/tests/services/setting',

integration-tests/cypress-esm-config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ async function runCypress () {
2020
specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js',
2121
},
2222
video: false,
23-
screenshotOnRunFailure: false,
23+
screenshotOnRunFailure: process.env.CYPRESS_SCREENSHOT_ON_RUN_FAILURE === 'true',
2424
},
2525
})
2626

integration-tests/cypress/cypress-reporting.spec.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2074,5 +2074,78 @@ moduleTypes.forEach(({
20742074
receiverPromise,
20752075
])
20762076
})
2077+
2078+
it('uploads screenshots for failed tests using the test trace id', async function () {
2079+
const envVars = getCiVisAgentlessConfig(receiver.port)
2080+
const command = version === '6.7.0'
2081+
? './node_modules/.bin/cypress run ' +
2082+
'--config-file cypress-config.json ' +
2083+
'--config screenshotOnRunFailure=true --spec "cypress/e2e/basic-fail.js"'
2084+
: `${testCommand} --config screenshotOnRunFailure=true`
2085+
let testOutput = ''
2086+
2087+
childProcess = exec(
2088+
command,
2089+
{
2090+
cwd,
2091+
env: {
2092+
...envVars,
2093+
CYPRESS_BASE_URL: webAppBaseUrl,
2094+
CYPRESS_SCREENSHOT_ON_RUN_FAILURE: 'true',
2095+
SPEC_PATTERN: 'cypress/e2e/basic-fail.js',
2096+
},
2097+
}
2098+
)
2099+
childProcess.stdout.on('data', data => { testOutput += data.toString() })
2100+
childProcess.stderr.on('data', data => { testOutput += data.toString() })
2101+
2102+
const receiverPromise = receiver.gatherPayloadsUntilChildExit(
2103+
childProcess,
2104+
({ url }) => url === '/api/v2/ci/tests/screenshots' || url === '/api/v2/citestcycle',
2105+
payloads => {
2106+
const screenshotPayload = payloads.find(({ url }) => url === '/api/v2/ci/tests/screenshots')
2107+
const events = payloads
2108+
.filter(({ url }) => url === '/api/v2/citestcycle')
2109+
.flatMap(({ payload }) => payload.events)
2110+
const failedTest = events.find(event =>
2111+
event.content.resource === 'cypress/e2e/basic-fail.js.basic fail suite can fail'
2112+
)
2113+
assert.ok(screenshotPayload)
2114+
assert.ok(failedTest)
2115+
const expectedTraceId = failedTest.content.trace_id.toString()
2116+
2117+
assertObjectContains(screenshotPayload, {
2118+
screenshotFile: {
2119+
name: 'screenshot',
2120+
filename: `${expectedTraceId}.png`,
2121+
type: 'image/png',
2122+
},
2123+
eventFile: {
2124+
name: 'event',
2125+
content: {
2126+
type: 'test_screenshot',
2127+
trace_id: expectedTraceId,
2128+
test_name: 'basic fail suite can fail',
2129+
test_suite: 'cypress/e2e/basic-fail.js',
2130+
filename: `${expectedTraceId}.png`,
2131+
content_type: 'image/png',
2132+
},
2133+
},
2134+
})
2135+
assert.deepStrictEqual(
2136+
[...screenshotPayload.screenshotFile.content.subarray(0, 8)],
2137+
[137, 80, 78, 71, 13, 10, 26, 10]
2138+
)
2139+
}, { hardTimeout: 60000 })
2140+
.catch(error => {
2141+
error.message += `\nCypress output:\n${testOutput}`
2142+
throw error
2143+
})
2144+
2145+
await Promise.all([
2146+
once(childProcess, 'exit'),
2147+
receiverPromise,
2148+
])
2149+
})
20772150
})
20782151
})

packages/datadog-plugin-cypress/src/cypress-plugin.js

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,55 @@ const CYPRESS_STATUS_TO_TEST_STATUS = {
128128
skipped: 'skip',
129129
}
130130

131+
const SCREENSHOT_ATTEMPT_RE = /\(attempt \d+\)/
132+
133+
function getScreenshotFilePath (screenshot) {
134+
return typeof screenshot === 'string' ? screenshot : screenshot?.path
135+
}
136+
137+
function isFailureScreenshot (screenshot) {
138+
return !!getScreenshotFilePath(screenshot) && screenshot?.testFailure !== false
139+
}
140+
141+
function getAttemptScreenshots (cypressTest, attemptIndex) {
142+
if (!Array.isArray(cypressTest.attempts)) {
143+
return []
144+
}
145+
const attempt = cypressTest.attempts[attemptIndex]
146+
if (!Array.isArray(attempt?.screenshots)) {
147+
return []
148+
}
149+
return attempt.screenshots.filter(isFailureScreenshot)
150+
}
151+
152+
function isScreenshotForTestAttempt (screenshot, titleParts, attemptIndex) {
153+
const screenshotFilePath = getScreenshotFilePath(screenshot)
154+
if (!screenshotFilePath || !isFailureScreenshot(screenshot)) {
155+
return false
156+
}
157+
for (const titlePart of titleParts) {
158+
if (!screenshotFilePath.includes(titlePart)) {
159+
return false
160+
}
161+
}
162+
if (attemptIndex === 0) {
163+
return !SCREENSHOT_ATTEMPT_RE.test(screenshotFilePath)
164+
}
165+
return screenshotFilePath.includes(`(attempt ${attemptIndex + 1})`)
166+
}
167+
168+
function getTestScreenshots (cypressTest, attemptIndex, specScreenshots) {
169+
const attemptScreenshots = getAttemptScreenshots(cypressTest, attemptIndex)
170+
if (attemptScreenshots.length > 0) {
171+
return attemptScreenshots
172+
}
173+
if (!Array.isArray(specScreenshots)) {
174+
return []
175+
}
176+
const titleParts = Array.isArray(cypressTest.title) ? cypressTest.title : []
177+
return specScreenshots.filter(screenshot => isScreenshotForTestAttempt(screenshot, titleParts, attemptIndex))
178+
}
179+
131180
function getSessionStatus (summary) {
132181
if (summary.totalFailed !== undefined && summary.totalFailed > 0) {
133182
return 'fail'
@@ -1034,9 +1083,11 @@ class CypressPlugin {
10341083
}
10351084

10361085
afterSpec (spec, results) {
1037-
const { tests, stats } = results || {}
1086+
const { tests, stats, screenshots } = results || {}
10381087
const cypressTests = tests || []
1088+
const specScreenshots = screenshots || []
10391089
const finishedTests = this.finishedTestsByFile[spec.relative] || []
1090+
const screenshotUploadPromises = []
10401091

10411092
if (!this.testSuiteSpan) {
10421093
// dd:testSuiteStart hasn't been triggered for whatever reason
@@ -1143,6 +1194,20 @@ class CypressPlugin {
11431194
if (cypressTest.displayError) {
11441195
latestError = new Error(cypressTest.displayError)
11451196
}
1197+
1198+
if (cypressTestStatus === 'fail' || finishedTest.testStatus === 'fail' || cypressTest.displayError) {
1199+
const testScreenshots = getTestScreenshots(cypressTest, attemptIndex, specScreenshots)
1200+
const screenshotUploadPromise = this.uploadTestScreenshots({
1201+
screenshots: testScreenshots,
1202+
traceId: finishedTest.testSpan.context().toTraceId(),
1203+
testName,
1204+
testSuite: spec.relative,
1205+
})
1206+
if (screenshotUploadPromise) {
1207+
screenshotUploadPromises.push(screenshotUploadPromise)
1208+
}
1209+
}
1210+
11461211
// Update test status - but NOT for non-ATF quarantined tests where we intentionally
11471212
// report 'fail' to Datadog even though Cypress sees it as 'pass'
11481213
const isQuarantinedTest = finishedTest.testSpan?.context()?._tags?.[TEST_MANAGEMENT_IS_QUARANTINED] === 'true'
@@ -1214,6 +1279,53 @@ class CypressPlugin {
12141279
this.testSuiteSpan = null
12151280
this.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'suite')
12161281
}
1282+
1283+
if (screenshotUploadPromises.length > 0) {
1284+
return Promise.all(screenshotUploadPromises).then(() => null)
1285+
}
1286+
}
1287+
1288+
/**
1289+
* Uploads failure screenshots for a Cypress test attempt.
1290+
*
1291+
* @param {object} options - Upload options
1292+
* @param {Array<object|string>} options.screenshots - Cypress screenshot objects or screenshot paths
1293+
* @param {string} options.traceId - Test trace id used as the screenshot key
1294+
* @param {string} options.testName - Test name associated with the screenshots
1295+
* @param {string} options.testSuite - Test suite associated with the screenshots
1296+
* @returns {Promise<void>|undefined}
1297+
*/
1298+
uploadTestScreenshots ({ screenshots, traceId, testName, testSuite }) {
1299+
const exporter = this.tracer?._tracer?._exporter
1300+
if (!screenshots.length || !exporter?.canUploadTestScreenshots?.() || !exporter.uploadTestScreenshot) {
1301+
return
1302+
}
1303+
1304+
const uploadedFilePaths = new Set()
1305+
const uploadPromises = []
1306+
1307+
for (const screenshot of screenshots) {
1308+
const filePath = getScreenshotFilePath(screenshot)
1309+
if (!filePath || uploadedFilePaths.has(filePath)) {
1310+
continue
1311+
}
1312+
uploadedFilePaths.add(filePath)
1313+
uploadPromises.push(new Promise(resolve => {
1314+
exporter.uploadTestScreenshot({
1315+
filePath,
1316+
traceId,
1317+
testName,
1318+
testSuite,
1319+
testEnvironmentMetadata: this.testEnvironmentMetadata,
1320+
}, () => {
1321+
resolve()
1322+
})
1323+
}))
1324+
}
1325+
1326+
if (uploadPromises.length > 0) {
1327+
return Promise.all(uploadPromises).then(() => {})
1328+
}
12171329
}
12181330

12191331
getTasks () {

packages/dd-trace/src/ci-visibility/exporters/agent-proxy/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class AgentProxyCiVisibilityExporter extends CiVisibilityExporter {
6868
evpProxyPrefix,
6969
})
7070
this._codeCoverageReportUrl = this._url
71+
this._testScreenshotUploadUrl = this._url
7172
if (isTestDynamicInstrumentationEnabled) {
7273
const canFowardLogs = getCanForwardDebuggerLogs(err, agentInfo)
7374
if (canFowardLogs) {

packages/dd-trace/src/ci-visibility/exporters/agentless/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class AgentlessCiVisibilityExporter extends CiVisibilityExporter {
2222
this._coverageWriter = new CoverageWriter({ url: this._coverageUrl })
2323

2424
this._codeCoverageReportUrl = url || new URL(`https://ci-intake.${site}`)
25+
this._testScreenshotUploadUrl = url || new URL(`https://ci-intake.${site}`)
2526

2627
if (isTestDynamicInstrumentationEnabled) {
2728
const DynamicInstrumentationLogsWriter = require('./di-logs-writer')

packages/dd-trace/src/ci-visibility/exporters/ci-visibility-exporter.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const { getKnownTests: getKnownTestsRequest } = require('../early-flake-detectio
88
const { getTestManagementTests: getTestManagementTestsRequest } =
99
require('../test-management/get-test-management-tests')
1010
const { uploadCoverageReport: uploadCoverageReportRequest } = require('../requests/upload-coverage-report')
11+
const { uploadTestScreenshot: uploadTestScreenshotRequest } = require('../requests/upload-test-screenshot')
1112
const log = require('../../log')
1213
const BufferingExporter = require('../../exporters/common/buffering-exporter')
1314
const { GIT_REPOSITORY_URL, GIT_COMMIT_SHA } = require('../../plugins/util/tags')
@@ -417,6 +418,43 @@ class CiVisibilityExporter extends BufferingExporter {
417418
evpProxyPrefix: this.evpProxyPrefix,
418419
}, callback)
419420
}
421+
422+
/**
423+
* Returns whether the exporter can upload test screenshots.
424+
*
425+
* @returns {boolean}
426+
*/
427+
canUploadTestScreenshots () {
428+
return !!this._testScreenshotUploadUrl
429+
}
430+
431+
/**
432+
* Uploads a single test screenshot to the CI intake.
433+
*
434+
* @param {object} options - Upload options
435+
* @param {string} options.filePath - Path to the screenshot file
436+
* @param {string} options.traceId - Test trace id used as the screenshot key
437+
* @param {string} options.testName - Test name associated with the screenshot
438+
* @param {string} options.testSuite - Test suite associated with the screenshot
439+
* @param {object} options.testEnvironmentMetadata - Test environment metadata containing git/CI tags
440+
* @param {Function} callback - Callback function (err)
441+
*/
442+
uploadTestScreenshot ({ filePath, traceId, testName, testSuite, testEnvironmentMetadata }, callback) {
443+
if (!this._testScreenshotUploadUrl) {
444+
return callback(new Error('Test screenshot upload URL not configured'))
445+
}
446+
447+
uploadTestScreenshotRequest({
448+
filePath,
449+
traceId,
450+
testName,
451+
testSuite,
452+
testEnvironmentMetadata,
453+
url: this._testScreenshotUploadUrl,
454+
isEvpProxy: !!this._isUsingEvpProxy,
455+
evpProxyPrefix: this.evpProxyPrefix,
456+
}, callback)
457+
}
420458
}
421459

422460
module.exports = CiVisibilityExporter

0 commit comments

Comments
 (0)