@lullabot/playwright-testing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +61 -0
  2. package/bin/github-a11y-summary +5 -0
  3. package/bin/github-failure-summary +8 -0
  4. package/lib/accessibility-baseline-file.d.ts +47 -0
  5. package/lib/accessibility-baseline-file.js +205 -0
  6. package/lib/accessibility-baseline.d.ts +19 -0
  7. package/lib/accessibility-baseline.js +38 -0
  8. package/lib/accessible-screenshot.d.ts +181 -0
  9. package/lib/accessible-screenshot.js +519 -0
  10. package/lib/focus.d.ts +14 -0
  11. package/lib/focus.js +28 -0
  12. package/lib/fonts.d.ts +18 -0
  13. package/lib/fonts.js +24 -0
  14. package/lib/frames.d.ts +7 -0
  15. package/lib/frames.js +27 -0
  16. package/lib/github/a11y-summary.d.ts +55 -0
  17. package/lib/github/a11y-summary.js +383 -0
  18. package/lib/github/attachments.d.ts +98 -0
  19. package/lib/github/attachments.js +297 -0
  20. package/lib/github/failure-summary.d.ts +144 -0
  21. package/lib/github/failure-summary.js +567 -0
  22. package/lib/github/index.d.ts +6 -0
  23. package/lib/github/index.js +35 -0
  24. package/lib/github/report-paths.d.ts +38 -0
  25. package/lib/github/report-paths.js +200 -0
  26. package/lib/hover.d.ts +13 -0
  27. package/lib/hover.js +61 -0
  28. package/lib/images.d.ts +118 -0
  29. package/lib/images.js +260 -0
  30. package/lib/index.d.ts +13 -0
  31. package/lib/index.js +29 -0
  32. package/lib/interaction-states.d.ts +22 -0
  33. package/lib/interaction-states.js +75 -0
  34. package/lib/mock/index.d.ts +1 -0
  35. package/lib/mock/index.js +5 -0
  36. package/lib/mock/youtube.d.ts +5 -0
  37. package/lib/mock/youtube.js +38 -0
  38. package/lib/pseudo-state.d.ts +17 -0
  39. package/lib/pseudo-state.js +50 -0
  40. package/lib/videos.d.ts +134 -0
  41. package/lib/videos.js +349 -0
  42. package/lib/visualdiff.d.ts +154 -0
  43. package/lib/visualdiff.js +197 -0
  44. package/package.json +47 -0
  45. package/src/accessibility-baseline-file.test.ts +181 -0
  46. package/src/accessibility-baseline-file.ts +208 -0
  47. package/src/accessibility-baseline.test.ts +601 -0
  48. package/src/accessibility-baseline.ts +50 -0
  49. package/src/accessible-screenshot.test.ts +597 -0
  50. package/src/accessible-screenshot.ts +809 -0
  51. package/src/focus.test.ts +34 -0
  52. package/src/focus.ts +27 -0
  53. package/src/fonts.test.ts +17 -0
  54. package/src/fonts.ts +23 -0
  55. package/src/frames.test.ts +75 -0
  56. package/src/frames.ts +26 -0
  57. package/src/github/a11y-summary.test.ts +439 -0
  58. package/src/github/a11y-summary.ts +421 -0
  59. package/src/github/attachments.test.ts +248 -0
  60. package/src/github/attachments.ts +328 -0
  61. package/src/github/failure-summary.test.ts +636 -0
  62. package/src/github/failure-summary.ts +720 -0
  63. package/src/github/index.test.ts +24 -0
  64. package/src/github/index.ts +35 -0
  65. package/src/github/report-paths.test.ts +222 -0
  66. package/src/github/report-paths.ts +208 -0
  67. package/src/hover.test.ts +76 -0
  68. package/src/hover.ts +64 -0
  69. package/src/images.test.ts +355 -0
  70. package/src/images.ts +299 -0
  71. package/src/index.ts +13 -0
  72. package/src/interaction-states.test.ts +48 -0
  73. package/src/interaction-states.ts +94 -0
  74. package/src/mock/index.ts +1 -0
  75. package/src/mock/youtube.test.ts +38 -0
  76. package/src/mock/youtube.ts +39 -0
  77. package/src/pseudo-state.test.ts +83 -0
  78. package/src/pseudo-state.ts +69 -0
  79. package/src/videos.test.ts +637 -0
  80. package/src/videos.ts +389 -0
  81. package/src/visualdiff.test.ts +452 -0
  82. package/src/visualdiff.ts +381 -0
