squally-reporter 0.2.2 → 0.4.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
@@ -1,82 +1,211 @@
1
- # squally-reporter
2
-
3
- Playwright reporter that sends test results to your Squally dashboard.
4
-
5
- Learn more at [squally-app.vercel.app](https://squally-app.vercel.app).
6
-
7
- ## Requirements
8
-
9
- - Node.js >= 22.19.0 (required by the `undici` dependency)
10
- - `@playwright/test` >= 1.30.0
11
-
12
- ## Install
13
-
14
- ```bash
15
- npm install squally-reporter
16
- ```
17
-
18
- ## Config
19
-
20
- Set two env vars (e.g. in CI secrets or `.env`):
21
-
22
- ```bash
23
- SQUALLY_API_URL=https://squally-app.vercel.app
24
- SQUALLY_API_KEY=your-project-api-key
25
- ```
26
-
27
- Find your API key on your project's Settings page in the Squally dashboard.
28
-
29
- Then add the reporter to `playwright.config.ts`:
30
-
31
- ```ts
32
- import { defineConfig } from "@playwright/test";
33
-
34
- export default defineConfig({
35
- reporter: [
36
- ["list"],
37
- ["squally-reporter"],
38
- ],
39
- });
40
- ```
41
-
42
- Alternatively, pass config explicitly instead of using env vars:
43
-
44
- ```ts
45
- reporter: [
46
- ["squally-reporter", { apiUrl: "https://squally-app.vercel.app", apiKey: "..." }],
47
- ],
48
- ```
49
-
50
- That's it - run your tests as usual (`npx playwright test`) and results will
51
- be sent to Squally after the run finishes.
52
-
53
- ## Tested revision (optional)
54
-
55
- If your test-automation code lives in the same repo as the application it
56
- tests, Squally can already tell which commit was tested from the CI
57
- provider's own commit env vars. If they're **separate repos** (e.g. a
58
- dedicated E2E-test repo pointed at a deployed app), set one of the following
59
- so Squally knows which application state was actually tested:
60
-
61
- ```bash
62
- # Same repo as the application under test - Squally will trust this CI
63
- # job's own commit (GITHUB_SHA / CI_COMMIT_SHA) as the tested revision.
64
- SAME_REPO_AS_APP=true
65
-
66
- # Separate repos - pass the tested revision explicitly (a commit SHA, tag,
67
- # or branch name/ref).
68
- TESTED_REVISION=v1.2.0
69
- ```
70
-
71
- Neither variable is required - without them, test results are still sent
72
- and stored exactly as before.
73
-
74
- ## Support
75
-
76
- Found a bug or have a question? Please open an issue on
77
- [GitHub Issues](https://github.com/squallydev-admin/squally-reporter/issues) -
78
- that's the primary way to report problems or ask questions.
79
-
80
- ## License
81
-
82
- MIT - see [LICENSE](./LICENSE).
1
+ # squally-reporter
2
+
3
+ Playwright reporter that sends test results to your Squally dashboard.
4
+
5
+ Learn more at [squally-app.vercel.app](https://squally-app.vercel.app).
6
+
7
+ ## Requirements
8
+
9
+ - Node.js >= 22.19.0 (required by the `undici` dependency)
10
+ - `@playwright/test` >= 1.30.0
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install -D squally-reporter
16
+ ```
17
+
18
+ ## Config
19
+
20
+ Set two env vars (e.g. in CI secrets or `.env`):
21
+
22
+ ```bash
23
+ SQUALLY_API_URL=https://squally-app.vercel.app
24
+ SQUALLY_API_KEY=your-project-api-key
25
+ ```
26
+
27
+ Find your API key on your project's Settings page in the Squally dashboard.
28
+
29
+ Then add the reporter to `playwright.config.ts`:
30
+
31
+ ```ts
32
+ import { defineConfig } from "@playwright/test";
33
+
34
+ export default defineConfig({
35
+ reporter: [
36
+ ["list"],
37
+ ["squally-reporter"],
38
+ ],
39
+ });
40
+ ```
41
+
42
+ Alternatively, pass config explicitly instead of using env vars:
43
+
44
+ ```ts
45
+ reporter: [
46
+ ["squally-reporter", { apiUrl: "https://squally-app.vercel.app", apiKey: "..." }],
47
+ ],
48
+ ```
49
+
50
+ That's it - run your tests as usual (`npx playwright test`) and results will
51
+ be sent to Squally after the run finishes.
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
+ - Two minutes per upload, and 30 seconds for the calls that surround it. An
87
+ unreachable or very slow server means a skipped upload and a `console.warn`,
88
+ not a CI job waiting indefinitely.
89
+
90
+ Upload is best-effort and fully non-blocking, same principle as the rest of
91
+ this reporter - a failed or skipped upload never fails your test run or
92
+ changes its exit code, it only logs a `console.warn` explaining what
93
+ happened.
94
+
95
+ ## Limits per run
96
+
97
+ Squally accepts at most **5,000 test results in a single run**. Beyond that the
98
+ whole report is rejected, so the reporter checks before sending and skips the
99
+ upload with a warning rather than letting the server discard it after the fact.
100
+ Nothing else about your test run is affected - this only means that one run
101
+ does not appear in the dashboard.
102
+
103
+ **Retries count separately.** Each attempt is its own result, so a suite of
104
+ 2,000 tests having a bad day reaches the ceiling sooner than the test count
105
+ suggests.
106
+
107
+ If you run into this, the practical way around it today is to let separate
108
+ parts of the suite report separately - for example as separate Playwright
109
+ projects or separate CI jobs, each producing its own run. Splitting one report
110
+ across several requests is not supported yet.
111
+
112
+ There is also a hard **~4.5 MB limit on the request itself**, coming from the
113
+ hosting platform rather than from Squally. Very large error messages, stack
114
+ traces or console output can hit it before the 5,000 mark; the reporter already
115
+ truncates each field to what the server accepts, which keeps normal runs well
116
+ clear of it.
117
+
118
+ ## Sharding
119
+
120
+ **Playwright's `--shard` is not merged automatically.** Each shard is a
121
+ separate process with its own reporter instance, so each one reports its own
122
+ run: a job split four ways shows up in Squally as four runs from the same
123
+ commit, each holding only its own slice of the suite. The reporter says so in
124
+ the log when it sees `--shard`.
125
+
126
+ What that does and does not affect:
127
+
128
+ - **Per-test flakiness stays correct.** A test lives in exactly one shard, so
129
+ it still contributes one data point per CI run, and its history, flip rate
130
+ and retry rate are unaffected.
131
+ - **Run-level numbers are off.** The run count, "runs per day", and each run's
132
+ own pass/fail (it only covers its slice) all reflect shards rather than CI
133
+ runs.
134
+
135
+ ### Reporting sharded runs as one run
136
+
137
+ Use Playwright's own blob reports: have each shard write a blob, then merge
138
+ them in one final job and let this reporter run on the merged result. That
139
+ produces exactly one run with every test in it.
140
+
141
+ ```yaml
142
+ test:
143
+ strategy:
144
+ matrix:
145
+ shard: [1, 2, 3, 4]
146
+ steps:
147
+ - uses: actions/checkout@v4
148
+ - run: npx playwright test --shard=${{ matrix.shard }}/4 --reporter=blob
149
+ - uses: actions/upload-artifact@v4
150
+ with:
151
+ name: blob-${{ matrix.shard }}
152
+ path: blob-report
153
+
154
+ merge:
155
+ needs: test
156
+ steps:
157
+ # Required - see the note below.
158
+ - uses: actions/checkout@v4
159
+ - uses: actions/download-artifact@v4
160
+ with:
161
+ pattern: blob-*
162
+ merge-multiple: true
163
+ path: all-blobs
164
+ - run: npx playwright merge-reports --reporter=squally-reporter ./all-blobs
165
+ env:
166
+ SQUALLY_API_URL: ${{ secrets.SQUALLY_API_URL }}
167
+ SQUALLY_API_KEY: ${{ secrets.SQUALLY_API_KEY }}
168
+ ```
169
+
170
+ **The merge job needs the repository checked out.** Branch, commit, commit
171
+ message and author are read on whichever machine the reporter runs on - during
172
+ a merge that is the merge job, not the shards. Without a checkout (and without
173
+ the CI provider's own commit variables) they all come out empty, and a run
174
+ without a branch is never counted as a run on your stable branch, which is what
175
+ Squally's broken/healthy verdicts are based on. `actions/checkout` in the merge
176
+ job is enough.
177
+
178
+ The run's start time is taken from the earliest test, not from when the
179
+ reporter started, so a merge that happens minutes after the tests still dates
180
+ the run correctly.
181
+
182
+ ## Tested revision (optional)
183
+
184
+ If your test-automation code lives in the same repo as the application it
185
+ tests, Squally can already tell which commit was tested from the CI
186
+ provider's own commit env vars. If they're **separate repos** (e.g. a
187
+ dedicated E2E-test repo pointed at a deployed app), set one of the following
188
+ so Squally knows which application state was actually tested:
189
+
190
+ ```bash
191
+ # Same repo as the application under test - Squally will trust this CI
192
+ # job's own commit (GITHUB_SHA / CI_COMMIT_SHA) as the tested revision.
193
+ SAME_REPO_AS_APP=true
194
+
195
+ # Separate repos - pass the tested revision explicitly (a commit SHA, tag,
196
+ # or branch name/ref).
197
+ TESTED_REVISION=v1.2.0
198
+ ```
199
+
200
+ Neither variable is required - without them, test results are still sent
201
+ and stored exactly as before.
202
+
203
+ ## Support
204
+
205
+ Found a bug or have a question? Please open an issue on
206
+ [GitHub Issues](https://github.com/squallydev-admin/squally-reporter/issues) -
207
+ that's the primary way to report problems or ask questions.
208
+
209
+ ## License
210
+
211
+ MIT - see [LICENSE](./LICENSE).
package/dist/index.d.ts CHANGED
@@ -9,9 +9,14 @@ export default class SquallyReporter implements Reporter {
9
9
  private enabled;
10
10
  private startedAt;
11
11
  private results;
12
+ private artifacts;
13
+ private earliestTestStart;
12
14
  constructor(options?: SquallyReporterOptions);
13
- onBegin(_config: FullConfig, _suite: Suite): void;
15
+ onBegin(config: FullConfig, _suite: Suite): void;
14
16
  onTestEnd(test: TestCase, result: TestResult): void;
15
17
  onEnd(result: FullResult): Promise<void>;
18
+ private runStartedAt;
19
+ private uploadArtifacts;
20
+ private presignAndUpload;
16
21
  }
17
22
  export {};
package/dist/index.js CHANGED
@@ -4,8 +4,113 @@ 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");
11
+ // Mirrors squally-app's ingest contract (app/api/ingest/route.ts,
12
+ // FIELD_LIMITS / checkStringField). Kept as a copy on purpose - the reporter
13
+ // ships independently of the app and cannot import from it - which means this
14
+ // table has to be updated when that one changes. It is deliberately not
15
+ // "roughly right": the server rejects the ENTIRE payload on the FIRST field
16
+ // that is too long, so one over-long value used to cost the whole run, every
17
+ // test result in it, with a bare "400 Bad Request" as the only clue.
18
+ //
19
+ // ciProvider is absent because the server does not length-check it, and this
20
+ // reporter only ever sends one of four fixed strings for it.
21
+ const FIELD_LIMITS = {
22
+ branch: 250,
23
+ commitSha: 100,
24
+ commitMessage: 500,
25
+ authorName: 100,
26
+ testedRevision: 200,
27
+ eventType: 100,
28
+ testName: 500,
29
+ filePath: 500,
30
+ errorMessage: 5000,
31
+ errorStack: 20000,
32
+ consoleOutput: 20000,
33
+ };
34
+ // ASCII only, like everything else in this file, and short: it has to fit
35
+ // INSIDE the limit it is protecting, and authorName only has 100 characters
36
+ // to give away.
37
+ const TRUNCATION_MARKER = "... [truncated]";
38
+ // Squally rejects a payload with more than this many entries outright - the
39
+ // whole run, not the surplus. Mirrored here so the reporter can say so up
40
+ // front instead of the customer finding out from a 400 after the suite has
41
+ // already finished.
42
+ //
43
+ // Deliberately NOT enforced by trimming: unlike an over-long error message,
44
+ // the surplus here is test results, and dropping them would turn a loud
45
+ // failure into a run that quietly looks like those tests never existed. The
46
+ // reporter reports what happened or says why it could not - it does not
47
+ // invent a smaller truth.
48
+ //
49
+ // Retries count as separate entries, so a suite of 2 000 tests reaches this
50
+ // faster than the number suggests.
51
+ const MAX_TEST_RESULTS = 5000;
52
+ // Visible rather than silent: a value that was cut says so, so nobody debugs
53
+ // a "wrong" commit message that is merely shortened. For testName/filePath
54
+ // this also alters Squally's test identity - unavoidable, since the
55
+ // alternative is losing the run outright, and two names sharing their first
56
+ // 500 characters would collide either way.
57
+ function truncate(value, max) {
58
+ if (value.length <= max)
59
+ return value;
60
+ if (max <= TRUNCATION_MARKER.length)
61
+ return value.slice(0, max);
62
+ return value.slice(0, max - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
63
+ }
64
+ function truncateOrNull(value, max) {
65
+ return value === null ? null : truncate(value, max);
66
+ }
67
+ // How much of a server error body to put in the log. Enough for the server's
68
+ // own one-line explanation, bounded so that an unexpected HTML error page
69
+ // from a proxy cannot dump kilobytes into the customer's CI output.
70
+ // undici leaves headersTimeout and bodyTimeout at 300 000 ms each when they
71
+ // are not set, and nothing here used to set them. A hung server therefore cost
72
+ // 5 minutes for the run POST, 5 for presign, 5 for confirm and 5 per upload
73
+ // round - with a hundred artifacts at five in parallel, well over an hour of a
74
+ // CI job sitting there with no output explaining what it is waiting for. That
75
+ // is worse for a customer than an error: an error at least says something.
76
+ //
77
+ // Two budgets, because the two kinds of request are not comparable. The JSON
78
+ // calls exchange kilobytes and have no business taking longer than half a
79
+ // minute. An upload can be a 50 MB trace over a CI runner's modest uplink,
80
+ // where two minutes is tight-ish but still far short of "hung".
81
+ const API_REQUEST_TIMEOUT_MS = 30000;
82
+ const UPLOAD_REQUEST_TIMEOUT_MS = 120000;
83
+ const ERROR_BODY_LOG_LIMIT = 500;
84
+ // Turns a failed response into something a customer can act on. Today the
85
+ // caller only sees "400 Bad Request"; the reason ("commitMessage exceeds max
86
+ // length of 500 characters") sits in the body and was being thrown away.
87
+ //
88
+ // Nothing from the REQUEST is logged, only the server's own response - the
89
+ // Authorization header must never reach the CI log.
90
+ // A timed-out request throws like any other network failure, and is handled
91
+ // like one - warn, carry on, never touch the exit code. It only gets its own
92
+ // wording because "fetch failed" would leave the customer guessing whether the
93
+ // server said no or simply never answered.
94
+ function describeRequestError(err, timeoutMs) {
95
+ const name = err instanceof Error ? err.name : "";
96
+ if (name === "TimeoutError" || name === "AbortError") {
97
+ return `no response within ${timeoutMs} ms - giving up`;
98
+ }
99
+ return String(err);
100
+ }
101
+ async function describeResponseError(response) {
102
+ let detail = "";
103
+ try {
104
+ const body = (await response.text()).trim();
105
+ if (body)
106
+ detail = ` - ${truncate(body, ERROR_BODY_LOG_LIMIT)}`;
107
+ }
108
+ catch {
109
+ // Reading the body is best-effort: a body that cannot be read must not
110
+ // turn a logged warning into a thrown error.
111
+ }
112
+ return `${response.status} ${response.statusText}${detail}`;
113
+ }
9
114
  // Maps Playwright's more granular statuses down to the three
10
115
  // statuses Squally stores (passed | failed | skipped).
11
116
  // "timedOut" and "interrupted" both count as failures for reporting purposes.
@@ -143,6 +248,29 @@ function detectEventType() {
143
248
  function toPosixPath(p) {
144
249
  return p.split(node_path_1.default.sep).join("/");
145
250
  }
251
+ // Playwright own assertion errors (expect(...).toHaveText(...) etc.) are
252
+ // colorized with ANSI escape codes in error.message/error.stack - useful in
253
+ // a terminal, but rendered as visible garbage once stored as plain text and
254
+ // shown in Squally UI. Test code that colors its own console output (e.g.
255
+ // via chalk/picocolors) can do the same to stdout/stderr. Stripped here,
256
+ // before anything is ever sent - not server/UI-side - so the raw escape
257
+ // codes never land in the database in the first place, and every later
258
+ // consumer (the existing keyword-based error categorization, the planned
259
+ // AI analysis, a fingerprint cache over normalized error signatures)
260
+ // does not have to strip them again itself.
261
+ //
262
+ // Matches the CSI form ANSI color/style codes actually take: an ESC
263
+ // character (code point 27), then an opening bracket, then any run of
264
+ // digits/semicolons, then one letter (e.g. ESC, bracket, 3, 1, m for red).
265
+ // No new dependency (e.g. strip-ansi) for a one-line regex, same reasoning
266
+ // as toPosixPath/the upload worker pool above. Built via String.fromCharCode
267
+ // rather than a hex escape directly in the source: this file has to stay
268
+ // plain, readable ASCII - a raw ESC byte pasted into source code is
269
+ // invisible in most editors/diffs and easy to mistake for empty content.
270
+ function stripAnsi(value) {
271
+ const ansiPattern = new RegExp(String.fromCharCode(27) + String.fromCharCode(92) + "[[0-9;]*[a-zA-Z]", "g");
272
+ return value.replace(ansiPattern, "");
273
+ }
146
274
  // Playwright captures stdout/stderr as two separate arrays (each internally
147
275
  // chronological, but not merged with each other), so a true byte-accurate
148
276
  // interleaving between the two streams isn't available here - stdout lines
@@ -152,13 +280,99 @@ function toPosixPath(p) {
152
280
  function formatConsoleOutput(result) {
153
281
  const lines = [];
154
282
  for (const chunk of result.stdout) {
155
- lines.push(`[log] ${chunk.toString().replace(/\s+$/, "")}`);
283
+ lines.push(`[log] ${stripAnsi(chunk.toString()).replace(/\s+$/, "")}`);
156
284
  }
157
285
  for (const chunk of result.stderr) {
158
- lines.push(`[error] ${chunk.toString().replace(/\s+$/, "")}`);
286
+ lines.push(`[error] ${stripAnsi(chunk.toString()).replace(/\s+$/, "")}`);
159
287
  }
160
288
  return lines.length > 0 ? lines.join("\n") : null;
161
289
  }
290
+ // Presigned upload URLs are valid for 15 minutes (server-side, see
291
+ // squally-app's lib/r2.ts). Uploading fully sequentially could get close to
292
+ // that window for a suite with a very large number of failing/retried
293
+ // tests, but firing everything at once isn't right either - CI runners
294
+ // often have modest outbound bandwidth/file-descriptor limits, and a
295
+ // thundering herd of large trace uploads competes with the test run itself
296
+ // for those resources. 5 concurrent uploads is a pragmatic middle ground:
297
+ // fast enough that even a few hundred artifacts finish in well under 15
298
+ // minutes, bounded enough not to overwhelm a typical CI runner.
299
+ const UPLOAD_CONCURRENCY = 5;
300
+ // Playwright does not let the process end on its own: the runner calls
301
+ // gracefullyCloseAll().then(() => process.exit(code)) (playwright-core's
302
+ // gracefullyProcessExitDoNotHang). process.exit() tears the event loop down
303
+ // immediately, and on Windows that aborts the whole process with
304
+ // Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src/win/async.c
305
+ // if any of our own I/O is still winding down at that moment - the exit code
306
+ // becomes 127 instead of 0, i.e. a green test run reports as failed.
307
+ //
308
+ // Measured, so the number is not a guess: with the run POST alone the process
309
+ // exits cleanly; from the second request on (presign) it aborts reproducibly.
310
+ // A 0 ms or 10 ms wait does not help, 100 ms survives two requests, and 250 ms
311
+ // survives the full path (ingest -> presign -> upload -> confirm) over
312
+ // repeated runs.
313
+ //
314
+ // This is a mitigation, not a cure: it widens the window rather than removing
315
+ // the race, because nothing in Node's public API reports "all my handles are
316
+ // really gone". Do not shrink it without re-running the Windows job in CI -
317
+ // that job exists specifically to catch this (see .github/workflows/ci.yml).
318
+ // The same crash was fixed once before for the single-request path and came
319
+ // back when 0.3.0 added the artifact requests.
320
+ const EXIT_DRAIN_MS = 250;
321
+ // No new dependency for this - a fixed-size worker pool that keeps pulling
322
+ // the next item until the list is exhausted is ~10 lines, not worth pulling
323
+ // in e.g. p-limit for.
324
+ async function runWithConcurrencyLimit(items, limit, worker) {
325
+ let nextIndex = 0;
326
+ async function runNext() {
327
+ const currentIndex = nextIndex++;
328
+ if (currentIndex >= items.length)
329
+ return;
330
+ await worker(items[currentIndex]);
331
+ return runNext();
332
+ }
333
+ const workerCount = Math.min(limit, items.length);
334
+ await Promise.all(Array.from({ length: workerCount }, () => runNext()));
335
+ }
336
+ // Only screenshots/traces from attempts worth looking at get uploaded -
337
+ // deliberately independent of how the customer configured
338
+ // screenshot/trace in playwright.config.ts. A "screenshot: 'on'" config
339
+ // would otherwise attach an image to every single passing test too: nobody
340
+ // opens a screenshot of a test that just passed, and every one of those
341
+ // uploads eats into the organization's artifact cap (Free tier: 1 GB) for
342
+ // zero benefit. "failed" already covers Playwright's timedOut/interrupted
343
+ // (mapStatus above collapses those down) - retryCount > 0 additionally
344
+ // covers a passed-on-retry attempt, since seeing what an earlier, failing
345
+ // attempt looked like is still useful even if the test eventually passed.
346
+ function attemptQualifiesForArtifacts(mappedStatus, retryCount) {
347
+ return mappedStatus === "failed" || retryCount > 0;
348
+ }
349
+ // A control character as separator, not an empty string or a slash -
350
+ // testName/filePath can contain arbitrary characters, and this key only
351
+ // needs to be unique, not human-readable (it is purely an internal lookup
352
+ // back to the local CollectedArtifact once the server round-trips these
353
+ // identity fields).
354
+ function artifactKey(testName, filePath, retryCount, type) {
355
+ return [testName, filePath, String(retryCount), type].join(String.fromCharCode(1));
356
+ }
357
+ function skipReasonMessage(reason) {
358
+ if (reason === "file_too_large")
359
+ return "file_too_large (limit 50 MB)";
360
+ if (reason === "cap_exceeded")
361
+ return "cap_exceeded (storage limit reached, upgrade your plan)";
362
+ if (reason === "not_found")
363
+ return "not_found (test result not found on server)";
364
+ return reason;
365
+ }
366
+ function confirmReasonMessage(reason) {
367
+ if (reason === "not_uploaded")
368
+ return "not_uploaded (upload did not reach storage)";
369
+ if (reason === "not_found")
370
+ return "not_found (test result not found on server)";
371
+ return reason;
372
+ }
373
+ function artifactLabel(type) {
374
+ return type === "screenshot" ? "Screenshot" : "Trace";
375
+ }
162
376
  class SquallyReporter {
163
377
  constructor(options = {}) {
164
378
  this.apiUrl = "";
@@ -169,6 +383,9 @@ class SquallyReporter {
169
383
  this.enabled = false;
170
384
  this.startedAt = new Date();
171
385
  this.results = [];
386
+ this.artifacts = [];
387
+ // Earliest TestResult.startTime seen, see the startedAt note in onEnd().
388
+ this.earliestTestStart = null;
172
389
  const apiUrl = options.apiUrl ?? process.env.SQUALLY_API_URL;
173
390
  const apiKey = options.apiKey ?? process.env.SQUALLY_API_KEY;
174
391
  if (!apiUrl || !apiKey) {
@@ -185,25 +402,88 @@ class SquallyReporter {
185
402
  this.apiKey = apiKey;
186
403
  this.enabled = true;
187
404
  }
188
- onBegin(_config, _suite) {
405
+ onBegin(config, _suite) {
189
406
  if (!this.enabled)
190
407
  return;
191
408
  this.startedAt = new Date();
409
+ // Each shard is its own process with its own reporter instance, so each
410
+ // one reports a run of its own. Measured: two shards over two spec files
411
+ // produce two runs holding two results each; a shard that receives no
412
+ // tests produces none at all.
413
+ //
414
+ // Worth saying out loud what this does and does not break: per-test
415
+ // history is unaffected, because a test lives in exactly one shard and so
416
+ // still contributes one data point per CI run. What gets distorted is the
417
+ // run level - the number of runs, each run's pass/fail (it only covers
418
+ // its own slice), and anything counted per run in the dashboard.
419
+ if (config.shard) {
420
+ console.warn(`[squally-reporter] Sharding detected (--shard=${config.shard.current}/${config.shard.total}). ` +
421
+ `Each shard reports separately, so this CI run will show up in Squally as up to ` +
422
+ `${config.shard.total} runs, each holding only its own slice of the suite. ` +
423
+ "Per-test flakiness stays correct; run counts and per-run pass/fail do not. " +
424
+ "For a single run, write a blob report per shard (--reporter=blob) and merge " +
425
+ "afterwards: npx playwright merge-reports --reporter=squally-reporter ./blob-report");
426
+ }
192
427
  }
193
428
  onTestEnd(test, result) {
194
429
  if (!this.enabled)
195
430
  return;
196
431
  const error = result.errors[0];
432
+ // Truncated HERE, once, not when the payload is built: testName/filePath
433
+ // are Squally's test identity, and /api/artifacts/presign resolves each
434
+ // artifact back to a TestResult through exactly these two values plus
435
+ // retryCount. Cutting them in two places, or cutting them only for the
436
+ // ingest payload, would make every artifact of a long-named test
437
+ // unmatchable server-side.
438
+ const testName = truncate(test.titlePath().slice(1).join(" > "), FIELD_LIMITS.testName);
439
+ const filePath = truncate(toPosixPath(node_path_1.default.relative(process.cwd(), test.location.file)), FIELD_LIMITS.filePath);
440
+ const status = mapStatus(result.status);
441
+ if (this.earliestTestStart === null || result.startTime < this.earliestTestStart) {
442
+ this.earliestTestStart = result.startTime;
443
+ }
197
444
  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),
445
+ testName,
446
+ filePath,
447
+ status,
201
448
  durationMs: result.duration,
202
449
  retryCount: result.retry,
203
- errorMessage: error?.message ?? null,
204
- errorStack: error?.stack ?? null,
205
- consoleOutput: formatConsoleOutput(result),
450
+ errorMessage: truncateOrNull(error?.message ? stripAnsi(error.message) : null, FIELD_LIMITS.errorMessage),
451
+ errorStack: truncateOrNull(error?.stack ? stripAnsi(error.stack) : null, FIELD_LIMITS.errorStack),
452
+ consoleOutput: truncateOrNull(formatConsoleOutput(result), FIELD_LIMITS.consoleOutput),
206
453
  });
454
+ if (!attemptQualifiesForArtifacts(status, result.retry))
455
+ return;
456
+ for (const attachment of result.attachments) {
457
+ if (attachment.name !== "screenshot" && attachment.name !== "trace")
458
+ continue;
459
+ // Attachments can be in-memory (body, no path) when added manually via
460
+ // testInfo.attach() - only file-backed ones (screenshot/trace always
461
+ // are, when Playwright itself captures them) can be uploaded here.
462
+ if (!attachment.path)
463
+ continue;
464
+ // Synchronous, not fs/promises - onTestEnd's return value isn't
465
+ // awaited by Playwright (Reporter['onTestEnd'] is typed as void, not
466
+ // Promise<void>), so an async stat call here would race with
467
+ // onEnd() reading this.artifacts before it resolved.
468
+ let sizeBytes;
469
+ try {
470
+ sizeBytes = (0, node_fs_1.statSync)(attachment.path).size;
471
+ }
472
+ catch {
473
+ // File already gone (e.g. cleaned up by a parallel worker) -
474
+ // nothing to upload, silently skip rather than warn for what's an
475
+ // expected, harmless race in some CI setups.
476
+ continue;
477
+ }
478
+ this.artifacts.push({
479
+ testName,
480
+ filePath,
481
+ retryCount: result.retry,
482
+ type: attachment.name,
483
+ localPath: attachment.path,
484
+ sizeBytes,
485
+ });
486
+ }
207
487
  }
208
488
  async onEnd(result) {
209
489
  if (!this.enabled)
@@ -215,53 +495,243 @@ class SquallyReporter {
215
495
  // sending anything.
216
496
  if (this.results.length === 0)
217
497
  return;
498
+ // Checked before the request, not after: the server would answer 400 and
499
+ // discard the entire run, and the customer would be left correlating a
500
+ // status code with a suite that finished minutes ago.
501
+ if (this.results.length > MAX_TEST_RESULTS) {
502
+ console.warn(`[squally-reporter] This run has ${this.results.length} test results, and Squally ` +
503
+ `accepts at most ${MAX_TEST_RESULTS} per run - nothing was sent. Note that retries ` +
504
+ "count separately. Split the suite across several Playwright projects or " +
505
+ "configurations so each reports its own run, or get in touch if you need a " +
506
+ "single run this size.");
507
+ return;
508
+ }
218
509
  const resolvedRevision = resolveTestedRevision();
510
+ // commitMessage is the one that bites in practice: `git log -1 %B` is the
511
+ // full body, and a squash merge carrying a PR description is routinely
512
+ // past 500 characters. Before this, that single value discarded every test
513
+ // result in the run.
219
514
  const payload = {
220
- branch: detectBranch(),
221
- commitSha: detectCommitSha(),
222
- commitMessage: detectCommitMessage(),
223
- authorName: detectAuthorName(),
515
+ branch: truncateOrNull(detectBranch(), FIELD_LIMITS.branch),
516
+ commitSha: truncateOrNull(detectCommitSha(), FIELD_LIMITS.commitSha),
517
+ commitMessage: truncateOrNull(detectCommitMessage(), FIELD_LIMITS.commitMessage),
518
+ authorName: truncateOrNull(detectAuthorName(), FIELD_LIMITS.authorName),
224
519
  ciProvider: detectCiProvider(),
225
520
  status: result.status === "passed" ? "passed" : "failed",
226
- startedAt: this.startedAt.toISOString(),
521
+ startedAt: this.runStartedAt().toISOString(),
227
522
  finishedAt: new Date().toISOString(),
228
523
  testResults: this.results,
229
- testedRevision: resolvedRevision?.revision ?? null,
524
+ testedRevision: truncateOrNull(resolvedRevision?.revision ?? null, FIELD_LIMITS.testedRevision),
230
525
  revisionSource: resolvedRevision?.source ?? null,
231
- eventType: detectEventType(),
526
+ eventType: truncateOrNull(detectEventType(), FIELD_LIMITS.eventType),
232
527
  };
233
- // Node's global fetch() pools connections through a shared, long-lived
234
- // keep-alive dispatcher by default. In a short-lived CLI process like
235
- // this reporter, that pooled socket can still be mid-teardown when
236
- // Playwright exits the process, which crashes with a libuv assertion on
237
- // Windows ("UV_HANDLE_CLOSING", src/win/async.c). Using our own Agent
238
- // for this one request and closing it explicitly guarantees no open
239
- // handle is left behind by the time onEnd() resolves. Importing fetch
240
- // from undici too (not Node's global one) avoids a possible instanceof
241
- // mismatch between Node's internal bundled undici and this installed
242
- // copy when passing our Agent as the dispatcher.
243
- const dispatcher = new undici_1.Agent({ keepAliveTimeout: 1, keepAliveMaxTimeout: 1 });
528
+ // Our own Agent rather than the global one: Node's global fetch() pools
529
+ // connections through a shared, long-lived keep-alive dispatcher, and a
530
+ // pooled socket outliving this run is exactly what we do not want in a
531
+ // short-lived CLI process. Importing fetch from undici too (not Node's
532
+ // global one) avoids a possible instanceof mismatch between Node's
533
+ // internal bundled undici and this installed copy when passing our Agent
534
+ // as the dispatcher.
535
+ //
536
+ // NOTE: this does NOT on its own prevent the Windows exit crash - that
537
+ // claim used to stand here and was wrong. Closing the dispatcher resolves
538
+ // before the sockets are actually gone; see EXIT_DRAIN_MS above. Measured
539
+ // with global fetch as well: dropping undici entirely does not help
540
+ // either, so the client is not what makes the difference.
541
+ // The per-request AbortSignals below are the real bound; these are a
542
+ // backstop so that nothing can outlive the longest budget even if a signal
543
+ // never fires.
544
+ const dispatcher = new undici_1.Agent({
545
+ keepAliveTimeout: 1,
546
+ keepAliveMaxTimeout: 1,
547
+ headersTimeout: UPLOAD_REQUEST_TIMEOUT_MS,
548
+ bodyTimeout: UPLOAD_REQUEST_TIMEOUT_MS,
549
+ });
244
550
  try {
245
- const response = await (0, undici_1.fetch)(`${this.apiUrl}/api/ingest`, {
551
+ let runId = null;
552
+ try {
553
+ const response = await (0, undici_1.fetch)(`${this.apiUrl}/api/ingest`, {
554
+ method: "POST",
555
+ headers: {
556
+ "Content-Type": "application/json",
557
+ Authorization: `Bearer ${this.apiKey}`,
558
+ },
559
+ body: JSON.stringify(payload),
560
+ dispatcher,
561
+ signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
562
+ });
563
+ if (!response.ok) {
564
+ // Never throw here - a failing reporter upload should not fail the
565
+ // whole CI run just because the dashboard was unreachable.
566
+ console.error(`[squally-reporter] Ingest failed: ${await describeResponseError(response)}`);
567
+ }
568
+ else {
569
+ const body = (await response.json());
570
+ runId = body.runId ?? null;
571
+ }
572
+ }
573
+ catch (err) {
574
+ console.error("[squally-reporter] Failed to reach Squally API:", describeRequestError(err, API_REQUEST_TIMEOUT_MS));
575
+ }
576
+ // Artifact upload is a separate, best-effort step layered on top of
577
+ // ingest, never a reason to fail it: it needs a runId (presign looks
578
+ // TestResults up by runId + identity fields, which only exist once
579
+ // ingest has actually inserted them), and there's nothing to do if
580
+ // this run had no failing/retried attempts with a screenshot/trace
581
+ // attached (attemptQualifiesForArtifacts already filtered that in
582
+ // onTestEnd) - no reason to make an empty presign call.
583
+ if (runId && this.artifacts.length > 0) {
584
+ await this.uploadArtifacts(runId, dispatcher);
585
+ }
586
+ }
587
+ finally {
588
+ await dispatcher.close();
589
+ // See EXIT_DRAIN_MS - closing the dispatcher resolves before the
590
+ // underlying sockets are fully gone, and Playwright exits the process
591
+ // the moment onEnd() resolves.
592
+ await new Promise((resolve) => setTimeout(resolve, EXIT_DRAIN_MS));
593
+ }
594
+ }
595
+ // onBegin's timestamp is normally the right answer: it fires before the
596
+ // first test and therefore includes global setup, webServer startup and so
597
+ // on. It is wrong in exactly one case - `npx playwright merge-reports`,
598
+ // where onBegin fires on the merging machine, minutes or hours after the
599
+ // tests actually ran, and would date the whole run to the merge.
600
+ //
601
+ // The earlier of the two is right in both situations: during a normal run
602
+ // onBegin precedes every test, during a merge it follows all of them. No
603
+ // detection of which mode we are in is needed.
604
+ runStartedAt() {
605
+ if (this.earliestTestStart === null)
606
+ return this.startedAt;
607
+ return this.earliestTestStart < this.startedAt ? this.earliestTestStart : this.startedAt;
608
+ }
609
+ // Top-level safety net - presignAndUpload already handles presign/
610
+ // upload/confirm failures individually (see Aufgabe 5's requirement that
611
+ // none of this ever affects the test suite's exit code), but this catch
612
+ // still exists in case something in there throws in a way that wasn't
613
+ // anticipated. The whole artifact feature must be additive: worst case,
614
+ // some screenshots/traces don't make it to Squally, but the customer's
615
+ // CI run itself is never at risk.
616
+ async uploadArtifacts(runId, dispatcher) {
617
+ try {
618
+ await this.presignAndUpload(runId, dispatcher);
619
+ }
620
+ catch (err) {
621
+ console.warn("[squally-reporter] Artifact upload failed unexpectedly:", err);
622
+ }
623
+ }
624
+ async presignAndUpload(runId, dispatcher) {
625
+ const presignArtifacts = this.artifacts.map((artifact) => ({
626
+ testName: artifact.testName,
627
+ filePath: artifact.filePath,
628
+ retryCount: artifact.retryCount,
629
+ type: artifact.type,
630
+ sizeBytes: artifact.sizeBytes,
631
+ }));
632
+ let presignResults;
633
+ try {
634
+ const response = await (0, undici_1.fetch)(`${this.apiUrl}/api/artifacts/presign`, {
246
635
  method: "POST",
247
636
  headers: {
248
637
  "Content-Type": "application/json",
249
638
  Authorization: `Bearer ${this.apiKey}`,
250
639
  },
251
- body: JSON.stringify(payload),
640
+ body: JSON.stringify({ runId, artifacts: presignArtifacts }),
252
641
  dispatcher,
642
+ signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
253
643
  });
254
644
  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}`);
645
+ console.warn(`[squally-reporter] Artifact presign failed: ${await describeResponseError(response)}` +
646
+ " - skipping artifact upload for this run");
647
+ return;
258
648
  }
649
+ const body = (await response.json());
650
+ presignResults = body.results;
259
651
  }
