Skip to content

Commit 00d73ef

Browse files
xinyejipabloerhard
authored andcommitted
feat(cypress): upload failure screenshots to the v2 media endpoint (#8981)
1 parent a229309 commit 00d73ef

18 files changed

Lines changed: 897 additions & 9 deletions

File tree

integration-tests/ci-visibility-intake.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const DEFAULT_TEST_MANAGEMENT_TESTS_RESPONSE_STATUS = 200
4747
class FakeCiVisIntake extends FakeAgent {
4848
#settings = DEFAULT_SETTINGS
4949
#settingsResponseStatusCode = 200
50+
#mediaResponseStatusCode = 201
5051
#suitesToSkip = DEFAULT_SUITES_TO_SKIP
5152
#skippableCoverage = DEFAULT_SKIPPABLE_COVERAGE
5253
#gitUploadStatus = DEFAULT_GIT_UPLOAD_STATUS
@@ -104,6 +105,12 @@ class FakeCiVisIntake extends FakeAgent {
104105
this.#settingsResponseStatusCode = statusCode
105106
}
106107

108+
// Lets a test simulate the media endpoint failing (e.g. 500) to verify the
109+
// cypress run still completes and reports normally when an upload fails.
110+
setMediaResponseStatusCode (statusCode) {
111+
this.#mediaResponseStatusCode = statusCode
112+
}
113+
107114
setWaitingTime (newWaitingTime) {
108115
this.#waitingTime = newWaitingTime
109116
}
@@ -225,6 +232,23 @@ class FakeCiVisIntake extends FakeAgent {
225232
})
226233
})
227234

235+
app.post('/api/v2/ci/test-runs/:traceId/media', express.raw({ limit: Infinity, type: '*/*' }), (req, res) => {
236+
res.status(this.#mediaResponseStatusCode).send()
237+
this.emit('message', {
238+
headers: req.headers,
239+
media: {
240+
traceId: req.params.traceId,
241+
contentType: req.headers['content-type'],
242+
// Metadata is carried as query params (not X-Dd-* headers) so it survives the Agent's
243+
// evp_proxy, which forwards only an allow-listed header set.
244+
idempotencyKey: req.query.idempotency_key,
245+
capturedAt: req.query.captured_at_ms,
246+
content: req.body,
247+
},
248+
url: req.url,
249+
})
250+
})
251+
228252
app.post([
229253
'/api/v2/libraries/tests/services/setting',
230254
'/evp_proxy/:version/api/v2/libraries/tests/services/setting',

integration-tests/cypress-esm-config.mjs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,13 @@ async function runCypress () {
2323
},
2424
specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js',
2525
},
26+
// Mirror the env-driven gating in cypress.config.js: off by default so most
27+
// specs do not capture screenshots; the failure-screenshot upload tests set
28+
// CYPRESS_ENABLE_FAILURE_SCREENSHOTS=true for their runs.
29+
// The 'esm' module type runs Cypress through this programmatic config rather
30+
// than cypress.config.js, so the same gating has to live here too.
2631
video: false,
27-
screenshotOnRunFailure: false,
32+
screenshotOnRunFailure: process.env.CYPRESS_ENABLE_FAILURE_SCREENSHOTS === 'true',
2833
},
2934
})
3035

