@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.
- package/README.md +61 -0
- package/bin/github-a11y-summary +5 -0
- package/bin/github-failure-summary +8 -0
- package/lib/accessibility-baseline-file.d.ts +47 -0
- package/lib/accessibility-baseline-file.js +205 -0
- package/lib/accessibility-baseline.d.ts +19 -0
- package/lib/accessibility-baseline.js +38 -0
- package/lib/accessible-screenshot.d.ts +181 -0
- package/lib/accessible-screenshot.js +519 -0
- package/lib/focus.d.ts +14 -0
- package/lib/focus.js +28 -0
- package/lib/fonts.d.ts +18 -0
- package/lib/fonts.js +24 -0
- package/lib/frames.d.ts +7 -0
- package/lib/frames.js +27 -0
- package/lib/github/a11y-summary.d.ts +55 -0
- package/lib/github/a11y-summary.js +383 -0
- package/lib/github/attachments.d.ts +98 -0
- package/lib/github/attachments.js +297 -0
- package/lib/github/failure-summary.d.ts +144 -0
- package/lib/github/failure-summary.js +567 -0
- package/lib/github/index.d.ts +6 -0
- package/lib/github/index.js +35 -0
- package/lib/github/report-paths.d.ts +38 -0
- package/lib/github/report-paths.js +200 -0
- package/lib/hover.d.ts +13 -0
- package/lib/hover.js +61 -0
- package/lib/images.d.ts +118 -0
- package/lib/images.js +260 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.js +29 -0
- package/lib/interaction-states.d.ts +22 -0
- package/lib/interaction-states.js +75 -0
- package/lib/mock/index.d.ts +1 -0
- package/lib/mock/index.js +5 -0
- package/lib/mock/youtube.d.ts +5 -0
- package/lib/mock/youtube.js +38 -0
- package/lib/pseudo-state.d.ts +17 -0
- package/lib/pseudo-state.js +50 -0
- package/lib/videos.d.ts +134 -0
- package/lib/videos.js +349 -0
- package/lib/visualdiff.d.ts +154 -0
- package/lib/visualdiff.js +197 -0
- package/package.json +47 -0
- package/src/accessibility-baseline-file.test.ts +181 -0
- package/src/accessibility-baseline-file.ts +208 -0
- package/src/accessibility-baseline.test.ts +601 -0
- package/src/accessibility-baseline.ts +50 -0
- package/src/accessible-screenshot.test.ts +597 -0
- package/src/accessible-screenshot.ts +809 -0
- package/src/focus.test.ts +34 -0
- package/src/focus.ts +27 -0
- package/src/fonts.test.ts +17 -0
- package/src/fonts.ts +23 -0
- package/src/frames.test.ts +75 -0
- package/src/frames.ts +26 -0
- package/src/github/a11y-summary.test.ts +439 -0
- package/src/github/a11y-summary.ts +421 -0
- package/src/github/attachments.test.ts +248 -0
- package/src/github/attachments.ts +328 -0
- package/src/github/failure-summary.test.ts +636 -0
- package/src/github/failure-summary.ts +720 -0
- package/src/github/index.test.ts +24 -0
- package/src/github/index.ts +35 -0
- package/src/github/report-paths.test.ts +222 -0
- package/src/github/report-paths.ts +208 -0
- package/src/hover.test.ts +76 -0
- package/src/hover.ts +64 -0
- package/src/images.test.ts +355 -0
- package/src/images.ts +299 -0
- package/src/index.ts +13 -0
- package/src/interaction-states.test.ts +48 -0
- package/src/interaction-states.ts +94 -0
- package/src/mock/index.ts +1 -0
- package/src/mock/youtube.test.ts +38 -0
- package/src/mock/youtube.ts +39 -0
- package/src/pseudo-state.test.ts +83 -0
- package/src/pseudo-state.ts +69 -0
- package/src/videos.test.ts +637 -0
- package/src/videos.ts +389 -0
- package/src/visualdiff.test.ts +452 -0
- 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
|
+
}
|