@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.
- package/README.md +61 -0
- package/bin/github-a11y-summary +5 -0
- package/bin/github-failure-summary +8 -0
- package/lib/accessibility-baseline-file.d.ts +47 -0
- package/lib/accessibility-baseline-file.js +205 -0
- package/lib/accessibility-baseline.d.ts +19 -0
- package/lib/accessibility-baseline.js +38 -0
- package/lib/accessible-screenshot.d.ts +181 -0
- package/lib/accessible-screenshot.js +519 -0
- package/lib/focus.d.ts +14 -0
- package/lib/focus.js +28 -0
- package/lib/fonts.d.ts +18 -0
- package/lib/fonts.js +24 -0
- package/lib/frames.d.ts +7 -0
- package/lib/frames.js +27 -0
- package/lib/github/a11y-summary.d.ts +55 -0
- package/lib/github/a11y-summary.js +383 -0
- package/lib/github/attachments.d.ts +98 -0
- package/lib/github/attachments.js +297 -0
- package/lib/github/failure-summary.d.ts +144 -0
- package/lib/github/failure-summary.js +567 -0
- package/lib/github/index.d.ts +6 -0
- package/lib/github/index.js +35 -0
- package/lib/github/report-paths.d.ts +38 -0
- package/lib/github/report-paths.js +200 -0
- package/lib/hover.d.ts +13 -0
- package/lib/hover.js +61 -0
- package/lib/images.d.ts +118 -0
- package/lib/images.js +260 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.js +29 -0
- package/lib/interaction-states.d.ts +22 -0
- package/lib/interaction-states.js +75 -0
- package/lib/mock/index.d.ts +1 -0
- package/lib/mock/index.js +5 -0
- package/lib/mock/youtube.d.ts +5 -0
- package/lib/mock/youtube.js +38 -0
- package/lib/pseudo-state.d.ts +17 -0
- package/lib/pseudo-state.js +50 -0
- package/lib/videos.d.ts +134 -0
- package/lib/videos.js +349 -0
- package/lib/visualdiff.d.ts +154 -0
- package/lib/visualdiff.js +197 -0
- package/package.json +47 -0
- package/src/accessibility-baseline-file.test.ts +181 -0
- package/src/accessibility-baseline-file.ts +208 -0
- package/src/accessibility-baseline.test.ts +601 -0
- package/src/accessibility-baseline.ts +50 -0
- package/src/accessible-screenshot.test.ts +597 -0
- package/src/accessible-screenshot.ts +809 -0
- package/src/focus.test.ts +34 -0
- package/src/focus.ts +27 -0
- package/src/fonts.test.ts +17 -0
- package/src/fonts.ts +23 -0
- package/src/frames.test.ts +75 -0
- package/src/frames.ts +26 -0
- package/src/github/a11y-summary.test.ts +439 -0
- package/src/github/a11y-summary.ts +421 -0
- package/src/github/attachments.test.ts +248 -0
- package/src/github/attachments.ts +328 -0
- package/src/github/failure-summary.test.ts +636 -0
- package/src/github/failure-summary.ts +720 -0
- package/src/github/index.test.ts +24 -0
- package/src/github/index.ts +35 -0
- package/src/github/report-paths.test.ts +222 -0
- package/src/github/report-paths.ts +208 -0
- package/src/hover.test.ts +76 -0
- package/src/hover.ts +64 -0
- package/src/images.test.ts +355 -0
- package/src/images.ts +299 -0
- package/src/index.ts +13 -0
- package/src/interaction-states.test.ts +48 -0
- package/src/interaction-states.ts +94 -0
- package/src/mock/index.ts +1 -0
- package/src/mock/youtube.test.ts +38 -0
- package/src/mock/youtube.ts +39 -0
- package/src/pseudo-state.test.ts +83 -0
- package/src/pseudo-state.ts +69 -0
- package/src/videos.test.ts +637 -0
- package/src/videos.ts +389 -0
- package/src/visualdiff.test.ts +452 -0
- 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&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 "quoted" 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
|
+
})
|