@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,55 @@
|
|
|
1
|
+
/** A test that has accessibility-related annotations and/or attachments. */
|
|
2
|
+
export interface A11yTestResult {
|
|
3
|
+
/** Test title path, e.g. "a11y > homepage has accessibility violations" */
|
|
4
|
+
title: string;
|
|
5
|
+
/** Source file, e.g. "tests/a11y.spec.ts" */
|
|
6
|
+
file: string;
|
|
7
|
+
/** Line number in the source file. */
|
|
8
|
+
line: number;
|
|
9
|
+
/** Accessibility annotations from testInfo.annotations. */
|
|
10
|
+
annotations: Array<{
|
|
11
|
+
type: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
}>;
|
|
14
|
+
/** Parsed WCAG scan violations (from a11y-wcag-scan-results attachment). */
|
|
15
|
+
violations: NormalizedViolation[];
|
|
16
|
+
}
|
|
17
|
+
interface NormalizedViolation {
|
|
18
|
+
rule: string;
|
|
19
|
+
impact: string;
|
|
20
|
+
description: string;
|
|
21
|
+
helpUrl: string;
|
|
22
|
+
targets: string[];
|
|
23
|
+
}
|
|
24
|
+
/** Parsed report with only accessibility-relevant data. */
|
|
25
|
+
export interface A11yReport {
|
|
26
|
+
tests: A11yTestResult[];
|
|
27
|
+
totalViolations: number;
|
|
28
|
+
totalBaselined: number;
|
|
29
|
+
totalStale: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Parse a Playwright JSON report and extract accessibility-related test data.
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseA11yResults(reportPath: string): A11yReport;
|
|
35
|
+
/**
|
|
36
|
+
* Generate Markdown for $GITHUB_STEP_SUMMARY.
|
|
37
|
+
*/
|
|
38
|
+
export declare function generateSummary(report: A11yReport): string;
|
|
39
|
+
/**
|
|
40
|
+
* Generate GitHub Actions annotation commands (::error, ::warning).
|
|
41
|
+
*
|
|
42
|
+
* All interpolated values are escaped — CSS selectors from axe-core targets
|
|
43
|
+
* are extracted from page DOM and could contain newlines or `%` sequences
|
|
44
|
+
* that would otherwise let a crafted page forge additional workflow commands.
|
|
45
|
+
*/
|
|
46
|
+
export declare function generateAnnotations(report: A11yReport): string;
|
|
47
|
+
/**
|
|
48
|
+
* CLI entry point. Called automatically when this file is run directly
|
|
49
|
+
* (e.g. `node lib/github/a11y-summary.js`), or can be imported and
|
|
50
|
+
* called from the bin wrapper.
|
|
51
|
+
*/
|
|
52
|
+
export declare function main(args?: string[], adapter?: {
|
|
53
|
+
commandName?: string;
|
|
54
|
+
}): void;
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.parseA11yResults = parseA11yResults;
|
|
37
|
+
exports.generateSummary = generateSummary;
|
|
38
|
+
exports.generateAnnotations = generateAnnotations;
|
|
39
|
+
exports.main = main;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
/** Annotation types that identify accessibility-relevant test data. */
|
|
43
|
+
const A11Y_ANNOTATION_TYPES = new Set([
|
|
44
|
+
'@a11y',
|
|
45
|
+
'Accessibility',
|
|
46
|
+
'Baselined a11y violation',
|
|
47
|
+
'Stale a11y baseline entry',
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* Escape a string for use inside a GitHub Actions workflow-command message
|
|
51
|
+
* body (the text after `::`). Without this, a crafted value containing a
|
|
52
|
+
* newline or `%` sequence could forge additional workflow commands.
|
|
53
|
+
* See https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions
|
|
54
|
+
*/
|
|
55
|
+
function escapeWorkflowCommand(value) {
|
|
56
|
+
return String(value)
|
|
57
|
+
.replace(/%/g, '%25')
|
|
58
|
+
.replace(/\r/g, '%0D')
|
|
59
|
+
.replace(/\n/g, '%0A');
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Escape a string for use inside a GitHub Actions workflow-command property
|
|
63
|
+
* value (e.g. `file=...`, `title=...`). Property values additionally require
|
|
64
|
+
* escaping of `:` and `,` on top of the message-body escapes.
|
|
65
|
+
*/
|
|
66
|
+
function escapeWorkflowCommandProperty(value) {
|
|
67
|
+
return escapeWorkflowCommand(value)
|
|
68
|
+
.replace(/:/g, '%3A')
|
|
69
|
+
.replace(/,/g, '%2C');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Escape a string for use inside a Markdown table cell. Backslashes must be
|
|
73
|
+
* escaped first (they are Markdown's escape character), then pipes and
|
|
74
|
+
* newlines can be substituted safely.
|
|
75
|
+
*/
|
|
76
|
+
function escapeMarkdownTableCell(value) {
|
|
77
|
+
return String(value)
|
|
78
|
+
.replace(/\\/g, '\\\\')
|
|
79
|
+
.replace(/\|/g, '\\|')
|
|
80
|
+
.replace(/\r?\n/g, ' ');
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Escape a string for use inside a Markdown inline code span (backticks).
|
|
84
|
+
* A literal backtick would close the span early and let the remainder render
|
|
85
|
+
* as Markdown — wrap the value in double backticks and pad literal backticks
|
|
86
|
+
* with spaces, which is the standard CommonMark escape for code spans.
|
|
87
|
+
*/
|
|
88
|
+
function escapeMarkdownCodeSpan(value) {
|
|
89
|
+
const s = String(value).replace(/\r?\n/g, ' ');
|
|
90
|
+
if (!s.includes('`'))
|
|
91
|
+
return `\`${s}\``;
|
|
92
|
+
return `\`\` ${s.replace(/``/g, '` `')} \`\``;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Parse a Playwright JSON report and extract accessibility-related test data.
|
|
96
|
+
*/
|
|
97
|
+
function parseA11yResults(reportPath) {
|
|
98
|
+
if (!fs.existsSync(reportPath)) {
|
|
99
|
+
throw new Error(`Playwright JSON report not found at ${reportPath}. ` +
|
|
100
|
+
'Ensure the Playwright JSON reporter is enabled.');
|
|
101
|
+
}
|
|
102
|
+
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
103
|
+
const tests = [];
|
|
104
|
+
let totalViolations = 0;
|
|
105
|
+
let totalBaselined = 0;
|
|
106
|
+
let totalStale = 0;
|
|
107
|
+
walkSuites(report.suites ?? [], '', tests);
|
|
108
|
+
for (const test of tests) {
|
|
109
|
+
for (const ann of test.annotations) {
|
|
110
|
+
if (ann.type === 'Baselined a11y violation')
|
|
111
|
+
totalBaselined++;
|
|
112
|
+
if (ann.type === 'Stale a11y baseline entry')
|
|
113
|
+
totalStale++;
|
|
114
|
+
}
|
|
115
|
+
totalViolations += test.violations.length;
|
|
116
|
+
}
|
|
117
|
+
return { tests, totalViolations, totalBaselined, totalStale };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Collect annotations from both test and result levels. Playwright stores
|
|
121
|
+
* annotations declared at test definition time on `test.annotations`, and
|
|
122
|
+
* annotations added during execution (e.g. `testInfo.annotations.push()`)
|
|
123
|
+
* on `result.annotations`. checkAccessibility() uses the latter.
|
|
124
|
+
*/
|
|
125
|
+
function collectAnnotations(test, lastResult) {
|
|
126
|
+
return [
|
|
127
|
+
...(test.annotations ?? []),
|
|
128
|
+
...(lastResult?.annotations ?? []),
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
/** Read and parse the WCAG scan attachment, if present. */
|
|
132
|
+
function parseWcagViolations(attachment) {
|
|
133
|
+
if (!attachment)
|
|
134
|
+
return [];
|
|
135
|
+
try {
|
|
136
|
+
const raw = attachment.body
|
|
137
|
+
? Buffer.from(attachment.body, 'base64').toString('utf8')
|
|
138
|
+
: attachment.path
|
|
139
|
+
? fs.readFileSync(attachment.path, 'utf8')
|
|
140
|
+
: null;
|
|
141
|
+
if (!raw)
|
|
142
|
+
return [];
|
|
143
|
+
const json = JSON.parse(raw);
|
|
144
|
+
if (!json?.violations)
|
|
145
|
+
return [];
|
|
146
|
+
return json.violations.map((v) => ({
|
|
147
|
+
rule: v.id,
|
|
148
|
+
impact: v.impact ?? 'unknown',
|
|
149
|
+
description: v.description,
|
|
150
|
+
helpUrl: v.helpUrl,
|
|
151
|
+
targets: v.nodes
|
|
152
|
+
?.flatMap((n) => n.target)
|
|
153
|
+
.filter((t) => typeof t === 'string') ?? [],
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Parse errors are non-fatal — we'll just report without violation details.
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** Extract an A11yTestResult from a Playwright test, or null if not a11y-related. */
|
|
162
|
+
function extractTestResult(spec, test, file) {
|
|
163
|
+
const lastResult = test.results?.[test.results.length - 1];
|
|
164
|
+
const attachments = lastResult?.attachments ?? [];
|
|
165
|
+
const a11yAnnotations = collectAnnotations(test, lastResult)
|
|
166
|
+
.filter((a) => A11Y_ANNOTATION_TYPES.has(a.type));
|
|
167
|
+
if (a11yAnnotations.length === 0)
|
|
168
|
+
return null;
|
|
169
|
+
const violations = parseWcagViolations(attachments.find((a) => a.name === 'a11y-wcag-scan-results'));
|
|
170
|
+
return {
|
|
171
|
+
title: spec.title,
|
|
172
|
+
file,
|
|
173
|
+
line: spec.line ?? 1,
|
|
174
|
+
annotations: a11yAnnotations,
|
|
175
|
+
violations,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function walkSuites(suites, parentFile, out) {
|
|
179
|
+
for (const suite of suites) {
|
|
180
|
+
const file = suite.file || parentFile;
|
|
181
|
+
for (const spec of suite.specs ?? []) {
|
|
182
|
+
for (const test of spec.tests ?? []) {
|
|
183
|
+
const result = extractTestResult(spec, test, file);
|
|
184
|
+
if (result)
|
|
185
|
+
out.push(result);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (suite.suites) {
|
|
189
|
+
walkSuites(suite.suites, file, out);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Headline with pass/fail counts. */
|
|
194
|
+
function renderHeadline(report) {
|
|
195
|
+
const hasViolations = report.totalViolations > 0;
|
|
196
|
+
const hasBaselined = report.totalBaselined > 0;
|
|
197
|
+
const hasStale = report.totalStale > 0;
|
|
198
|
+
if (!hasViolations && !hasBaselined && !hasStale) {
|
|
199
|
+
return ':white_check_mark: All accessibility checks passed.\n';
|
|
200
|
+
}
|
|
201
|
+
const parts = [];
|
|
202
|
+
if (hasViolations)
|
|
203
|
+
parts.push(`**${report.totalViolations}** violation(s)`);
|
|
204
|
+
if (hasBaselined)
|
|
205
|
+
parts.push(`**${report.totalBaselined}** baselined`);
|
|
206
|
+
if (hasStale)
|
|
207
|
+
parts.push(`**${report.totalStale}** stale baseline entries`);
|
|
208
|
+
return parts.join(' · ') + '\n';
|
|
209
|
+
}
|
|
210
|
+
/** Icon for an `Accessibility` annotation based on its violation count. */
|
|
211
|
+
function iconForAccessibilityAnnotation(description) {
|
|
212
|
+
// Descriptions look like "WCAG scan: 0 violations ..." or "WCAG scan: 3 violations ...".
|
|
213
|
+
const colonIdx = description.lastIndexOf(': ');
|
|
214
|
+
const afterColon = colonIdx >= 0 ? description.substring(colonIdx + 2) : description;
|
|
215
|
+
const violationCount = parseInt(afterColon, 10);
|
|
216
|
+
return violationCount > 0 ? ':x:' : ':white_check_mark:';
|
|
217
|
+
}
|
|
218
|
+
function iconForAnnotation(ann) {
|
|
219
|
+
switch (ann.type) {
|
|
220
|
+
case 'Baselined a11y violation':
|
|
221
|
+
return ':white_check_mark:';
|
|
222
|
+
case 'Stale a11y baseline entry':
|
|
223
|
+
return ':warning:';
|
|
224
|
+
case 'Accessibility':
|
|
225
|
+
return iconForAccessibilityAnnotation(ann.description ?? '');
|
|
226
|
+
default:
|
|
227
|
+
return ':information_source:';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** Render annotation lines, deduplicated by type+description. */
|
|
231
|
+
function renderAnnotations(annotations) {
|
|
232
|
+
const lines = [];
|
|
233
|
+
const seen = new Set();
|
|
234
|
+
for (const ann of annotations) {
|
|
235
|
+
const key = `${ann.type}:${ann.description ?? ''}`;
|
|
236
|
+
if (seen.has(key))
|
|
237
|
+
continue;
|
|
238
|
+
seen.add(key);
|
|
239
|
+
lines.push(`${iconForAnnotation(ann)} **${ann.type}**: ${ann.description ?? ''}\n`);
|
|
240
|
+
}
|
|
241
|
+
return lines;
|
|
242
|
+
}
|
|
243
|
+
/** Render the violation table for a single test. */
|
|
244
|
+
function renderViolationTable(violations) {
|
|
245
|
+
if (violations.length === 0)
|
|
246
|
+
return [];
|
|
247
|
+
const lines = [
|
|
248
|
+
'| Rule | Impact | Description | Targets |',
|
|
249
|
+
'|------|--------|-------------|---------|',
|
|
250
|
+
];
|
|
251
|
+
for (const v of violations) {
|
|
252
|
+
const shown = v.targets.slice(0, 3).map(escapeMarkdownCodeSpan).join(', ');
|
|
253
|
+
const targets = v.targets.length > 3
|
|
254
|
+
? `${shown}, +${v.targets.length - 3} more`
|
|
255
|
+
: shown;
|
|
256
|
+
const rule = escapeMarkdownTableCell(v.rule);
|
|
257
|
+
const impact = escapeMarkdownTableCell(v.impact);
|
|
258
|
+
const description = escapeMarkdownTableCell(v.description);
|
|
259
|
+
lines.push(`| [${rule}](${v.helpUrl}) | ${impact} | ${description} | ${targets} |`);
|
|
260
|
+
}
|
|
261
|
+
lines.push('');
|
|
262
|
+
return lines;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Generate Markdown for $GITHUB_STEP_SUMMARY.
|
|
266
|
+
*/
|
|
267
|
+
function generateSummary(report) {
|
|
268
|
+
if (report.tests.length === 0) {
|
|
269
|
+
return '## Accessibility Results\n\nNo accessibility tests were found in the report.\n';
|
|
270
|
+
}
|
|
271
|
+
const lines = ['## Accessibility Results\n', renderHeadline(report)];
|
|
272
|
+
const seenTitles = new Set();
|
|
273
|
+
for (const test of report.tests) {
|
|
274
|
+
const testAnnotations = test.annotations.filter(a => a.type !== '@a11y');
|
|
275
|
+
const hasTestViolations = test.violations.length > 0;
|
|
276
|
+
if (testAnnotations.length === 0 && !hasTestViolations)
|
|
277
|
+
continue;
|
|
278
|
+
// Skip duplicate test titles (different browsers report the same spec).
|
|
279
|
+
if (seenTitles.has(test.title))
|
|
280
|
+
continue;
|
|
281
|
+
seenTitles.add(test.title);
|
|
282
|
+
const testLines = [`### ${test.title}\n`];
|
|
283
|
+
testLines.push(...renderAnnotations(testAnnotations));
|
|
284
|
+
testLines.push(...renderViolationTable(test.violations));
|
|
285
|
+
lines.push(...testLines);
|
|
286
|
+
}
|
|
287
|
+
return lines.join('\n');
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Generate GitHub Actions annotation commands (::error, ::warning).
|
|
291
|
+
*
|
|
292
|
+
* All interpolated values are escaped — CSS selectors from axe-core targets
|
|
293
|
+
* are extracted from page DOM and could contain newlines or `%` sequences
|
|
294
|
+
* that would otherwise let a crafted page forge additional workflow commands.
|
|
295
|
+
*/
|
|
296
|
+
function generateAnnotations(report) {
|
|
297
|
+
const lines = [];
|
|
298
|
+
for (const test of report.tests) {
|
|
299
|
+
const file = escapeWorkflowCommandProperty(test.file);
|
|
300
|
+
const line = escapeWorkflowCommandProperty(String(test.line));
|
|
301
|
+
// Emit ::error for each violation.
|
|
302
|
+
for (const v of test.violations) {
|
|
303
|
+
const targets = v.targets.slice(0, 3).join(', ');
|
|
304
|
+
const title = escapeWorkflowCommandProperty(`${v.rule} (${v.impact})`);
|
|
305
|
+
const msg = escapeWorkflowCommand(`a11y: ${v.rule} (${v.impact}) — ${v.description}. Targets: ${targets}`);
|
|
306
|
+
lines.push(`::error file=${file},line=${line},title=${title}::${msg}`);
|
|
307
|
+
}
|
|
308
|
+
// Emit ::warning for stale baseline entries.
|
|
309
|
+
for (const ann of test.annotations) {
|
|
310
|
+
if (ann.type === 'Stale a11y baseline entry') {
|
|
311
|
+
const title = escapeWorkflowCommandProperty('Stale a11y baseline');
|
|
312
|
+
const msg = escapeWorkflowCommand(ann.description ?? '');
|
|
313
|
+
lines.push(`::warning file=${file},line=${line},title=${title}::${msg}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return lines.join('\n');
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* CLI entry point. Called automatically when this file is run directly
|
|
321
|
+
* (e.g. `node lib/github/a11y-summary.js`), or can be imported and
|
|
322
|
+
* called from the bin wrapper.
|
|
323
|
+
*/
|
|
324
|
+
function main(args = process.argv.slice(2), adapter = {}) {
|
|
325
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
326
|
+
process.stdout.write(`Usage: ${adapter.commandName ?? 'playwright-testing-a11y-summary'} [options]\n\n` +
|
|
327
|
+
'Options:\n' +
|
|
328
|
+
' --report-path=PATH Playwright JSON report (default: test-results/results.json)\n' +
|
|
329
|
+
' --mode=summary Write a Markdown accessibility summary (default)\n' +
|
|
330
|
+
' --mode=annotations Write GitHub Actions annotations\n' +
|
|
331
|
+
' -h, --help Show this help\n');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
let mode = 'summary';
|
|
335
|
+
let reportPath = 'test-results/results.json';
|
|
336
|
+
for (const arg of args) {
|
|
337
|
+
if (arg.startsWith('--mode='))
|
|
338
|
+
mode = arg.slice('--mode='.length);
|
|
339
|
+
if (arg.startsWith('--report-path='))
|
|
340
|
+
reportPath = arg.slice('--report-path='.length);
|
|
341
|
+
}
|
|
342
|
+
const validModes = new Set(['summary', 'annotations']);
|
|
343
|
+
if (!validModes.has(mode)) {
|
|
344
|
+
console.error(`Unknown mode: ${mode}. Use summary or annotations.`);
|
|
345
|
+
process.exit(2);
|
|
346
|
+
}
|
|
347
|
+
// Resolve relative to cwd.
|
|
348
|
+
const resolvedPath = path.resolve(reportPath);
|
|
349
|
+
// Missing reports are intentionally non-fatal — the JSON report may be
|
|
350
|
+
// absent if no tests ran. Any other failure (parse error, permissions, etc.)
|
|
351
|
+
// should propagate so CI surfaces the real problem instead of silently
|
|
352
|
+
// reporting zero violations.
|
|
353
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
354
|
+
console.error(`Playwright JSON report not found at ${resolvedPath} — skipping a11y summary.`);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const report = parseA11yResults(resolvedPath);
|
|
358
|
+
switch (mode) {
|
|
359
|
+
case 'summary': {
|
|
360
|
+
const md = generateSummary(report);
|
|
361
|
+
const summaryFile = process.env.GITHUB_STEP_SUMMARY;
|
|
362
|
+
if (summaryFile) {
|
|
363
|
+
fs.appendFileSync(summaryFile, md);
|
|
364
|
+
console.log('Accessibility summary written to $GITHUB_STEP_SUMMARY');
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
// Not in GitHub Actions — just print.
|
|
368
|
+
process.stdout.write(md);
|
|
369
|
+
}
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
case 'annotations': {
|
|
373
|
+
const output = generateAnnotations(report);
|
|
374
|
+
if (output)
|
|
375
|
+
process.stdout.write(output + '\n');
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// Auto-invoke when run directly (node lib/github/a11y-summary.js).
|
|
381
|
+
if (require.main === module) {
|
|
382
|
+
main();
|
|
383
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** The subset of `fetch` this module uses, so tests can substitute their own. */
|
|
2
|
+
export type FetchLike = (url: string, init: {
|
|
3
|
+
method: string;
|
|
4
|
+
headers: Record<string, string>;
|
|
5
|
+
body: Buffer;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
}) => Promise<{
|
|
8
|
+
ok: boolean;
|
|
9
|
+
status: number;
|
|
10
|
+
text(): Promise<string>;
|
|
11
|
+
}>;
|
|
12
|
+
export interface AttachmentUploaderOptions {
|
|
13
|
+
/**
|
|
14
|
+
* A user PAT. Installation tokens (including the Actions GITHUB_TOKEN) are
|
|
15
|
+
* rejected by the endpoint, so leaving this unset is the normal case.
|
|
16
|
+
*/
|
|
17
|
+
token?: string;
|
|
18
|
+
/** Numeric repository ID — `GITHUB_REPOSITORY_ID` in Actions, not `owner/repo`. */
|
|
19
|
+
repositoryId?: string | number;
|
|
20
|
+
/** Stop after this many uploads in one run. Defaults to 20. */
|
|
21
|
+
maxUploads?: number;
|
|
22
|
+
/** Stop once this many bytes have been uploaded. Defaults to 20 MiB. */
|
|
23
|
+
maxTotalBytes?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Skip any single file larger than this. Defaults to 10 MiB, which is what
|
|
26
|
+
* GitHub accepts for an image.
|
|
27
|
+
*/
|
|
28
|
+
maxFileBytes?: number;
|
|
29
|
+
/** Per-request timeout. Defaults to 15 seconds. */
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
fetchImpl?: FetchLike;
|
|
32
|
+
log?: (message: string) => void;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Why a call returned null. Every one of these is a null from a method that
|
|
36
|
+
* never throws, and they need different things said about them: a file that
|
|
37
|
+
* could not be read is a path problem, one over the size limit is not.
|
|
38
|
+
*/
|
|
39
|
+
export type SkipReason = 'disabled' | 'unreadable' | 'too-large' | 'budget';
|
|
40
|
+
export interface UploadStats {
|
|
41
|
+
uploaded: number;
|
|
42
|
+
skipped: number;
|
|
43
|
+
bytes: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Guess a MIME type from a file extension. The endpoint requires one, and it
|
|
47
|
+
* is what decides whether GitHub renders the attachment inline.
|
|
48
|
+
*/
|
|
49
|
+
export declare function mimeTypeFor(filePath: string): string;
|
|
50
|
+
export declare class AttachmentUploader {
|
|
51
|
+
private readonly token;
|
|
52
|
+
private readonly repositoryId;
|
|
53
|
+
private readonly maxUploads;
|
|
54
|
+
private readonly maxTotalBytes;
|
|
55
|
+
private readonly maxFileBytes;
|
|
56
|
+
private readonly timeoutMs;
|
|
57
|
+
private readonly fetchImpl;
|
|
58
|
+
private readonly log;
|
|
59
|
+
private disabled;
|
|
60
|
+
private reason;
|
|
61
|
+
private lastSkip;
|
|
62
|
+
private stats;
|
|
63
|
+
constructor(options?: AttachmentUploaderOptions);
|
|
64
|
+
get enabled(): boolean;
|
|
65
|
+
/** Why uploads stopped, or null while they are still running. */
|
|
66
|
+
get disabledReason(): string | null;
|
|
67
|
+
/**
|
|
68
|
+
* Why the most recent call returned null, or null when it returned a URL.
|
|
69
|
+
*
|
|
70
|
+
* A file that could not be read and one the size limits refused both come
|
|
71
|
+
* back as null from a still-running uploader, and a caller that cannot tell
|
|
72
|
+
* them apart reports the wrong problem — an oversized screenshot reads as a
|
|
73
|
+
* missing one, which sends the reader hunting for a path mapping that is not
|
|
74
|
+
* wrong.
|
|
75
|
+
*/
|
|
76
|
+
get lastSkipReason(): SkipReason | null;
|
|
77
|
+
getStats(): UploadStats;
|
|
78
|
+
/**
|
|
79
|
+
* Upload one file and return the URL to embed, or null if the upload did not
|
|
80
|
+
* happen. Never throws and never rejects: a broken undocumented endpoint
|
|
81
|
+
* must not fail anyone's build.
|
|
82
|
+
*/
|
|
83
|
+
upload(filePath: string, displayName?: string): Promise<string | null>;
|
|
84
|
+
/**
|
|
85
|
+
* Upload bytes that never touched the disk. Playwright's JSON reporter
|
|
86
|
+
* inlines attachments added with `testInfo.attach({ body })` as base64, which
|
|
87
|
+
* is how the accessibility screenshots arrive.
|
|
88
|
+
*/
|
|
89
|
+
uploadBuffer(body: Buffer, displayName: string, contentType: string): Promise<string | null>;
|
|
90
|
+
/** Switch the uploader off for the rest of the run and say why. */
|
|
91
|
+
private disable;
|
|
92
|
+
/**
|
|
93
|
+
* Record one file that was not uploaded, and why. Most reasons leave the
|
|
94
|
+
* uploader running; `disable()` routes through here for the one that does
|
|
95
|
+
* not, so `lastSkipReason` is set however a call came back null.
|
|
96
|
+
*/
|
|
97
|
+
private skip;
|
|
98
|
+
}
|