@lullabot/playwright-testing 1.0.0 → 1.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 (68) hide show
  1. package/bin/github-a11y-summary +2 -2
  2. package/bin/github-failure-summary +4 -4
  3. package/lib/accessibility-baseline-file.d.ts +6 -6
  4. package/lib/accessibility-baseline-file.js +29 -23
  5. package/lib/accessibility-baseline.js +5 -3
  6. package/lib/accessible-screenshot.d.ts +4 -4
  7. package/lib/accessible-screenshot.js +110 -88
  8. package/lib/focus.js +4 -1
  9. package/lib/frames.js +4 -4
  10. package/lib/github/a11y-summary.js +74 -79
  11. package/lib/github/attachments.d.ts +1 -1
  12. package/lib/github/attachments.js +36 -34
  13. package/lib/github/failure-summary.d.ts +4 -4
  14. package/lib/github/failure-summary.js +140 -108
  15. package/lib/github/index.d.ts +6 -6
  16. package/lib/github/report-paths.js +17 -9
  17. package/lib/hover.js +17 -17
  18. package/lib/images.js +15 -12
  19. package/lib/index.d.ts +13 -13
  20. package/lib/interaction-states.d.ts +2 -2
  21. package/lib/interaction-states.js +7 -7
  22. package/lib/mock/index.d.ts +1 -1
  23. package/lib/mock/youtube.d.ts +1 -1
  24. package/lib/mock/youtube.js +1 -1
  25. package/lib/pseudo-state.d.ts +2 -2
  26. package/lib/pseudo-state.js +15 -7
  27. package/lib/videos.js +21 -10
  28. package/lib/visualdiff.d.ts +4 -4
  29. package/lib/visualdiff.js +35 -27
  30. package/package.json +3 -3
  31. package/src/accessibility-baseline-file.test.ts +190 -154
  32. package/src/accessibility-baseline-file.ts +107 -83
  33. package/src/accessibility-baseline.test.ts +869 -433
  34. package/src/accessibility-baseline.ts +41 -21
  35. package/src/accessible-screenshot.test.ts +551 -423
  36. package/src/accessible-screenshot.ts +358 -251
  37. package/src/focus.test.ts +23 -23
  38. package/src/focus.ts +7 -2
  39. package/src/fonts.test.ts +13 -13
  40. package/src/fonts.ts +1 -1
  41. package/src/frames.test.ts +10 -8
  42. package/src/frames.ts +5 -5
  43. package/src/github/a11y-summary.test.ts +436 -332
  44. package/src/github/a11y-summary.ts +189 -179
  45. package/src/github/attachments.test.ts +228 -167
  46. package/src/github/attachments.ts +144 -121
  47. package/src/github/failure-summary.test.ts +660 -445
  48. package/src/github/failure-summary.ts +376 -274
  49. package/src/github/index.test.ts +14 -14
  50. package/src/github/index.ts +6 -6
  51. package/src/github/report-paths.test.ts +211 -156
  52. package/src/github/report-paths.ts +75 -61
  53. package/src/hover.test.ts +50 -46
  54. package/src/hover.ts +18 -18
  55. package/src/images.test.ts +251 -206
  56. package/src/images.ts +52 -43
  57. package/src/index.ts +13 -13
  58. package/src/interaction-states.test.ts +72 -42
  59. package/src/interaction-states.ts +43 -29
  60. package/src/mock/index.ts +1 -1
  61. package/src/mock/youtube.test.ts +26 -26
  62. package/src/mock/youtube.ts +4 -6
  63. package/src/pseudo-state.test.ts +58 -51
  64. package/src/pseudo-state.ts +32 -27
  65. package/src/videos.test.ts +559 -399
  66. package/src/videos.ts +68 -44
  67. package/src/visualdiff.test.ts +416 -315
  68. package/src/visualdiff.ts +140 -99
@@ -1,5 +1,5 @@
1
- import * as fs from 'fs'
2
- import * as path from 'path'
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
3
 
4
4
  /**
5
5
  * Uploads files to GitHub's user-attachments endpoint — the programmatic
@@ -28,9 +28,9 @@ import * as path from 'path'
28
28
  * and nothing here is worth failing a build over.
29
29
  */
30
30
 
