@lullabot/playwright-testing 1.0.0 → 1.0.1
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/bin/github-a11y-summary +2 -2
- package/bin/github-failure-summary +4 -4
- package/lib/accessibility-baseline-file.d.ts +6 -6
- package/lib/accessibility-baseline-file.js +29 -23
- package/lib/accessibility-baseline.js +5 -3
- package/lib/accessible-screenshot.d.ts +4 -4
- package/lib/accessible-screenshot.js +88 -76
- package/lib/focus.js +4 -1
- package/lib/frames.js +4 -4
- package/lib/github/a11y-summary.js +74 -79
- package/lib/github/attachments.d.ts +1 -1
- package/lib/github/attachments.js +36 -34
- package/lib/github/failure-summary.d.ts +4 -4
- package/lib/github/failure-summary.js +139 -107
- package/lib/github/index.d.ts +6 -6
- package/lib/github/report-paths.js +17 -9
- package/lib/hover.js +17 -17
- package/lib/images.js +15 -12
- package/lib/index.d.ts +13 -13
- package/lib/interaction-states.d.ts +2 -2
- package/lib/interaction-states.js +7 -7
- package/lib/mock/index.d.ts +1 -1
- package/lib/mock/youtube.d.ts +1 -1
- package/lib/mock/youtube.js +1 -1
- package/lib/pseudo-state.d.ts +2 -2
- package/lib/pseudo-state.js +15 -7
- package/lib/videos.js +21 -10
- package/lib/visualdiff.d.ts +4 -4
- package/lib/visualdiff.js +35 -27
- package/package.json +3 -3
- package/src/accessibility-baseline-file.test.ts +190 -154
- package/src/accessibility-baseline-file.ts +107 -83
- package/src/accessibility-baseline.test.ts +633 -433
- package/src/accessibility-baseline.ts +41 -21
- package/src/accessible-screenshot.test.ts +551 -423
- package/src/accessible-screenshot.ts +334 -245
- package/src/focus.test.ts +23 -23
- package/src/focus.ts +7 -2
- package/src/fonts.test.ts +13 -13
- package/src/fonts.ts +1 -1
- package/src/frames.test.ts +10 -8
- package/src/frames.ts +5 -5
- package/src/github/a11y-summary.test.ts +436 -332
- package/src/github/a11y-summary.ts +189 -179
- package/src/github/attachments.test.ts +228 -167
- package/src/github/attachments.ts +144 -121
- package/src/github/failure-summary.test.ts +659 -445
- package/src/github/failure-summary.ts +376 -274
- package/src/github/index.test.ts +14 -14
- package/src/github/index.ts +6 -6
- package/src/github/report-paths.test.ts +211 -156
- package/src/github/report-paths.ts +75 -61
- package/src/hover.test.ts +50 -46
- package/src/hover.ts +18 -18
- package/src/images.test.ts +251 -206
- package/src/images.ts +52 -43
- package/src/index.ts +13 -13
- package/src/interaction-states.test.ts +72 -42
- package/src/interaction-states.ts +43 -29
- package/src/mock/index.ts +1 -1
- package/src/mock/youtube.test.ts +26 -26
- package/src/mock/youtube.ts +4 -6
- package/src/pseudo-state.test.ts +58 -51
- package/src/pseudo-state.ts +32 -27
- package/src/videos.test.ts +559 -399
- package/src/videos.ts +68 -44
- package/src/visualdiff.test.ts +416 -315
- package/src/visualdiff.ts +140 -99
|
@@ -41,10 +41,10 @@ const fs = __importStar(require("fs"));
|
|
|
41
41
|
const path = __importStar(require("path"));
|
|
42
42
|
/** Annotation types that identify accessibility-relevant test data. */
|
|
43
43
|
const A11Y_ANNOTATION_TYPES = new Set([
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
"@a11y",
|
|
45
|
+
"Accessibility",
|
|
46
|
+
"Baselined a11y violation",
|
|
47
|
+
"Stale a11y baseline entry",
|
|
48
48
|
]);
|
|
49
49
|
/**
|
|
50
50
|
* Escape a string for use inside a GitHub Actions workflow-command message
|
|
@@ -54,9 +54,9 @@ const A11Y_ANNOTATION_TYPES = new Set([
|
|
|
54
54
|
*/
|
|
55
55
|
function escapeWorkflowCommand(value) {
|
|
56
56
|
return String(value)
|
|
57
|
-
.replace(/%/g,
|
|
58
|
-
.replace(/\r/g,
|
|
59
|
-
.replace(/\n/g,
|
|
57
|
+
.replace(/%/g, "%25")
|
|
58
|
+
.replace(/\r/g, "%0D")
|
|
59
|
+
.replace(/\n/g, "%0A");
|
|
60
60
|
}
|
|
61
61
|
/**
|
|
62
62
|
* Escape a string for use inside a GitHub Actions workflow-command property
|
|
@@ -64,9 +64,7 @@ function escapeWorkflowCommand(value) {
|
|
|
64
64
|
* escaping of `:` and `,` on top of the message-body escapes.
|
|
65
65
|
*/
|
|
66
66
|
function escapeWorkflowCommandProperty(value) {
|
|
67
|
-
return escapeWorkflowCommand(value)
|
|
68
|
-
.replace(/:/g, '%3A')
|
|
69
|
-
.replace(/,/g, '%2C');
|
|
67
|
+
return escapeWorkflowCommand(value).replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
70
68
|
}
|
|
71
69
|
/**
|
|
72
70
|
* Escape a string for use inside a Markdown table cell. Backslashes must be
|
|
@@ -75,9 +73,9 @@ function escapeWorkflowCommandProperty(value) {
|
|
|
75
73
|
*/
|
|
76
74
|
function escapeMarkdownTableCell(value) {
|
|
77
75
|
return String(value)
|
|
78
|
-
.replace(/\\/g,
|
|
79
|
-
.replace(/\|/g,
|
|
80
|
-
.replace(/\r?\n/g,
|
|
76
|
+
.replace(/\\/g, "\\\\")
|
|
77
|
+
.replace(/\|/g, "\\|")
|
|
78
|
+
.replace(/\r?\n/g, " ");
|
|
81
79
|
}
|
|
82
80
|
/**
|
|
83
81
|
* Escape a string for use inside a Markdown inline code span (backticks).
|
|
@@ -86,10 +84,10 @@ function escapeMarkdownTableCell(value) {
|
|
|
86
84
|
* with spaces, which is the standard CommonMark escape for code spans.
|
|
87
85
|
*/
|
|
88
86
|
function escapeMarkdownCodeSpan(value) {
|
|
89
|
-
const s = String(value).replace(/\r?\n/g,
|
|
90
|
-
if (!s.includes(
|
|
87
|
+
const s = String(value).replace(/\r?\n/g, " ");
|
|
88
|
+
if (!s.includes("`"))
|
|
91
89
|
return `\`${s}\``;
|
|
92
|
-
return `\`\` ${s.replace(/``/g,
|
|
90
|
+
return `\`\` ${s.replace(/``/g, "` `")} \`\``;
|
|
93
91
|
}
|
|
94
92
|
/**
|
|
95
93
|
* Parse a Playwright JSON report and extract accessibility-related test data.
|
|
@@ -97,19 +95,19 @@ function escapeMarkdownCodeSpan(value) {
|
|
|
97
95
|
function parseA11yResults(reportPath) {
|
|
98
96
|
if (!fs.existsSync(reportPath)) {
|
|
99
97
|
throw new Error(`Playwright JSON report not found at ${reportPath}. ` +
|
|
100
|
-
|
|
98
|
+
"Ensure the Playwright JSON reporter is enabled.");
|
|
101
99
|
}
|
|
102
|
-
const report = JSON.parse(fs.readFileSync(reportPath,
|
|
100
|
+
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
|
|
103
101
|
const tests = [];
|
|
104
102
|
let totalViolations = 0;
|
|
105
103
|
let totalBaselined = 0;
|
|
106
104
|
let totalStale = 0;
|
|
107
|
-
walkSuites(report.suites ?? [],
|
|
105
|
+
walkSuites(report.suites ?? [], "", tests);
|
|
108
106
|
for (const test of tests) {
|
|
109
107
|
for (const ann of test.annotations) {
|
|
110
|
-
if (ann.type ===
|
|
108
|
+
if (ann.type === "Baselined a11y violation")
|
|
111
109
|
totalBaselined++;
|
|
112
|
-
if (ann.type ===
|
|
110
|
+
if (ann.type === "Stale a11y baseline entry")
|
|
113
111
|
totalStale++;
|
|
114
112
|
}
|
|
115
113
|
totalViolations += test.violations.length;
|
|
@@ -123,10 +121,7 @@ function parseA11yResults(reportPath) {
|
|
|
123
121
|
* on `result.annotations`. checkAccessibility() uses the latter.
|
|
124
122
|
*/
|
|
125
123
|
function collectAnnotations(test, lastResult) {
|
|
126
|
-
return [
|
|
127
|
-
...(test.annotations ?? []),
|
|
128
|
-
...(lastResult?.annotations ?? []),
|
|
129
|
-
];
|
|
124
|
+
return [...(test.annotations ?? []), ...(lastResult?.annotations ?? [])];
|
|
130
125
|
}
|
|
131
126
|
/** Read and parse the WCAG scan attachment, if present. */
|
|
132
127
|
function parseWcagViolations(attachment) {
|
|
@@ -134,9 +129,9 @@ function parseWcagViolations(attachment) {
|
|
|
134
129
|
return [];
|
|
135
130
|
try {
|
|
136
131
|
const raw = attachment.body
|
|
137
|
-
? Buffer.from(attachment.body,
|
|
132
|
+
? Buffer.from(attachment.body, "base64").toString("utf8")
|
|
138
133
|
: attachment.path
|
|
139
|
-
? fs.readFileSync(attachment.path,
|
|
134
|
+
? fs.readFileSync(attachment.path, "utf8")
|
|
140
135
|
: null;
|
|
141
136
|
if (!raw)
|
|
142
137
|
return [];
|
|
@@ -145,12 +140,12 @@ function parseWcagViolations(attachment) {
|
|
|
145
140
|
return [];
|
|
146
141
|
return json.violations.map((v) => ({
|
|
147
142
|
rule: v.id,
|
|
148
|
-
impact: v.impact ??
|
|
143
|
+
impact: v.impact ?? "unknown",
|
|
149
144
|
description: v.description,
|
|
150
145
|
helpUrl: v.helpUrl,
|
|
151
146
|
targets: v.nodes
|
|
152
147
|
?.flatMap((n) => n.target)
|
|
153
|
-
.filter((t) => typeof t ===
|
|
148
|
+
.filter((t) => typeof t === "string") ?? [],
|
|
154
149
|
}));
|
|
155
150
|
}
|
|
156
151
|
catch {
|
|
@@ -162,11 +157,10 @@ function parseWcagViolations(attachment) {
|
|
|
162
157
|
function extractTestResult(spec, test, file) {
|
|
163
158
|
const lastResult = test.results?.[test.results.length - 1];
|
|
164
159
|
const attachments = lastResult?.attachments ?? [];
|
|
165
|
-
const a11yAnnotations = collectAnnotations(test, lastResult)
|
|
166
|
-
.filter((a) => A11Y_ANNOTATION_TYPES.has(a.type));
|
|
160
|
+
const a11yAnnotations = collectAnnotations(test, lastResult).filter((a) => A11Y_ANNOTATION_TYPES.has(a.type));
|
|
167
161
|
if (a11yAnnotations.length === 0)
|
|
168
162
|
return null;
|
|
169
|
-
const violations = parseWcagViolations(attachments.find((a) => a.name ===
|
|
163
|
+
const violations = parseWcagViolations(attachments.find((a) => a.name === "a11y-wcag-scan-results"));
|
|
170
164
|
return {
|
|
171
165
|
title: spec.title,
|
|
172
166
|
file,
|
|
@@ -196,7 +190,7 @@ function renderHeadline(report) {
|
|
|
196
190
|
const hasBaselined = report.totalBaselined > 0;
|
|
197
191
|
const hasStale = report.totalStale > 0;
|
|
198
192
|
if (!hasViolations && !hasBaselined && !hasStale) {
|
|
199
|
-
return
|
|
193
|
+
return ":white_check_mark: All accessibility checks passed.\n";
|
|
200
194
|
}
|
|
201
195
|
const parts = [];
|
|
202
196
|
if (hasViolations)
|
|
@@ -205,26 +199,26 @@ function renderHeadline(report) {
|
|
|
205
199
|
parts.push(`**${report.totalBaselined}** baselined`);
|
|
206
200
|
if (hasStale)
|
|
207
201
|
parts.push(`**${report.totalStale}** stale baseline entries`);
|
|
208
|
-
return parts.join(
|
|
202
|
+
return parts.join(" · ") + "\n";
|
|
209
203
|
}
|
|
210
204
|
/** Icon for an `Accessibility` annotation based on its violation count. */
|
|
211
205
|
function iconForAccessibilityAnnotation(description) {
|
|
212
206
|
// Descriptions look like "WCAG scan: 0 violations ..." or "WCAG scan: 3 violations ...".
|
|
213
|
-
const colonIdx = description.lastIndexOf(
|
|
207
|
+
const colonIdx = description.lastIndexOf(": ");
|
|
214
208
|
const afterColon = colonIdx >= 0 ? description.substring(colonIdx + 2) : description;
|
|
215
209
|
const violationCount = parseInt(afterColon, 10);
|
|
216
|
-
return violationCount > 0 ?
|
|
210
|
+
return violationCount > 0 ? ":x:" : ":white_check_mark:";
|
|
217
211
|
}
|
|
218
212
|
function iconForAnnotation(ann) {
|
|
219
213
|
switch (ann.type) {
|
|
220
|
-
case
|
|
221
|
-
return
|
|
222
|
-
case
|
|
223
|
-
return
|
|
224
|
-
case
|
|
225
|
-
return iconForAccessibilityAnnotation(ann.description ??
|
|
214
|
+
case "Baselined a11y violation":
|
|
215
|
+
return ":white_check_mark:";
|
|
216
|
+
case "Stale a11y baseline entry":
|
|
217
|
+
return ":warning:";
|
|
218
|
+
case "Accessibility":
|
|
219
|
+
return iconForAccessibilityAnnotation(ann.description ?? "");
|
|
226
220
|
default:
|
|
227
|
-
return
|
|
221
|
+
return ":information_source:";
|
|
228
222
|
}
|
|
229
223
|
}
|
|
230
224
|
/** Render annotation lines, deduplicated by type+description. */
|
|
@@ -232,11 +226,11 @@ function renderAnnotations(annotations) {
|
|
|
232
226
|
const lines = [];
|
|
233
227
|
const seen = new Set();
|
|
234
228
|
for (const ann of annotations) {
|
|
235
|
-
const key = `${ann.type}:${ann.description ??
|
|
229
|
+
const key = `${ann.type}:${ann.description ?? ""}`;
|
|
236
230
|
if (seen.has(key))
|
|
237
231
|
continue;
|
|
238
232
|
seen.add(key);
|
|
239
|
-
lines.push(`${iconForAnnotation(ann)} **${ann.type}**: ${ann.description ??
|
|
233
|
+
lines.push(`${iconForAnnotation(ann)} **${ann.type}**: ${ann.description ?? ""}\n`);
|
|
240
234
|
}
|
|
241
235
|
return lines;
|
|
242
236
|
}
|
|
@@ -245,20 +239,18 @@ function renderViolationTable(violations) {
|
|
|
245
239
|
if (violations.length === 0)
|
|
246
240
|
return [];
|
|
247
241
|
const lines = [
|
|
248
|
-
|
|
249
|
-
|
|
242
|
+
"| Rule | Impact | Description | Targets |",
|
|
243
|
+
"|------|--------|-------------|---------|",
|
|
250
244
|
];
|
|
251
245
|
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;
|
|
246
|
+
const shown = v.targets.slice(0, 3).map(escapeMarkdownCodeSpan).join(", ");
|
|
247
|
+
const targets = v.targets.length > 3 ? `${shown}, +${v.targets.length - 3} more` : shown;
|
|
256
248
|
const rule = escapeMarkdownTableCell(v.rule);
|
|
257
249
|
const impact = escapeMarkdownTableCell(v.impact);
|
|
258
250
|
const description = escapeMarkdownTableCell(v.description);
|
|
259
251
|
lines.push(`| [${rule}](${v.helpUrl}) | ${impact} | ${description} | ${targets} |`);
|
|
260
252
|
}
|
|
261
|
-
lines.push(
|
|
253
|
+
lines.push("");
|
|
262
254
|
return lines;
|
|
263
255
|
}
|
|
264
256
|
/**
|
|
@@ -266,12 +258,15 @@ function renderViolationTable(violations) {
|
|
|
266
258
|
*/
|
|
267
259
|
function generateSummary(report) {
|
|
268
260
|
if (report.tests.length === 0) {
|
|
269
|
-
return
|
|
261
|
+
return "## Accessibility Results\n\nNo accessibility tests were found in the report.\n";
|
|
270
262
|
}
|
|
271
|
-
const lines = [
|
|
263
|
+
const lines = [
|
|
264
|
+
"## Accessibility Results\n",
|
|
265
|
+
renderHeadline(report),
|
|
266
|
+
];
|
|
272
267
|
const seenTitles = new Set();
|
|
273
268
|
for (const test of report.tests) {
|
|
274
|
-
const testAnnotations = test.annotations.filter(a => a.type !==
|
|
269
|
+
const testAnnotations = test.annotations.filter((a) => a.type !== "@a11y");
|
|
275
270
|
const hasTestViolations = test.violations.length > 0;
|
|
276
271
|
if (testAnnotations.length === 0 && !hasTestViolations)
|
|
277
272
|
continue;
|
|
@@ -284,7 +279,7 @@ function generateSummary(report) {
|
|
|
284
279
|
testLines.push(...renderViolationTable(test.violations));
|
|
285
280
|
lines.push(...testLines);
|
|
286
281
|
}
|
|
287
|
-
return lines.join(
|
|
282
|
+
return lines.join("\n");
|
|
288
283
|
}
|
|
289
284
|
/**
|
|
290
285
|
* Generate GitHub Actions annotation commands (::error, ::warning).
|
|
@@ -300,21 +295,21 @@ function generateAnnotations(report) {
|
|
|
300
295
|
const line = escapeWorkflowCommandProperty(String(test.line));
|
|
301
296
|
// Emit ::error for each violation.
|
|
302
297
|
for (const v of test.violations) {
|
|
303
|
-
const targets = v.targets.slice(0, 3).join(
|
|
298
|
+
const targets = v.targets.slice(0, 3).join(", ");
|
|
304
299
|
const title = escapeWorkflowCommandProperty(`${v.rule} (${v.impact})`);
|
|
305
300
|
const msg = escapeWorkflowCommand(`a11y: ${v.rule} (${v.impact}) — ${v.description}. Targets: ${targets}`);
|
|
306
301
|
lines.push(`::error file=${file},line=${line},title=${title}::${msg}`);
|
|
307
302
|
}
|
|
308
303
|
// Emit ::warning for stale baseline entries.
|
|
309
304
|
for (const ann of test.annotations) {
|
|
310
|
-
if (ann.type ===
|
|
311
|
-
const title = escapeWorkflowCommandProperty(
|
|
312
|
-
const msg = escapeWorkflowCommand(ann.description ??
|
|
305
|
+
if (ann.type === "Stale a11y baseline entry") {
|
|
306
|
+
const title = escapeWorkflowCommandProperty("Stale a11y baseline");
|
|
307
|
+
const msg = escapeWorkflowCommand(ann.description ?? "");
|
|
313
308
|
lines.push(`::warning file=${file},line=${line},title=${title}::${msg}`);
|
|
314
309
|
}
|
|
315
310
|
}
|
|
316
311
|
}
|
|
317
|
-
return lines.join(
|
|
312
|
+
return lines.join("\n");
|
|
318
313
|
}
|
|
319
314
|
/**
|
|
320
315
|
* CLI entry point. Called automatically when this file is run directly
|
|
@@ -322,24 +317,24 @@ function generateAnnotations(report) {
|
|
|
322
317
|
* called from the bin wrapper.
|
|
323
318
|
*/
|
|
324
319
|
function main(args = process.argv.slice(2), adapter = {}) {
|
|
325
|
-
if (args.includes(
|
|
326
|
-
process.stdout.write(`Usage: ${adapter.commandName ??
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
320
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
321
|
+
process.stdout.write(`Usage: ${adapter.commandName ?? "playwright-testing-a11y-summary"} [options]\n\n` +
|
|
322
|
+
"Options:\n" +
|
|
323
|
+
" --report-path=PATH Playwright JSON report (default: test-results/results.json)\n" +
|
|
324
|
+
" --mode=summary Write a Markdown accessibility summary (default)\n" +
|
|
325
|
+
" --mode=annotations Write GitHub Actions annotations\n" +
|
|
326
|
+
" -h, --help Show this help\n");
|
|
332
327
|
return;
|
|
333
328
|
}
|
|
334
|
-
let mode =
|
|
335
|
-
let reportPath =
|
|
329
|
+
let mode = "summary";
|
|
330
|
+
let reportPath = "test-results/results.json";
|
|
336
331
|
for (const arg of args) {
|
|
337
|
-
if (arg.startsWith(
|
|
338
|
-
mode = arg.slice(
|
|
339
|
-
if (arg.startsWith(
|
|
340
|
-
reportPath = arg.slice(
|
|
332
|
+
if (arg.startsWith("--mode="))
|
|
333
|
+
mode = arg.slice("--mode=".length);
|
|
334
|
+
if (arg.startsWith("--report-path="))
|
|
335
|
+
reportPath = arg.slice("--report-path=".length);
|
|
341
336
|
}
|
|
342
|
-
const validModes = new Set([
|
|
337
|
+
const validModes = new Set(["summary", "annotations"]);
|
|
343
338
|
if (!validModes.has(mode)) {
|
|
344
339
|
console.error(`Unknown mode: ${mode}. Use summary or annotations.`);
|
|
345
340
|
process.exit(2);
|
|
@@ -356,12 +351,12 @@ function main(args = process.argv.slice(2), adapter = {}) {
|
|
|
356
351
|
}
|
|
357
352
|
const report = parseA11yResults(resolvedPath);
|
|
358
353
|
switch (mode) {
|
|
359
|
-
case
|
|
354
|
+
case "summary": {
|
|
360
355
|
const md = generateSummary(report);
|
|
361
356
|
const summaryFile = process.env.GITHUB_STEP_SUMMARY;
|
|
362
357
|
if (summaryFile) {
|
|
363
358
|
fs.appendFileSync(summaryFile, md);
|
|
364
|
-
console.log(
|
|
359
|
+
console.log("Accessibility summary written to $GITHUB_STEP_SUMMARY");
|
|
365
360
|
}
|
|
366
361
|
else {
|
|
367
362
|
// Not in GitHub Actions — just print.
|
|
@@ -369,10 +364,10 @@ function main(args = process.argv.slice(2), adapter = {}) {
|
|
|
369
364
|
}
|
|
370
365
|
break;
|
|
371
366
|
}
|
|
372
|
-
case
|
|
367
|
+
case "annotations": {
|
|
373
368
|
const output = generateAnnotations(report);
|
|
374
369
|
if (output)
|
|
375
|
-
process.stdout.write(output +
|
|
370
|
+
process.stdout.write(output + "\n");
|
|
376
371
|
break;
|
|
377
372
|
}
|
|
378
373
|
}
|
|
@@ -36,7 +36,7 @@ export interface AttachmentUploaderOptions {
|
|
|
36
36
|
* never throws, and they need different things said about them: a file that
|
|
37
37
|
* could not be read is a path problem, one over the size limit is not.
|
|
38
38
|
*/
|
|
39
|
-
export type SkipReason =
|
|
39
|
+
export type SkipReason = "disabled" | "unreadable" | "too-large" | "budget";
|
|
40
40
|
export interface UploadStats {
|
|
41
41
|
uploaded: number;
|
|
42
42
|
skipped: number;
|
|
@@ -63,7 +63,7 @@ const path = __importStar(require("path"));
|
|
|
63
63
|
* run: retrying an endpoint that has answered 404 once only wastes CI time,
|
|
64
64
|
* and nothing here is worth failing a build over.
|
|
65
65
|
*/
|
|
66
|
-
const UPLOAD_ORIGIN =
|
|
66
|
+
const UPLOAD_ORIGIN = "https://uploads.github.com";
|
|
67
67
|
const DEFAULT_MAX_UPLOADS = 20;
|
|
68
68
|
const DEFAULT_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
69
69
|
/**
|
|
@@ -74,24 +74,25 @@ const DEFAULT_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
|
74
74
|
* the run into one skipped file.
|
|
75
75
|
*/
|
|
76
76
|
const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
77
|
-
const DEFAULT_TIMEOUT_MS =
|
|
77
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
78
78
|
const MIME_TYPES = {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
79
|
+
".png": "image/png",
|
|
80
|
+
".jpg": "image/jpeg",
|
|
81
|
+
".jpeg": "image/jpeg",
|
|
82
|
+
".gif": "image/gif",
|
|
83
|
+
".webp": "image/webp",
|
|
84
|
+
".svg": "image/svg+xml",
|
|
85
|
+
".webm": "video/webm",
|
|
86
|
+
".mp4": "video/mp4",
|
|
87
|
+
".mov": "video/quicktime",
|
|
88
88
|
};
|
|
89
89
|
/**
|
|
90
90
|
* Guess a MIME type from a file extension. The endpoint requires one, and it
|
|
91
91
|
* is what decides whether GitHub renders the attachment inline.
|
|
92
92
|
*/
|
|
93
93
|
function mimeTypeFor(filePath) {
|
|
94
|
-
return MIME_TYPES[path.extname(filePath).toLowerCase()] ??
|
|
94
|
+
return (MIME_TYPES[path.extname(filePath).toLowerCase()] ??
|
|
95
|
+
"application/octet-stream");
|
|
95
96
|
}
|
|
96
97
|
class AttachmentUploader {
|
|
97
98
|
token;
|
|
@@ -107,21 +108,22 @@ class AttachmentUploader {
|
|
|
107
108
|
lastSkip = null;
|
|
108
109
|
stats = { uploaded: 0, skipped: 0, bytes: 0 };
|
|
109
110
|
constructor(options = {}) {
|
|
110
|
-
this.token = String(options.token ??
|
|
111
|
-
this.repositoryId = String(options.repositoryId ??
|
|
111
|
+
this.token = String(options.token ?? "");
|
|
112
|
+
this.repositoryId = String(options.repositoryId ?? "");
|
|
112
113
|
this.maxUploads = options.maxUploads ?? DEFAULT_MAX_UPLOADS;
|
|
113
114
|
this.maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
|
|
114
115
|
this.maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
|
|
115
116
|
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
116
|
-
this.fetchImpl =
|
|
117
|
+
this.fetchImpl =
|
|
118
|
+
options.fetchImpl ?? globalThis.fetch;
|
|
117
119
|
this.log = options.log ?? (() => { });
|
|
118
120
|
if (!this.token) {
|
|
119
121
|
this.disabled = true;
|
|
120
|
-
this.reason =
|
|
122
|
+
this.reason = "no upload token configured";
|
|
121
123
|
}
|
|
122
124
|
else if (!this.repositoryId) {
|
|
123
125
|
this.disabled = true;
|
|
124
|
-
this.reason =
|
|
126
|
+
this.reason = "no repository ID available";
|
|
125
127
|
}
|
|
126
128
|
else {
|
|
127
129
|
this.disabled = false;
|
|
@@ -156,14 +158,14 @@ class AttachmentUploader {
|
|
|
156
158
|
*/
|
|
157
159
|
async upload(filePath, displayName) {
|
|
158
160
|
if (this.disabled)
|
|
159
|
-
return this.skip(
|
|
161
|
+
return this.skip("disabled");
|
|
160
162
|
let body;
|
|
161
163
|
try {
|
|
162
164
|
body = fs.readFileSync(filePath);
|
|
163
165
|
}
|
|
164
166
|
catch {
|
|
165
167
|
// A missing file is this file's problem, not a reason to stop the run.
|
|
166
|
-
return this.skip(
|
|
168
|
+
return this.skip("unreadable", `Attachment upload skipped, unreadable file: ${filePath}`);
|
|
167
169
|
}
|
|
168
170
|
return this.uploadBuffer(body, displayName ?? path.basename(filePath), mimeTypeFor(filePath));
|
|
169
171
|
}
|
|
@@ -174,7 +176,7 @@ class AttachmentUploader {
|
|
|
174
176
|
*/
|
|
175
177
|
async uploadBuffer(body, displayName, contentType) {
|
|
176
178
|
if (this.disabled)
|
|
177
|
-
return this.skip(
|
|
179
|
+
return this.skip("disabled");
|
|
178
180
|
const size = body.length;
|
|
179
181
|
const name = sanitizeName(displayName);
|
|
180
182
|
// A count budget is reached once and stays reached, so stop there.
|
|
@@ -186,11 +188,11 @@ class AttachmentUploader {
|
|
|
186
188
|
// one large screenshot must not cost the run every smaller one behind it,
|
|
187
189
|
// so skip it and carry on. `maxUploads` bounds how often this can repeat.
|
|
188
190
|
if (size > this.maxFileBytes) {
|
|
189
|
-
return this.skip(
|
|
191
|
+
return this.skip("too-large", `Attachment skipped, ${size} bytes is over the ${this.maxFileBytes} byte limit: ${name}`);
|
|
190
192
|
}
|
|
191
193
|
if (this.stats.bytes + size > this.maxTotalBytes) {
|
|
192
194
|
const left = this.maxTotalBytes - this.stats.bytes;
|
|
193
|
-
return this.skip(
|
|
195
|
+
return this.skip("budget", `Attachment skipped, ${size} bytes does not fit the ${left} bytes left in the budget: ${name}`);
|
|
194
196
|
}
|
|
195
197
|
const query = new URLSearchParams({
|
|
196
198
|
name,
|
|
@@ -199,13 +201,13 @@ class AttachmentUploader {
|
|
|
199
201
|
});
|
|
200
202
|
try {
|
|
201
203
|
const response = await this.fetchImpl(`${UPLOAD_ORIGIN}/user-attachments/assets?${query}`, {
|
|
202
|
-
method:
|
|
204
|
+
method: "POST",
|
|
203
205
|
headers: {
|
|
204
206
|
Authorization: `Bearer ${this.token}`,
|
|
205
|
-
Accept:
|
|
207
|
+
Accept: "application/json",
|
|
206
208
|
// Required. Without it the endpoint answers 400 "Invalid
|
|
207
209
|
// Content-Type", and fetch sends no default for a Buffer body.
|
|
208
|
-
|
|
210
|
+
"Content-Type": contentType,
|
|
209
211
|
},
|
|
210
212
|
body,
|
|
211
213
|
signal: AbortSignal.timeout(this.timeoutMs),
|
|
@@ -216,7 +218,7 @@ class AttachmentUploader {
|
|
|
216
218
|
}
|
|
217
219
|
const url = parseUploadUrl(text);
|
|
218
220
|
if (!url) {
|
|
219
|
-
return this.disable(
|
|
221
|
+
return this.disable("the attachments endpoint returned no URL");
|
|
220
222
|
}
|
|
221
223
|
this.stats.uploaded++;
|
|
222
224
|
this.stats.bytes += size;
|
|
@@ -233,7 +235,7 @@ class AttachmentUploader {
|
|
|
233
235
|
this.disabled = true;
|
|
234
236
|
this.reason = reason;
|
|
235
237
|
this.log(`Attachment uploads disabled — ${reason}.`);
|
|
236
|
-
return this.skip(
|
|
238
|
+
return this.skip("disabled");
|
|
237
239
|
}
|
|
238
240
|
/**
|
|
239
241
|
* Record one file that was not uploaded, and why. Most reasons leave the
|
|
@@ -257,7 +259,7 @@ function parseUploadUrl(text) {
|
|
|
257
259
|
try {
|
|
258
260
|
const parsed = JSON.parse(text);
|
|
259
261
|
const url = parsed?.url ?? parsed?.href ?? parsed?.asset?.href;
|
|
260
|
-
return typeof url ===
|
|
262
|
+
return typeof url === "string" && url ? url : null;
|
|
261
263
|
}
|
|
262
264
|
catch {
|
|
263
265
|
return null;
|
|
@@ -265,9 +267,9 @@ function parseUploadUrl(text) {
|
|
|
265
267
|
}
|
|
266
268
|
/** Condense a response body into one line worth putting in a CI log. */
|
|
267
269
|
function summarize(text) {
|
|
268
|
-
const collapsed = text.replace(/\s+/g,
|
|
270
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
269
271
|
if (!collapsed)
|
|
270
|
-
return
|
|
272
|
+
return "no response body";
|
|
271
273
|
return collapsed.length > 200 ? `${collapsed.slice(0, 200)}…` : collapsed;
|
|
272
274
|
}
|
|
273
275
|
/**
|
|
@@ -280,9 +282,9 @@ function summarize(text) {
|
|
|
280
282
|
function trimDashes(value) {
|
|
281
283
|
let start = 0;
|
|
282
284
|
let end = value.length;
|
|
283
|
-
while (start < end && value[start] ===
|
|
285
|
+
while (start < end && value[start] === "-")
|
|
284
286
|
start++;
|
|
285
|
-
while (end > start && value[end - 1] ===
|
|
287
|
+
while (end > start && value[end - 1] === "-")
|
|
286
288
|
end--;
|
|
287
289
|
return value.slice(start, end);
|
|
288
290
|
}
|
|
@@ -292,6 +294,6 @@ function trimDashes(value) {
|
|
|
292
294
|
*/
|
|
293
295
|
function sanitizeName(name) {
|
|
294
296
|
// Truncate before trimming, so a trailing dash left by the cut goes too.
|
|
295
|
-
const cleaned = name.replace(/[^A-Za-z0-9._-]+/g,
|
|
296
|
-
return trimDashes(cleaned) ||
|
|
297
|
+
const cleaned = name.replace(/[^A-Za-z0-9._-]+/g, "-").slice(0, 120);
|
|
298
|
+
return trimDashes(cleaned) || "attachment";
|
|
297
299
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { AttachmentUploader, AttachmentUploaderOptions } from
|
|
2
|
-
import { PathPrefix, PathResolution } from
|
|
3
|
-
export type ImageKind =
|
|
1
|
+
import { AttachmentUploader, AttachmentUploaderOptions } from "./attachments";
|
|
2
|
+
import { PathPrefix, PathResolution } from "./report-paths";
|
|
3
|
+
export type ImageKind = "diff" | "actual" | "expected" | "previous" | "a11y" | "screenshot";
|
|
4
4
|
export interface FailureImage {
|
|
5
5
|
/** Attachment name as it appears in the report. */
|
|
6
6
|
name: string;
|
|
@@ -46,7 +46,7 @@ export interface FailureReport {
|
|
|
46
46
|
totalImages: number;
|
|
47
47
|
}
|
|
48
48
|
/** Which snapshot images to include. `diff` is the one that shows the problem. */
|
|
49
|
-
export type IncludeMode =
|
|
49
|
+
export type IncludeMode = "diff" | "all";
|
|
50
50
|
/**
|
|
51
51
|
* Prefix of the HTML comment carrying the failure count in a generated comment
|
|
52
52
|
* body. Invisible when rendered, and greppable by a workflow that has to decide
|