@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,636 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import * as fs from 'fs'
3
+ import * as os from 'os'
4
+ import * as path from 'path'
5
+ import {
6
+ parseFailures,
7
+ resolveImagePaths,
8
+ generateSummary,
9
+ generateComment,
10
+ uploadImages,
11
+ defuseMaskTriggers,
12
+ FailureReport,
13
+ } from './failure-summary'
14
+ import { createPathResolver } from './report-paths'
15
+ import { AttachmentUploader, FetchLike } from './attachments'
16
+
17
+ let tmpDir: string
18
+
19
+ beforeEach(() => {
20
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'failure-summary-test-'))
21
+ })
22
+
23
+ afterEach(() => {
24
+ fs.rmSync(tmpDir, { recursive: true, force: true })
25
+ })
26
+
27
+ interface TestResultCase {
28
+ status?: string
29
+ attachments?: any[]
30
+ error?: { message: string }
31
+ }
32
+
33
+ interface TestCase extends TestResultCase {
34
+ title?: string
35
+ outcome?: string
36
+ results?: TestResultCase[]
37
+ }
38
+
39
+ /** Build a minimal Playwright JSON report and write it to disk. */
40
+ function writeReport(cases: TestCase[]): string {
41
+ const report = {
42
+ suites: [{
43
+ title: '',
44
+ file: 'tests/visual.spec.ts',
45
+ specs: cases.map((testCase, index) => ({
46
+ title: testCase.title ?? `case ${index}`,
47
+ line: 10 + index,
48
+ tests: [{
49
+ status: testCase.outcome,
50
+ results: testCase.results ?? [{
51
+ status: testCase.status ?? 'failed',
52
+ attachments: testCase.attachments ?? [],
53
+ error: testCase.error,
54
+ }],
55
+ }],
56
+ })),
57
+ }],
58
+ }
59
+
60
+ const reportPath = path.join(tmpDir, 'results.json')
61
+ fs.writeFileSync(reportPath, JSON.stringify(report))
62
+ return reportPath
63
+ }
64
+
65
+ function image(name: string, filePath = `/tmp/${name}`) {
66
+ return { name, contentType: 'image/png', path: filePath }
67
+ }
68
+
69
+ describe('parseFailures', () => {
70
+ it('collects failing tests and ignores passing ones', () => {
71
+ const reportPath = writeReport([
72
+ { title: 'homepage matches', status: 'failed', attachments: [image('homepage-diff.png')] },
73
+ { title: 'about page matches', status: 'passed' },
74
+ ])
75
+
76
+ const report = parseFailures(reportPath)
77
+
78
+ expect(report.tests).toHaveLength(1)
79
+ expect(report.tests[0].title).toBe('homepage matches')
80
+ expect(report.totalFailed).toBe(1)
81
+ expect(report.totalFlaky).toBe(0)
82
+ expect(report.totalImages).toBe(1)
83
+ })
84
+
85
+ it('collects a test that passes on retry as flaky', () => {
86
+ const reportPath = writeReport([{
87
+ title: 'homepage eventually matches',
88
+ outcome: 'flaky',
89
+ results: [
90
+ {
91
+ status: 'failed',
92
+ error: { message: 'Screenshot comparison failed' },
93
+ attachments: [image('homepage-diff.png')],
94
+ },
95
+ { status: 'passed' },
96
+ ],
97
+ }])
98
+
99
+ const report = parseFailures(reportPath)
100
+
101
+ expect(report.tests).toHaveLength(1)
102
+ expect(report.tests[0]).toMatchObject({
103
+ title: 'homepage eventually matches',
104
+ status: 'flaky',
105
+ error: 'Screenshot comparison failed',
106
+ })
107
+ expect(report.tests[0].images.map(i => i.kind)).toEqual(['diff'])
108
+ expect(report.totalFailed).toBe(0)
109
+ expect(report.totalFlaky).toBe(1)
110
+ expect(report.totalImages).toBe(1)
111
+ })
112
+
113
+ it('classifies the snapshot comparison images', () => {
114
+ const reportPath = writeReport([{
115
+ attachments: [
116
+ image('homepage-diff.png'),
117
+ image('homepage-actual.png'),
118
+ image('homepage-expected.png'),
119
+ ],
120
+ }])
121
+
122
+ const diffOnly = parseFailures(reportPath, 'diff')
123
+ expect(diffOnly.tests[0].images.map(i => i.kind)).toEqual(['diff'])
124
+
125
+ const all = parseFailures(reportPath, 'all')
126
+ expect(all.tests[0].images.map(i => i.kind)).toEqual(['diff', 'actual', 'expected'])
127
+ })
128
+
129
+ it('keeps an inlined attachment body, which is how a11y screenshots arrive', () => {
130
+ const reportPath = writeReport([{
131
+ attachments: [
132
+ { name: 'a11y-violation-screenshot', contentType: 'image/png', body: 'aGVsbG8=' },
133
+ ],
134
+ }])
135
+
136
+ const image = parseFailures(reportPath).tests[0].images[0]
137
+
138
+ expect(image.body).toBe('aGVsbG8=')
139
+ expect(image.filePath).toBeUndefined()
140
+ })
141
+
142
+ it('keeps an accessibility screenshot even when the test passed', () => {
143
+ // A baselined violation still passes, and its screenshot is still the
144
+ // thing worth looking at.
145
+ const reportPath = writeReport([{
146
+ title: 'homepage a11y',
147
+ status: 'passed',
148
+ attachments: [image('a11y-violation-screenshot')],
149
+ }])
150
+
151
+ const report = parseFailures(reportPath)
152
+
153
+ expect(report.tests).toHaveLength(1)
154
+ expect(report.tests[0].images[0].kind).toBe('a11y')
155
+ expect(report.totalFailed).toBe(0)
156
+ })
157
+
158
+ it('ignores attachments that are not images', () => {
159
+ const reportPath = writeReport([{
160
+ attachments: [
161
+ { name: 'trace.zip', contentType: 'application/zip', path: '/tmp/trace.zip' },
162
+ { name: 'stdout.txt', contentType: 'text/plain', body: 'aGk=' },
163
+ ],
164
+ }])
165
+
166
+ expect(parseFailures(reportPath).tests[0].images).toHaveLength(0)
167
+ })
168
+
169
+ it('takes the first line of the error, without ANSI codes', () => {
170
+ const reportPath = writeReport([{
171
+ error: { message: '\u001b[31mScreenshot comparison failed\u001b[0m\n\n expected 1000 pixels' },
172
+ }])
173
+
174
+ expect(parseFailures(reportPath).tests[0].error).toBe('Screenshot comparison failed')
175
+ })
176
+ })
177
+
178
+ describe('generateSummary', () => {
179
+ function reportWith(url?: string): FailureReport {
180
+ return {
181
+ tests: [{
182
+ title: 'homepage matches',
183
+ file: 'tests/visual.spec.ts',
184
+ line: 10,
185
+ status: 'failed',
186
+ error: 'Screenshot comparison failed',
187
+ images: [{ name: 'homepage-diff.png', contentType: 'image/png', kind: 'diff', url }],
188
+ }],
189
+ totalFailed: 1,
190
+ totalFlaky: 0,
191
+ totalImages: 1,
192
+ }
193
+ }
194
+
195
+ it('points at the comment rather than embedding images', () => {
196
+ // A summary is rendered once when the job ends, before a just-uploaded
197
+ // attachment resolves, and that dead render is cached for good.
198
+ const summary = generateSummary(reportWith('https://example.test/a'))
199
+
200
+ expect(summary).not.toContain('<img')
201
+ expect(summary).toContain('1 screenshot(s) uploaded — see the pull request comment.')
202
+ expect(summary).toContain('homepage matches')
203
+ })
204
+
205
+ it('points at the artifact when nothing was uploaded', () => {
206
+ const summary = generateSummary(reportWith(undefined))
207
+
208
+ expect(summary).not.toContain('<img')
209
+ expect(summary).toContain('not uploaded')
210
+ })
211
+
212
+ it('says an absent token is why nothing was uploaded', () => {
213
+ const summary = generateSummary(reportWith(undefined), {
214
+ uploadReason: 'no upload token configured',
215
+ })
216
+
217
+ expect(summary).toContain('not uploaded (no upload token configured)')
218
+ })
219
+
220
+ it('says so when the images were not where the report said they were', () => {
221
+ // The reason a reader most needs, and the one that used to be silent: the
222
+ // token is fine, the files are on the other side of a container boundary.
223
+ const report = reportWith(undefined)
224
+ report.tests[0].images[0].filePath = '/var/www/html/test/playwright/test-results/home-1-diff.png'
225
+ report.tests[0].images[0].unreadable = true
226
+
227
+ const summary = generateSummary(report)
228
+
229
+ expect(summary).toContain('1 screenshot(s) could not be read')
230
+ expect(summary).toContain('/var/www/html/test/playwright/test-results/home-1-diff.png')
231
+ expect(summary).toContain('--path-prefix=')
232
+ expect(summary).toContain('not uploaded (1 could not be read')
233
+ })
234
+
235
+ it('does not mention a path boundary when there is none', () => {
236
+ expect(generateSummary(reportWith(undefined))).not.toContain('could not be read')
237
+ })
238
+
239
+ it('does not claim zero failing tests when only a11y captured something', () => {
240
+ const summary = generateSummary({
241
+ tests: [{
242
+ title: 'homepage a11y',
243
+ file: 'tests/a11y.spec.ts',
244
+ line: 7,
245
+ status: 'passed',
246
+ images: [{ name: 'a11y-violation-screenshot', contentType: 'image/png', kind: 'a11y' }],
247
+ }],
248
+ totalFailed: 0,
249
+ totalFlaky: 0,
250
+ totalImages: 1,
251
+ })
252
+
253
+ expect(summary).not.toContain('**0** failing test(s)')
254
+ expect(summary).toContain('No failing tests · **1** screenshot(s)')
255
+ // Nothing failed, so do not head the section "Test Failures".
256
+ expect(summary).toContain('## Test Screenshots')
257
+ })
258
+
259
+ it('calls out flakes when retries recover every failure', () => {
260
+ const summary = generateSummary({
261
+ tests: [{
262
+ title: 'homepage eventually matches',
263
+ file: 'tests/visual.spec.ts',
264
+ line: 10,
265
+ status: 'flaky',
266
+ error: 'Screenshot comparison failed',
267
+ images: [],
268
+ }],
269
+ totalFailed: 0,
270
+ totalFlaky: 1,
271
+ totalImages: 0,
272
+ })
273
+
274
+ expect(summary).toContain('## Test Flakes')
275
+ expect(summary).toContain('No failing tests · :warning: **1** flaky test(s)')
276
+ expect(summary).toContain('`tests/visual.spec.ts:10` — flaky')
277
+ })
278
+
279
+ it('says so plainly when there is nothing to report', () => {
280
+ const summary = generateSummary({ tests: [], totalFailed: 0, totalFlaky: 0, totalImages: 0 })
281
+
282
+ expect(summary).toContain('No failing tests')
283
+ expect(summary).not.toContain('<details>')
284
+ })
285
+
286
+ it('escapes the asset URL, which may one day carry query parameters', () => {
287
+ const report = reportWith('https://example.test/a?x=1&y=2')
288
+
289
+ expect(generateComment(report)).toContain('src="https://example.test/a?x=1&amp;y=2"')
290
+ })
291
+
292
+ it('escapes quotes coming from a test title', () => {
293
+ const report = reportWith('https://example.test/a')
294
+ report.tests[0].title = 'renders "quoted" text'
295
+
296
+ expect(generateComment(report)).toContain('alt="diff for renders &quot;quoted&quot; text"')
297
+ })
298
+ })
299
+
300
+ describe('generateComment', () => {
301
+ const report: FailureReport = {
302
+ tests: [{
303
+ title: 'homepage matches',
304
+ file: 'tests/visual.spec.ts',
305
+ line: 10,
306
+ status: 'failed',
307
+ images: [{ name: 'homepage-diff.png', contentType: 'image/png', kind: 'diff' }],
308
+ }],
309
+ totalFailed: 1,
310
+ totalFlaky: 0,
311
+ totalImages: 1,
312
+ }
313
+
314
+ it('summarises the failures and links to the summary', () => {
315
+ const comment = generateComment(report, { summaryUrl: 'https://example.test/run/1' })
316
+
317
+ expect(comment).toContain('**1** failing test(s)')
318
+ expect(comment).toContain('homepage matches')
319
+ expect(comment).toContain('(https://example.test/run/1)')
320
+ })
321
+
322
+ it('says why the screenshots it promised are not there', () => {
323
+ // The bullet list still advertises the screenshots, so a comment with no
324
+ // images has to account for them or it reads as a broken feature.
325
+ const comment = generateComment(report, { uploadReason: 'no upload token configured' })
326
+
327
+ expect(comment).toContain('1 screenshot(s) not shown — no upload token configured')
328
+ })
329
+
330
+ it('distinguishes unreadable images from an absent token', () => {
331
+ const unreadable: FailureReport = {
332
+ ...report,
333
+ tests: [{
334
+ ...report.tests[0],
335
+ images: [{ ...report.tests[0].images[0], unreadable: true }],
336
+ }],
337
+ }
338
+
339
+ const comment = generateComment(unreadable)
340
+
341
+ expect(comment).toContain('1 could not be read from the path recorded in the report')
342
+ })
343
+
344
+ it('embeds uploaded images in a collapsed block', () => {
345
+ // A comment re-renders on every read, so an attachment resolves however
346
+ // recently it was uploaded. This is the only place they display.
347
+ const withUrls: FailureReport = {
348
+ ...report,
349
+ tests: [{
350
+ ...report.tests[0],
351
+ images: [{
352
+ name: 'homepage-diff.png',
353
+ contentType: 'image/png',
354
+ kind: 'diff',
355
+ url: 'https://github.com/user-attachments/assets/abc',
356
+ }],
357
+ }],
358
+ }
359
+
360
+ const comment = generateComment(withUrls)
361
+
362
+ expect(comment).toContain('<details>')
363
+ expect(comment).toContain('<img src="https://github.com/user-attachments/assets/abc"')
364
+ expect(comment).toContain('width="640"')
365
+ })
366
+
367
+ it('shows no image block when nothing was uploaded', () => {
368
+ const comment = generateComment(report)
369
+
370
+ expect(comment).not.toContain('<img')
371
+ expect(comment).not.toContain('<details>')
372
+ })
373
+
374
+ it('marks the failure and flake counts for a workflow to read', () => {
375
+ // The empty-state sentence contains the words "failing test", so anything
376
+ // grepping the prose would treat a green run as a failure.
377
+ expect(generateComment(report)).toContain('<!-- playwright-testing-failures: 1 -->')
378
+ expect(generateComment(report)).toContain('<!-- playwright-testing-flakes: 0 -->')
379
+
380
+ const green = generateComment({ tests: [], totalFailed: 0, totalFlaky: 0, totalImages: 0 })
381
+ expect(green).toContain('<!-- playwright-testing-failures: 0 -->')
382
+ expect(green).toContain('<!-- playwright-testing-flakes: 0 -->')
383
+ expect(green).toMatch(/No failing tests/)
384
+ })
385
+
386
+ it('reports flaky tests and marks the comment as worth posting', () => {
387
+ const flaky: FailureReport = {
388
+ tests: [{
389
+ title: 'homepage eventually matches',
390
+ file: 'tests/visual.spec.ts',
391
+ line: 10,
392
+ status: 'flaky',
393
+ error: 'Screenshot comparison failed',
394
+ images: [],
395
+ }],
396
+ totalFailed: 0,
397
+ totalFlaky: 1,
398
+ totalImages: 0,
399
+ }
400
+
401
+ const comment = generateComment(flaky)
402
+
403
+ expect(comment).toContain(':warning: **1** flaky test(s)')
404
+ expect(comment).toContain('`flaky` homepage eventually matches')
405
+ expect(comment).toContain('<!-- playwright-testing-failures: 0 -->')
406
+ expect(comment).toContain('<!-- playwright-testing-flakes: 1 -->')
407
+ })
408
+
409
+ it('truncates a long list rather than posting a wall of text', () => {
410
+ const many: FailureReport = {
411
+ tests: Array.from({ length: 12 }, (_, i) => ({
412
+ title: `case ${i}`,
413
+ file: 'tests/visual.spec.ts',
414
+ line: i,
415
+ status: 'failed',
416
+ images: [],
417
+ })),
418
+ totalFailed: 12,
419
+ totalFlaky: 0,
420
+ totalImages: 0,
421
+ }
422
+
423
+ expect(generateComment(many)).toContain('…and 2 more')
424
+ })
425
+ })
426
+
427
+ describe('defuseMaskTriggers', () => {
428
+ it('breaks up the pattern the runner redacts in job summaries', () => {
429
+ const defused = defuseMaskTriggers('sends Bearer tokens')
430
+
431
+ expect(defused).not.toContain('Bearer tokens')
432
+ expect(defused.replace(/\u200B/g, '')).toBe('sends Bearer tokens')
433
+ })
434
+
435
+ it('leaves the bare word alone, which the runner does not redact', () => {
436
+ expect(defuseMaskTriggers('Bearer')).toBe('Bearer')
437
+ })
438
+ })
439
+
440
+ describe('resolveImagePaths', () => {
441
+ /**
442
+ * A report as it arrives from a run inside DDEV: every path absolute and
443
+ * under /var/www/html, read from a checkout that has no such directory.
444
+ */
445
+ function containerRun(diffPath: string) {
446
+ const raw = {
447
+ config: {
448
+ // What Playwright actually records: the *test* directory, which is why
449
+ // the report's own config is no use for locating the mount point.
450
+ rootDir: '/var/www/html/test/playwright/tests',
451
+ projects: [{ name: 'chromium', outputDir: '/var/www/html/test/playwright/test-results' }],
452
+ },
453
+ suites: [{
454
+ title: '',
455
+ file: 'tests/visual.spec.ts',
456
+ specs: [{
457
+ title: 'homepage matches',
458
+ line: 10,
459
+ tests: [{
460
+ results: [{
461
+ status: 'failed',
462
+ attachments: [{ name: 'homepage-1-diff.png', contentType: 'image/png', path: diffPath }],
463
+ }],
464
+ }],
465
+ }],
466
+ }],
467
+ }
468
+
469
+ const reportPath = path.join(tmpDir, 'test/playwright/test-results/results.json')
470
+ fs.mkdirSync(path.dirname(reportPath), { recursive: true })
471
+ fs.writeFileSync(reportPath, JSON.stringify(raw))
472
+
473
+ return reportPath
474
+ }
475
+
476
+ it('finds a diff image written by a container and read from outside it', () => {
477
+ // The bind mount that makes the two views differ also makes this work:
478
+ // the same file is under the checkout, at a different prefix.
479
+ const diffPath = path.join(tmpDir, 'test/playwright/test-results/homepage-chromium/homepage-1-diff.png')
480
+ fs.mkdirSync(path.dirname(diffPath), { recursive: true })
481
+ fs.writeFileSync(diffPath, 'png')
482
+
483
+ const reportPath = containerRun(
484
+ '/var/www/html/test/playwright/test-results/homepage-chromium/homepage-1-diff.png',
485
+ )
486
+ const report = parseFailures(reportPath)
487
+
488
+ const summary = resolveImagePaths(report, createPathResolver({ reportPath }))
489
+
490
+ expect(summary.unreadable).toEqual([])
491
+ expect(summary.remapped).toBe(1)
492
+ expect(summary.used).toEqual([{ from: '/var/www/html', to: tmpDir }])
493
+ expect(report.tests[0].images[0].filePath).toBe(diffPath)
494
+ expect(report.tests[0].images[0].unreadable).toBeUndefined()
495
+ })
496
+
497
+ it('marks an image it cannot reach, rather than passing the path on', () => {
498
+ const reportPath = containerRun(
499
+ '/var/www/html/test/playwright/test-results/never-written/never-written-diff.png',
500
+ )
501
+ const report = parseFailures(reportPath)
502
+
503
+ const summary = resolveImagePaths(report, createPathResolver({ reportPath }))
504
+
505
+ expect(summary.remapped).toBe(0)
506
+ expect(summary.unreadable).toEqual([
507
+ '/var/www/html/test/playwright/test-results/never-written/never-written-diff.png',
508
+ ])
509
+ expect(report.tests[0].images[0].unreadable).toBe(true)
510
+ })
511
+
512
+ it('leaves an inlined accessibility screenshot alone', () => {
513
+ // These carry a body rather than a path, which is why they were the one
514
+ // attachment kind that never hit the boundary.
515
+ const report = parseFailures(writeReport([{
516
+ title: 'homepage a11y',
517
+ status: 'failed',
518
+ attachments: [{
519
+ name: 'a11y-violation-screenshot',
520
+ contentType: 'image/png',
521
+ body: 'aGVsbG8=',
522
+ }],
523
+ }]))
524
+
525
+ const summary = resolveImagePaths(report, createPathResolver({ reportPath: '/nowhere/results.json' }))
526
+
527
+ expect(summary).toEqual({ remapped: 0, used: [], unreadable: [] })
528
+ expect(report.tests[0].images[0].body).toBe('aGVsbG8=')
529
+ })
530
+ })
531
+
532
+ describe('uploadImages', () => {
533
+ it('uploads an inlined body, giving it a name derived from the content type', async () => {
534
+ const names: string[] = []
535
+ const impl: FetchLike = async (url) => {
536
+ names.push(new URL(url).searchParams.get('name') ?? '')
537
+ return { ok: true, status: 201, text: async () => '{"url":"https://example.test/a"}' }
538
+ }
539
+
540
+ const report: FailureReport = {
541
+ tests: [{
542
+ title: 'a', file: 'f', line: 1, status: 'passed',
543
+ images: [{
544
+ name: 'a11y-violation-screenshot',
545
+ body: Buffer.from('png bytes').toString('base64'),
546
+ contentType: 'image/png',
547
+ kind: 'a11y',
548
+ }],
549
+ }],
550
+ totalFailed: 0,
551
+ totalFlaky: 0,
552
+ totalImages: 1,
553
+ }
554
+
555
+ await uploadImages(report, new AttachmentUploader({ token: 't', repositoryId: '1', fetchImpl: impl }))
556
+
557
+ expect(names).toEqual(['a11y-violation-screenshot.png'])
558
+ expect(report.tests[0].images[0].url).toBe('https://example.test/a')
559
+ })
560
+
561
+ it('records a URL per image and leaves the rest alone once uploads stop', async () => {
562
+ const filePath = path.join(tmpDir, 'diff.png')
563
+ fs.writeFileSync(filePath, Buffer.alloc(8, 1))
564
+
565
+ const responses = [
566
+ { ok: true, status: 201, body: '{"url":"https://example.test/one"}' },
567
+ { ok: false, status: 404, body: '{"message":"Not Found"}' },
568
+ ]
569
+ const impl: FetchLike = async () => {
570
+ const next = responses.shift()!
571
+ return { ok: next.ok, status: next.status, text: async () => next.body }
572
+ }
573
+
574
+ const report: FailureReport = {
575
+ tests: [{
576
+ title: 'a', file: 'f', line: 1, status: 'failed',
577
+ images: [
578
+ { name: 'one.png', filePath, contentType: 'image/png', kind: 'diff' },
579
+ { name: 'two.png', filePath, contentType: 'image/png', kind: 'diff' },
580
+ { name: 'three.png', filePath, contentType: 'image/png', kind: 'diff' },
581
+ ],
582
+ }],
583
+ totalFailed: 1,
584
+ totalFlaky: 0,
585
+ totalImages: 3,
586
+ }
587
+
588
+ await uploadImages(report, new AttachmentUploader({ token: 't', repositoryId: '1', fetchImpl: impl }))
589
+
590
+ const urls = report.tests[0].images.map(i => i.url)
591
+ expect(urls).toEqual(['https://example.test/one', undefined, undefined])
592
+ })
593
+
594
+ it('marks an image the size limit refused without calling it unreadable', async () => {
595
+ // The two render as the same absence, and only one of them is fixed by a
596
+ // path mapping — so the summary must not send the reader after the wrong
597
+ // one.
598
+ const big = path.join(tmpDir, 'big.png')
599
+ fs.writeFileSync(big, Buffer.alloc(200, 1))
600
+ const small = path.join(tmpDir, 'small.png')
601
+ fs.writeFileSync(small, Buffer.alloc(8, 1))
602
+
603
+ const impl: FetchLike = async () => ({
604
+ ok: true,
605
+ status: 201,
606
+ text: async () => '{"url":"https://example.test/small"}',
607
+ })
608
+
609
+ const report: FailureReport = {
610
+ tests: [{
611
+ title: 'a', file: 'f', line: 1, status: 'failed',
612
+ images: [
613
+ { name: 'big.png', filePath: big, contentType: 'image/png', kind: 'diff' },
614
+ { name: 'small.png', filePath: small, contentType: 'image/png', kind: 'diff' },
615
+ ],
616
+ }],
617
+ totalFailed: 1,
618
+ totalFlaky: 0,
619
+ totalImages: 2,
620
+ }
621
+
622
+ await uploadImages(
623
+ report,
624
+ new AttachmentUploader({ token: 't', repositoryId: '1', maxFileBytes: 100, fetchImpl: impl }),
625
+ )
626
+
627
+ const [oversize, uploaded] = report.tests[0].images
628
+ expect(oversize.oversize).toBe(true)
629
+ expect(oversize.unreadable).toBeUndefined()
630
+ // The one behind it still went, which is the point of skipping rather
631
+ // than stopping.
632
+ expect(uploaded.url).toBe('https://example.test/small')
633
+
634
+ expect(generateComment(report)).toContain('1 screenshot(s) not shown — 1 too large to upload')
635
+ })
636
+ })