squally-reporter 0.2.1 → 0.3.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 +39 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +303 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,6 +50,45 @@ reporter: [
|
|
|
50
50
|
That's it - run your tests as usual (`npx playwright test`) and results will
|
|
51
51
|
be sent to Squally after the run finishes.
|
|
52
52
|
|
|
53
|
+
## Screenshot/trace upload
|
|
54
|
+
|
|
55
|
+
If your `playwright.config.ts` captures screenshots or traces, the reporter
|
|
56
|
+
uploads them to Squally so they show up next to the failing test in the
|
|
57
|
+
dashboard - no separate setup beyond the two env vars above.
|
|
58
|
+
|
|
59
|
+
**Only from attempts worth looking at:** screenshots/traces are uploaded
|
|
60
|
+
only for attempts that **failed**, or that **needed a retry** (even if that
|
|
61
|
+
retry then passed) - never for an attempt that passed on its first try,
|
|
62
|
+
regardless of how `screenshot`/`trace` are configured. Nobody looks at a
|
|
63
|
+
screenshot of a test that just passed, and uploading one anyway would only
|
|
64
|
+
eat into your organization's storage for no benefit.
|
|
65
|
+
|
|
66
|
+
**You need to actually enable capture in `playwright.config.ts`** - without
|
|
67
|
+
it, Playwright doesn't produce any attachments, so there's nothing for the
|
|
68
|
+
reporter to find or upload (no error either way, it's a silent no-op):
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
export default defineConfig({
|
|
72
|
+
use: {
|
|
73
|
+
screenshot: "only-on-failure",
|
|
74
|
+
trace: "retain-on-failure",
|
|
75
|
+
},
|
|
76
|
+
// ...
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Limits:**
|
|
81
|
+
- 50 MB per individual file. Larger files are skipped (logged as a
|
|
82
|
+
`console.warn`, the test result itself is still reported as usual).
|
|
83
|
+
- A total storage cap per organization, depending on your Squally plan.
|
|
84
|
+
Once reached, further uploads are skipped the same way until you either
|
|
85
|
+
free up space or upgrade.
|
|
86
|
+
|
|
87
|
+
Upload is best-effort and fully non-blocking, same principle as the rest of
|
|
88
|
+
this reporter - a failed or skipped upload never fails your test run or
|
|
89
|
+
changes its exit code, it only logs a `console.warn` explaining what
|
|
90
|
+
happened.
|
|
91
|
+
|
|
53
92
|
## Tested revision (optional)
|
|
54
93
|
|
|
55
94
|
If your test-automation code lives in the same repo as the application it
|
package/dist/index.d.ts
CHANGED
|
@@ -6,11 +6,15 @@ interface SquallyReporterOptions {
|
|
|
6
6
|
export default class SquallyReporter implements Reporter {
|
|
7
7
|
private apiUrl;
|
|
8
8
|
private apiKey;
|
|
9
|
+
private enabled;
|
|
9
10
|
private startedAt;
|
|
10
11
|
private results;
|
|
12
|
+
private artifacts;
|
|
11
13
|
constructor(options?: SquallyReporterOptions);
|
|
12
14
|
onBegin(_config: FullConfig, _suite: Suite): void;
|
|
13
15
|
onTestEnd(test: TestCase, result: TestResult): void;
|
|
14
16
|
onEnd(result: FullResult): Promise<void>;
|
|
17
|
+
private uploadArtifacts;
|
|
18
|
+
private presignAndUpload;
|
|
15
19
|
}
|
|
16
20
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
const node_child_process_1 = require("node:child_process");
|
|
7
|
+
const node_fs_1 = require("node:fs");
|
|
8
|
+
const promises_1 = require("node:fs/promises");
|
|
7
9
|
const node_path_1 = __importDefault(require("node:path"));
|
|
8
10
|
const undici_1 = require("undici");
|
|
9
11
|
// Maps Playwright's more granular statuses down to the three
|
|
@@ -143,6 +145,29 @@ function detectEventType() {
|
|
|
143
145
|
function toPosixPath(p) {
|
|
144
146
|
return p.split(node_path_1.default.sep).join("/");
|
|
145
147
|
}
|
|
148
|
+
// Playwright own assertion errors (expect(...).toHaveText(...) etc.) are
|
|
149
|
+
// colorized with ANSI escape codes in error.message/error.stack - useful in
|
|
150
|
+
// a terminal, but rendered as visible garbage once stored as plain text and
|
|
151
|
+
// shown in Squally UI. Test code that colors its own console output (e.g.
|
|
152
|
+
// via chalk/picocolors) can do the same to stdout/stderr. Stripped here,
|
|
153
|
+
// before anything is ever sent - not server/UI-side - so the raw escape
|
|
154
|
+
// codes never land in the database in the first place, and every later
|
|
155
|
+
// consumer (the existing keyword-based error categorization, the planned
|
|
156
|
+
// AI analysis, a fingerprint cache over normalized error signatures)
|
|
157
|
+
// does not have to strip them again itself.
|
|
158
|
+
//
|
|
159
|
+
// Matches the CSI form ANSI color/style codes actually take: an ESC
|
|
160
|
+
// character (code point 27), then an opening bracket, then any run of
|
|
161
|
+
// digits/semicolons, then one letter (e.g. ESC, bracket, 3, 1, m for red).
|
|
162
|
+
// No new dependency (e.g. strip-ansi) for a one-line regex, same reasoning
|
|
163
|
+
// as toPosixPath/the upload worker pool above. Built via String.fromCharCode
|
|
164
|
+
// rather than a hex escape directly in the source: this file has to stay
|
|
165
|
+
// plain, readable ASCII - a raw ESC byte pasted into source code is
|
|
166
|
+
// invisible in most editors/diffs and easy to mistake for empty content.
|
|
167
|
+
function stripAnsi(value) {
|
|
168
|
+
const ansiPattern = new RegExp(String.fromCharCode(27) + String.fromCharCode(92) + "[[0-9;]*[a-zA-Z]", "g");
|
|
169
|
+
return value.replace(ansiPattern, "");
|
|
170
|
+
}
|
|
146
171
|
// Playwright captures stdout/stderr as two separate arrays (each internally
|
|
147
172
|
// chronological, but not merged with each other), so a true byte-accurate
|
|
148
173
|
// interleaving between the two streams isn't available here - stdout lines
|
|
@@ -152,43 +177,164 @@ function toPosixPath(p) {
|
|
|
152
177
|
function formatConsoleOutput(result) {
|
|
153
178
|
const lines = [];
|
|
154
179
|
for (const chunk of result.stdout) {
|
|
155
|
-
lines.push(`[log] ${chunk.toString().replace(/\s+$/, "")}`);
|
|
180
|
+
lines.push(`[log] ${stripAnsi(chunk.toString()).replace(/\s+$/, "")}`);
|
|
156
181
|
}
|
|
157
182
|
for (const chunk of result.stderr) {
|
|
158
|
-
lines.push(`[error] ${chunk.toString().replace(/\s+$/, "")}`);
|
|
183
|
+
lines.push(`[error] ${stripAnsi(chunk.toString()).replace(/\s+$/, "")}`);
|
|
159
184
|
}
|
|
160
185
|
return lines.length > 0 ? lines.join("\n") : null;
|
|
161
186
|
}
|
|
187
|
+
// Presigned upload URLs are valid for 15 minutes (server-side, see
|
|
188
|
+
// squally-app's lib/r2.ts). Uploading fully sequentially could get close to
|
|
189
|
+
// that window for a suite with a very large number of failing/retried
|
|
190
|
+
// tests, but firing everything at once isn't right either - CI runners
|
|
191
|
+
// often have modest outbound bandwidth/file-descriptor limits, and a
|
|
192
|
+
// thundering herd of large trace uploads competes with the test run itself
|
|
193
|
+
// for those resources. 5 concurrent uploads is a pragmatic middle ground:
|
|
194
|
+
// fast enough that even a few hundred artifacts finish in well under 15
|
|
195
|
+
// minutes, bounded enough not to overwhelm a typical CI runner.
|
|
196
|
+
const UPLOAD_CONCURRENCY = 5;
|
|
197
|
+
// No new dependency for this - a fixed-size worker pool that keeps pulling
|
|
198
|
+
// the next item until the list is exhausted is ~10 lines, not worth pulling
|
|
199
|
+
// in e.g. p-limit for.
|
|
200
|
+
async function runWithConcurrencyLimit(items, limit, worker) {
|
|
201
|
+
let nextIndex = 0;
|
|
202
|
+
async function runNext() {
|
|
203
|
+
const currentIndex = nextIndex++;
|
|
204
|
+
if (currentIndex >= items.length)
|
|
205
|
+
return;
|
|
206
|
+
await worker(items[currentIndex]);
|
|
207
|
+
return runNext();
|
|
208
|
+
}
|
|
209
|
+
const workerCount = Math.min(limit, items.length);
|
|
210
|
+
await Promise.all(Array.from({ length: workerCount }, () => runNext()));
|
|
211
|
+
}
|
|
212
|
+
// Only screenshots/traces from attempts worth looking at get uploaded -
|
|
213
|
+
// deliberately independent of how the customer configured
|
|
214
|
+
// screenshot/trace in playwright.config.ts. A "screenshot: 'on'" config
|
|
215
|
+
// would otherwise attach an image to every single passing test too: nobody
|
|
216
|
+
// opens a screenshot of a test that just passed, and every one of those
|
|
217
|
+
// uploads eats into the organization's artifact cap (Free tier: 1 GB) for
|
|
218
|
+
// zero benefit. "failed" already covers Playwright's timedOut/interrupted
|
|
219
|
+
// (mapStatus above collapses those down) - retryCount > 0 additionally
|
|
220
|
+
// covers a passed-on-retry attempt, since seeing what an earlier, failing
|
|
221
|
+
// attempt looked like is still useful even if the test eventually passed.
|
|
222
|
+
function attemptQualifiesForArtifacts(mappedStatus, retryCount) {
|
|
223
|
+
return mappedStatus === "failed" || retryCount > 0;
|
|
224
|
+
}
|
|
225
|
+
// A control character as separator, not an empty string or a slash -
|
|
226
|
+
// testName/filePath can contain arbitrary characters, and this key only
|
|
227
|
+
// needs to be unique, not human-readable (it is purely an internal lookup
|
|
228
|
+
// back to the local CollectedArtifact once the server round-trips these
|
|
229
|
+
// identity fields).
|
|
230
|
+
function artifactKey(testName, filePath, retryCount, type) {
|
|
231
|
+
return [testName, filePath, String(retryCount), type].join(String.fromCharCode(1));
|
|
232
|
+
}
|
|
233
|
+
function skipReasonMessage(reason) {
|
|
234
|
+
if (reason === "file_too_large")
|
|
235
|
+
return "file_too_large (limit 50 MB)";
|
|
236
|
+
if (reason === "cap_exceeded")
|
|
237
|
+
return "cap_exceeded (storage limit reached, upgrade your plan)";
|
|
238
|
+
if (reason === "not_found")
|
|
239
|
+
return "not_found (test result not found on server)";
|
|
240
|
+
return reason;
|
|
241
|
+
}
|
|
242
|
+
function confirmReasonMessage(reason) {
|
|
243
|
+
if (reason === "not_uploaded")
|
|
244
|
+
return "not_uploaded (upload did not reach storage)";
|
|
245
|
+
if (reason === "not_found")
|
|
246
|
+
return "not_found (test result not found on server)";
|
|
247
|
+
return reason;
|
|
248
|
+
}
|
|
249
|
+
function artifactLabel(type) {
|
|
250
|
+
return type === "screenshot" ? "Screenshot" : "Trace";
|
|
251
|
+
}
|
|
162
252
|
class SquallyReporter {
|
|
163
253
|
constructor(options = {}) {
|
|
254
|
+
this.apiUrl = "";
|
|
255
|
+
this.apiKey = "";
|
|
256
|
+
// Missing/invalid config must never take down the caller's whole test
|
|
257
|
+
// suite over just a reporting integration - see the constructor below.
|
|
258
|
+
// Every onXxx hook checks this first and no-ops if it's false.
|
|
259
|
+
this.enabled = false;
|
|
164
260
|
this.startedAt = new Date();
|
|
165
261
|
this.results = [];
|
|
262
|
+
this.artifacts = [];
|
|
166
263
|
const apiUrl = options.apiUrl ?? process.env.SQUALLY_API_URL;
|
|
167
264
|
const apiKey = options.apiKey ?? process.env.SQUALLY_API_KEY;
|
|
168
265
|
if (!apiUrl || !apiKey) {
|
|
169
|
-
|
|
170
|
-
|
|
266
|
+
// Deliberately NOT thrown - a missing/wrong Squally API key must
|
|
267
|
+
// only disable reporting to Squally, not fail the caller's entire
|
|
268
|
+
// CI pipeline before a single test has even run. Warn loudly
|
|
269
|
+
// instead and continue as a no-op reporter.
|
|
270
|
+
console.warn("[squally-reporter] no API key configured, skipping report - set " +
|
|
271
|
+
"SQUALLY_API_URL/SQUALLY_API_KEY as env vars, or pass apiUrl/apiKey " +
|
|
272
|
+
"in playwright.config.ts");
|
|
273
|
+
return;
|
|
171
274
|
}
|
|
172
275
|
this.apiUrl = apiUrl.replace(/\/$/, ""); // strip trailing slash if present
|
|
173
276
|
this.apiKey = apiKey;
|
|
277
|
+
this.enabled = true;
|
|
174
278
|
}
|
|
175
279
|
onBegin(_config, _suite) {
|
|
280
|
+
if (!this.enabled)
|
|
281
|
+
return;
|
|
176
282
|
this.startedAt = new Date();
|
|
177
283
|
}
|
|
178
284
|
onTestEnd(test, result) {
|
|
285
|
+
if (!this.enabled)
|
|
286
|
+
return;
|
|
179
287
|
const error = result.errors[0];
|
|
288
|
+
const testName = test.titlePath().slice(1).join(" > ");
|
|
289
|
+
const filePath = toPosixPath(node_path_1.default.relative(process.cwd(), test.location.file));
|
|
290
|
+
const status = mapStatus(result.status);
|
|
180
291
|
this.results.push({
|
|
181
|
-
testName
|
|
182
|
-
filePath
|
|
183
|
-
status
|
|
292
|
+
testName,
|
|
293
|
+
filePath,
|
|
294
|
+
status,
|
|
184
295
|
durationMs: result.duration,
|
|
185
296
|
retryCount: result.retry,
|
|
186
|
-
errorMessage: error?.message
|
|
187
|
-
errorStack: error?.stack
|
|
297
|
+
errorMessage: error?.message ? stripAnsi(error.message) : null,
|
|
298
|
+
errorStack: error?.stack ? stripAnsi(error.stack) : null,
|
|
188
299
|
consoleOutput: formatConsoleOutput(result),
|
|
189
300
|
});
|
|
301
|
+
if (!attemptQualifiesForArtifacts(status, result.retry))
|
|
302
|
+
return;
|
|
303
|
+
for (const attachment of result.attachments) {
|
|
304
|
+
if (attachment.name !== "screenshot" && attachment.name !== "trace")
|
|
305
|
+
continue;
|
|
306
|
+
// Attachments can be in-memory (body, no path) when added manually via
|
|
307
|
+
// testInfo.attach() - only file-backed ones (screenshot/trace always
|
|
308
|
+
// are, when Playwright itself captures them) can be uploaded here.
|
|
309
|
+
if (!attachment.path)
|
|
310
|
+
continue;
|
|
311
|
+
// Synchronous, not fs/promises - onTestEnd's return value isn't
|
|
312
|
+
// awaited by Playwright (Reporter['onTestEnd'] is typed as void, not
|
|
313
|
+
// Promise<void>), so an async stat call here would race with
|
|
314
|
+
// onEnd() reading this.artifacts before it resolved.
|
|
315
|
+
let sizeBytes;
|
|
316
|
+
try {
|
|
317
|
+
sizeBytes = (0, node_fs_1.statSync)(attachment.path).size;
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
// File already gone (e.g. cleaned up by a parallel worker) -
|
|
321
|
+
// nothing to upload, silently skip rather than warn for what's an
|
|
322
|
+
// expected, harmless race in some CI setups.
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
this.artifacts.push({
|
|
326
|
+
testName,
|
|
327
|
+
filePath,
|
|
328
|
+
retryCount: result.retry,
|
|
329
|
+
type: attachment.name,
|
|
330
|
+
localPath: attachment.path,
|
|
331
|
+
sizeBytes,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
190
334
|
}
|
|
191
335
|
async onEnd(result) {
|
|
336
|
+
if (!this.enabled)
|
|
337
|
+
return;
|
|
192
338
|
// A run with 0 test results is meaningless for Squally's flakiness
|
|
193
339
|
// calculation and would just be dashboard clutter - regardless of why
|
|
194
340
|
// there were none (npx playwright test --list, an empty testDir, a
|
|
@@ -223,26 +369,166 @@ class SquallyReporter {
|
|
|
223
369
|
// copy when passing our Agent as the dispatcher.
|
|
224
370
|
const dispatcher = new undici_1.Agent({ keepAliveTimeout: 1, keepAliveMaxTimeout: 1 });
|
|
225
371
|
try {
|
|
226
|
-
|
|
372
|
+
let runId = null;
|
|
373
|
+
try {
|
|
374
|
+
const response = await (0, undici_1.fetch)(`${this.apiUrl}/api/ingest`, {
|
|
375
|
+
method: "POST",
|
|
376
|
+
headers: {
|
|
377
|
+
"Content-Type": "application/json",
|
|
378
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
379
|
+
},
|
|
380
|
+
body: JSON.stringify(payload),
|
|
381
|
+
dispatcher,
|
|
382
|
+
});
|
|
383
|
+
if (!response.ok) {
|
|
384
|
+
// Never throw here - a failing reporter upload should not fail the
|
|
385
|
+
// whole CI run just because the dashboard was unreachable.
|
|
386
|
+
console.error(`[squally-reporter] Ingest failed: ${response.status} ${response.statusText}`);
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
const body = (await response.json());
|
|
390
|
+
runId = body.runId ?? null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
console.error("[squally-reporter] Failed to reach Squally API:", err);
|
|
395
|
+
}
|
|
396
|
+
// Artifact upload is a separate, best-effort step layered on top of
|
|
397
|
+
// ingest, never a reason to fail it: it needs a runId (presign looks
|
|
398
|
+
// TestResults up by runId + identity fields, which only exist once
|
|
399
|
+
// ingest has actually inserted them), and there's nothing to do if
|
|
400
|
+
// this run had no failing/retried attempts with a screenshot/trace
|
|
401
|
+
// attached (attemptQualifiesForArtifacts already filtered that in
|
|
402
|
+
// onTestEnd) - no reason to make an empty presign call.
|
|
403
|
+
if (runId && this.artifacts.length > 0) {
|
|
404
|
+
await this.uploadArtifacts(runId, dispatcher);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
finally {
|
|
408
|
+
await dispatcher.close();
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Top-level safety net - presignAndUpload already handles presign/
|
|
412
|
+
// upload/confirm failures individually (see Aufgabe 5's requirement that
|
|
413
|
+
// none of this ever affects the test suite's exit code), but this catch
|
|
414
|
+
// still exists in case something in there throws in a way that wasn't
|
|
415
|
+
// anticipated. The whole artifact feature must be additive: worst case,
|
|
416
|
+
// some screenshots/traces don't make it to Squally, but the customer's
|
|
417
|
+
// CI run itself is never at risk.
|
|
418
|
+
async uploadArtifacts(runId, dispatcher) {
|
|
419
|
+
try {
|
|
420
|
+
await this.presignAndUpload(runId, dispatcher);
|
|
421
|
+
}
|
|
422
|
+
catch (err) {
|
|
423
|
+
console.warn("[squally-reporter] Artifact upload failed unexpectedly:", err);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
async presignAndUpload(runId, dispatcher) {
|
|
427
|
+
const presignArtifacts = this.artifacts.map((artifact) => ({
|
|
428
|
+
testName: artifact.testName,
|
|
429
|
+
filePath: artifact.filePath,
|
|
430
|
+
retryCount: artifact.retryCount,
|
|
431
|
+
type: artifact.type,
|
|
432
|
+
sizeBytes: artifact.sizeBytes,
|
|
433
|
+
}));
|
|
434
|
+
let presignResults;
|
|
435
|
+
try {
|
|
436
|
+
const response = await (0, undici_1.fetch)(`${this.apiUrl}/api/artifacts/presign`, {
|
|
227
437
|
method: "POST",
|
|
228
438
|
headers: {
|
|
229
439
|
"Content-Type": "application/json",
|
|
230
440
|
Authorization: `Bearer ${this.apiKey}`,
|
|
231
441
|
},
|
|
232
|
-
body: JSON.stringify(
|
|
442
|
+
body: JSON.stringify({ runId, artifacts: presignArtifacts }),
|
|
233
443
|
dispatcher,
|
|
234
444
|
});
|
|
235
445
|
if (!response.ok) {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
console.error(`[squally-reporter] Ingest failed: ${response.status} ${response.statusText}`);
|
|
446
|
+
console.warn(`[squally-reporter] Artifact presign failed: ${response.status} ${response.statusText} - skipping artifact upload for this run`);
|
|
447
|
+
return;
|
|
239
448
|
}
|
|
449
|
+
const body = (await response.json());
|
|
450
|
+
presignResults = body.results;
|
|
240
451
|
}
|
|
241
452
|
catch (err) {
|
|
242
|
-
console.
|
|
453
|
+
console.warn("[squally-reporter] Artifact presign request failed:", err);
|
|
454
|
+
return;
|
|
243
455
|
}
|
|
244
|
-
|
|
245
|
-
|
|
456
|
+
// Maps a presign result back to the local file it came from -
|
|
457
|
+
// testName/filePath/retryCount/type is exactly what was sent, so it's
|
|
458
|
+
// guaranteed to match one of this.artifacts (barring a server bug),
|
|
459
|
+
// but a missing lookup is handled defensively below instead of assumed.
|
|
460
|
+
const artifactsByKey = new Map(this.artifacts.map((artifact) => [
|
|
461
|
+
artifactKey(artifact.testName, artifact.filePath, artifact.retryCount, artifact.type),
|
|
462
|
+
artifact,
|
|
463
|
+
]));
|
|
464
|
+
const uploads = [];
|
|
465
|
+
for (const item of presignResults) {
|
|
466
|
+
const local = artifactsByKey.get(artifactKey(item.testName, item.filePath ?? "", item.retryCount, item.type));
|
|
467
|
+
if (!local)
|
|
468
|
+
continue;
|
|
469
|
+
if (item.skip) {
|
|
470
|
+
console.warn(`[squally-reporter] ${artifactLabel(item.type)} for '${item.testName}' skipped: ${skipReasonMessage(item.reason)}`);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
uploads.push({ artifact: local, uploadUrl: item.uploadUrl });
|
|
474
|
+
}
|
|
475
|
+
if (uploads.length === 0)
|
|
476
|
+
return;
|
|
477
|
+
// See UPLOAD_CONCURRENCY's own comment above for why this is bounded
|
|
478
|
+
// rather than either fully sequential or fully parallel.
|
|
479
|
+
await runWithConcurrencyLimit(uploads, UPLOAD_CONCURRENCY, async ({ artifact, uploadUrl }) => {
|
|
480
|
+
try {
|
|
481
|
+
const fileBody = await (0, promises_1.readFile)(artifact.localPath);
|
|
482
|
+
const response = await (0, undici_1.fetch)(uploadUrl, {
|
|
483
|
+
method: "PUT",
|
|
484
|
+
body: fileBody,
|
|
485
|
+
dispatcher,
|
|
486
|
+
});
|
|
487
|
+
if (!response.ok) {
|
|
488
|
+
console.warn(`[squally-reporter] Upload failed for ${artifact.type} of '${artifact.testName}': ${response.status} ${response.statusText}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
console.warn(`[squally-reporter] Upload failed for ${artifact.type} of '${artifact.testName}':`, err);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
// Confirm every artifact that was actually presigned (not the ones
|
|
496
|
+
// presign already skipped - those never got a key, and re-checking
|
|
497
|
+
// them here would just produce a confusing second "not_uploaded"
|
|
498
|
+
// warning for something that was never attempted in the first place).
|
|
499
|
+
// Confirm's own headObject check is the source of truth for whether an
|
|
500
|
+
// upload really landed - deliberately not tracking PUT success/failure
|
|
501
|
+
// above to decide what to confirm, since a PUT that reported success
|
|
502
|
+
// but didn't truly land (or vice versa) should still be caught here.
|
|
503
|
+
const confirmArtifacts = uploads.map(({ artifact }) => ({
|
|
504
|
+
testName: artifact.testName,
|
|
505
|
+
filePath: artifact.filePath,
|
|
506
|
+
retryCount: artifact.retryCount,
|
|
507
|
+
type: artifact.type,
|
|
508
|
+
}));
|
|
509
|
+
try {
|
|
510
|
+
const response = await (0, undici_1.fetch)(`${this.apiUrl}/api/artifacts/confirm`, {
|
|
511
|
+
method: "POST",
|
|
512
|
+
headers: {
|
|
513
|
+
"Content-Type": "application/json",
|
|
514
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
515
|
+
},
|
|
516
|
+
body: JSON.stringify({ runId, artifacts: confirmArtifacts }),
|
|
517
|
+
dispatcher,
|
|
518
|
+
});
|
|
519
|
+
if (!response.ok) {
|
|
520
|
+
console.warn(`[squally-reporter] Artifact confirm failed: ${response.status} ${response.statusText}`);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const body = (await response.json());
|
|
524
|
+
for (const item of body.results) {
|
|
525
|
+
if (item.confirmed)
|
|
526
|
+
continue;
|
|
527
|
+
console.warn(`[squally-reporter] ${artifactLabel(item.type)} for '${item.testName}' not confirmed: ${confirmReasonMessage(item.reason)}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
console.warn("[squally-reporter] Artifact confirm request failed:", err);
|
|
246
532
|
}
|
|
247
533
|
}
|
|
248
534
|
}
|