31
- const UPLOAD_ORIGIN = 'https://uploads.github.com'
32
- const DEFAULT_MAX_UPLOADS = 20
33
- const DEFAULT_MAX_TOTAL_BYTES = 20 * 1024 * 1024
31
+ const UPLOAD_ORIGIN = "https://uploads.github.com";
32
+ const DEFAULT_MAX_UPLOADS = 20;
33
+ const DEFAULT_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
34
34
  /**
35
35
  * GitHub's own ceiling for a single image or GIF, as documented for the
36
36
  * `gh --attach` flag over this endpoint. Video is 10 MB on free plans and
@@ -38,48 +38,53 @@ const DEFAULT_MAX_TOTAL_BYTES = 20 * 1024 * 1024
38
38
  * is the one that binds. Checking it here turns a rejection that would stop
39
39
  * the run into one skipped file.
40
40
  */
41
- const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024
42
- const DEFAULT_TIMEOUT_MS = 15_000
41
+ const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
42
+ const DEFAULT_TIMEOUT_MS = 15_000;
43
43
 
44
44
  const MIME_TYPES: Record<string, string> = {
45
- '.png': 'image/png',
46
- '.jpg': 'image/jpeg',
47
- '.jpeg': 'image/jpeg',
48
- '.gif': 'image/gif',
49
- '.webp': 'image/webp',
50
- '.svg': 'image/svg+xml',
51
- '.webm': 'video/webm',
52
- '.mp4': 'video/mp4',
53
- '.mov': 'video/quicktime',
54
- }
45
+ ".png": "image/png",
46
+ ".jpg": "image/jpeg",
47
+ ".jpeg": "image/jpeg",
48
+ ".gif": "image/gif",
49
+ ".webp": "image/webp",
50
+ ".svg": "image/svg+xml",
51
+ ".webm": "video/webm",
52
+ ".mp4": "video/mp4",
53
+ ".mov": "video/quicktime",
54
+ };
55
55
 
56
56
  /** The subset of `fetch` this module uses, so tests can substitute their own. */
57
57
  export type FetchLike = (
58
58
  url: string,
59
- init: { method: string; headers: Record<string, string>; body: Buffer; signal?: AbortSignal },
60
- ) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>
59
+ init: {
60
+ method: string;
61
+ headers: Record<string, string>;
62
+ body: Buffer;
63
+ signal?: AbortSignal;
64
+ },
65
+ ) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
61
66
 
62
67
  export interface AttachmentUploaderOptions {
63
68
  /**
64
69
  * A user PAT. Installation tokens (including the Actions GITHUB_TOKEN) are
65
70
  * rejected by the endpoint, so leaving this unset is the normal case.
66
71
  */
67
- token?: string
72
+ token?: string;
68
73
  /** Numeric repository ID — `GITHUB_REPOSITORY_ID` in Actions, not `owner/repo`. */
69
- repositoryId?: string | number
74
+ repositoryId?: string | number;
70
75
  /** Stop after this many uploads in one run. Defaults to 20. */
71
- maxUploads?: number
76
+ maxUploads?: number;
72
77
  /** Stop once this many bytes have been uploaded. Defaults to 20 MiB. */
73
- maxTotalBytes?: number
78
+ maxTotalBytes?: number;
74
79
  /**
75
80
  * Skip any single file larger than this. Defaults to 10 MiB, which is what
76
81
  * GitHub accepts for an image.
77
82
  */
78
- maxFileBytes?: number
83
+ maxFileBytes?: number;
79
84
  /** Per-request timeout. Defaults to 15 seconds. */
80
- timeoutMs?: number
81
- fetchImpl?: FetchLike
82
- log?: (message: string) => void
85
+ timeoutMs?: number;
86
+ fetchImpl?: FetchLike;
87
+ log?: (message: string) => void;
83
88
  }
84
89
 
85
90
  /**
@@ -87,12 +92,12 @@ export interface AttachmentUploaderOptions {
87
92
  * never throws, and they need different things said about them: a file that
88
93
  * could not be read is a path problem, one over the size limit is not.
89
94
  */
90
- export type SkipReason = 'disabled' | 'unreadable' | 'too-large' | 'budget'
95
+ export type SkipReason = "disabled" | "unreadable" | "too-large" | "budget";
91
96
 
92
97
  export interface UploadStats {
93
- uploaded: number
94
- skipped: number
95
- bytes: number
98
+ uploaded: number;
99
+ skipped: number;
100
+ bytes: number;
96
101
  }
97
102
 