260
652
  catch (err) {
261
- console.error("[squally-reporter] Failed to reach Squally API:", err);
653
+ console.warn("[squally-reporter] Artifact presign request failed:", describeRequestError(err, API_REQUEST_TIMEOUT_MS));
654
+ return;
262
655
  }
263
- finally {
264
- await dispatcher.close();
656
+ // Maps a presign result back to the local file it came from -
657
+ // testName/filePath/retryCount/type is exactly what was sent, so it's
658
+ // guaranteed to match one of this.artifacts (barring a server bug),
659
+ // but a missing lookup is handled defensively below instead of assumed.
660
+ const artifactsByKey = new Map(this.artifacts.map((artifact) => [
661
+ artifactKey(artifact.testName, artifact.filePath, artifact.retryCount, artifact.type),
662
+ artifact,
663
+ ]));
664
+ const uploads = [];
665
+ for (const item of presignResults) {
666
+ const local = artifactsByKey.get(artifactKey(item.testName, item.filePath ?? "", item.retryCount, item.type));
667
+ if (!local)
668
+ continue;
669
+ if (item.skip) {
670
+ console.warn(`[squally-reporter] ${artifactLabel(item.type)} for '${item.testName}' skipped: ${skipReasonMessage(item.reason)}`);
671
+ continue;
672
+ }
673
+ uploads.push({ artifact: local, uploadUrl: item.uploadUrl });
674
+ }
675
+ if (uploads.length === 0)
676
+ return;
677
+ // See UPLOAD_CONCURRENCY's own comment above for why this is bounded
678
+ // rather than either fully sequential or fully parallel.
679
+ await runWithConcurrencyLimit(uploads, UPLOAD_CONCURRENCY, async ({ artifact, uploadUrl }) => {
680
+ try {
681
+ const fileBody = await (0, promises_1.readFile)(artifact.localPath);
682
+ const response = await (0, undici_1.fetch)(uploadUrl, {
683
+ method: "PUT",
684
+ body: fileBody,
685
+ dispatcher,
686
+ signal: AbortSignal.timeout(UPLOAD_REQUEST_TIMEOUT_MS),
687
+ });
688
+ if (!response.ok) {
689
+ console.warn(`[squally-reporter] Upload failed for ${artifact.type} of '${artifact.testName}': ` +
690
+ (await describeResponseError(response)));
691
+ }
692
+ }
693
+ catch (err) {
694
+ console.warn(`[squally-reporter] Upload failed for ${artifact.type} of '${artifact.testName}':`, describeRequestError(err, UPLOAD_REQUEST_TIMEOUT_MS));
695
+ }
696
+ });
697
+ // Confirm every artifact that was actually presigned (not the ones
698
+ // presign already skipped - those never got a key, and re-checking
699
+ // them here would just produce a confusing second "not_uploaded"
700
+ // warning for something that was never attempted in the first place).
701
+ // Confirm's own headObject check is the source of truth for whether an
702
+ // upload really landed - deliberately not tracking PUT success/failure
703
+ // above to decide what to confirm, since a PUT that reported success
704
+ // but didn't truly land (or vice versa) should still be caught here.
705
+ const confirmArtifacts = uploads.map(({ artifact }) => ({
706
+ testName: artifact.testName,
707
+ filePath: artifact.filePath,
708
+ retryCount: artifact.retryCount,
709
+ type: artifact.type,
710
+ }));
711
+ try {
712
+ const response = await (0, undici_1.fetch)(`${this.apiUrl}/api/artifacts/confirm`, {
713
+ method: "POST",
714
+ headers: {
715
+ "Content-Type": "application/json",
716
+ Authorization: `Bearer ${this.apiKey}`,
717
+ },
718
+ body: JSON.stringify({ runId, artifacts: confirmArtifacts }),
719
+ dispatcher,
720
+ signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
721
+ });
722
+ if (!response.ok) {
723
+ console.warn(`[squally-reporter] Artifact confirm failed: ${await describeResponseError(response)}`);
724
+ return;
725
+ }
726
+ const body = (await response.json());
727
+ for (const item of body.results) {
728
+ if (item.confirmed)
729
+ continue;
730
+ console.warn(`[squally-reporter] ${artifactLabel(item.type)} for '${item.testName}' not confirmed: ${confirmReasonMessage(item.reason)}`);
731
+ }
732
+ }
733
+ catch (err) {
734
+ console.warn("[squally-reporter] Artifact confirm request failed:", describeRequestError(err, API_REQUEST_TIMEOUT_MS));
265
735
  }
266
736
  }
