squally-reporter 0.3.0 → 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,121 +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
- ## 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
-
92
- ## Tested revision (optional)
93
-
94
- If your test-automation code lives in the same repo as the application it
95
- tests, Squally can already tell which commit was tested from the CI
96
- provider's own commit env vars. If they're **separate repos** (e.g. a
97
- dedicated E2E-test repo pointed at a deployed app), set one of the following
98
- so Squally knows which application state was actually tested:
99
-
100
- ```bash
101
- # Same repo as the application under test - Squally will trust this CI
102
- # job's own commit (GITHUB_SHA / CI_COMMIT_SHA) as the tested revision.
103
- SAME_REPO_AS_APP=true
104
-
105
- # Separate repos - pass the tested revision explicitly (a commit SHA, tag,
106
- # or branch name/ref).
107
- TESTED_REVISION=v1.2.0
108
- ```
109
-
110
- Neither variable is required - without them, test results are still sent
111
- and stored exactly as before.
112
-
113
- ## Support
114
-
115
- Found a bug or have a question? Please open an issue on
116
- [GitHub Issues](https://github.com/squallydev-admin/squally-reporter/issues) -
117
- that's the primary way to report problems or ask questions.
118
-
119
- ## License
120
-
121
- 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
@@ -10,10 +10,12 @@ export default class SquallyReporter implements Reporter {
10
10
  private startedAt;
11
11
  private results;
12
12
  private artifacts;
13
+ private earliestTestStart;
13
14
  constructor(options?: SquallyReporterOptions);
14
- onBegin(_config: FullConfig, _suite: Suite): void;
15
+ onBegin(config: FullConfig, _suite: Suite): void;
15
16
  onTestEnd(test: TestCase, result: TestResult): void;
16
17
  onEnd(result: FullResult): Promise<void>;
18
+ private runStartedAt;
17
19
  private uploadArtifacts;
18
20
  private presignAndUpload;
19
21
  }
package/dist/index.js CHANGED
@@ -8,6 +8,109 @@ const node_fs_1 = require("node:fs");
8
8
  const promises_1 = require("node:fs/promises");
9
9
  const node_path_1 = __importDefault(require("node:path"));
10
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
+ }
11
114
  // Maps Playwright's more granular statuses down to the three
12
115
  // statuses Squally stores (passed | failed | skipped).
13
116
  // "timedOut" and "interrupted" both count as failures for reporting purposes.
@@ -194,6 +297,27 @@ function formatConsoleOutput(result) {
194
297
  // fast enough that even a few hundred artifacts finish in well under 15
195
298
  // minutes, bounded enough not to overwhelm a typical CI runner.
196
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;
197
321
  // No new dependency for this - a fixed-size worker pool that keeps pulling
198
322
  // the next item until the list is exhausted is ~10 lines, not worth pulling
199
323
  // in e.g. p-limit for.
@@ -260,6 +384,8 @@ class SquallyReporter {
260
384
  this.startedAt = new Date();
261
385
  this.results = [];
262
386
  this.artifacts = [];
387
+ // Earliest TestResult.startTime seen, see the startedAt note in onEnd().
388
+ this.earliestTestStart = null;
263
389
  const apiUrl = options.apiUrl ?? process.env.SQUALLY_API_URL;
264
390
  const apiKey = options.apiKey ?? process.env.SQUALLY_API_KEY;
265
391
  if (!apiUrl || !apiKey) {
@@ -276,27 +402,54 @@ class SquallyReporter {
276
402
  this.apiKey = apiKey;
277
403
  this.enabled = true;
278
404
  }
279
- onBegin(_config, _suite) {
405
+ onBegin(config, _suite) {
280
406
  if (!this.enabled)
281
407
  return;
282
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
+ }
283
427
  }
284
428
  onTestEnd(test, result) {
285
429
  if (!this.enabled)
286
430
  return;
287
431
  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));
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);
290
440
  const status = mapStatus(result.status);
441
+ if (this.earliestTestStart === null || result.startTime < this.earliestTestStart) {
442
+ this.earliestTestStart = result.startTime;
443
+ }
291
444
  this.results.push({
292
445
  testName,
293
446
  filePath,
294
447
  status,
295
448
  durationMs: result.duration,
296
449
  retryCount: result.retry,
297
- errorMessage: error?.message ? stripAnsi(error.message) : null,
298
- errorStack: error?.stack ? stripAnsi(error.stack) : null,
299
- 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),
300
453
  });
301
454
  if (!attemptQualifiesForArtifacts(status, result.retry))
302
455
  return;
@@ -342,32 +495,58 @@ class SquallyReporter {
342
495
  // sending anything.
343
496
  if (this.results.length === 0)
344
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
+ }
345
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.
346
514
  const payload = {
347
- branch: detectBranch(),
348
- commitSha: detectCommitSha(),
349
- commitMessage: detectCommitMessage(),
350
- 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),
351
519
  ciProvider: detectCiProvider(),
352
520
  status: result.status === "passed" ? "passed" : "failed",
353
- startedAt: this.startedAt.toISOString(),
521
+ startedAt: this.runStartedAt().toISOString(),
354
522
  finishedAt: new Date().toISOString(),
355
523
  testResults: this.results,
356
- testedRevision: resolvedRevision?.revision ?? null,
524
+ testedRevision: truncateOrNull(resolvedRevision?.revision ?? null, FIELD_LIMITS.testedRevision),
357
525
  revisionSource: resolvedRevision?.source ?? null,
358
- eventType: detectEventType(),
526
+ eventType: truncateOrNull(detectEventType(), FIELD_LIMITS.eventType),
359
527
  };
