@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,328 @@
1
+ import * as fs from 'fs'
2
+ import * as path from 'path'
3
+
4
+ /**
5
+ * Uploads files to GitHub's user-attachments endpoint — the programmatic
6
+ * equivalent of dragging an image into a comment box, which is the only way to
7
+ * get an image into a job summary or a PR comment.
8
+ *
9
+ * The endpoint is undocumented, and two things about it drive this design:
10
+ *
11
+ * 1. It only accepts *user* tokens. The Actions `GITHUB_TOKEN` and GitHub App
12
+ * installation tokens are both refused with a 404, so the feature is opt-in
13
+ * behind a PAT and every caller must cope with it being switched off.
14
+ * 2. The URL it returns is a renderer-only handle. Fetching it directly is a
15
+ * 404 with or without credentials; GitHub rewrites it to a signed, short
16
+ * lived URL when it renders the surrounding Markdown. Never try to verify
17
+ * an upload by reading it back — a 201 with a URL in the body is the only
18
+ * success signal there is.
19
+ *
20
+ * What it accepts is no longer guesswork, even though the endpoint has no
21
+ * documentation of its own: `gh --attach` (CLI 2.99.0) is a supported wrapper
22
+ * over the same upload, and GitHub documents its formats and size limits. That
23
+ * flag needs the same kind of user token, which is why it is no help here —
24
+ * see docs/working-with-tests/screenshots-in-ci.md.
25
+ *
26
+ * A single endpoint failure still disables the uploader for the rest of the
27
+ * run: retrying an endpoint that has answered 404 once only wastes CI time,
28
+ * and nothing here is worth failing a build over.
29
+ */
30
+
31
+ const UPLOAD_ORIGIN = 'https://uploads.github.com'
32
+ const DEFAULT_MAX_UPLOADS = 20
33
+ const DEFAULT_MAX_TOTAL_BYTES = 20 * 1024 * 1024
34
+ /**
35
+ * GitHub's own ceiling for a single image or GIF, as documented for the
36
+ * `gh --attach` flag over this endpoint. Video is 10 MB on free plans and
37
+ * 100 MB on paid ones; this uploader carries screenshots, so the image number
38
+ * is the one that binds. Checking it here turns a rejection that would stop
39
+ * the run into one skipped file.
40
+ */
41
+ const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024
42
+ const DEFAULT_TIMEOUT_MS = 15_000
43
+
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
+ }
55
+
56
+ /** The subset of `fetch` this module uses, so tests can substitute their own. */
57
+ export type FetchLike = (
58
+ url: string,
59
+ init: { method: string; headers: Record<string, string>; body: Buffer; signal?: AbortSignal },
60
+ ) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>
61
+
62
+ export interface AttachmentUploaderOptions {
63
+ /**
64
+ * A user PAT. Installation tokens (including the Actions GITHUB_TOKEN) are
65
+ * rejected by the endpoint, so leaving this unset is the normal case.
66
+ */
67
+ token?: string
68
+ /** Numeric repository ID — `GITHUB_REPOSITORY_ID` in Actions, not `owner/repo`. */
69
+ repositoryId?: string | number
70
+ /** Stop after this many uploads in one run. Defaults to 20. */
71
+ maxUploads?: number
72
+ /** Stop once this many bytes have been uploaded. Defaults to 20 MiB. */
73
+ maxTotalBytes?: number
74
+ /**
75
+ * Skip any single file larger than this. Defaults to 10 MiB, which is what
76
+ * GitHub accepts for an image.
77
+ */
78
+ maxFileBytes?: number
79
+ /** Per-request timeout. Defaults to 15 seconds. */
80
+ timeoutMs?: number
81
+ fetchImpl?: FetchLike
82
+ log?: (message: string) => void
83
+ }
84
+
85
+ /**
86
+ * Why a call returned null. Every one of these is a null from a method that
87
+ * never throws, and they need different things said about them: a file that
88
+ * could not be read is a path problem, one over the size limit is not.
89
+ */
90
+ export type SkipReason = 'disabled' | 'unreadable' | 'too-large' | 'budget'
91
+
92
+ export interface UploadStats {
93
+ uploaded: number
94
+ skipped: number
95
+ bytes: number
96
+ }
97
+
98
+ /**
99
+ * Guess a MIME type from a file extension. The endpoint requires one, and it
100
+ * is what decides whether GitHub renders the attachment inline.
101
+ */
102
+ export function mimeTypeFor(filePath: string): string {
103
+ return MIME_TYPES[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream'
104
+ }
105
+
106
+ 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 }
120
+
121
+ 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
+
131
+ if (!this.token) {
132
+ this.disabled = true
133
+ this.reason = 'no upload token configured'
134
+ } else if (!this.repositoryId) {
135
+ this.disabled = true
136
+ this.reason = 'no repository ID available'
137
+ } else {
138
+ this.disabled = false
139
+ }
140
+ }
141
+
142
+ get enabled(): boolean {
143
+ return !this.disabled
144
+ }
145
+
146
+ /** Why uploads stopped, or null while they are still running. */
147
+ get disabledReason(): string | null {
148
+ return this.reason
149
+ }
150
+
151
+ /**
152
+ * Why the most recent call returned null, or null when it returned a URL.
153
+ *
154
+ * A file that could not be read and one the size limits refused both come
155
+ * back as null from a still-running uploader, and a caller that cannot tell
156
+ * them apart reports the wrong problem — an oversized screenshot reads as a
157
+ * missing one, which sends the reader hunting for a path mapping that is not
158
+ * wrong.
159
+ */
160
+ get lastSkipReason(): SkipReason | null {
161
+ return this.lastSkip
162
+ }
163
+
164
+ getStats(): UploadStats {
165
+ return { ...this.stats }
166
+ }
167
+
168
+ /**
169
+ * Upload one file and return the URL to embed, or null if the upload did not
170
+ * happen. Never throws and never rejects: a broken undocumented endpoint
171
+ * must not fail anyone's build.
172
+ */
173
+ async upload(filePath: string, displayName?: string): Promise<string | null> {
174
+ if (this.disabled) return this.skip('disabled')
175
+
176
+ let body: Buffer
177
+ try {
178
+ body = fs.readFileSync(filePath)
179
+ } catch {
180
+ // 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}`)
182
+ }
183
+
184
+ return this.uploadBuffer(body, displayName ?? path.basename(filePath), mimeTypeFor(filePath))
185
+ }
186
+
187
+ /**
188
+ * Upload bytes that never touched the disk. Playwright's JSON reporter
189
+ * inlines attachments added with `testInfo.attach({ body })` as base64, which
190
+ * is how the accessibility screenshots arrive.
191
+ */
192
+ async uploadBuffer(body: Buffer, displayName: string, contentType: string): Promise<string | null> {
193
+ if (this.disabled) return this.skip('disabled')
194
+
195
+ const size = body.length
196
+ const name = sanitizeName(displayName)
197
+
198
+ // A count budget is reached once and stays reached, so stop there.
199
+ if (this.stats.uploaded >= this.maxUploads) {
200
+ return this.disable(`upload limit of ${this.maxUploads} reached`)
201
+ }
202
+
203
+ // Size limits are the individual file's problem. GitHub refuses one over
204
+ // its own ceiling, and a run has only so many bytes to spend; either way
205
+ // one large screenshot must not cost the run every smaller one behind it,
206
+ // so skip it and carry on. `maxUploads` bounds how often this can repeat.
207
+ if (size > this.maxFileBytes) {
208
+ return this.skip(
209
+ 'too-large',
210
+ `Attachment skipped, ${size} bytes is over the ${this.maxFileBytes} byte limit: ${name}`,
211
+ )
212
+ }
213
+ if (this.stats.bytes + size > this.maxTotalBytes) {
214
+ const left = this.maxTotalBytes - this.stats.bytes
215
+ return this.skip(
216
+ 'budget',
217
+ `Attachment skipped, ${size} bytes does not fit the ${left} bytes left in the budget: ${name}`,
218
+ )
219
+ }
220
+
221
+ const query = new URLSearchParams({
222
+ name,
223
+ content_type: contentType,
224
+ repository_id: this.repositoryId,
225
+ })
226
+
227
+ 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,
236
+ },
237
+ body,
238
+ signal: AbortSignal.timeout(this.timeoutMs),
239
+ })
240
+
241
+ const text = await response.text()
242
+ if (!response.ok) {
243
+ return this.disable(
244
+ `HTTP ${response.status} from the attachments endpoint: ${summarize(text)}`,
245
+ )
246
+ }
247
+
248
+ const url = parseUploadUrl(text)
249
+ if (!url) {
250
+ return this.disable('the attachments endpoint returned no URL')
251
+ }
252
+
253
+ this.stats.uploaded++
254
+ this.stats.bytes += size
255
+ this.lastSkip = null
256
+ return url
257
+ } catch (error) {
258
+ const message = error instanceof Error ? error.message : String(error)
259
+ return this.disable(`upload failed: ${message}`)
260
+ }
261
+ }
262
+
263
+ /** Switch the uploader off for the rest of the run and say why. */
264
+ 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')
269
+ }
270
+
271
+ /**
272
+ * Record one file that was not uploaded, and why. Most reasons leave the
273
+ * uploader running; `disable()` routes through here for the one that does
274
+ * not, so `lastSkipReason` is set however a call came back null.
275
+ */
276
+ private skip(reason: SkipReason, message?: string): null {
277
+ this.stats.skipped++
278
+ this.lastSkip = reason
279
+ if (message) this.log(message)
280
+ return null
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Pull the asset URL out of a response body. The endpoint has been seen to
286
+ * answer with `url`; `href` appears in third-party write-ups, so accept both.
287
+ */
288
+ function parseUploadUrl(text: string): string | null {
289
+ 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
293
+ } catch {
294
+ return null
295
+ }
296
+ }
297
+
298
+ /** Condense a response body into one line worth putting in a CI log. */
299
+ 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
303
+ }
304
+
305
+ /**
306
+ * Trim leading and trailing dashes.
307
+ *
308
+ * An index scan rather than `/^-+|-+$/`, because that pattern backtracks
309
+ * quadratically over a long run of dashes and this input is derived from test
310
+ * titles — which nobody controls.
311
+ */
312
+ 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)
318
+ }
319
+
320
+ /**
321
+ * Reduce a file name to something safe to put in a query string. Test titles
322
+ * reach this by way of snapshot file names and can contain anything.
323
+ */
324
+ function sanitizeName(name: string): string {
325
+ // 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'
328
+ }