@lullabot/playwright-testing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +61 -0
  2. package/bin/github-a11y-summary +5 -0
  3. package/bin/github-failure-summary +8 -0
  4. package/lib/accessibility-baseline-file.d.ts +47 -0
  5. package/lib/accessibility-baseline-file.js +205 -0
  6. package/lib/accessibility-baseline.d.ts +19 -0
  7. package/lib/accessibility-baseline.js +38 -0
  8. package/lib/accessible-screenshot.d.ts +181 -0
  9. package/lib/accessible-screenshot.js +519 -0
  10. package/lib/focus.d.ts +14 -0
  11. package/lib/focus.js +28 -0
  12. package/lib/fonts.d.ts +18 -0
  13. package/lib/fonts.js +24 -0
  14. package/lib/frames.d.ts +7 -0
  15. package/lib/frames.js +27 -0
  16. package/lib/github/a11y-summary.d.ts +55 -0
  17. package/lib/github/a11y-summary.js +383 -0
  18. package/lib/github/attachments.d.ts +98 -0
  19. package/lib/github/attachments.js +297 -0
  20. package/lib/github/failure-summary.d.ts +144 -0
  21. package/lib/github/failure-summary.js +567 -0
  22. package/lib/github/index.d.ts +6 -0
  23. package/lib/github/index.js +35 -0
  24. package/lib/github/report-paths.d.ts +38 -0
  25. package/lib/github/report-paths.js +200 -0
  26. package/lib/hover.d.ts +13 -0
  27. package/lib/hover.js +61 -0
  28. package/lib/images.d.ts +118 -0
  29. package/lib/images.js +260 -0
  30. package/lib/index.d.ts +13 -0
  31. package/lib/index.js +29 -0
  32. package/lib/interaction-states.d.ts +22 -0
  33. package/lib/interaction-states.js +75 -0
  34. package/lib/mock/index.d.ts +1 -0
  35. package/lib/mock/index.js +5 -0
  36. package/lib/mock/youtube.d.ts +5 -0
  37. package/lib/mock/youtube.js +38 -0
  38. package/lib/pseudo-state.d.ts +17 -0
  39. package/lib/pseudo-state.js +50 -0
  40. package/lib/videos.d.ts +134 -0
  41. package/lib/videos.js +349 -0
  42. package/lib/visualdiff.d.ts +154 -0
  43. package/lib/visualdiff.js +197 -0
  44. package/package.json +47 -0
  45. package/src/accessibility-baseline-file.test.ts +181 -0
  46. package/src/accessibility-baseline-file.ts +208 -0
  47. package/src/accessibility-baseline.test.ts +601 -0
  48. package/src/accessibility-baseline.ts +50 -0
  49. package/src/accessible-screenshot.test.ts +597 -0
  50. package/src/accessible-screenshot.ts +809 -0
  51. package/src/focus.test.ts +34 -0
  52. package/src/focus.ts +27 -0
  53. package/src/fonts.test.ts +17 -0
  54. package/src/fonts.ts +23 -0
  55. package/src/frames.test.ts +75 -0
  56. package/src/frames.ts +26 -0
  57. package/src/github/a11y-summary.test.ts +439 -0
  58. package/src/github/a11y-summary.ts +421 -0
  59. package/src/github/attachments.test.ts +248 -0
  60. package/src/github/attachments.ts +328 -0
  61. package/src/github/failure-summary.test.ts +636 -0
  62. package/src/github/failure-summary.ts +720 -0
  63. package/src/github/index.test.ts +24 -0
  64. package/src/github/index.ts +35 -0
  65. package/src/github/report-paths.test.ts +222 -0
  66. package/src/github/report-paths.ts +208 -0
  67. package/src/hover.test.ts +76 -0
  68. package/src/hover.ts +64 -0
  69. package/src/images.test.ts +355 -0
  70. package/src/images.ts +299 -0
  71. package/src/index.ts +13 -0
  72. package/src/interaction-states.test.ts +48 -0
  73. package/src/interaction-states.ts +94 -0
  74. package/src/mock/index.ts +1 -0
  75. package/src/mock/youtube.test.ts +38 -0
  76. package/src/mock/youtube.ts +39 -0
  77. package/src/pseudo-state.test.ts +83 -0
  78. package/src/pseudo-state.ts +69 -0
  79. package/src/videos.test.ts +637 -0
  80. package/src/videos.ts +389 -0
  81. package/src/visualdiff.test.ts +452 -0
  82. package/src/visualdiff.ts +381 -0
@@ -0,0 +1,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
+ }