360
- // Node's global fetch() pools connections through a shared, long-lived
361
- // keep-alive dispatcher by default. In a short-lived CLI process like
362
- // this reporter, that pooled socket can still be mid-teardown when
363
- // Playwright exits the process, which crashes with a libuv assertion on
364
- // Windows ("UV_HANDLE_CLOSING", src/win/async.c). Using our own Agent
365
- // for this one request and closing it explicitly guarantees no open
366
- // handle is left behind by the time onEnd() resolves. Importing fetch
367
- // from undici too (not Node's global one) avoids a possible instanceof
368
- // mismatch between Node's internal bundled undici and this installed
369
- // copy when passing our Agent as the dispatcher.
370
- 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
+ });
371
550
  try {
372
551
  let runId = null;
373
552
  try {
@@ -379,11 +558,12 @@ class SquallyReporter {
379
558
  },
380
559
  body: JSON.stringify(payload),
381
560
  dispatcher,
561
+ signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
382
562
  });
383
563
  if (!response.ok) {
384
564
  // Never throw here - a failing reporter upload should not fail the
385
565
  // whole CI run just because the dashboard was unreachable.
386
- console.error(`[squally-reporter] Ingest failed: ${response.status} ${response.statusText}`);
566
+ console.error(`[squally-reporter] Ingest failed: ${await describeResponseError(response)}`);
387
567
  }
388
568
  else {
389
569
  const body = (await response.json());
@@ -391,7 +571,7 @@ class SquallyReporter {
391
571
  }
392
572
  }
393
573
  catch (err) {
394
- console.error("[squally-reporter] Failed to reach Squally API:", err);
574
+ console.error("[squally-reporter] Failed to reach Squally API:", describeRequestError(err, API_REQUEST_TIMEOUT_MS));
395
575
  }
396
576
  // Artifact upload is a separate, best-effort step layered on top of
397
577
  // ingest, never a reason to fail it: it needs a runId (presign looks
@@ -406,8 +586,26 @@ class SquallyReporter {
406
586
  }
407
587
  finally {
408
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));
409
593
  }
410
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
+ }
411
609
  // Top-level safety net - presignAndUpload already handles presign/
412
610
  // upload/confirm failures individually (see Aufgabe 5's requirement that
413
611
  // none of this ever affects the test suite's exit code), but this catch
@@ -441,16 +639,18 @@ class SquallyReporter {
441
639
  },
442
640
  body: JSON.stringify({ runId, artifacts: presignArtifacts }),
443
641
  dispatcher,
642
+ signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
444
643
  });
445
644
  if (!response.ok) {
446
- console.warn(`[squally-reporter] Artifact presign failed: ${response.status} ${response.statusText} - skipping artifact upload for this run`);
645
+ console.warn(`[squally-reporter] Artifact presign failed: ${await describeResponseError(response)}` +
646
+ " - skipping artifact upload for this run");
447
647
  return;
448
648
  }
449
649
  const body = (await response.json());
450
650
  presignResults = body.results;
451
651
  }
452
652
  catch (err) {
453
- console.warn("[squally-reporter] Artifact presign request failed:", err);
653
+ console.warn("[squally-reporter] Artifact presign request failed:", describeRequestError(err, API_REQUEST_TIMEOUT_MS));
454
654
  return;
455
655
  }
456
656
  // Maps a presign result back to the local file it came from -
@@ -483,13 +683,15 @@ class SquallyReporter {
483
683
  method: "PUT",
484
684
  body: fileBody,
485
685
  dispatcher,
686
+ signal: AbortSignal.timeout(UPLOAD_REQUEST_TIMEOUT_MS),
486
687
  });
487
688
  if (!response.ok) {
488
- console.warn(`[squally-reporter] Upload failed for ${artifact.type} of '${artifact.testName}': ${response.status} ${response.statusText}`);
689
+ console.warn(`[squally-reporter] Upload failed for ${artifact.type} of '${artifact.testName}': ` +
690
+ (await describeResponseError(response)));
489
691
  }
490
692
  }
491
693
  catch (err) {
492
- console.warn(`[squally-reporter] Upload failed for ${artifact.type} of '${artifact.testName}':`, err);
694
+ console.warn(`[squally-reporter] Upload failed for ${artifact.type} of '${artifact.testName}':`, describeRequestError(err, UPLOAD_REQUEST_TIMEOUT_MS));
493
695
  }
494
696
  });
495
697
  // Confirm every artifact that was actually presigned (not the ones
@@ -515,9 +717,10 @@ class SquallyReporter {
515
717
  },
516
718
  body: JSON.stringify({ runId, artifacts: confirmArtifacts }),
517
719
  dispatcher,
720
+ signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
518
721
  });
519
722
  if (!response.ok) {
520
- console.warn(`[squally-reporter] Artifact confirm failed: ${response.status} ${response.statusText}`);
723
+ console.warn(`[squally-reporter] Artifact confirm failed: ${await describeResponseError(response)}`);
521
724
  return;
522
725
  }
523
726
  const body = (await response.json());
@@ -528,7 +731,7 @@ class SquallyReporter {
528
731
  }
529
732
  }
530
733
  catch (err) {
531
- console.warn("[squally-reporter] Artifact confirm request failed:", err);
734
+ console.warn("[squally-reporter] Artifact confirm request failed:", describeRequestError(err, API_REQUEST_TIMEOUT_MS));
532
735
  }
533
736
  }
534
737
  }
package/package.json CHANGED
@@ -1,38 +1,40 @@
1
- {
2
- "name": "squally-reporter",
3
- "version": "0.3.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
- },
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
+ }