integration-tests/cypress.config.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ module.exports = defineConfig({
6060
},
6161
specPattern: process.env.SPEC_PATTERN || 'cypress/e2e/**/*.cy.js',
6262
},
63+
// Off by default so most specs do not capture screenshots; the failure-screenshot
64+
// upload tests set CYPRESS_ENABLE_FAILURE_SCREENSHOTS=true for their runs.
6365
video: false,
64-
screenshotOnRunFailure: false,
66+
screenshotOnRunFailure: process.env.CYPRESS_ENABLE_FAILURE_SCREENSHOTS === 'true',
6567
})

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

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,5 +915,198 @@ moduleTypes.forEach(({
915915
fs.rmSync(subprojectDir, { recursive: true, force: true })
916916
}
917917
})
918+
919+
const reportMethods = [
920+
{ name: 'evp proxy', getEnvVars: getCiVisEvpProxyConfig },
921+
{ name: 'agentless', getEnvVars: getCiVisAgentlessConfig },
922+
]
923+
924+
reportMethods.forEach(({ name: reportMethod, getEnvVars }) => {
925+
context(`reporting with ${reportMethod}`, () => {
926+
// 6.7.0 uses a flat JSON config that can't read env to enable screenshots, so over10It skips it.
927+
// TODO: use over10It for evp proxy too once the Agent can forward the media endpoint.
928+
const onlyAgentlessIt = reportMethod === 'agentless' ? over10It : it.skip
929+
930+
function runCypressWithFailureScreenshots (specToRun) {
931+
let testOutput = ''
932+
childProcess = exec(
933+
testCommand,
934+
{
935+
cwd,
936+
env: {
937+
...getEnvVars(receiver.port),
938+
CYPRESS_BASE_URL: webAppBaseUrl,
939+
SPEC_PATTERN: specToRun,
940+
CYPRESS_ENABLE_FAILURE_SCREENSHOTS: 'true',
941+
DD_TEST_FAILURE_SCREENSHOTS_ENABLED: 'true',
942+
},
943+
}
944+
)
945+
childProcess.stdout?.on('data', (d) => { testOutput += d.toString() })
946+
childProcess.stderr?.on('data', (d) => { testOutput += d.toString() })
947+
return () => testOutput
948+
}
949+
950+
onlyAgentlessIt('uploads failure screenshots to the v2 media endpoint', async function () {
951+
const getTestOutput = runCypressWithFailureScreenshots('cypress/e2e/basic-fail.js')
952+
953+
const receiverPromise = receiver
954+
.gatherPayloadsUntilChildExit(
955+
childProcess,
956+
({ url }) => url.startsWith('/api/v2/ci/test-runs/') || url.endsWith('/api/v2/citestcycle'),
957+
(payloads) => {
958+
const testOutput = getTestOutput()
959+
const mediaPayloads = payloads.filter(({ url }) => url.startsWith('/api/v2/ci/test-runs/'))
960+
const failedTest = payloads
961+
.filter(({ url }) => url.endsWith('/api/v2/citestcycle'))
962+
.flatMap(({ payload }) => payload.events)
963+
.filter(event => event.type === 'test')
964+
.find(event => event.content.resource === 'cypress/e2e/basic-fail.js.basic fail suite can fail')
965+
966+
assert.ok(failedTest, `failed test event should be reported\n${testOutput}`)
967+
const expectedTraceId = failedTest.content.trace_id.toString()
968+
969+
const screenshotPayload = mediaPayloads.find(({ media }) => media.contentType === 'image/png')
970+
assert.ok(screenshotPayload, `a screenshot should be uploaded to the v2 media endpoint\n${testOutput}`)
971+
assert.strictEqual(
972+
screenshotPayload.url.split('?')[0],
973+
`/api/v2/ci/test-runs/${expectedTraceId}/media`
974+
)
975+
assert.strictEqual(screenshotPayload.media.traceId, expectedTraceId)
976+
assert.strictEqual(screenshotPayload.headers['dd-api-key'], '1')
977+
978+
// v2 metadata rides the query string, not X-Dd-* headers, so it survives the
979+
// Agent's evp_proxy (which strips non-allow-listed headers). The idempotency key is
980+
// `<traceId>:<hex(filename)>` (filename hex-encoded so a non-ASCII title and the
981+
// proxy's query-charset validation can't break it), reused on retry so the media
982+
// service overwrites instead of duplicating the stored object.
983+
const { idempotencyKey } = screenshotPayload.media
984+
assert.ok(idempotencyKey, 'media upload should send an idempotency_key query param')
985+
assert.match(
986+
idempotencyKey,
987+
new RegExp(`^${expectedTraceId}:`),
988+
`idempotency key ${idempotencyKey} should start with the trace id`
989+
)
990+
991+
const capturedAt = Number(screenshotPayload.media.capturedAt)
992+
assert.ok(
993+
Number.isInteger(capturedAt) && capturedAt > 0,
994+
`captured_at_ms should be a positive integer, got ${screenshotPayload.media.capturedAt}`
995+
)
996+
assert.ok(
997+
!('x-dd-idempotency-key' in screenshotPayload.headers),
998+
'v2 must not send metadata as X-Dd-* headers (the Agent evp_proxy strips them)'
999+
)
1000+
assert.ok(
1001+
!('test-drive-test-failure-media-bucket' in screenshotPayload.headers),
1002+
'v2 must not send the v1 test-drive-test-failure-media-bucket header'
1003+
)
1004+
assert.deepStrictEqual(
1005+
[...screenshotPayload.media.content.subarray(0, 8)],
1006+
[137, 80, 78, 71, 13, 10, 26, 10]
1007+
)
1008+
}, { hardTimeout: 60000 })
1009+
.catch((error) => {
1010+
error.message += `\nCypress output:\n${getTestOutput()}`
1011+
throw error
1012+
})
1013+
1014+
await Promise.all([
1015+
once(childProcess, 'exit'),
1016+
receiverPromise,
1017+
])
1018+
})
1019+
1020+
onlyAgentlessIt('uploads only the auto failure frame, not a manual cy.screenshot()', async function () {
1021+
const getTestOutput = runCypressWithFailureScreenshots('cypress/e2e/manual-screenshot-before-fail.js')
1022+
1023+
const receiverPromise = receiver
1024+
.gatherPayloadsUntilChildExit(
1025+
childProcess,
1026+
({ url }) => url.startsWith('/api/v2/ci/test-runs/') || url.endsWith('/api/v2/citestcycle'),
1027+
(payloads) => {
1028+
const testOutput = getTestOutput()
1029+
const mediaPayloads = payloads.filter(({ url }) => url.startsWith('/api/v2/ci/test-runs/'))
1030+
const failedTest = payloads
1031+
.filter(({ url }) => url.endsWith('/api/v2/citestcycle'))
1032+
.flatMap(({ payload }) => payload.events)
1033+
.filter(event => event.type === 'test')
1034+
.find(event =>
1035+
event.content.resource ===
1036+
'cypress/e2e/manual-screenshot-before-fail.js.manual screenshot before fail suite ' +
1037+
'takes a manual screenshot then fails'
1038+
)
1039+
1040+
assert.ok(failedTest, `failed test event should be reported\n${testOutput}`)
1041+
const expectedTraceId = failedTest.content.trace_id.toString()
1042+
1043+
const screenshotPayloads = mediaPayloads.filter(({ media }) => media.contentType === 'image/png')
1044+
assert.strictEqual(
1045+
screenshotPayloads.length,
1046+
1,
1047+
`only the auto failure screenshot should be uploaded\n${testOutput}`
1048+
)
1049+
const [screenshotPayload] = screenshotPayloads
1050+
assert.strictEqual(screenshotPayload.media.traceId, expectedTraceId)
1051+
1052+
const decodeKeyFilename = (key) => {
1053+
const [, hexFilename] = (key || '').split(':')
1054+
return hexFilename ? Buffer.from(hexFilename, 'hex').toString('utf8') : ''
1055+
}
1056+
assert.match(
1057+
decodeKeyFilename(screenshotPayload.media.idempotencyKey),
1058+
/\(failed\)/,
1059+
`the uploaded screenshot should be the auto failure frame\n${testOutput}`
1060+
)
1061+
1062+
const manualUpload = mediaPayloads.find(({ media }) =>
1063+
decodeKeyFilename(media.idempotencyKey).includes('before-failure')
1064+
)
1065+
assert.ok(
1066+
!manualUpload,
1067+
`the manual cy.screenshot() must not be uploaded to the media endpoint\n${testOutput}`
1068+
)
1069+
}, { hardTimeout: 60000 })
1070+
.catch((error) => {
1071+
error.message += `\nCypress output:\n${getTestOutput()}`
1072+
throw error
1073+
})
1074+
1075+
await Promise.all([
1076+
once(childProcess, 'exit'),
1077+
receiverPromise,
1078+
])
1079+
})
1080+
1081+
onlyAgentlessIt('continues normally when the media upload endpoint fails', async function () {
1082+
receiver.setMediaResponseStatusCode(500)
1083+
const getTestOutput = runCypressWithFailureScreenshots('cypress/e2e/basic-fail.js')
1084+
1085+
const receiverPromise = receiver
1086+
.gatherPayloadsUntilChildExit(
1087+
childProcess,
1088+
({ url }) => url.endsWith('/api/v2/citestcycle'),
1089+
(payloads) => {
1090+
const testOutput = getTestOutput()
1091+
const failedTest = payloads
1092+
.flatMap(({ payload }) => payload.events)
1093+
.filter(event => event.type === 'test')
1094+
.find(event => event.content.resource === 'cypress/e2e/basic-fail.js.basic fail suite can fail')
1095+
1096+
assert.ok(failedTest, `the failed test should still be reported when media upload fails\n${testOutput}`)
1097+
assert.strictEqual(failedTest.content.meta[TEST_STATUS], 'fail')
1098+
}, { hardTimeout: 60000 })
1099+
.catch((error) => {
1100+
error.message += `\nCypress output:\n${getTestOutput()}`
1101+
throw error
1102+
})
1103+
1104+
await Promise.all([
1105+
once(childProcess, 'exit'),
1106+
receiverPromise,
1107+
])
1108+
})
1109+
})
1110+
})
9181111
})
9191112
})
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/* eslint-disable */
2+
describe('manual screenshot before fail suite', () => {
3+
beforeEach(() => {
4+
cy.visit('/')
5+
})
6+
7+
it('takes a manual screenshot then fails', () => {
8+
// Manual capture (not a failure frame): must NOT be uploaded to the failure-screenshot endpoint.
9+
cy.screenshot('before-failure')
10+
cy.get('.hello-world')
11+
.should('have.text', 'Hello warld')
12+
})
13+
})