267
737
  }
package/package.json CHANGED
@@ -1,38 +1,40 @@
1
- {
2
- "name": "squally-reporter",
3
- "version": "0.2.2",
4
- "description": "Playwright reporter that sends test results to your Squally dashboard.",
5
- "author": "Squally <squally.dev@gmail.com>",
6
- "keywords": [
7
- "playwright",
8
- "playwright-reporter",
9
- "flaky-tests",
10
- "test-results",
11
- "ci"
12
- ],
13
- "main": "dist/index.js",
14
- "types": "dist/index.d.ts",
15
- "files": [
16
- "dist"
17
- ],
18
- "engines": {
19
- "node": ">=22.19.0"
20
- },
21
- "scripts": {
22
- "build": "tsc",
23
- "prepublishOnly": "npm run build",
24
- "prepare": "npm run build"
25
- },
26
- "peerDependencies": {
27
- "@playwright/test": ">=1.30.0"
28
- },
29
- "devDependencies": {
30
- "@playwright/test": "^1.48.0",
31
- "@types/node": "^20.14.0",
32
- "typescript": "^5.6.0"
33
- },
34
- "license": "MIT",
35
- "dependencies": {
36
- "undici": "^8.10.0"
37
- }
38
- }
1
+ {
2
+ "name": "squally-reporter",
3
+ "version": "0.4.0",
4
+ "description": "Playwright reporter that sends test results to your Squally dashboard.",
5
+ "author": "Squally <squally.dev@gmail.com>",
6
+ "keywords": [
7
+ "playwright",
8
+ "playwright-reporter",
9
+ "flaky-tests",
10
+ "test-results",
11
+ "ci"
12
+ ],
13
+ "main": "dist/index.js",
14
+ "types": "dist/index.d.ts",
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "engines": {
19
+ "node": ">=22.19.0"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc",
23
+ "prepublishOnly": "npm run build",
24
+ "prepare": "npm run build",
25
+ "test:exit-code": "node test/exit-code/run.mjs",
26
+ "test": "npm run build && npm run test:exit-code"
27
+ },
28
+ "peerDependencies": {
29
+ "@playwright/test": ">=1.30.0"
30
+ },
31
+ "devDependencies": {
32
+ "@playwright/test": "^1.48.0",
33
+ "@types/node": "^20.14.0",
34
+ "typescript": "^5.6.0"
35
+ },
36
+ "license": "MIT",
37
+ "dependencies": {
38
+ "undici": "^8.10.0"
39
+ }
40
+ }