@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,297 @@
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.AttachmentUploader = void 0;
37
+ exports.mimeTypeFor = mimeTypeFor;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ /**
41
+ * Uploads files to GitHub's user-attachments endpoint — the programmatic
42
+ * equivalent of dragging an image into a comment box, which is the only way to
43
+ * get an image into a job summary or a PR comment.
44
+ *
45
+ * The endpoint is undocumented, and two things about it drive this design:
46
+ *
47
+ * 1. It only accepts *user* tokens. The Actions `GITHUB_TOKEN` and GitHub App
48
+ * installation tokens are both refused with a 404, so the feature is opt-in
49
+ * behind a PAT and every caller must cope with it being switched off.
50
+ * 2. The URL it returns is a renderer-only handle. Fetching it directly is a
51
+ * 404 with or without credentials; GitHub rewrites it to a signed, short
52
+ * lived URL when it renders the surrounding Markdown. Never try to verify
53
+ * an upload by reading it back — a 201 with a URL in the body is the only
54
+ * success signal there is.
55
+ *
56
+ * What it accepts is no longer guesswork, even though the endpoint has no
57
+ * documentation of its own: `gh --attach` (CLI 2.99.0) is a supported wrapper
58
+ * over the same upload, and GitHub documents its formats and size limits. That
59
+ * flag needs the same kind of user token, which is why it is no help here —
60
+ * see docs/working-with-tests/screenshots-in-ci.md.
61
+ *
62
+ * A single endpoint failure still disables the uploader for the rest of the
63
+ * run: retrying an endpoint that has answered 404 once only wastes CI time,
64
+ * and nothing here is worth failing a build over.
65
+ */
66
+ const UPLOAD_ORIGIN = 'https://uploads.github.com';
67
+ const DEFAULT_MAX_UPLOADS = 20;
68
+ const DEFAULT_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
69
+ /**
70
+ * GitHub's own ceiling for a single image or GIF, as documented for the
71
+ * `gh --attach` flag over this endpoint. Video is 10 MB on free plans and
72
+ * 100 MB on paid ones; this uploader carries screenshots, so the image number
73
+ * is the one that binds. Checking it here turns a rejection that would stop
74
+ * the run into one skipped file.
75
+ */
76
+ const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
77
+ const DEFAULT_TIMEOUT_MS = 15000;
78
+ const MIME_TYPES = {
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
+ };
89
+ /**
90
+ * Guess a MIME type from a file extension. The endpoint requires one, and it
91
+ * is what decides whether GitHub renders the attachment inline.
92
+ */
93
+ function mimeTypeFor(filePath) {
94
+ return MIME_TYPES[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
95
+ }
96
+ class AttachmentUploader {
97
+ token;
98
+ repositoryId;
99
+ maxUploads;
100
+ maxTotalBytes;
101
+ maxFileBytes;
102
+ timeoutMs;
103
+ fetchImpl;
104
+ log;
105
+ disabled;
106
+ reason = null;
107
+ lastSkip = null;
108
+ stats = { uploaded: 0, skipped: 0, bytes: 0 };
109
+ constructor(options = {}) {
110
+ this.token = String(options.token ?? '');
111
+ this.repositoryId = String(options.repositoryId ?? '');
112
+ this.maxUploads = options.maxUploads ?? DEFAULT_MAX_UPLOADS;
113
+ this.maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
114
+ this.maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
115
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
116
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
117
+ this.log = options.log ?? (() => { });
118
+ if (!this.token) {
119
+ this.disabled = true;
120
+ this.reason = 'no upload token configured';
121
+ }
122
+ else if (!this.repositoryId) {
123
+ this.disabled = true;
124
+ this.reason = 'no repository ID available';
125
+ }
126
+ else {
127
+ this.disabled = false;
128
+ }
129
+ }
130
+ get enabled() {
131
+ return !this.disabled;
132
+ }
133
+ /** Why uploads stopped, or null while they are still running. */
134
+ get disabledReason() {
135
+ return this.reason;
136
+ }
137
+ /**
138
+ * Why the most recent call returned null, or null when it returned a URL.
139
+ *
140
+ * A file that could not be read and one the size limits refused both come
141
+ * back as null from a still-running uploader, and a caller that cannot tell
142
+ * them apart reports the wrong problem — an oversized screenshot reads as a
143
+ * missing one, which sends the reader hunting for a path mapping that is not
144
+ * wrong.
145
+ */
146
+ get lastSkipReason() {
147
+ return this.lastSkip;
148
+ }
149
+ getStats() {
150
+ return { ...this.stats };
151
+ }
152
+ /**
153
+ * Upload one file and return the URL to embed, or null if the upload did not
154
+ * happen. Never throws and never rejects: a broken undocumented endpoint
155
+ * must not fail anyone's build.
156
+ */
157
+ async upload(filePath, displayName) {
158
+ if (this.disabled)
159
+ return this.skip('disabled');
160
+ let body;
161
+ try {
162
+ body = fs.readFileSync(filePath);
163
+ }
164
+ catch {
165
+ // A missing file is this file's problem, not a reason to stop the run.
166
+ return this.skip('unreadable', `Attachment upload skipped, unreadable file: ${filePath}`);
167
+ }
168
+ return this.uploadBuffer(body, displayName ?? path.basename(filePath), mimeTypeFor(filePath));
169
+ }
170
+ /**
171
+ * Upload bytes that never touched the disk. Playwright's JSON reporter
172
+ * inlines attachments added with `testInfo.attach({ body })` as base64, which
173
+ * is how the accessibility screenshots arrive.
174
+ */
175
+ async uploadBuffer(body, displayName, contentType) {
176
+ if (this.disabled)
177
+ return this.skip('disabled');
178
+ const size = body.length;
179
+ const name = sanitizeName(displayName);
180
+ // A count budget is reached once and stays reached, so stop there.
181
+ if (this.stats.uploaded >= this.maxUploads) {
182
+ return this.disable(`upload limit of ${this.maxUploads} reached`);
183
+ }
184
+ // Size limits are the individual file's problem. GitHub refuses one over
185
+ // its own ceiling, and a run has only so many bytes to spend; either way
186
+ // one large screenshot must not cost the run every smaller one behind it,
187
+ // so skip it and carry on. `maxUploads` bounds how often this can repeat.
188
+ if (size > this.maxFileBytes) {
189
+ return this.skip('too-large', `Attachment skipped, ${size} bytes is over the ${this.maxFileBytes} byte limit: ${name}`);
190
+ }
191
+ if (this.stats.bytes + size > this.maxTotalBytes) {
192
+ const left = this.maxTotalBytes - this.stats.bytes;
193
+ return this.skip('budget', `Attachment skipped, ${size} bytes does not fit the ${left} bytes left in the budget: ${name}`);
194
+ }
195
+ const query = new URLSearchParams({
196
+ name,
197
+ content_type: contentType,
198
+ repository_id: this.repositoryId,
199
+ });
200
+ try {
201
+ const response = await this.fetchImpl(`${UPLOAD_ORIGIN}/user-attachments/assets?${query}`, {
202
+ method: 'POST',
203
+ headers: {
204
+ Authorization: `Bearer ${this.token}`,
205
+ Accept: 'application/json',
206
+ // Required. Without it the endpoint answers 400 "Invalid
207
+ // Content-Type", and fetch sends no default for a Buffer body.
208
+ 'Content-Type': contentType,
209
+ },
210
+ body,
211
+ signal: AbortSignal.timeout(this.timeoutMs),
212
+ });
213
+ const text = await response.text();
214
+ if (!response.ok) {
215
+ return this.disable(`HTTP ${response.status} from the attachments endpoint: ${summarize(text)}`);
216
+ }
217
+ const url = parseUploadUrl(text);
218
+ if (!url) {
219
+ return this.disable('the attachments endpoint returned no URL');
220
+ }
221
+ this.stats.uploaded++;
222
+ this.stats.bytes += size;
223
+ this.lastSkip = null;
224
+ return url;
225
+ }
226
+ catch (error) {
227
+ const message = error instanceof Error ? error.message : String(error);
228
+ return this.disable(`upload failed: ${message}`);
229
+ }
230
+ }
231
+ /** Switch the uploader off for the rest of the run and say why. */
232
+ disable(reason) {
233
+ this.disabled = true;
234
+ this.reason = reason;
235
+ this.log(`Attachment uploads disabled — ${reason}.`);
236
+ return this.skip('disabled');
237
+ }
238
+ /**
239
+ * Record one file that was not uploaded, and why. Most reasons leave the
240
+ * uploader running; `disable()` routes through here for the one that does
241
+ * not, so `lastSkipReason` is set however a call came back null.
242
+ */
243
+ skip(reason, message) {
244
+ this.stats.skipped++;
245
+ this.lastSkip = reason;
246
+ if (message)
247
+ this.log(message);
248
+ return null;
249
+ }
250
+ }
251
+ exports.AttachmentUploader = AttachmentUploader;
252
+ /**
253
+ * Pull the asset URL out of a response body. The endpoint has been seen to
254
+ * answer with `url`; `href` appears in third-party write-ups, so accept both.
255
+ */
256
+ function parseUploadUrl(text) {
257
+ try {
258
+ const parsed = JSON.parse(text);
259
+ const url = parsed?.url ?? parsed?.href ?? parsed?.asset?.href;
260
+ return typeof url === 'string' && url ? url : null;
261
+ }
262
+ catch {
263
+ return null;
264
+ }
265
+ }
266
+ /** Condense a response body into one line worth putting in a CI log. */
267
+ function summarize(text) {
268
+ const collapsed = text.replace(/\s+/g, ' ').trim();
269
+ if (!collapsed)
270
+ return 'no response body';
271
+ return collapsed.length > 200 ? `${collapsed.slice(0, 200)}…` : collapsed;
272
+ }
273
+ /**
274
+ * Trim leading and trailing dashes.
275
+ *
276
+ * An index scan rather than `/^-+|-+$/`, because that pattern backtracks
277
+ * quadratically over a long run of dashes and this input is derived from test
278
+ * titles — which nobody controls.
279
+ */
280
+ function trimDashes(value) {
281
+ let start = 0;
282
+ let end = value.length;
283
+ while (start < end && value[start] === '-')
284
+ start++;
285
+ while (end > start && value[end - 1] === '-')
286
+ end--;
287
+ return value.slice(start, end);
288
+ }
289
+ /**
290
+ * Reduce a file name to something safe to put in a query string. Test titles
291
+ * reach this by way of snapshot file names and can contain anything.
292
+ */
293
+ function sanitizeName(name) {
294
+ // Truncate before trimming, so a trailing dash left by the cut goes too.
295
+ const cleaned = name.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 120);
296
+ return trimDashes(cleaned) || 'attachment';
297
+ }
@@ -0,0 +1,144 @@
1
+ import { AttachmentUploader, AttachmentUploaderOptions } from './attachments';
2
+ import { PathPrefix, PathResolution } from './report-paths';
3
+ export type ImageKind = 'diff' | 'actual' | 'expected' | 'previous' | 'a11y' | 'screenshot';
4
+ export interface FailureImage {
5
+ /** Attachment name as it appears in the report. */
6
+ name: string;
7
+ /** Path on disk. Attachments the reporter inlined have none. */
8
+ filePath?: string;
9
+ /**
10
+ * Base64 bytes, for attachments added with `testInfo.attach({ body })`. The
11
+ * JSON reporter inlines those rather than writing them out, which is how the
12
+ * accessibility screenshots arrive.
13
+ */
14
+ body?: string;
15
+ contentType: string;
16
+ kind: ImageKind;
17
+ /** Populated once the image has been uploaded. */
18
+ url?: string;
19
+ /**
20
+ * Set when the recorded path could not be found on this side of a container
21
+ * boundary. Distinguishes "there was nothing to upload with" from "there was
22
+ * nowhere to upload to", which otherwise render identically.
23
+ */
24
+ unreadable?: boolean;
25
+ /**
26
+ * Set when the uploader would not carry the image: over GitHub's 10 MB
27
+ * ceiling, or over what is left of the run's byte budget. Uploading carried
28
+ * on around it, so this is one image's story rather than the run's.
29
+ */
30
+ oversize?: boolean;
31
+ }
32
+ export interface FailedTest {
33
+ title: string;
34
+ file: string;
35
+ line: number;
36
+ /** Display status, including the synthetic `flaky` status for a retry that passed. */
37
+ status: string;
38
+ /** First error message, trimmed to something a comment can carry. */
39
+ error?: string;
40
+ images: FailureImage[];
41
+ }
42
+ export interface FailureReport {
43
+ tests: FailedTest[];
44
+ totalFailed: number;
45
+ totalFlaky: number;
46
+ totalImages: number;
47
+ }
48
+ /** Which snapshot images to include. `diff` is the one that shows the problem. */
49
+ export type IncludeMode = 'diff' | 'all';
50
+ /**
51
+ * Prefix of the HTML comment carrying the failure count in a generated comment
52
+ * body. Invisible when rendered, and greppable by a workflow that has to decide
53
+ * whether the run is worth commenting on.
54
+ */
55
+ export declare const FAILURE_MARKER_PREFIX = "<!-- playwright-testing-failures: ";
56
+ /** Machine-readable flaky-test count used by the comment assembly action. */
57
+ export declare const FLAKE_MARKER_PREFIX = "<!-- playwright-testing-flakes: ";
58
+ /**
59
+ * Neutralise text that the Actions runner would redact.
60
+ *
61
+ * Job summaries pass through the runner's secret masking (comments posted over
62
+ * the API do not). The masker replaces `Bearer <value>` with `***` and takes
63
+ * the following characters with it, so a test name or error containing the
64
+ * word can silently swallow the Markdown that follows it. A zero-width space
65
+ * inside the word reads identically and no longer matches.
66
+ */
67
+ export declare function defuseMaskTriggers(text: string): string;
68
+ /**
69
+ * Parse a Playwright JSON report into the failures worth reporting.
70
+ */
71
+ export declare function parseFailures(reportPath: string, include?: IncludeMode): FailureReport;
72
+ /** What re-rooting the report's attachment paths achieved. */
73
+ export interface PathResolutionSummary {
74
+ /** Attachments whose recorded path had to be rewritten to be readable. */
75
+ remapped: number;
76
+ /** The mappings that were used, for the log line. */
77
+ used: PathPrefix[];
78
+ /** Recorded paths with no readable file behind them, on either side. */
79
+ unreadable: string[];
80
+ }
81
+ /**
82
+ * Point every image at a file this process can open, and note the ones where
83
+ * that was not possible.
84
+ *
85
+ * Runs whether or not uploads are switched on: an unreachable attachment is
86
+ * worth reporting even on a fork build that was never going to upload it,
87
+ * because it is the same misconfiguration either way.
88
+ */
89
+ export declare function resolveImagePaths(report: FailureReport, resolve: (filePath: string) => PathResolution): PathResolutionSummary;
90
+ /**
91
+ * Upload every collected image, annotating each one with its URL. Images the
92
+ * uploader declines are simply left without a URL, which the renderers treat
93
+ * as "link to the artifact instead".
94
+ */
95
+ export declare function uploadImages(report: FailureReport, uploader: AttachmentUploader): Promise<void>;
96
+ export interface SummaryOptions {
97
+ artifactHint?: string;
98
+ /**
99
+ * Why uploading did not happen at all, phrased to sit inside a sentence —
100
+ * `no upload token configured`, say. Leave unset when uploads ran.
101
+ */
102
+ uploadReason?: string;
103
+ }
104
+ /**
105
+ * Render the job summary: a heading, a headline, and one collapsed block per
106
+ * test holding its images.
107
+ *
108
+ * Deliberately table-free. Test titles and error messages are arbitrary text,
109
+ * and the runner's masking can eat a cell delimiter and corrupt a whole row.
110
+ */
111
+ export declare function generateSummary(report: FailureReport, options?: SummaryOptions): string;
112
+ /**
113
+ * Render the pull request comment. This is where the screenshots go.
114
+ *
115
+ * A comment is rendered afresh every time it is read, so an attachment resolves
116
+ * however recently it was uploaded. A job summary is rendered once when the job
117
+ * ends, before a just-uploaded attachment is resolvable, and that result is
118
+ * what everyone sees from then on.
119
+ *
120
+ * The cost is emailed notifications: those are rendered once when sent, and the
121
+ * signed URLs behind these images expire minutes later, so the images will be
122
+ * broken in the email even though they are fine on the web.
123
+ */
124
+ export interface FailureCommentOptions {
125
+ summaryUrl?: string;
126
+ title?: string;
127
+ uploadReason?: string;
128
+ /** Adapter namespace for the machine-readable failure marker. */
129
+ failureMarkerPrefix?: string;
130
+ /** Adapter namespace for the machine-readable flaky-test marker. */
131
+ flakeMarkerPrefix?: string;
132
+ }
133
+ export declare function generateComment(report: FailureReport, options?: FailureCommentOptions): string;
134
+ export declare function uploaderFromEnvironment(overrides?: AttachmentUploaderOptions): AttachmentUploader;
135
+ /**
136
+ * CLI entry point. Runs once and writes both outputs, so images are never
137
+ * uploaded twice for the same run.
138
+ */
139
+ export interface FailureSummaryMainOptions {
140
+ commandName?: string;
141
+ failureMarkerPrefix?: string;
142
+ flakeMarkerPrefix?: string;
143
+ }
144
+ export declare function main(args?: string[], adapter?: FailureSummaryMainOptions): Promise<void>;