98
103
  /**
@@ -100,52 +105,56 @@ export interface UploadStats {
100
105
  * is what decides whether GitHub renders the attachment inline.
101
106
  */
102
107
  export function mimeTypeFor(filePath: string): string {
103
- return MIME_TYPES[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream'
108
+ return (
109
+ MIME_TYPES[path.extname(filePath).toLowerCase()] ??
110
+ "application/octet-stream"
111
+ );
104
112
  }
105
113
 
106
114
  export class AttachmentUploader {
107
- private readonly token: string
108
- private readonly repositoryId: string
109
- private readonly maxUploads: number
110
- private readonly maxTotalBytes: number
111
- private readonly maxFileBytes: number
112
- private readonly timeoutMs: number
113
- private readonly fetchImpl: FetchLike
114
- private readonly log: (message: string) => void
115
-
116
- private disabled: boolean
117
- private reason: string | null = null
118
- private lastSkip: SkipReason | null = null
119
- private stats: UploadStats = { uploaded: 0, skipped: 0, bytes: 0 }
115
+ private readonly token: string;
116
+ private readonly repositoryId: string;
117
+ private readonly maxUploads: number;
118
+ private readonly maxTotalBytes: number;
119
+ private readonly maxFileBytes: number;
120
+ private readonly timeoutMs: number;
121
+ private readonly fetchImpl: FetchLike;
122
+ private readonly log: (message: string) => void;
123
+
124
+ private disabled: boolean;
125
+ private reason: string | null = null;
126
+ private lastSkip: SkipReason | null = null;
127
+ private stats: UploadStats = { uploaded: 0, skipped: 0, bytes: 0 };
120
128
 
121
129
  constructor(options: AttachmentUploaderOptions = {}) {
122
- this.token = String(options.token ?? '')
123
- this.repositoryId = String(options.repositoryId ?? '')
124
- this.maxUploads = options.maxUploads ?? DEFAULT_MAX_UPLOADS
125
- this.maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES
126
- this.maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES
127
- this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
128
- this.fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as FetchLike)
129
- this.log = options.log ?? (() => {})
130
+ this.token = String(options.token ?? "");
131
+ this.repositoryId = String(options.repositoryId ?? "");
132
+ this.maxUploads = options.maxUploads ?? DEFAULT_MAX_UPLOADS;
133
+ this.maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
134
+ this.maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
135
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
136
+ this.fetchImpl =
137
+ options.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
138
+ this.log = options.log ?? (() => {});
130
139
 
131
140
  if (!this.token) {
132
- this.disabled = true
133
- this.reason = 'no upload token configured'
141
+ this.disabled = true;
142
+ this.reason = "no upload token configured";
134
143
  } else if (!this.repositoryId) {
135
- this.disabled = true
136
- this.reason = 'no repository ID available'
144
+ this.disabled = true;
145
+ this.reason = "no repository ID available";
137
146
  } else {
138
- this.disabled = false
147
+ this.disabled = false;
139
148
  }
140
149
  }
141
150
 
142
151
  get enabled(): boolean {
143
- return !this.disabled
152
+ return !this.disabled;
144
153
  }
145
154
 
146
155
  /** Why uploads stopped, or null while they are still running. */
147
156
  get disabledReason(): string | null {
148
- return this.reason
157
+ return this.reason;
149
158
  }
150
159
 
151
160
  /**
@@ -158,11 +167,11 @@ export class AttachmentUploader {
158
167
  * wrong.
159
168
  */
160
169
  get lastSkipReason(): SkipReason | null {
161
- return this.lastSkip
170
+ return this.lastSkip;
162
171
  }
163
172
 
164
173
  getStats(): UploadStats {
165
- return { ...this.stats }
174
+ return { ...this.stats };
166
175
  }
167
176
 
168
177
  /**
@@ -171,17 +180,24 @@ export class AttachmentUploader {
171
180
  * must not fail anyone's build.
172
181
  */
173
182
  async upload(filePath: string, displayName?: string): Promise<string | null> {
174
- if (this.disabled) return this.skip('disabled')
183
+ if (this.disabled) return this.skip("disabled");
175
184
 
176
- let body: Buffer
185
+ let body: Buffer;
177
186
  try {
178
- body = fs.readFileSync(filePath)
187
+ body = fs.readFileSync(filePath);
179
188
  } catch {
180
189
  // A missing file is this file's problem, not a reason to stop the run.
181
- return this.skip('unreadable', `Attachment upload skipped, unreadable file: ${filePath}`)
190
+ return this.skip(
191
+ "unreadable",
192
+ `Attachment upload skipped, unreadable file: ${filePath}`,
193
+ );
182
194
  }
183
195
 
184
- return this.uploadBuffer(body, displayName ?? path.basename(filePath), mimeTypeFor(filePath))
196
+ return this.uploadBuffer(
197
+ body,
198
+ displayName ?? path.basename(filePath),
199
+ mimeTypeFor(filePath),
200
+ );
185
201
  }
186
202
 
187
203
  /**
@@ -189,15 +205,19 @@ export class AttachmentUploader {
189
205
  * inlines attachments added with `testInfo.attach({ body })` as base64, which
190
206
  * is how the accessibility screenshots arrive.
191
207
  */
192
- async uploadBuffer(body: Buffer, displayName: string, contentType: string): Promise<string | null> {
193
- if (this.disabled) return this.skip('disabled')
208
+ async uploadBuffer(
209
+ body: Buffer,
210
+ displayName: string,
211
+ contentType: string,
212
+ ): Promise<string | null> {
213
+ if (this.disabled) return this.skip("disabled");
194
214
 
195
- const size = body.length
196
- const name = sanitizeName(displayName)
215
+ const size = body.length;
216
+ const name = sanitizeName(displayName);
197
217
 
198
218
  // A count budget is reached once and stays reached, so stop there.
199
219
  if (this.stats.uploaded >= this.maxUploads) {
200
- return this.disable(`upload limit of ${this.maxUploads} reached`)
220
+ return this.disable(`upload limit of ${this.maxUploads} reached`);
201
221
  }
202
222
 
203
223
  // Size limits are the individual file's problem. GitHub refuses one over
@@ -206,66 +226,69 @@ export class AttachmentUploader {
206
226
  // so skip it and carry on. `maxUploads` bounds how often this can repeat.
207
227
  if (size > this.maxFileBytes) {
208
228
  return this.skip(
209
- 'too-large',
229
+ "too-large",
210
230
  `Attachment skipped, ${size} bytes is over the ${this.maxFileBytes} byte limit: ${name}`,
211
- )
231
+ );
212
232
  }
213
233
  if (this.stats.bytes + size > this.maxTotalBytes) {
214
- const left = this.maxTotalBytes - this.stats.bytes
234
+ const left = this.maxTotalBytes - this.stats.bytes;
215
235
  return this.skip(
216
- 'budget',
236
+ "budget",
217
237
  `Attachment skipped, ${size} bytes does not fit the ${left} bytes left in the budget: ${name}`,
218
- )
238
+ );
219
239
  }
220
240
 
221
241
  const query = new URLSearchParams({
222
242
  name,
223
243
  content_type: contentType,
224
244
  repository_id: this.repositoryId,
225
- })
245
+ });
226
246
 
