@@ -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+ / \( f a i l e d \) / ,
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} )
0 commit comments