@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,421 @@
|
|
|
1
|
+
import * as fs from 'fs'
|
|
2
|
+
import * as path from 'path'
|
|
3
|
+
|
|
4
|
+
/** Annotation types that identify accessibility-relevant test data. */
|
|
5
|
+
const A11Y_ANNOTATION_TYPES = new Set([
|
|
6
|
+
'@a11y',
|
|
7
|
+
'Accessibility',
|
|
8
|
+
'Baselined a11y violation',
|
|
9
|
+
'Stale a11y baseline entry',
|
|
10
|
+
])
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Escape a string for use inside a GitHub Actions workflow-command message
|
|
14
|
+
* body (the text after `::`). Without this, a crafted value containing a
|
|
15
|
+
* newline or `%` sequence could forge additional workflow commands.
|
|
16
|
+
* See https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions
|
|
17
|
+
*/
|
|
18
|
+
function escapeWorkflowCommand(value: string): string {
|
|
19
|
+
return String(value)
|
|
20
|
+
.replace(/%/g, '%25')
|
|
21
|
+
.replace(/\r/g, '%0D')
|
|
22
|
+
.replace(/\n/g, '%0A')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Escape a string for use inside a GitHub Actions workflow-command property
|
|
27
|
+
* value (e.g. `file=...`, `title=...`). Property values additionally require
|
|
28
|
+
* escaping of `:` and `,` on top of the message-body escapes.
|
|
29
|
+
*/
|
|
30
|
+
function escapeWorkflowCommandProperty(value: string): string {
|
|
31
|
+
return escapeWorkflowCommand(value)
|
|
32
|
+
.replace(/:/g, '%3A')
|
|
33
|
+
.replace(/,/g, '%2C')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Escape a string for use inside a Markdown table cell. Backslashes must be
|
|
38
|
+
* escaped first (they are Markdown's escape character), then pipes and
|
|
39
|
+
* newlines can be substituted safely.
|
|
40
|
+
*/
|
|
41
|
+
function escapeMarkdownTableCell(value: string): string {
|
|
42
|
+
return String(value)
|
|
43
|
+
.replace(/\\/g, '\\\\')
|
|
44
|
+
.replace(/\|/g, '\\|')
|
|
45
|
+
.replace(/\r?\n/g, ' ')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Escape a string for use inside a Markdown inline code span (backticks).
|
|
50
|
+
* A literal backtick would close the span early and let the remainder render
|
|
51
|
+
* as Markdown — wrap the value in double backticks and pad literal backticks
|
|
52
|
+
* with spaces, which is the standard CommonMark escape for code spans.
|
|
53
|
+
*/
|
|
54
|
+
function escapeMarkdownCodeSpan(value: string): string {
|
|
55
|
+
const s = String(value).replace(/\r?\n/g, ' ')
|
|
56
|
+
if (!s.includes('`')) return `\`${s}\``
|
|
57
|
+
return `\`\` ${s.replace(/``/g, '` `')} \`\``
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A test that has accessibility-related annotations and/or attachments. */
|
|
61
|
+
export interface A11yTestResult {
|
|
62
|
+
/** Test title path, e.g. "a11y > homepage has accessibility violations" */
|
|
63
|
+
title: string
|
|
64
|
+
/** Source file, e.g. "tests/a11y.spec.ts" */
|
|
65
|
+
file: string
|
|
66
|
+
/** Line number in the source file. */
|
|
67
|
+
line: number
|
|
68
|
+
/** Accessibility annotations from testInfo.annotations. */
|
|
69
|
+
annotations: Array<{ type: string; description?: string }>
|
|
70
|
+
/** Parsed WCAG scan violations (from a11y-wcag-scan-results attachment). */
|
|
71
|
+
violations: NormalizedViolation[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface NormalizedViolation {
|
|
75
|
+
rule: string
|
|
76
|
+
impact: string
|
|
77
|
+
description: string
|
|
78
|
+
helpUrl: string
|
|
79
|
+
targets: string[]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Parsed report with only accessibility-relevant data. */
|
|
83
|
+
export interface A11yReport {
|
|
84
|
+
tests: A11yTestResult[]
|
|
85
|
+
totalViolations: number
|
|
86
|
+
totalBaselined: number
|
|
87
|
+
totalStale: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse a Playwright JSON report and extract accessibility-related test data.
|
|
92
|
+
*/
|
|
93
|
+
export function parseA11yResults(reportPath: string): A11yReport {
|
|
94
|
+
if (!fs.existsSync(reportPath)) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Playwright JSON report not found at ${reportPath}. ` +
|
|
97
|
+
'Ensure the Playwright JSON reporter is enabled.'
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'))
|
|
102
|
+
const tests: A11yTestResult[] = []
|
|
103
|
+
let totalViolations = 0
|
|
104
|
+
let totalBaselined = 0
|
|
105
|
+
let totalStale = 0
|
|
106
|
+
|
|
107
|
+
walkSuites(report.suites ?? [], '', tests)
|
|
108
|
+
|
|
109
|
+
for (const test of tests) {
|
|
110
|
+
for (const ann of test.annotations) {
|
|
111
|
+
if (ann.type === 'Baselined a11y violation') totalBaselined++
|
|
112
|
+
if (ann.type === 'Stale a11y baseline entry') totalStale++
|
|
113
|
+
}
|
|
114
|
+
totalViolations += test.violations.length
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { tests, totalViolations, totalBaselined, totalStale }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Collect annotations from both test and result levels. Playwright stores
|
|
122
|
+
* annotations declared at test definition time on `test.annotations`, and
|
|
123
|
+
* annotations added during execution (e.g. `testInfo.annotations.push()`)
|
|
124
|
+
* on `result.annotations`. checkAccessibility() uses the latter.
|
|
125
|
+
*/
|
|
126
|
+
function collectAnnotations(test: any, lastResult: any): any[] {
|
|
127
|
+
return [
|
|
128
|
+
...(test.annotations ?? []),
|
|
129
|
+
...(lastResult?.annotations ?? []),
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Read and parse the WCAG scan attachment, if present. */
|
|
134
|
+
function parseWcagViolations(attachment: any): NormalizedViolation[] {
|
|
135
|
+
if (!attachment) return []
|
|
136
|
+
try {
|
|
137
|
+
const raw = attachment.body
|
|
138
|
+
? Buffer.from(attachment.body, 'base64').toString('utf8')
|
|
139
|
+
: attachment.path
|
|
140
|
+
? fs.readFileSync(attachment.path, 'utf8')
|
|
141
|
+
: null
|
|
142
|
+
if (!raw) return []
|
|
143
|
+
const json = JSON.parse(raw)
|
|
144
|
+
if (!json?.violations) return []
|
|
145
|
+
return json.violations.map((v: any) => ({
|
|
146
|
+
rule: v.id,
|
|
147
|
+
impact: v.impact ?? 'unknown',
|
|
148
|
+
description: v.description,
|
|
149
|
+
helpUrl: v.helpUrl,
|
|
150
|
+
targets: v.nodes
|
|
151
|
+
?.flatMap((n: any) => n.target)
|
|
152
|
+
.filter((t: any) => typeof t === 'string') ?? [],
|
|
153
|
+
}))
|
|
154
|
+
} catch {
|
|
155
|
+
// Parse errors are non-fatal — we'll just report without violation details.
|
|
156
|
+
return []
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Extract an A11yTestResult from a Playwright test, or null if not a11y-related. */
|
|
161
|
+
function extractTestResult(spec: any, test: any, file: string): A11yTestResult | null {
|
|
162
|
+
const lastResult = test.results?.[test.results.length - 1]
|
|
163
|
+
const attachments = lastResult?.attachments ?? []
|
|
164
|
+
|
|
165
|
+
const a11yAnnotations = collectAnnotations(test, lastResult)
|
|
166
|
+
.filter((a: any) => A11Y_ANNOTATION_TYPES.has(a.type))
|
|
167
|
+
|
|
168
|
+
if (a11yAnnotations.length === 0) return null
|
|
169
|
+
|
|
170
|
+
const violations = parseWcagViolations(
|
|
171
|
+
attachments.find((a: any) => a.name === 'a11y-wcag-scan-results'),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
title: spec.title,
|
|
176
|
+
file,
|
|
177
|
+
line: spec.line ?? 1,
|
|
178
|
+
annotations: a11yAnnotations,
|
|
179
|
+
violations,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function walkSuites(
|
|
184
|
+
suites: any[],
|
|
185
|
+
parentFile: string,
|
|
186
|
+
out: A11yTestResult[],
|
|
187
|
+
) {
|
|
188
|
+
for (const suite of suites) {
|
|
189
|
+
const file = suite.file || parentFile
|
|
190
|
+
|
|
191
|
+
for (const spec of suite.specs ?? []) {
|
|
192
|
+
for (const test of spec.tests ?? []) {
|
|
193
|
+
const result = extractTestResult(spec, test, file)
|
|
194
|
+
if (result) out.push(result)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (suite.suites) {
|
|
199
|
+
walkSuites(suite.suites, file, out)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Headline with pass/fail counts. */
|
|
205
|
+
function renderHeadline(report: A11yReport): string {
|
|
206
|
+
const hasViolations = report.totalViolations > 0
|
|
207
|
+
const hasBaselined = report.totalBaselined > 0
|
|
208
|
+
const hasStale = report.totalStale > 0
|
|
209
|
+
|
|
210
|
+
if (!hasViolations && !hasBaselined && !hasStale) {
|
|
211
|
+
return ':white_check_mark: All accessibility checks passed.\n'
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const parts: string[] = []
|
|
215
|
+
if (hasViolations) parts.push(`**${report.totalViolations}** violation(s)`)
|
|
216
|
+
if (hasBaselined) parts.push(`**${report.totalBaselined}** baselined`)
|
|
217
|
+
if (hasStale) parts.push(`**${report.totalStale}** stale baseline entries`)
|
|
218
|
+
return parts.join(' · ') + '\n'
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Icon for an `Accessibility` annotation based on its violation count. */
|
|
222
|
+
function iconForAccessibilityAnnotation(description: string): string {
|
|
223
|
+
// Descriptions look like "WCAG scan: 0 violations ..." or "WCAG scan: 3 violations ...".
|
|
224
|
+
const colonIdx = description.lastIndexOf(': ')
|
|
225
|
+
const afterColon = colonIdx >= 0 ? description.substring(colonIdx + 2) : description
|
|
226
|
+
const violationCount = parseInt(afterColon, 10)
|
|
227
|
+
return violationCount > 0 ? ':x:' : ':white_check_mark:'
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function iconForAnnotation(ann: { type: string; description?: string }): string {
|
|
231
|
+
switch (ann.type) {
|
|
232
|
+
case 'Baselined a11y violation':
|
|
233
|
+
return ':white_check_mark:'
|
|
234
|
+
case 'Stale a11y baseline entry':
|
|
235
|
+
return ':warning:'
|
|
236
|
+
case 'Accessibility':
|
|
237
|
+
return iconForAccessibilityAnnotation(ann.description ?? '')
|
|
238
|
+
default:
|
|
239
|
+
return ':information_source:'
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Render annotation lines, deduplicated by type+description. */
|
|
244
|
+
function renderAnnotations(
|
|
245
|
+
annotations: Array<{ type: string; description?: string }>,
|
|
246
|
+
): string[] {
|
|
247
|
+
const lines: string[] = []
|
|
248
|
+
const seen = new Set<string>()
|
|
249
|
+
for (const ann of annotations) {
|
|
250
|
+
const key = `${ann.type}:${ann.description ?? ''}`
|
|
251
|
+
if (seen.has(key)) continue
|
|
252
|
+
seen.add(key)
|
|
253
|
+
lines.push(`${iconForAnnotation(ann)} **${ann.type}**: ${ann.description ?? ''}\n`)
|
|
254
|
+
}
|
|
255
|
+
return lines
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Render the violation table for a single test. */
|
|
259
|
+
function renderViolationTable(violations: NormalizedViolation[]): string[] {
|
|
260
|
+
if (violations.length === 0) return []
|
|
261
|
+
const lines: string[] = [
|
|
262
|
+
'| Rule | Impact | Description | Targets |',
|
|
263
|
+
'|------|--------|-------------|---------|',
|
|
264
|
+
]
|
|
265
|
+
for (const v of violations) {
|
|
266
|
+
const shown = v.targets.slice(0, 3).map(escapeMarkdownCodeSpan).join(', ')
|
|
267
|
+
const targets = v.targets.length > 3
|
|
268
|
+
? `${shown}, +${v.targets.length - 3} more`
|
|
269
|
+
: shown
|
|
270
|
+
const rule = escapeMarkdownTableCell(v.rule)
|
|
271
|
+
const impact = escapeMarkdownTableCell(v.impact)
|
|
272
|
+
const description = escapeMarkdownTableCell(v.description)
|
|
273
|
+
lines.push(`| [${rule}](${v.helpUrl}) | ${impact} | ${description} | ${targets} |`)
|
|
274
|
+
}
|
|
275
|
+
lines.push('')
|
|
276
|
+
return lines
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Generate Markdown for $GITHUB_STEP_SUMMARY.
|
|
281
|
+
*/
|
|
282
|
+
export function generateSummary(report: A11yReport): string {
|
|
283
|
+
if (report.tests.length === 0) {
|
|
284
|
+
return '## Accessibility Results\n\nNo accessibility tests were found in the report.\n'
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const lines: string[] = ['## Accessibility Results\n', renderHeadline(report)]
|
|
288
|
+
const seenTitles = new Set<string>()
|
|
289
|
+
|
|
290
|
+
for (const test of report.tests) {
|
|
291
|
+
const testAnnotations = test.annotations.filter(a => a.type !== '@a11y')
|
|
292
|
+
const hasTestViolations = test.violations.length > 0
|
|
293
|
+
if (testAnnotations.length === 0 && !hasTestViolations) continue
|
|
294
|
+
|
|
295
|
+
// Skip duplicate test titles (different browsers report the same spec).
|
|
296
|
+
if (seenTitles.has(test.title)) continue
|
|
297
|
+
seenTitles.add(test.title)
|
|
298
|
+
|
|
299
|
+
const testLines: string[] = [`### ${test.title}\n`]
|
|
300
|
+
testLines.push(...renderAnnotations(testAnnotations))
|
|
301
|
+
testLines.push(...renderViolationTable(test.violations))
|
|
302
|
+
|
|
303
|
+
lines.push(...testLines)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return lines.join('\n')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Generate GitHub Actions annotation commands (::error, ::warning).
|
|
311
|
+
*
|
|
312
|
+
* All interpolated values are escaped — CSS selectors from axe-core targets
|
|
313
|
+
* are extracted from page DOM and could contain newlines or `%` sequences
|
|
314
|
+
* that would otherwise let a crafted page forge additional workflow commands.
|
|
315
|
+
*/
|
|
316
|
+
export function generateAnnotations(report: A11yReport): string {
|
|
317
|
+
const lines: string[] = []
|
|
318
|
+
|
|
319
|
+
for (const test of report.tests) {
|
|
320
|
+
const file = escapeWorkflowCommandProperty(test.file)
|
|
321
|
+
const line = escapeWorkflowCommandProperty(String(test.line))
|
|
322
|
+
|
|
323
|
+
// Emit ::error for each violation.
|
|
324
|
+
for (const v of test.violations) {
|
|
325
|
+
const targets = v.targets.slice(0, 3).join(', ')
|
|
326
|
+
const title = escapeWorkflowCommandProperty(`${v.rule} (${v.impact})`)
|
|
327
|
+
const msg = escapeWorkflowCommand(
|
|
328
|
+
`a11y: ${v.rule} (${v.impact}) — ${v.description}. Targets: ${targets}`,
|
|
329
|
+
)
|
|
330
|
+
lines.push(`::error file=${file},line=${line},title=${title}::${msg}`)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Emit ::warning for stale baseline entries.
|
|
334
|
+
for (const ann of test.annotations) {
|
|
335
|
+
if (ann.type === 'Stale a11y baseline entry') {
|
|
336
|
+
const title = escapeWorkflowCommandProperty('Stale a11y baseline')
|
|
337
|
+
const msg = escapeWorkflowCommand(ann.description ?? '')
|
|
338
|
+
lines.push(`::warning file=${file},line=${line},title=${title}::${msg}`)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return lines.join('\n')
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* CLI entry point. Called automatically when this file is run directly
|
|
348
|
+
* (e.g. `node lib/github/a11y-summary.js`), or can be imported and
|
|
349
|
+
* called from the bin wrapper.
|
|
350
|
+
*/
|
|
351
|
+
export function main(
|
|
352
|
+
args: string[] = process.argv.slice(2),
|
|
353
|
+
adapter: { commandName?: string } = {},
|
|
354
|
+
): void {
|
|
355
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
356
|
+
process.stdout.write(
|
|
357
|
+
`Usage: ${adapter.commandName ?? 'playwright-testing-a11y-summary'} [options]\n\n` +
|
|
358
|
+
'Options:\n' +
|
|
359
|
+
' --report-path=PATH Playwright JSON report (default: test-results/results.json)\n' +
|
|
360
|
+
' --mode=summary Write a Markdown accessibility summary (default)\n' +
|
|
361
|
+
' --mode=annotations Write GitHub Actions annotations\n' +
|
|
362
|
+
' -h, --help Show this help\n',
|
|
363
|
+
)
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
let mode = 'summary'
|
|
368
|
+
let reportPath = 'test-results/results.json'
|
|
369
|
+
|
|
370
|
+
for (const arg of args) {
|
|
371
|
+
if (arg.startsWith('--mode=')) mode = arg.slice('--mode='.length)
|
|
372
|
+
if (arg.startsWith('--report-path=')) reportPath = arg.slice('--report-path='.length)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const validModes = new Set(['summary', 'annotations'])
|
|
376
|
+
if (!validModes.has(mode)) {
|
|
377
|
+
console.error(`Unknown mode: ${mode}. Use summary or annotations.`)
|
|
378
|
+
process.exit(2)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Resolve relative to cwd.
|
|
382
|
+
const resolvedPath = path.resolve(reportPath)
|
|
383
|
+
|
|
384
|
+
// Missing reports are intentionally non-fatal — the JSON report may be
|
|
385
|
+
// absent if no tests ran. Any other failure (parse error, permissions, etc.)
|
|
386
|
+
// should propagate so CI surfaces the real problem instead of silently
|
|
387
|
+
// reporting zero violations.
|
|
388
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
389
|
+
console.error(
|
|
390
|
+
`Playwright JSON report not found at ${resolvedPath} — skipping a11y summary.`,
|
|
391
|
+
)
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const report = parseA11yResults(resolvedPath)
|
|
396
|
+
|
|
397
|
+
switch (mode) {
|
|
398
|
+
case 'summary': {
|
|
399
|
+
const md = generateSummary(report)
|
|
400
|
+
const summaryFile = process.env.GITHUB_STEP_SUMMARY
|
|
401
|
+
if (summaryFile) {
|
|
402
|
+
fs.appendFileSync(summaryFile, md)
|
|
403
|
+
console.log('Accessibility summary written to $GITHUB_STEP_SUMMARY')
|
|
404
|
+
} else {
|
|
405
|
+
// Not in GitHub Actions — just print.
|
|
406
|
+
process.stdout.write(md)
|
|
407
|
+
}
|
|
408
|
+
break
|
|
409
|
+
}
|
|
410
|
+
case 'annotations': {
|
|
411
|
+
const output = generateAnnotations(report)
|
|
412
|
+
if (output) process.stdout.write(output + '\n')
|
|
413
|
+
break
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Auto-invoke when run directly (node lib/github/a11y-summary.js).
|
|
419
|
+
if (require.main === module) {
|
|
420
|
+
main()
|
|
421
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
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 { AttachmentUploader, mimeTypeFor, FetchLike } from './attachments'
|
|
6
|
+
|
|
7
|
+
let tmpDir: string
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'attachments-test-'))
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/** Write a file of a given size and return its path. */
|
|
18
|
+
function writeFile(name: string, bytes = 16): string {
|
|
19
|
+
const filePath = path.join(tmpDir, name)
|
|
20
|
+
fs.writeFileSync(filePath, Buffer.alloc(bytes, 1))
|
|
21
|
+
return filePath
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A fetch stub that records calls and replays canned responses. */
|
|
25
|
+
function stubFetch(responses: Array<{ ok: boolean; status: number; body: string }>) {
|
|
26
|
+
const calls: string[] = []
|
|
27
|
+
const headers: Array<Record<string, string>> = []
|
|
28
|
+
const impl: FetchLike = async (url, init) => {
|
|
29
|
+
calls.push(url)
|
|
30
|
+
headers.push(init.headers)
|
|
31
|
+
const next = responses.shift() ?? { ok: true, status: 201, body: '{"url":"https://example.test/a"}' }
|
|
32
|
+
return {
|
|
33
|
+
ok: next.ok,
|
|
34
|
+
status: next.status,
|
|
35
|
+
text: async () => next.body,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { impl, calls, headers }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('mimeTypeFor', () => {
|
|
42
|
+
it('maps the image types GitHub renders inline', () => {
|
|
43
|
+
expect(mimeTypeFor('a/b/diff.png')).toBe('image/png')
|
|
44
|
+
expect(mimeTypeFor('shot.JPEG')).toBe('image/jpeg')
|
|
45
|
+
expect(mimeTypeFor('clip.webm')).toBe('video/webm')
|
|
46
|
+
expect(mimeTypeFor('icon.svg')).toBe('image/svg+xml')
|
|
47
|
+
expect(mimeTypeFor('clip.MOV')).toBe('video/quicktime')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('falls back to a generic type for anything else', () => {
|
|
51
|
+
expect(mimeTypeFor('report.txt')).toBe('application/octet-stream')
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('AttachmentUploader', () => {
|
|
56
|
+
it('is off without a token, which is the normal case', async () => {
|
|
57
|
+
const uploader = new AttachmentUploader({ repositoryId: '1' })
|
|
58
|
+
expect(uploader.enabled).toBe(false)
|
|
59
|
+
expect(uploader.disabledReason).toMatch(/no upload token/)
|
|
60
|
+
expect(await uploader.upload(writeFile('a.png'))).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('is off without a repository ID', () => {
|
|
64
|
+
const uploader = new AttachmentUploader({ token: 't' })
|
|
65
|
+
expect(uploader.enabled).toBe(false)
|
|
66
|
+
expect(uploader.disabledReason).toMatch(/repository ID/)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('returns the asset URL and passes the endpoint what it needs', async () => {
|
|
70
|
+
const { impl, calls } = stubFetch([
|
|
71
|
+
{ ok: true, status: 201, body: '{"url":"https://github.com/user-attachments/assets/abc"}' },
|
|
72
|
+
])
|
|
73
|
+
const uploader = new AttachmentUploader({ token: 't', repositoryId: '42', fetchImpl: impl })
|
|
74
|
+
|
|
75
|
+
const url = await uploader.upload(writeFile('diff.png'))
|
|
76
|
+
|
|
77
|
+
expect(url).toBe('https://github.com/user-attachments/assets/abc')
|
|
78
|
+
expect(calls[0]).toContain('repository_id=42')
|
|
79
|
+
expect(calls[0]).toContain('content_type=image%2Fpng')
|
|
80
|
+
expect(calls[0]).toContain('name=diff.png')
|
|
81
|
+
expect(uploader.getStats().uploaded).toBe(1)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('sends a Content-Type, which the endpoint rejects the request without', async () => {
|
|
85
|
+
// Node's fetch sends no Content-Type for a Buffer body, and the endpoint
|
|
86
|
+
// answers 400 "Invalid Content-Type" when it is missing.
|
|
87
|
+
const { impl, headers } = stubFetch([])
|
|
88
|
+
const uploader = new AttachmentUploader({ token: 't', repositoryId: '1', fetchImpl: impl })
|
|
89
|
+
|
|
90
|
+
await uploader.upload(writeFile('diff.png'))
|
|
91
|
+
|
|
92
|
+
expect(headers[0]['Content-Type']).toBe('image/png')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('reports the response body when the endpoint refuses', async () => {
|
|
96
|
+
const { impl } = stubFetch([
|
|
97
|
+
{ ok: false, status: 400, body: '{"message":"Invalid Content-Type"}' },
|
|
98
|
+
])
|
|
99
|
+
const uploader = new AttachmentUploader({ token: 't', repositoryId: '1', fetchImpl: impl })
|
|
100
|
+
|
|
101
|
+
await uploader.upload(writeFile('a.png'))
|
|
102
|
+
|
|
103
|
+
expect(uploader.disabledReason).toContain('Invalid Content-Type')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('accepts href as well as url, since write-ups disagree', async () => {
|
|
107
|
+
const { impl } = stubFetch([{ ok: true, status: 201, body: '{"href":"https://example.test/h"}' }])
|
|
108
|
+
const uploader = new AttachmentUploader({ token: 't', repositoryId: '1', fetchImpl: impl })
|
|
109
|
+
|
|
110
|
+
expect(await uploader.upload(writeFile('a.png'))).toBe('https://example.test/h')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('stops for the rest of the run after one failure', async () => {
|
|
114
|
+
const { impl, calls } = stubFetch([{ ok: false, status: 404, body: '{"message":"Not Found"}' }])
|
|
115
|
+
const uploader = new AttachmentUploader({ token: 't', repositoryId: '1', fetchImpl: impl })
|
|
116
|
+
|
|
117
|
+
expect(await uploader.upload(writeFile('one.png'))).toBeNull()
|
|
118
|
+
expect(uploader.enabled).toBe(false)
|
|
119
|
+
expect(uploader.disabledReason).toMatch(/HTTP 404/)
|
|
120
|
+
|
|
121
|
+
// The second attempt must not reach the endpoint at all.
|
|
122
|
+
expect(await uploader.upload(writeFile('two.png'))).toBeNull()
|
|
123
|
+
expect(calls).toHaveLength(1)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('stops when the endpoint answers without a URL', async () => {
|
|
127
|
+
const { impl } = stubFetch([{ ok: true, status: 200, body: 'not json' }])
|
|
128
|
+
const uploader = new AttachmentUploader({ token: 't', repositoryId: '1', fetchImpl: impl })
|
|
129
|
+
|
|
130
|
+
expect(await uploader.upload(writeFile('a.png'))).toBeNull()
|
|
131
|
+
expect(uploader.disabledReason).toMatch(/no URL/)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('stops rather than throwing when fetch rejects', async () => {
|
|
135
|
+
const impl: FetchLike = async () => {
|
|
136
|
+
throw new Error('socket hang up')
|
|
137
|
+
}
|
|
138
|
+
const uploader = new AttachmentUploader({ token: 't', repositoryId: '1', fetchImpl: impl })
|
|
139
|
+
|
|
140
|
+
expect(await uploader.upload(writeFile('a.png'))).toBeNull()
|
|
141
|
+
expect(uploader.disabledReason).toMatch(/socket hang up/)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('enforces the upload count budget and says so', async () => {
|
|
145
|
+
const { impl } = stubFetch([])
|
|
146
|
+
const uploader = new AttachmentUploader({
|
|
147
|
+
token: 't',
|
|
148
|
+
repositoryId: '1',
|
|
149
|
+
maxUploads: 1,
|
|
150
|
+
fetchImpl: impl,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
expect(await uploader.upload(writeFile('one.png'))).not.toBeNull()
|
|
154
|
+
expect(await uploader.upload(writeFile('two.png'))).toBeNull()
|
|
155
|
+
expect(uploader.disabledReason).toMatch(/upload limit of 1/)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('skips a file over GitHub\'s size limit and keeps going', async () => {
|
|
159
|
+
// GitHub refuses an image over 10 MB, and a rejection would otherwise
|
|
160
|
+
// switch uploading off for every image behind it.
|
|
161
|
+
const { impl, calls } = stubFetch([])
|
|
162
|
+
const uploader = new AttachmentUploader({
|
|
163
|
+
token: 't',
|
|
164
|
+
repositoryId: '1',
|
|
165
|
+
maxFileBytes: 100,
|
|
166
|
+
fetchImpl: impl,
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
expect(await uploader.upload(writeFile('huge.png', 200))).toBeNull()
|
|
170
|
+
expect(uploader.enabled).toBe(true)
|
|
171
|
+
expect(uploader.lastSkipReason).toBe('too-large')
|
|
172
|
+
// Nothing was sent for it: the limit is checked before the request.
|
|
173
|
+
expect(calls).toHaveLength(0)
|
|
174
|
+
|
|
175
|
+
expect(await uploader.upload(writeFile('small.png', 10))).not.toBeNull()
|
|
176
|
+
expect(uploader.lastSkipReason).toBeNull()
|
|
177
|
+
expect(uploader.getStats()).toEqual({ uploaded: 1, skipped: 1, bytes: 10 })
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('skips a file that does not fit the byte budget, rather than stopping', async () => {
|
|
181
|
+
const { impl } = stubFetch([])
|
|
182
|
+
const uploader = new AttachmentUploader({
|
|
183
|
+
token: 't',
|
|
184
|
+
repositoryId: '1',
|
|
185
|
+
maxTotalBytes: 100,
|
|
186
|
+
fetchImpl: impl,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
expect(await uploader.upload(writeFile('first.png', 60))).not.toBeNull()
|
|
190
|
+
expect(await uploader.upload(writeFile('big.png', 60))).toBeNull()
|
|
191
|
+
expect(uploader.lastSkipReason).toBe('budget')
|
|
192
|
+
|
|
193
|
+
// The budget is a ceiling on the total, not a stop: what still fits goes.
|
|
194
|
+
expect(await uploader.upload(writeFile('last.png', 30))).not.toBeNull()
|
|
195
|
+
expect(uploader.enabled).toBe(true)
|
|
196
|
+
expect(uploader.getStats().bytes).toBe(90)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('tells an unreadable file apart from one the limits refused', async () => {
|
|
200
|
+
const { impl } = stubFetch([{ ok: false, status: 404, body: '{"message":"Not Found"}' }])
|
|
201
|
+
const uploader = new AttachmentUploader({
|
|
202
|
+
token: 't',
|
|
203
|
+
repositoryId: '1',
|
|
204
|
+
maxFileBytes: 100,
|
|
205
|
+
fetchImpl: impl,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
await uploader.upload(path.join(tmpDir, 'missing.png'))
|
|
209
|
+
expect(uploader.lastSkipReason).toBe('unreadable')
|
|
210
|
+
|
|
211
|
+
await uploader.upload(writeFile('huge.png', 200))
|
|
212
|
+
expect(uploader.lastSkipReason).toBe('too-large')
|
|
213
|
+
|
|
214
|
+
// A dead endpoint is the run's problem rather than any one file's.
|
|
215
|
+
await uploader.upload(writeFile('fine.png', 10))
|
|
216
|
+
expect(uploader.lastSkipReason).toBe('disabled')
|
|
217
|
+
expect(uploader.enabled).toBe(false)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('skips an unreadable file without giving up on the rest', async () => {
|
|
221
|
+
const { impl } = stubFetch([])
|
|
222
|
+
const uploader = new AttachmentUploader({ token: 't', repositoryId: '1', fetchImpl: impl })
|
|
223
|
+
|
|
224
|
+
expect(await uploader.upload(path.join(tmpDir, 'missing.png'))).toBeNull()
|
|
225
|
+
expect(uploader.enabled).toBe(true)
|
|
226
|
+
expect(await uploader.upload(writeFile('present.png'))).not.toBeNull()
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('reduces a name of nothing but dashes to a usable one', async () => {
|
|
230
|
+
// Trimming these with /^-+|-+$/ backtracks quadratically, so the trim is an
|
|
231
|
+
// index scan instead. Long runs of dashes are the shape that provokes it.
|
|
232
|
+
const { impl, calls } = stubFetch([])
|
|
233
|
+
const uploader = new AttachmentUploader({ token: 't', repositoryId: '1', fetchImpl: impl })
|
|
234
|
+
|
|
235
|
+
await uploader.upload(writeFile('a.png'), `${'-'.repeat(50000)}b.png`)
|
|
236
|
+
|
|
237
|
+
expect(calls[0]).toContain('name=attachment')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('reduces awkward file names to something a query string can carry', async () => {
|
|
241
|
+
const { impl, calls } = stubFetch([])
|
|
242
|
+
const uploader = new AttachmentUploader({ token: 't', repositoryId: '1', fetchImpl: impl })
|
|
243
|
+
|
|
244
|
+
await uploader.upload(writeFile('weird.png'), 'homepage renders "correctly" & fast.png')
|
|
245
|
+
|
|
246
|
+
expect(calls[0]).toContain('name=homepage-renders-correctly-fast.png')
|
|
247
|
+
})
|
|
248
|
+
})
|