227
247
  try {
228
- const response = await this.fetchImpl(`${UPLOAD_ORIGIN}/user-attachments/assets?${query}`, {
229
- method: 'POST',
230
- headers: {
231
- Authorization: `Bearer ${this.token}`,
232
- Accept: 'application/json',
233
- // Required. Without it the endpoint answers 400 "Invalid
234
- // Content-Type", and fetch sends no default for a Buffer body.
235
- 'Content-Type': contentType,
248
+ const response = await this.fetchImpl(
249
+ `${UPLOAD_ORIGIN}/user-attachments/assets?${query}`,
250
+ {
251
+ method: "POST",
252
+ headers: {
253
+ Authorization: `Bearer ${this.token}`,
254
+ Accept: "application/json",
255
+ // Required. Without it the endpoint answers 400 "Invalid
256
+ // Content-Type", and fetch sends no default for a Buffer body.
257
+ "Content-Type": contentType,
258
+ },
259
+ body,
260
+ signal: AbortSignal.timeout(this.timeoutMs),
236
261
  },
237
- body,
238
- signal: AbortSignal.timeout(this.timeoutMs),
239
- })
262
+ );
240
263
 
241
- const text = await response.text()
264
+ const text = await response.text();
242
265
  if (!response.ok) {
243
266
  return this.disable(
244
267
  `HTTP ${response.status} from the attachments endpoint: ${summarize(text)}`,
245
- )
268
+ );
246
269
  }
247
270
 
248
- const url = parseUploadUrl(text)
271
+ const url = parseUploadUrl(text);
249
272
  if (!url) {
250
- return this.disable('the attachments endpoint returned no URL')
273
+ return this.disable("the attachments endpoint returned no URL");
251
274
  }
252
275
 
253
- this.stats.uploaded++
254
- this.stats.bytes += size
255
- this.lastSkip = null
256
- return url
276
+ this.stats.uploaded++;
277
+ this.stats.bytes += size;
278
+ this.lastSkip = null;
279
+ return url;
257
280
  } catch (error) {
258
- const message = error instanceof Error ? error.message : String(error)
259
- return this.disable(`upload failed: ${message}`)
281
+ const message = error instanceof Error ? error.message : String(error);
282
+ return this.disable(`upload failed: ${message}`);
260
283
  }
