squally-reporter 0.2.2 → 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 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
@@ -9,9 +9,12 @@ export default class SquallyReporter implements Reporter {
9
9
  private enabled;
10
10
  private startedAt;
11
11
  private results;
12
+ private artifacts;
12
13
  constructor(options?: SquallyReporterOptions);
13
14
  onBegin(_config: FullConfig, _suite: Suite): void;
14
15
  onTestEnd(test: TestCase, result: TestResult): void;
15
16
  onEnd(result: FullResult): Promise<void>;
17
+ private uploadArtifacts;
18
+ private presignAndUpload;
16
19
  }
17
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,13 +177,78 @@ 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 = {}) {
164
254
  this.apiUrl = "";
@@ -169,6 +259,7 @@ class SquallyReporter {
169
259
  this.enabled = false;
170
260
  this.startedAt = new Date();
171
261
  this.results = [];
262
+ this.artifacts = [];
172
263
  const apiUrl = options.apiUrl ?? process.env.SQUALLY_API_URL;
173
264
  const apiKey = options.apiKey ?? process.env.SQUALLY_API_KEY;
174
265
  if (!apiUrl || !apiKey) {
@@ -194,16 +285,52 @@ class SquallyReporter {
194
285
  if (!this.enabled)
195
286
  return;
196
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);
197
291
  this.results.push({
198
- testName: test.titlePath().slice(1).join(" > "),
199
- filePath: toPosixPath(node_path_1.default.relative(process.cwd(), test.location.file)),
200
- status: mapStatus(result.status),
292
+ testName,
293
+ filePath,
294
+ status,
201
295
  durationMs: result.duration,
202
296
  retryCount: result.retry,
203
- errorMessage: error?.message ?? null,
204
- errorStack: error?.stack ?? null,
297
+ errorMessage: error?.message ? stripAnsi(error.message) : null,
298
+ errorStack: error?.stack ? stripAnsi(error.stack) : null,
205
299
  consoleOutput: formatConsoleOutput(result),
206
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
+ }
207
334
  }
208
335
  async onEnd(result) {
209
336
  if (!this.enabled)
@@ -242,26 +369,166 @@ class SquallyReporter {
242
369
  // copy when passing our Agent as the dispatcher.
243
370
  const dispatcher = new undici_1.Agent({ keepAliveTimeout: 1, keepAliveMaxTimeout: 1 });
244
371
  try {
245
- const response = await (0, undici_1.fetch)(`${this.apiUrl}/api/ingest`, {
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`, {
246
437
  method: "POST",
247
438
  headers: {
248
439
  "Content-Type": "application/json",
249
440
  Authorization: `Bearer ${this.apiKey}`,
250
441
  },
251
- body: JSON.stringify(payload),
442
+ body: JSON.stringify({ runId, artifacts: presignArtifacts }),
252
443
  dispatcher,
253
444
  });
254
445
  if (!response.ok) {
255
- // Never throw here - a failing reporter upload should not fail the
256
- // whole CI run just because the dashboard was unreachable.
257
- 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;
258
448
  }
449
+ const body = (await response.json());
450
+ presignResults = body.results;
259
451
  }
260
452
  catch (err) {
261
- console.error("[squally-reporter] Failed to reach Squally API:", err);
453
+ console.warn("[squally-reporter] Artifact presign request failed:", err);
454
+ return;
262
455
  }
263
- finally {
264
- await dispatcher.close();
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);
265
532
  }
266
533
  }
267
534
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squally-reporter",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Playwright reporter that sends test results to your Squally dashboard.",
5
5
  "author": "Squally <squally.dev@gmail.com>",
6
6
  "keywords": [