@@ -0,0 +1,720 @@
1
+ import * as fs from 'fs'
2
+ import * as path from 'path'
3
+ import { AttachmentUploader, AttachmentUploaderOptions } from './attachments'
4
+ import { PathPrefix, PathResolution, createPathResolver, parsePathPrefix } from './report-paths'
5
+
6
+ /**
7
+ * Turn a Playwright JSON report into a job summary — and optionally a PR
8
+ * comment body — with the failure screenshots embedded inline.
9
+ *
10
+ * Playwright already captures everything worth looking at: `toHaveScreenshot`
11
+ * attaches `-expected`/`-actual`/`-diff` images when a comparison fails, and
12
+ * checkAccessibility() attaches a full-page screenshot with the violating
13
+ * elements outlined. Both normally end up zipped inside an artifact nobody
14
+ * downloads. Given an upload token they can be embedded where the failure is
15
+ * actually read.
16
+ *
17
+ * Without a token this still produces a useful text summary, so the feature
18
+ * degrades rather than disappearing.
19
+ */
20
+
21
+ /** Attachment names Playwright gives the three snapshot comparison images. */
22
+ const SNAPSHOT_SUFFIXES: Array<{ suffix: string; kind: ImageKind }> = [
23
+ { suffix: '-diff', kind: 'diff' },
24
+ { suffix: '-actual', kind: 'actual' },
25
+ { suffix: '-expected', kind: 'expected' },
26
+ { suffix: '-previous', kind: 'previous' },
27
+ ]
28
+
29
+ /** Attachment name used by checkAccessibility() for its highlighted screenshot. */
30
+ const A11Y_SCREENSHOT = 'a11y-violation-screenshot'
31
+
32
+ export type ImageKind = 'diff' | 'actual' | 'expected' | 'previous' | 'a11y' | 'screenshot'
33
+
34
+ export interface FailureImage {
35
+ /** Attachment name as it appears in the report. */
36
+ name: string
37
+ /** Path on disk. Attachments the reporter inlined have none. */
38
+ filePath?: string
39
+ /**
40
+ * Base64 bytes, for attachments added with `testInfo.attach({ body })`. The
41
+ * JSON reporter inlines those rather than writing them out, which is how the
42
+ * accessibility screenshots arrive.
43
+ */
44
+ body?: string
45
+ contentType: string
46
+ kind: ImageKind
47
+ /** Populated once the image has been uploaded. */
48
+ url?: string
49
+ /**
50
+ * Set when the recorded path could not be found on this side of a container
51
+ * boundary. Distinguishes "there was nothing to upload with" from "there was
52
+ * nowhere to upload to", which otherwise render identically.
53
+ */
54
+ unreadable?: boolean
55
+ /**
56
+ * Set when the uploader would not carry the image: over GitHub's 10 MB
57
+ * ceiling, or over what is left of the run's byte budget. Uploading carried
58
+ * on around it, so this is one image's story rather than the run's.
59
+ */
60
+ oversize?: boolean
61
+ }
62
+
63
+ export interface FailedTest {
64
+ title: string
65
+ file: string
66
+ line: number
67
+ /** Display status, including the synthetic `flaky` status for a retry that passed. */
68
+ status: string
69
+ /** First error message, trimmed to something a comment can carry. */
70
+ error?: string
71
+ images: FailureImage[]
72
+ }
73
+
74
+ export interface FailureReport {
75
+ tests: FailedTest[]
76
+ totalFailed: number
77
+ totalFlaky: number
78
+ totalImages: number
79
+ }
80
+
81
+ /** Which snapshot images to include. `diff` is the one that shows the problem. */
82
+ export type IncludeMode = 'diff' | 'all'
83
+
84
+ /**
85
+ * Prefix of the HTML comment carrying the failure count in a generated comment
86
+ * body. Invisible when rendered, and greppable by a workflow that has to decide
87
+ * whether the run is worth commenting on.
88
+ */
89
+ export const FAILURE_MARKER_PREFIX = '<!-- playwright-testing-failures: '
90
+
91
+ /** Machine-readable flaky-test count used by the comment assembly action. */
92
+ export const FLAKE_MARKER_PREFIX = '<!-- playwright-testing-flakes: '
93
+
94
+ /**
95
+ * Neutralise text that the Actions runner would redact.
96
+ *
97
+ * Job summaries pass through the runner's secret masking (comments posted over
98
+ * the API do not). The masker replaces `Bearer <value>` with `***` and takes
99
+ * the following characters with it, so a test name or error containing the
100
+ * word can silently swallow the Markdown that follows it. A zero-width space
101
+ * inside the word reads identically and no longer matches.
102
+ */
103
+ export function defuseMaskTriggers(text: string): string {
104
+ return text.replace(/Bearer(?=\s)/g, 'Bear\u200Ber')
105
+ }
106
+
107
+ /** Strip ANSI colour codes Playwright puts in error messages. */
108
+ function stripAnsi(text: string): string {
109
+ return text.replace(/\u001b\[[0-9;]*m/g, '')
110
+ }
111
+
112
+ function classifyAttachment(name: string, contentType: string): ImageKind | null {
113
+ if (!contentType.startsWith('image/')) return null
114
+ if (name === A11Y_SCREENSHOT) return 'a11y'
115
+
116
+ const withoutExtension = name.replace(/\.[^.]+$/, '')
117
+ for (const { suffix, kind } of SNAPSHOT_SUFFIXES) {
118
+ if (withoutExtension.endsWith(suffix)) return kind
119
+ }
120
+ return 'screenshot'
121
+ }
122
+
123
+ /**
124
+ * Collect the images worth showing for one test result.
125
+ *
126
+ * Accessibility screenshots are kept even when the test passed, because a
127
+ * baselined violation still passes and its screenshot is still the thing you
128
+ * want to look at.
129
+ */
130
+ function collectImages(attachments: any[], include: IncludeMode): FailureImage[] {
131
+ const images: FailureImage[] = []
132
+
133
+ for (const attachment of attachments) {
134
+ const name = String(attachment?.name ?? '')
135
+ const contentType = String(attachment?.contentType ?? '')
136
+ const kind = classifyAttachment(name, contentType)
137
+ if (!kind) continue
138
+
139
+ // Uploading three near-identical images per failure is rarely worth it;
140
+ // the diff is the one that shows what changed.
141
+ if (include === 'diff' && (kind === 'expected' || kind === 'actual' || kind === 'previous')) {
142
+ continue
143
+ }
144
+
145
+ images.push({
146
+ name,
147
+ filePath: attachment?.path ? String(attachment.path) : undefined,
148
+ body: attachment?.body ? String(attachment.body) : undefined,
149
+ contentType,
150
+ kind,
151
+ })
152
+ }
153
+
154
+ return images
155
+ }
156
+
157
+ function firstError(result: any): string | undefined {
158
+ const message = result?.error?.message ?? result?.errors?.[0]?.message
159
+ if (typeof message !== 'string' || !message.trim()) return undefined
160
+ const cleaned = stripAnsi(message).trim().split('\n')[0]
161
+ return cleaned.length > 300 ? `${cleaned.slice(0, 300)}…` : cleaned
162
+ }
163
+
164
+ function walkSuites(suites: any[], parentFile: string, include: IncludeMode, out: FailedTest[]): void {
165
+ for (const suite of suites ?? []) {
166
+ const file = suite.file || parentFile
167
+
168
+ for (const spec of suite.specs ?? []) {
169
+ for (const test of spec.tests ?? []) {
170
+ const results = test.results ?? []
171
+ const result = results[results.length - 1]
172
+ if (!result) continue
173
+
174
+ // Playwright reports each retry as another result. A passing final
175
+ // attempt therefore hides the failed attempt unless we deliberately
176
+ // retain it and give the test its Playwright outcome: flaky.
177
+ const failedAttempts = results.slice(0, -1).filter((attempt: any) => (
178
+ attempt?.status !== 'passed' && attempt?.status !== 'skipped'
179
+ ))
180
+ // JSON reports expose Playwright's computed outcome on the test. Keep
181
+ // the attempt-based fallback for older or hand-written reports that do
182
+ // not carry it.
183
+ const flaky = test.status === 'flaky' || (
184
+ test.status === undefined && result.status === 'passed' && failedAttempts.length > 0
185
+ )
186
+ const reportedResult = flaky ? failedAttempts[failedAttempts.length - 1] : result
187
+
188
+ const images = collectImages(reportedResult.attachments ?? [], include)
189
+ const failed = result.status !== 'passed' && result.status !== 'skipped'
190
+ const hasA11yImage = images.some(image => image.kind === 'a11y')
191
+ if (!failed && !flaky && !hasA11yImage) continue
192
+
193
+ out.push({
194
+ title: spec.title,
195
+ file,
196
+ line: spec.line ?? 1,
197
+ status: flaky ? 'flaky' : String(result.status ?? 'unknown'),
198
+ error: firstError(reportedResult),
199
+ images,
200
+ })
201
+ }
202
+ }
203
+
204
+ walkSuites(suite.suites ?? [], file, include, out)
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Parse a Playwright JSON report into the failures worth reporting.
210
+ */
211
+ export function parseFailures(reportPath: string, include: IncludeMode = 'diff'): FailureReport {
212
+ const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'))
213
+ const tests: FailedTest[] = []
214
+ walkSuites(report.suites ?? [], '', include, tests)
215
+
216
+ const totalFailed = tests.filter(test => (
217
+ test.status !== 'passed' && test.status !== 'skipped' && test.status !== 'flaky'
218
+ )).length
219
+ const totalFlaky = tests.filter(test => test.status === 'flaky').length
220
+ const totalImages = tests.reduce((sum, test) => sum + test.images.length, 0)
221
+
222
+ return { tests, totalFailed, totalFlaky, totalImages }
223
+ }
224
+
225
+ /** What re-rooting the report's attachment paths achieved. */
226
+ export interface PathResolutionSummary {
227
+ /** Attachments whose recorded path had to be rewritten to be readable. */
228
+ remapped: number
229
+ /** The mappings that were used, for the log line. */
230
+ used: PathPrefix[]
231
+ /** Recorded paths with no readable file behind them, on either side. */
232
+ unreadable: string[]
233
+ }
234
+
235
+ /**
236
+ * Point every image at a file this process can open, and note the ones where
237
+ * that was not possible.
238
+ *
239
+ * Runs whether or not uploads are switched on: an unreachable attachment is
240
+ * worth reporting even on a fork build that was never going to upload it,
241
+ * because it is the same misconfiguration either way.
242
+ */
243
+ export function resolveImagePaths(
244
+ report: FailureReport,
245
+ resolve: (filePath: string) => PathResolution,
246
+ ): PathResolutionSummary {
247
+ const summary: PathResolutionSummary = { remapped: 0, used: [], unreadable: [] }
248
+
249
+ for (const test of report.tests) {
250
+ for (const image of test.images) {
251
+ if (!image.filePath) continue
252
+
253
+ const resolution = resolve(image.filePath)
254
+ if (!resolution.found) {
255
+ image.unreadable = true
256
+ summary.unreadable.push(image.filePath)
257
+ continue
258
+ }
259
+
260
+ if (resolution.path === image.filePath) continue
261
+
262
+ summary.remapped++
263
+ image.filePath = resolution.path
264
+
265
+ const prefix = resolution.prefix
266
+ if (prefix && !summary.used.some(used => used.from === prefix.from && used.to === prefix.to)) {
267
+ summary.used.push(prefix)
268
+ }
269
+ }
270
+ }
271
+
272
+ return summary
273
+ }
274
+
275
+ /**
276
+ * Upload every collected image, annotating each one with its URL. Images the
277
+ * uploader declines are simply left without a URL, which the renderers treat
278
+ * as "link to the artifact instead".
279
+ */
280
+ export async function uploadImages(
281
+ report: FailureReport,
282
+ uploader: AttachmentUploader,
283
+ ): Promise<void> {
284
+ for (const test of report.tests) {
285
+ for (const image of test.images) {
286
+ let url: string | null = null
287
+
288
+ if (image.filePath) {
289
+ url = await uploader.upload(image.filePath, path.basename(image.filePath))
290
+ } else if (image.body) {
291
+ url = await uploader.uploadBuffer(
292
+ Buffer.from(image.body, 'base64'),
293
+ `${image.name}${extensionFor(image.contentType)}`,
294
+ image.contentType,
295
+ )
296
+ }
297
+
298
+ if (url) {
299
+ image.url = url
300
+ continue
301
+ }
302
+
303
+ // Every one of these is a null, and they need different things said
304
+ // about them. resolveImagePaths() has already checked the file is there,
305
+ // so 'unreadable' here means the read itself failed; the size reasons
306
+ // are the uploader declining this one image rather than stopping.
307
+ switch (uploader.lastSkipReason) {
308
+ case 'unreadable':
309
+ image.unreadable = true
310
+ break
311
+ case 'too-large':
312
+ case 'budget':
313
+ image.oversize = true
314
+ break
315
+ }
316
+ }
317
+ }
318
+ }
319
+
320
+ /** Inlined attachments carry no file name, so derive one from the content type. */
321
+ function extensionFor(contentType: string): string {
322
+ if (contentType === 'image/png') return '.png'
323
+ if (contentType === 'image/jpeg') return '.jpg'
324
+ if (contentType === 'image/gif') return '.gif'
325
+ if (contentType === 'image/webp') return '.webp'
326
+ return ''
327
+ }
328
+
329
+ function headline(report: FailureReport): string {
330
+ if (report.tests.length === 0) return ':white_check_mark: No failing tests or flaky tests with screenshots.'
331
+
332
+ // Nothing failed, but an accessibility check still captured something worth
333
+ // seeing — a baselined violation passes and is still screenshotted.
334
+ const parts = report.totalFailed === 0
335
+ ? [':white_check_mark: No failing tests']
336
+ : [`**${report.totalFailed}** failing test(s)`]
337
+
338
+ if (report.totalFlaky > 0) parts.push(`:warning: **${report.totalFlaky}** flaky test(s)`)
339
+
340
+ if (report.totalImages > 0) parts.push(`**${report.totalImages}** screenshot(s)`)
341
+ return parts.join(' · ')
342
+ }
343
+
344
+ /** Every image across every test, which is what the diagnostics count. */
345
+ function allImages(report: FailureReport): FailureImage[] {
346
+ return report.tests.flatMap(test => test.images)
347
+ }
348
+
349
+ /**
350
+ * Say why a set of images was not uploaded, or nothing when they were.
351
+ *
352
+ * "No token" and "the files were not where the report said" are different
353
+ * problems with different fixes, and until they are named apart a reader has
354
+ * no way to tell which one they have. An absent token is the dominant reason
355
+ * when it applies: nothing was going to be uploaded regardless.
356
+ */
357
+ function missingImageReason(images: FailureImage[], uploadReason?: string): string | undefined {
358
+ if (uploadReason) return uploadReason
359
+
360
+ const reasons: string[] = []
361
+
362
+ const unreadable = images.filter(image => image.unreadable).length
363
+ if (unreadable > 0) {
364
+ reasons.push(`${unreadable} could not be read from the path recorded in the report`)
365
+ }
366
+
367
+ const oversize = images.filter(image => image.oversize).length
368
+ if (oversize > 0) {
369
+ reasons.push(`${oversize} too large to upload`)
370
+ }
371
+
372
+ return reasons.length > 0 ? reasons.join('; ') : undefined
373
+ }
374
+
375
+ export interface SummaryOptions {
376
+ artifactHint?: string
377
+ /**
378
+ * Why uploading did not happen at all, phrased to sit inside a sentence —
379
+ * `no upload token configured`, say. Leave unset when uploads ran.
380
+ */
381
+ uploadReason?: string
382
+ }
383
+
384
+ /**
385
+ * Render the job summary: a heading, a headline, and one collapsed block per
386
+ * test holding its images.
387
+ *
388
+ * Deliberately table-free. Test titles and error messages are arbitrary text,
389
+ * and the runner's masking can eat a cell delimiter and corrupt a whole row.
390
+ */
391
+ export function generateSummary(report: FailureReport, options: SummaryOptions = {}): string {
392
+ // A baselined accessibility violation is screenshotted without failing
393
+ // anything, so a green run can still have something to show here.
394
+ const heading = report.totalFailed > 0
395
+ ? '## Test Failures'
396
+ : report.totalFlaky > 0 ? '## Test Flakes' : '## Test Screenshots'
397
+ const lines: string[] = [`${heading}\n`, `${headline(report)}\n`]
398
+
399
+ if (report.tests.length === 0) return lines.join('\n')
400
+
401
+ // Name the path boundary where it will be read, rather than leaving the
402
+ // reader to conclude they forgot the token.
403
+ const unreadable = allImages(report).filter(image => image.unreadable)
404
+ if (unreadable.length > 0) {
405
+ const example = defuseMaskTriggers(unreadable[0].filePath ?? '')
406
+ lines.push(
407
+ `> :warning: **${unreadable.length} screenshot(s) could not be read.** The report records them ` +
408
+ `under \`${example}\`, which does not exist where this step ran. Playwright ran somewhere else — ` +
409
+ 'a container, most likely — so run this command there too, or pass ' +
410
+ '`--path-prefix=CONTAINER_PATH:LOCAL_PATH` to map one onto the other.\n',
411
+ )
412
+ }
413
+
414
+ for (const test of report.tests) {
415
+ const title = defuseMaskTriggers(test.title)
416
+ lines.push(`### ${title}\n`)
417
+ lines.push(`\`${test.file}:${test.line}\` — ${test.status}\n`)
418
+
419
+ if (test.error) {
420
+ lines.push(`> ${defuseMaskTriggers(test.error)}\n`)
421
+ }
422
+
423
+ if (test.images.length === 0) continue
424
+
425
+ // The images go in the pull request comment, not here. A job summary is
426
+ // rendered once, when the job finishes, and an attachment uploaded seconds
427
+ // earlier is not resolvable yet — so it renders as a dead link and the
428
+ // cached result never improves, however often the page is reloaded.
429
+ const embedded = test.images.filter(image => image.url).length
430
+ if (embedded > 0) {
431
+ lines.push(`${embedded} screenshot(s) uploaded — see the pull request comment.\n`)
432
+ } else {
433
+ const hint = options.artifactHint ?? 'the Playwright report artifact'
434
+ const reason = missingImageReason(test.images, options.uploadReason)
435
+ const because = reason ? ` (${reason})` : ''
436
+ lines.push(`${test.images.length} screenshot(s) captured, not uploaded${because} — download ${hint}.\n`)
437
+ }
438
+ }
439
+
440
+ return lines.join('\n')
441
+ }
442
+
443
+ /** A collapsed block of images for one test, used in the comment. */
444
+ function renderImageBlock(test: FailedTest): string[] {
445
+ const embedded = test.images.filter(image => image.url)
446
+ if (embedded.length === 0) return []
447
+
448
+ const title = defuseMaskTriggers(test.title)
449
+ const lines = ['<details>', `<summary>Screenshots (${embedded.length})</summary>\n`]
450
+
451
+ for (const image of embedded) {
452
+ lines.push(`**${image.kind}**\n`)
453
+ const src = escapeAttribute(image.url ?? '')
454
+ lines.push(`<img src="${src}" alt="${image.kind} for ${escapeAttribute(title)}" width="640">\n`)
455
+ }
456
+
457
+ lines.push('</details>\n')
458
+ return lines
459
+ }
460
+
461
+ /**
462
+ * Render the pull request comment. This is where the screenshots go.
463
+ *
464
+ * A comment is rendered afresh every time it is read, so an attachment resolves
465
+ * however recently it was uploaded. A job summary is rendered once when the job
466
+ * ends, before a just-uploaded attachment is resolvable, and that result is
467
+ * what everyone sees from then on.
468
+ *
469
+ * The cost is emailed notifications: those are rendered once when sent, and the
470
+ * signed URLs behind these images expire minutes later, so the images will be
471
+ * broken in the email even though they are fine on the web.
472
+ */
473
+ export interface FailureCommentOptions {
474
+ summaryUrl?: string
475
+ title?: string
476
+ uploadReason?: string
477
+ /** Adapter namespace for the machine-readable failure marker. */
478
+ failureMarkerPrefix?: string
479
+ /** Adapter namespace for the machine-readable flaky-test marker. */
480
+ flakeMarkerPrefix?: string
481
+ }
482
+
483
+ export function generateComment(
484
+ report: FailureReport,
485
+ options: FailureCommentOptions = {},
486
+ ): string {
487
+ const heading = options.title ?? 'Playwright results'
488
+ const lines: string[] = [`### ${heading}\n`, `${headline(report)}\n`]
489
+
490
+ if (report.tests.length > 0) {
491
+ for (const test of report.tests.slice(0, 10)) {
492
+ const title = defuseMaskTriggers(test.title)
493
+ const images = test.images.length > 0 ? ` — ${test.images.length} screenshot(s)` : ''
494
+ lines.push(`- \`${test.status}\` ${title}${images}`)
495
+ }
496
+ if (report.tests.length > 10) {
497
+ lines.push(`- …and ${report.tests.length - 10} more`)
498
+ }
499
+ lines.push('')
500
+
501
+ for (const test of report.tests) {
502
+ const block = renderImageBlock(test)
503
+ if (block.length === 0) continue
504
+ lines.push(`**${defuseMaskTriggers(test.title)}**\n`)
505
+ lines.push(...block)
506
+ }
507
+
508
+ // An empty comment where images were expected reads as a broken feature.
509
+ // Whatever the reason, it belongs where the images would have been.
510
+ const missing = allImages(report).filter(image => !image.url)
511
+ const reason = missingImageReason(missing, options.uploadReason)
512
+ if (missing.length > 0 && reason) {
513
+ lines.push(`_${missing.length} screenshot(s) not shown — ${reason}._\n`)
514
+ }
515
+ }
516
+
517
+ if (options.summaryUrl) {
518
+ lines.push(`[Full run](${options.summaryUrl})\n`)
519
+ }
520
+
521
+ // A machine-readable count, so a workflow assembling several of these can
522
+ // decide whether to post at all without grepping prose — the empty-state
523
+ // sentence contains the words "failing test" too.
524
+ lines.push(`${options.failureMarkerPrefix ?? FAILURE_MARKER_PREFIX}${report.totalFailed} -->\n`)
525
+ lines.push(`${options.flakeMarkerPrefix ?? FLAKE_MARKER_PREFIX}${report.totalFlaky} -->\n`)
526
+
527
+ return lines.join('\n')
528
+ }
529
+
530
+ function escapeAttribute(value: string): string {
531
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
532
+ }
533
+
534
+ interface CliOptions {
535
+ reportPath: string
536
+ commentPath?: string
537
+ include: IncludeMode
538
+ maxUploads?: number
539
+ title?: string
540
+ pathPrefixes: PathPrefix[]
541
+ }
542
+
543
+ function parseArgs(args: string[]): CliOptions {
544
+ const options: CliOptions = {
545
+ reportPath: 'test-results/results.json',
546
+ include: 'diff',
547
+ pathPrefixes: [],
548
+ }
549
+
550
+ for (const arg of args) {
551
+ // Repeatable: a project can have more than one mount to translate.
552
+ if (arg.startsWith('--path-prefix=')) {
553
+ const prefix = parsePathPrefix(arg.slice('--path-prefix='.length))
554
+ if (prefix) {
555
+ options.pathPrefixes.push(prefix)
556
+ } else {
557
+ console.error(`Ignoring --path-prefix: expected FROM:TO, got ${arg.slice('--path-prefix='.length)}`)
558
+ }
559
+ }
560
+ if (arg.startsWith('--report-path=')) options.reportPath = arg.slice('--report-path='.length)
561
+ if (arg.startsWith('--comment-path=')) options.commentPath = arg.slice('--comment-path='.length)
562
+ if (arg.startsWith('--title=')) options.title = arg.slice('--title='.length)
563
+ if (arg.startsWith('--include=')) {
564
+ const value = arg.slice('--include='.length)
565
+ options.include = value === 'all' ? 'all' : 'diff'
566
+ }
567
+ if (arg.startsWith('--max-uploads=')) {
568
+ const value = Number.parseInt(arg.slice('--max-uploads='.length), 10)
569
+ if (Number.isFinite(value) && value >= 0) options.maxUploads = value
570
+ }
571
+ }
572
+
573
+ return options
574
+ }
575
+
576
+ /**
577
+ * Raise a warning where someone will see it.
578
+ *
579
+ * A `::warning::` command has to go to stdout for the runner to pick it up,
580
+ * which is only safe once the summary itself is going to a file — otherwise it
581
+ * would land in the middle of the Markdown. Outside Actions, or when the
582
+ * summary is on stdout, stderr is the only sensible place.
583
+ */
584
+ function warn(message: string): void {
585
+ const inActions = process.env.GITHUB_ACTIONS === 'true' && !!process.env.GITHUB_STEP_SUMMARY
586
+ if (inActions) {
587
+ process.stdout.write(`::warning title=Playwright screenshots::${escapeAnnotation(message)}\n`)
588
+ } else {
589
+ console.error(`Warning: ${message}`)
590
+ }
591
+ }
592
+
593
+ /** Workflow commands are line-based, so anything that ends a line is encoded. */
594
+ function escapeAnnotation(message: string): string {
595
+ return message.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
596
+ }
597
+
598
+ /** Build the run's job summary URL, which is the best anchor a comment can link to. */
599
+ function summaryUrl(): string | undefined {
600
+ const { GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID } = process.env
601
+ if (!GITHUB_SERVER_URL || !GITHUB_REPOSITORY || !GITHUB_RUN_ID) return undefined
602
+ return `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}`
603
+ }
604
+
605
+ export function uploaderFromEnvironment(
606
+ overrides: AttachmentUploaderOptions = {},
607
+ ): AttachmentUploader {
608
+ return new AttachmentUploader({
609
+ token: process.env.SCREENSHOT_GITHUB_TOKEN,
610
+ repositoryId: process.env.GITHUB_REPOSITORY_ID,
611
+ log: message => console.error(message),
612
+ ...overrides,
613
+ })
614
+ }
615
+
616
+ /**
617
+ * CLI entry point. Runs once and writes both outputs, so images are never
618
+ * uploaded twice for the same run.
619
+ */
620
+ export interface FailureSummaryMainOptions {
621
+ commandName?: string
622
+ failureMarkerPrefix?: string
623
+ flakeMarkerPrefix?: string
624
+ }
625
+
626
+ export async function main(
627
+ args: string[] = process.argv.slice(2),
628
+ adapter: FailureSummaryMainOptions = {},
629
+ ): Promise<void> {
630
+ if (args.includes('--help') || args.includes('-h')) {
631
+ process.stdout.write(
632
+ `Usage: ${adapter.commandName ?? 'playwright-testing-failure-summary'} [options]\n\n` +
633
+ 'Options:\n' +
634
+ ' --report-path=PATH Playwright JSON report (default: test-results/results.json)\n' +
635
+ ' --comment-path=PATH Write a pull request comment body\n' +
636
+ ' --include=diff|all Select comparison images (default: diff)\n' +
637
+ ' --path-prefix=FROM:TO Remap attachment paths; repeatable\n' +
638
+ ' --max-uploads=COUNT Limit uploaded screenshots\n' +
639
+ ' --title=TEXT Set the comment heading\n' +
640
+ ' -h, --help Show this help\n',
641
+ )
642
+ return
643
+ }
644
+
645
+ const options = parseArgs(args)
646
+ const resolvedPath = path.resolve(options.reportPath)
647
+
648
+ // A missing report is not an error: the suite may not have run at all.
649
+ if (!fs.existsSync(resolvedPath)) {
650
+ console.error(`Playwright JSON report not found at ${resolvedPath} — skipping failure summary.`)
651
+ return
652
+ }
653
+
654
+ const report = parseFailures(resolvedPath, options.include)
655
+
656
+ // The report records where the run wrote its attachments, which is not
657
+ // necessarily anywhere this process can reach. Settle that before uploading,
658
+ // so an unreachable file is reported as one rather than as a silent skip.
659
+ const paths = resolveImagePaths(
660
+ report,
661
+ createPathResolver({ reportPath: resolvedPath, prefixes: options.pathPrefixes }),
662
+ )
663
+ if (paths.remapped > 0) {
664
+ const mappings = paths.used.map(prefix => `${prefix.from} → ${prefix.to}`).join(', ')
665
+ console.error(`Remapped ${paths.remapped} attachment path(s): ${mappings || 'no mapping recorded'}`)
666
+ }
667
+ if (paths.unreadable.length > 0) {
668
+ warn(
669
+ `${paths.unreadable.length} screenshot(s) could not be read, starting with ${paths.unreadable[0]}. ` +
670
+ 'Playwright ran somewhere this command cannot reach — a container, most likely. Run it there too, ' +
671
+ 'or pass --path-prefix=CONTAINER_PATH:LOCAL_PATH.',
672
+ )
673
+ }
674
+
675
+ const uploader = uploaderFromEnvironment(
676
+ options.maxUploads === undefined ? {} : { maxUploads: options.maxUploads },
677
+ )
678
+ if (uploader.enabled) {
679
+ await uploadImages(report, uploader)
680
+ const stats = uploader.getStats()
681
+ console.error(`Uploaded ${stats.uploaded} screenshot(s), skipped ${stats.skipped}.`)
682
+ if (uploader.disabledReason) {
683
+ // Reaching an undocumented endpoint that no longer answers is a real
684
+ // fault, unlike an absent token on a fork build.
685
+ warn(`Screenshot uploads stopped early — ${uploader.disabledReason}.`)
686
+ }
687
+ } else {
688
+ console.error(`Screenshot uploads are off — ${uploader.disabledReason}.`)
689
+ }
690
+
691
+ const uploadReason = uploader.disabledReason ?? undefined
692
+ const summary = generateSummary(report, { uploadReason })
693
+ const summaryFile = process.env.GITHUB_STEP_SUMMARY
694
+ if (summaryFile) {
695
+ fs.appendFileSync(summaryFile, summary)
696
+ console.error('Failure summary written to $GITHUB_STEP_SUMMARY')
697
+ } else {
698
+ process.stdout.write(summary)
699
+ }
700
+
701
+ if (options.commentPath) {
702
+ const comment = generateComment(report, {
703
+ summaryUrl: summaryUrl(),
704
+ title: options.title,
705
+ uploadReason,
706
+ failureMarkerPrefix: adapter.failureMarkerPrefix,
707
+ flakeMarkerPrefix: adapter.flakeMarkerPrefix,
708
+ })
709
+ fs.writeFileSync(path.resolve(options.commentPath), comment)
710
+ console.error(`Comment body written to ${options.commentPath}`)
711
+ }
712
+ }
713
+
714
+ // Auto-invoke when run directly (node lib/github/failure-summary.js).
715
+ if (require.main === module) {
716
+ main().catch((error: unknown) => {
717
+ console.error(error)
718
+ process.exit(1)
719
+ })
720
+ }