Skip to content

Commit ddad680

Browse files
juan-fernandezleoromanovsky
authored andcommitted
feat(playwright): upload failure screenshots (#9433)
1 parent 8ee276b commit ddad680

10 files changed

Lines changed: 800 additions & 106 deletions

File tree

integration-tests/ci-visibility-intake.js

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class FakeCiVisIntake extends FakeAgent {
4848
#settings = DEFAULT_SETTINGS
4949
#settingsResponseStatusCode = 200
5050
#settingsResponseStatusCodes = []
51+
#mediaResponseDelayMs = 0
5152
#mediaResponseStatusCode = 201
5253
#suitesToSkip = DEFAULT_SUITES_TO_SKIP
5354
#skippableCoverage = DEFAULT_SKIPPABLE_COVERAGE
@@ -119,6 +120,14 @@ class FakeCiVisIntake extends FakeAgent {
119120
this.#mediaResponseStatusCode = statusCode
120121
}
121122

123+
/**
124+
* @param {number} delayMs - Delay before responding to screenshot uploads
125+
* @returns {void}
126+
*/
127+
setMediaResponseDelay (delayMs) {
128+
this.#mediaResponseDelayMs = delayMs
129+
}
130+
122131
setWaitingTime (newWaitingTime) {
123132
this.#waitingTime = newWaitingTime
124133
}
@@ -241,20 +250,30 @@ class FakeCiVisIntake extends FakeAgent {
241250
})
242251

243252
app.post('/api/v2/ci/test-runs/:traceId/media', express.raw({ limit: Infinity, type: '*/*' }), (req, res) => {
244-
res.status(this.#mediaResponseStatusCode).send()
245-
this.emit('message', {
246-
headers: req.headers,
247-
media: {
248-
traceId: req.params.traceId,
249-
contentType: req.headers['content-type'],
250-
// Metadata is carried as query params (not X-Dd-* headers) so it survives the Agent's
251-
// evp_proxy, which forwards only an allow-listed header set.
252-
idempotencyKey: req.query.idempotency_key,
253-
capturedAt: req.query.captured_at_ms,
254-
content: req.body,
255-
},
256-
url: req.url,
257-
})
253+
const receivedAtMs = Date.now()
254+
const respond = () => {
255+
res.status(this.#mediaResponseStatusCode).send()
256+
this.emit('message', {
257+
headers: req.headers,
258+
media: {
259+
traceId: req.params.traceId,
260+
contentType: req.headers['content-type'],
261+
// Metadata is carried as query params (not X-Dd-* headers) so it survives the Agent's
262+
// evp_proxy, which forwards only an allow-listed header set.
263+
idempotencyKey: req.query.idempotency_key,
264+
capturedAt: req.query.captured_at_ms,
265+
content: req.body,
266+
receivedAtMs,
267+
},
268+
url: req.url,
269+
})
270+
}
271+
272+
if (this.#mediaResponseDelayMs > 0) {
273+
setTimeout(respond, this.#mediaResponseDelayMs)
274+
} else {
275+
respond()
276+
}
258277
})
259278

260279
app.post([
@@ -406,6 +425,7 @@ class FakeCiVisIntake extends FakeAgent {
406425
this.#knownTestsStatusCode = DEFAULT_KNOWN_TESTS_RESPONSE_STATUS
407426
this.#knownTestsPageIndex = 0
408427
this.#infoResponse = DEFAULT_INFO_RESPONSE
428+
this.#mediaResponseDelayMs = 0
409429
this.#testManagementResponseStatusCode = DEFAULT_TEST_MANAGEMENT_TESTS_RESPONSE_STATUS
410430
this.#testManagementResponse = DEFAULT_TEST_MANAGEMENT_TESTS
411431
this.#skippableSuitesResponseStatusCode = 200
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
'use strict'
2+
3+
const { test, expect } = require('@playwright/test')
4+
5+
test('does not upload programmatic screenshots', async ({ page }, testInfo) => {
6+
await page.goto(process.env.PW_BASE_URL)
7+
8+
await page.screenshot({ path: testInfo.outputPath('programmatic-screenshot.png') })
9+
})
10+
11+
test('uploads only the automatic failure screenshot', async ({ page }, testInfo) => {
12+
await page.goto(process.env.PW_BASE_URL)
13+
14+
const manualScreenshotPath = testInfo.outputPath('test-failed-99.png')
15+
await page.screenshot({ path: manualScreenshotPath })
16+
await testInfo.attach('screenshot', {
17+
path: manualScreenshotPath,
18+
contentType: 'image/png',
19+
})
20+
21+
const injectedScreenshotPath = testInfo.outputPath('test-failed-98.png')
22+
await page.screenshot({ path: injectedScreenshotPath })
23+
testInfo.attachments.push({
24+
name: 'screenshot',
25+
path: injectedScreenshotPath,
26+
contentType: 'image/png',
27+
})
28+
29+
expect(true).toBe(false)
30+
})

integration-tests/playwright.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const projects = [
88
name: 'chromium',
99
use: {
1010
...devices['Desktop Chrome'],
11+
screenshot: process.env.PLAYWRIGHT_FAILURE_SCREENSHOT_MODE || 'off',
1112
},
1213
},
1314
]
@@ -34,6 +35,7 @@ if (process.env.ADD_DUPLICATE_PLAYWRIGHT_PROJECT) {
3435

3536
const config = {
3637
baseURL: process.env.PW_BASE_URL,
38+
outputDir: process.env.PLAYWRIGHT_OUTPUT_DIR,
3739
testDir: process.env.TEST_DIR || './ci-visibility/playwright-tests',
3840
timeout: Number(process.env.TEST_TIMEOUT) || 30000,
3941
fullyParallel: process.env.FULLY_PARALLEL === 'true',

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

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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')
4446
const { DD_HOST_CPU_COUNT } = require('../../packages/dd-trace/src/plugins/util/env')
4547
const { ERROR_MESSAGE } = require('../../packages/dd-trace/src/constants')
@@ -50,6 +52,10 @@ const latest = 'latest'
5052
const { oldest } = require('./versions')
5153
const versions = [oldest, latest]
5254
const 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

5460
function 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'), /^test-failed-\d+\.png$/)
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

Comments
 (0)