261
284
  }
262
285
 
263
286
  /** Switch the uploader off for the rest of the run and say why. */
264
287
  private disable(reason: string): null {
265
- this.disabled = true
266
- this.reason = reason
267
- this.log(`Attachment uploads disabled — ${reason}.`)
268
- return this.skip('disabled')
288
+ this.disabled = true;
289
+ this.reason = reason;
290
+ this.log(`Attachment uploads disabled — ${reason}.`);
291
+ return this.skip("disabled");
269
292
  }
270
293
 
271
294
  /**
@@ -274,10 +297,10 @@ export class AttachmentUploader {
274
297
  * not, so `lastSkipReason` is set however a call came back null.
275
298
  */
276
299
  private skip(reason: SkipReason, message?: string): null {
277
- this.stats.skipped++
278
- this.lastSkip = reason
279
- if (message) this.log(message)
280
- return null
300
+ this.stats.skipped++;
301
+ this.lastSkip = reason;
302
+ if (message) this.log(message);
303
+ return null;
281
304
  }
282
305
  }
283
306
 
@@ -287,19 +310,19 @@ export class AttachmentUploader {
287
310
  */
288
311
  function parseUploadUrl(text: string): string | null {
289
312
  try {
290
- const parsed = JSON.parse(text)
291
- const url = parsed?.url ?? parsed?.href ?? parsed?.asset?.href
292
- return typeof url === 'string' && url ? url : null
313
+ const parsed = JSON.parse(text);
314
+ const url = parsed?.url ?? parsed?.href ?? parsed?.asset?.href;
315
+ return typeof url === "string" && url ? url : null;
293
316
  } catch {
294
- return null
317
+ return null;
295
318
  }
296
319
  }
297
320
 
298
321
  /** Condense a response body into one line worth putting in a CI log. */
299
322
  function summarize(text: string): string {
300
- const collapsed = text.replace(/\s+/g, ' ').trim()
301
- if (!collapsed) return 'no response body'
302
- return collapsed.length > 200 ? `${collapsed.slice(0, 200)}…` : collapsed
323
+ const collapsed = text.replace(/\s+/g, " ").trim();
324
+ if (!collapsed) return "no response body";
325
+ return collapsed.length > 200 ? `${collapsed.slice(0, 200)}…` : collapsed;
303
326
  }
304
327
 
305
328
  /**
@@ -310,11 +333,11 @@ function summarize(text: string): string {
310
333
  * titles — which nobody controls.
311
334
  */
312
335
  function trimDashes(value: string): string {
313
- let start = 0
314
- let end = value.length
315
- while (start < end && value[start] === '-') start++
316
- while (end > start && value[end - 1] === '-') end--
317
- return value.slice(start, end)
336
+ let start = 0;
337
+ let end = value.length;
338
+ while (start < end && value[start] === "-") start++;
339
+ while (end > start && value[end - 1] === "-") end--;
340
+ return value.slice(start, end);
318
341
  }
319
342
 
320
343
  /**
@@ -323,6 +346,6 @@ function trimDashes(value: string): string {
323
346
  */
324
347
  function sanitizeName(name: string): string {
325
348
  // Truncate before trimming, so a trailing dash left by the cut goes too.
326
- const cleaned = name.replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 120)
327
- return trimDashes(cleaned) || 'attachment'
349
+ const cleaned = name.replace(/[^A-Za-z0-9._-]+/g, "-").slice(0, 120);
350
+ return trimDashes(cleaned) || "attachment";
328
351
  }