@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.
- package/bin/github-a11y-summary +2 -2
- package/bin/github-failure-summary +4 -4
- package/lib/accessibility-baseline-file.d.ts +6 -6
- package/lib/accessibility-baseline-file.js +29 -23
- package/lib/accessibility-baseline.js +5 -3
- package/lib/accessible-screenshot.d.ts +4 -4
- package/lib/accessible-screenshot.js +110 -88
- package/lib/focus.js +4 -1
- package/lib/frames.js +4 -4
- package/lib/github/a11y-summary.js +74 -79
- package/lib/github/attachments.d.ts +1 -1
- package/lib/github/attachments.js +36 -34
- package/lib/github/failure-summary.d.ts +4 -4
- package/lib/github/failure-summary.js +140 -108
- package/lib/github/index.d.ts +6 -6
- package/lib/github/report-paths.js +17 -9
- package/lib/hover.js +17 -17
- package/lib/images.js +15 -12
- package/lib/index.d.ts +13 -13
- package/lib/interaction-states.d.ts +2 -2
- package/lib/interaction-states.js +7 -7
- package/lib/mock/index.d.ts +1 -1
- package/lib/mock/youtube.d.ts +1 -1
- package/lib/mock/youtube.js +1 -1
- package/lib/pseudo-state.d.ts +2 -2
- package/lib/pseudo-state.js +15 -7
- package/lib/videos.js +21 -10
- package/lib/visualdiff.d.ts +4 -4
- package/lib/visualdiff.js +35 -27
- package/package.json +3 -3
- package/src/accessibility-baseline-file.test.ts +190 -154
- package/src/accessibility-baseline-file.ts +107 -83
- package/src/accessibility-baseline.test.ts +869 -433
- package/src/accessibility-baseline.ts +41 -21
- package/src/accessible-screenshot.test.ts +551 -423
- package/src/accessible-screenshot.ts +358 -251
- package/src/focus.test.ts +23 -23
- package/src/focus.ts +7 -2
- package/src/fonts.test.ts +13 -13
- package/src/fonts.ts +1 -1
- package/src/frames.test.ts +10 -8
- package/src/frames.ts +5 -5
- package/src/github/a11y-summary.test.ts +436 -332
- package/src/github/a11y-summary.ts +189 -179
- package/src/github/attachments.test.ts +228 -167
- package/src/github/attachments.ts +144 -121
- package/src/github/failure-summary.test.ts +660 -445
- package/src/github/failure-summary.ts +376 -274
- package/src/github/index.test.ts +14 -14
- package/src/github/index.ts +6 -6
- package/src/github/report-paths.test.ts +211 -156
- package/src/github/report-paths.ts +75 -61
- package/src/hover.test.ts +50 -46
- package/src/hover.ts +18 -18
- package/src/images.test.ts +251 -206
- package/src/images.ts +52 -43
- package/src/index.ts +13 -13
- package/src/interaction-states.test.ts +72 -42
- package/src/interaction-states.ts +43 -29
- package/src/mock/index.ts +1 -1
- package/src/mock/youtube.test.ts +26 -26
- package/src/mock/youtube.ts +4 -6
- package/src/pseudo-state.test.ts +58 -51
- package/src/pseudo-state.ts +32 -27
- package/src/videos.test.ts +559 -399
- package/src/videos.ts +68 -44
- package/src/visualdiff.test.ts +416 -315
- package/src/visualdiff.ts +140 -99
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import * as fs from
|
|
2
|
-
import * as path from
|
|
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 =
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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: {
|
|
60
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
129
|
-
|
|
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 =
|
|
141
|
+
this.disabled = true;
|
|
142
|
+
this.reason = "no upload token configured";
|
|
134
143
|
} else if (!this.repositoryId) {
|
|
135
|
-
this.disabled = true
|
|
136
|
-
this.reason =
|
|
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(
|
|
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(
|
|
190
|
+
return this.skip(
|
|
191
|
+
"unreadable",
|
|
192
|
+
`Attachment upload skipped, unreadable file: ${filePath}`,
|
|
193
|
+
);
|
|
182
194
|
}
|
|
183
195
|
|
|
184
|
-
return this.uploadBuffer(
|
|
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(
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 ===
|
|
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,
|
|
301
|
-
if (!collapsed) return
|
|
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] ===
|
|
316
|
-
while (end > start && value[end - 1] ===
|
|
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,
|
|
327
|
-
return trimDashes(cleaned) ||
|
|
349
|
+
const cleaned = name.replace(/[^A-Za-z0-9._-]+/g, "-").slice(0, 120);
|
|
350
|
+
return trimDashes(cleaned) || "attachment";
|
|
328
351
|
}
|