packages/datadog-instrumentations/src/cypress-config.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,10 @@ function injectSupportFile (config) {
113113
* @param {object} config Cypress resolved config object
114114
* @param {Function[]} userAfterSpecHandlers user's after:spec handlers collected from wrappedOn
115115
* @param {Function[]} userAfterRunHandlers user's after:run handlers collected from wrappedOn
116+
* @param {Function[]} userAfterScreenshotHandlers user's after:screenshot handlers collected from wrappedOn
116117
* @returns {object} the config object (possibly modified)
117118
*/
118-
function registerDdTraceHooks (on, config, userAfterSpecHandlers, userAfterRunHandlers) {
119+
function registerDdTraceHooks (on, config, userAfterSpecHandlers, userAfterRunHandlers, userAfterScreenshotHandlers) {
119120
const wrapperFile = injectSupportFile(config)
120121

121122
const cleanupWrapper = () => {
@@ -136,6 +137,7 @@ function registerDdTraceHooks (on, config, userAfterSpecHandlers, userAfterRunHa
136137

137138
const registerNoopHandlers = () => {
138139
for (const h of userAfterSpecHandlers) on('after:spec', h)
140+
for (const h of userAfterScreenshotHandlers) on('after:screenshot', h)
139141
registerAfterRunWithCleanup()
140142
on('task', noopTask)
141143
}
@@ -153,6 +155,7 @@ function registerDdTraceHooks (on, config, userAfterSpecHandlers, userAfterRunHa
153155
config,
154156
userAfterSpecHandlers,
155157
userAfterRunHandlers,
158+
userAfterScreenshotHandlers,
156159
cleanupWrapper,
157160
registered: false,
158161
configPromise: undefined,
@@ -176,12 +179,15 @@ function wrapSetupNodeEvents (originalSetupNodeEvents) {
176179
return function ddSetupNodeEvents (on, config) {
177180
const userAfterSpecHandlers = []
178181
const userAfterRunHandlers = []
182+
const userAfterScreenshotHandlers = []
179183

180184
const wrappedOn = (event, handler) => {
181185
if (event === 'after:spec') {
182186
userAfterSpecHandlers.push(handler)
183187
} else if (event === 'after:run') {
184188
userAfterRunHandlers.push(handler)
189+
} else if (event === 'after:screenshot') {
190+
userAfterScreenshotHandlers.push(handler)
185191
} else {
186192
on(event, handler)
187193
}
@@ -197,7 +203,8 @@ function wrapSetupNodeEvents (originalSetupNodeEvents) {
197203
on,
198204
mergeReturnedConfig(config, result),
199205
userAfterSpecHandlers,
200-
userAfterRunHandlers
206+
userAfterRunHandlers,
207+
userAfterScreenshotHandlers
201208
)
202209
})
203210
}
@@ -206,7 +213,8 @@ function wrapSetupNodeEvents (originalSetupNodeEvents) {
206213
on,
207214
mergeReturnedConfig(config, maybePromise),
208215
userAfterSpecHandlers,
209-
userAfterRunHandlers
216+
userAfterRunHandlers,
217+
userAfterScreenshotHandlers
210218
)
211219
}
212220
}

0 commit comments

Comments
 (0)