@@ -40,6 +40,8 @@ const {
4040 DD_CI_LIBRARY_CONFIGURATION_ERROR_SETTINGS ,
4141 DD_CI_LIBRARY_CONFIGURATION_ERROR_KNOWN_TESTS ,
4242 DD_CI_LIBRARY_CONFIGURATION_ERROR_TEST_MANAGEMENT_TESTS ,
43+ TEST_FAILURE_SCREENSHOT_UPLOADED ,
44+ TEST_FAILURE_SCREENSHOT_UPLOAD_ERROR ,
4345} = require ( '../../packages/dd-trace/src/plugins/util/test' )
4446const { DD_HOST_CPU_COUNT } = require ( '../../packages/dd-trace/src/plugins/util/env' )
4547const { ERROR_MESSAGE } = require ( '../../packages/dd-trace/src/constants' )
@@ -50,6 +52,10 @@ const latest = 'latest'
5052const { oldest } = require ( './versions' )
5153const versions = [ oldest , latest ]
5254const REQUEST_ERROR_TAG_TEST_DIR = './ci-visibility/playwright-tests-request-error-tag'
55+ const SCREENSHOT_CAPTURE_DISABLED_WARNING =
56+ 'DD_TEST_FAILURE_SCREENSHOTS_ENABLED is true, but Playwright screenshot capture is disabled.'
57+ const SCREENSHOT_UPLOAD_UNSUPPORTED_WARNING =
58+ 'DD_TEST_FAILURE_SCREENSHOTS_ENABLED is true, but Playwright screenshot upload is not supported'
5359
5460function assertRequestErrorTag ( events , tag ) {
5561 const eventTypes = [ 'test_session_end' , 'test_module_end' , 'test_suite_end' , 'test' ]
@@ -338,6 +344,232 @@ versions.forEach((version) => {
338344 } )
339345 } )
340346
347+ contextNewVersions ( 'failure screenshots' , ( ) => {
348+ const screenshotModes = [ 'on' , 'only-on-failure' ]
349+ if ( version === latest || satisfies ( version , '>=1.49.0' ) ) {
350+ screenshotModes . push ( 'on-first-failure' )
351+ }
352+ let screenshotRunId = 0
353+
354+ function runWithFailureScreenshots (
355+ receiver ,
356+ run ,
357+ screenshotMode = 'only-on-failure' ,
358+ isScreenshotUploadEnabled = true ,
359+ testOptimizationConfig = getCiVisAgentlessConfig ( receiver . port )
360+ ) {
361+ let testOutput = ''
362+ const proc = run (
363+ './node_modules/.bin/playwright test -c playwright.config.js' ,
364+ {
365+ cwd,
366+ env : {
367+ ...testOptimizationConfig ,
368+ PW_BASE_URL : `http://localhost:${ webAppPort } ` ,
369+ TEST_DIR : './ci-visibility/playwright-tests-screenshot' ,
370+ PLAYWRIGHT_FAILURE_SCREENSHOT_MODE : screenshotMode ,
371+ PLAYWRIGHT_OUTPUT_DIR : `./test-results-failure-screenshots-${ ++ screenshotRunId } ` ,
372+ DD_TEST_FAILURE_SCREENSHOTS_ENABLED : isScreenshotUploadEnabled ? 'true' : undefined ,
373+ DD_TRACE_DEBUG : 'true' ,
374+ DD_TRACE_LOG_LEVEL : 'warn' ,
375+ } ,
376+ }
377+ )
378+ proc . stdout ?. on ( 'data' , chunk => { testOutput += chunk . toString ( ) } )
379+ proc . stderr ?. on ( 'data' , chunk => { testOutput += chunk . toString ( ) } )
380+ return { proc, getTestOutput : ( ) => testOutput }
381+ }
382+
383+ for ( const screenshotMode of screenshotModes ) {
384+ it ( `uploads only automatic failure screenshots with screenshot: '${ screenshotMode } '` , async ( receiver , run ) => {
385+ const { proc, getTestOutput } = runWithFailureScreenshots ( receiver , run , screenshotMode )
386+ const payloadsPromise = receiver
387+ . gatherPayloadsUntilChildExit (
388+ proc ,
389+ ( { url } ) => url . startsWith ( '/api/v2/ci/test-runs/' ) || url . endsWith ( '/api/v2/citestcycle' ) ,
390+ ( payloads ) => {
391+ const testOutput = getTestOutput ( )
392+ const mediaPayloads = payloads . filter ( ( { url } ) => url . startsWith ( '/api/v2/ci/test-runs/' ) )
393+ const failedTest = payloads
394+ . filter ( ( { url } ) => url . endsWith ( '/api/v2/citestcycle' ) )
395+ . flatMap ( ( { payload } ) => payload . events )
396+ . filter ( event => event . type === 'test' )
397+ . find ( event => event . content . meta [ TEST_NAME ] === 'uploads only the automatic failure screenshot' )
398+
399+ assert . ok ( failedTest , `failed test event should be reported\n${ testOutput } ` )
400+ assert . strictEqual ( failedTest . content . meta [ TEST_FAILURE_SCREENSHOT_UPLOADED ] , 'true' )
401+ assert . strictEqual ( failedTest . content . meta [ TEST_FAILURE_SCREENSHOT_UPLOAD_ERROR ] , undefined )
402+ assert . strictEqual (
403+ mediaPayloads . length ,
404+ 1 ,
405+ `only the automatic screenshot should upload\n${ testOutput } `
406+ )
407+
408+ const [ screenshotPayload ] = mediaPayloads
409+ const expectedTraceId = failedTest . content . trace_id . toString ( )
410+ assert . strictEqual ( screenshotPayload . media . traceId , expectedTraceId )
411+ assert . strictEqual ( screenshotPayload . media . contentType , 'image/png' )
412+ assert . strictEqual ( screenshotPayload . headers [ 'dd-api-key' ] , '1' )
413+ assert . strictEqual (
414+ screenshotPayload . url . split ( '?' ) [ 0 ] ,
415+ `/api/v2/ci/test-runs/${ expectedTraceId } /media`
416+ )
417+
418+ const [ idempotencyTraceId , encodedFilename ] = screenshotPayload . media . idempotencyKey . split ( ':' )
419+ assert . strictEqual ( idempotencyTraceId , expectedTraceId )
420+ assert . match ( Buffer . from ( encodedFilename , 'hex' ) . toString ( 'utf8' ) , / ^ t e s t - f a i l e d - \d + \. p n g $ / )
421+
422+ const capturedAt = Number ( screenshotPayload . media . capturedAt )
423+ assert . ok ( Number . isInteger ( capturedAt ) && capturedAt > 0 )
424+ assert . deepStrictEqual (
425+ [ ...screenshotPayload . media . content . subarray ( 0 , 8 ) ] ,
426+ [ 137 , 80 , 78 , 71 , 13 , 10 , 26 , 10 ]
427+ )
428+ } ,
429+ { hardTimeout : 60000 }
430+ )
431+ . catch ( ( error ) => {
432+ error . message += `\nPlaywright output:\n${ getTestOutput ( ) } `
433+ throw error
434+ } )
435+
436+ const [ [ exitCode ] ] = await Promise . all ( [ once ( proc , 'exit' ) , payloadsPromise ] )
437+ assert . strictEqual ( exitCode , 1 )
438+ assert . ok ( ! getTestOutput ( ) . includes ( SCREENSHOT_CAPTURE_DISABLED_WARNING ) )
439+ } )
440+ }
441+
442+ for ( const isScreenshotUploadEnabled of [ true , false ] ) {
443+ const testName = isScreenshotUploadEnabled
444+ ? 'warns when screenshot upload is enabled but screenshot capture is off'
445+ : 'does not warn when screenshot upload is disabled'
446+
447+ it ( testName , async ( receiver , run ) => {
448+ const { proc, getTestOutput } = runWithFailureScreenshots (
449+ receiver ,
450+ run ,
451+ 'off' ,
452+ isScreenshotUploadEnabled
453+ )
454+ const payloadsPromise = receiver . gatherPayloadsUntilChildExit (
455+ proc ,
456+ ( { url } ) => url . startsWith ( '/api/v2/ci/test-runs/' ) || url . endsWith ( '/api/v2/citestcycle' ) ,
457+ ( payloads ) => {
458+ const mediaPayloads = payloads . filter ( ( { url } ) => url . startsWith ( '/api/v2/ci/test-runs/' ) )
459+ const failedTest = payloads
460+ . filter ( ( { url } ) => url . endsWith ( '/api/v2/citestcycle' ) )
461+ . flatMap ( ( { payload } ) => payload . events )
462+ . filter ( event => event . type === 'test' )
463+ . find ( event => event . content . meta [ TEST_NAME ] === 'uploads only the automatic failure screenshot' )
464+
465+ assert . ok ( failedTest , `failed test event should be reported\n${ getTestOutput ( ) } ` )
466+ assert . strictEqual ( failedTest . content . meta [ TEST_FAILURE_SCREENSHOT_UPLOADED ] , undefined )
467+ assert . strictEqual ( failedTest . content . meta [ TEST_FAILURE_SCREENSHOT_UPLOAD_ERROR ] , undefined )
468+ assert . strictEqual ( mediaPayloads . length , 0 )
469+ } ,
470+ { hardTimeout : 60000 }
471+ )
472+
473+ const [ [ exitCode ] ] = await Promise . all ( [ once ( proc , 'exit' ) , payloadsPromise ] )
474+ assert . strictEqual ( exitCode , 1 )
475+ const warningCount = getTestOutput ( ) . split ( SCREENSHOT_CAPTURE_DISABLED_WARNING ) . length - 1
476+ assert . strictEqual ( warningCount , isScreenshotUploadEnabled ? 1 : 0 , getTestOutput ( ) )
477+ } )
478+ }
479+
480+ it ( 'warns when the active transport cannot upload screenshots' , async ( receiver , run ) => {
481+ receiver . setInfoResponse ( { endpoints : [ ] } )
482+ const { proc, getTestOutput } = runWithFailureScreenshots (
483+ receiver ,
484+ run ,
485+ 'only-on-failure' ,
486+ true ,
487+ getCiVisEvpProxyConfig ( receiver . port )
488+ )
489+
490+ const [ exitCode ] = await once ( proc , 'exit' )
491+ assert . strictEqual ( exitCode , 1 )
492+ const warningCount = getTestOutput ( ) . split ( SCREENSHOT_UPLOAD_UNSUPPORTED_WARNING ) . length - 1
493+ assert . strictEqual ( warningCount , 1 , getTestOutput ( ) )
494+ assert . ok ( ! getTestOutput ( ) . includes ( SCREENSHOT_CAPTURE_DISABLED_WARNING ) )
495+ } )
496+
497+ it ( 'does not warn when the active transport can upload screenshots' , async ( receiver , run ) => {
498+ receiver . setInfoResponse ( { endpoints : [ '/evp_proxy/v4' ] } )
499+ const { proc, getTestOutput } = runWithFailureScreenshots (
500+ receiver ,
501+ run ,
502+ 'only-on-failure' ,
503+ true ,
504+ getCiVisEvpProxyConfig ( receiver . port )
505+ )
506+
507+ const [ exitCode ] = await once ( proc , 'exit' )
508+ assert . strictEqual ( exitCode , 1 )
509+ assert . ok ( ! getTestOutput ( ) . includes ( SCREENSHOT_UPLOAD_UNSUPPORTED_WARNING ) , getTestOutput ( ) )
510+ assert . ok ( ! getTestOutput ( ) . includes ( SCREENSHOT_CAPTURE_DISABLED_WARNING ) , getTestOutput ( ) )
511+ } )
512+
513+ it ( 'excludes screenshot upload time from the failed test duration' , async ( receiver , run ) => {
514+ receiver . setMediaResponseDelay ( 500 )
515+ const { proc, getTestOutput } = runWithFailureScreenshots ( receiver , run )
516+ const payloadsPromise = receiver . gatherPayloadsUntilChildExit (
517+ proc ,
518+ ( { url } ) => url . startsWith ( '/api/v2/ci/test-runs/' ) || url . endsWith ( '/api/v2/citestcycle' ) ,
519+ ( payloads ) => {
520+ const mediaPayloads = payloads . filter ( ( { url } ) => url . startsWith ( '/api/v2/ci/test-runs/' ) )
521+ const failedTest = payloads
522+ . filter ( ( { url } ) => url . endsWith ( '/api/v2/citestcycle' ) )
523+ . flatMap ( ( { payload } ) => payload . events )
524+ . filter ( event => event . type === 'test' )
525+ . find ( event => event . content . meta [ TEST_NAME ] === 'uploads only the automatic failure screenshot' )
526+
527+ assert . ok ( failedTest , `failed test event should be reported\n${ getTestOutput ( ) } ` )
528+ assert . strictEqual ( mediaPayloads . length , 1 )
529+ const [ screenshotPayload ] = mediaPayloads
530+ const testEndTimeMs = ( Number ( failedTest . content . start ) + Number ( failedTest . content . duration ) ) / 1e6
531+ assert . ok (
532+ testEndTimeMs <= screenshotPayload . media . receivedAtMs + 100 ,
533+ `test span should finish before the screenshot upload starts\n${ getTestOutput ( ) } `
534+ )
535+ } ,
536+ { hardTimeout : 60000 }
537+ )
538+
539+ const [ [ exitCode ] ] = await Promise . all ( [ once ( proc , 'exit' ) , payloadsPromise ] )
540+ assert . strictEqual ( exitCode , 1 )
541+ } )
542+
543+ it ( 'reports upload errors without changing the Playwright result' , async ( receiver , run ) => {
544+ receiver . setMediaResponseStatusCode ( 500 )
545+ const { proc, getTestOutput } = runWithFailureScreenshots ( receiver , run )
546+ const payloadsPromise = receiver
547+ . gatherPayloadsUntilChildExit (
548+ proc ,
549+ ( { url } ) => url . endsWith ( '/api/v2/citestcycle' ) ,
550+ ( payloads ) => {
551+ const failedTest = payloads
552+ . flatMap ( ( { payload } ) => payload . events )
553+ . filter ( event => event . type === 'test' )
554+ . find ( event => event . content . meta [ TEST_NAME ] === 'uploads only the automatic failure screenshot' )
555+
556+ assert . ok ( failedTest , `failed test event should be reported\n${ getTestOutput ( ) } ` )
557+ assert . strictEqual ( failedTest . content . meta [ TEST_STATUS ] , 'fail' )
558+ assert . strictEqual ( failedTest . content . meta [ TEST_FAILURE_SCREENSHOT_UPLOAD_ERROR ] , 'true' )
559+ assert . strictEqual ( failedTest . content . meta [ TEST_FAILURE_SCREENSHOT_UPLOADED ] , undefined )
560+ } ,
561+ { hardTimeout : 60000 }
562+ )
563+ . catch ( ( error ) => {
564+ error . message += `\nPlaywright output:\n${ getTestOutput ( ) } `
565+ throw error
566+ } )
567+
568+ const [ [ exitCode ] ] = await Promise . all ( [ once ( proc , 'exit' ) , payloadsPromise ] )
569+ assert . strictEqual ( exitCode , 1 )
570+ } )
571+ } )
572+
341573 it ( 'works when tests are compiled to a different location' , async ( receiver , run ) => {
342574 let testOutput = ''
343575 const receiverPromise = receiver
0 commit comments