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 +211 -121
- package/dist/index.d.ts +3 -1
- package/dist/index.js +235 -32
- package/package.json +40 -38
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
so
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
289
|
-
|
|
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.
|
|
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
|
|
361
|
-
//
|
|
362
|
-
// this
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
|
|
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: ${
|
|
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: ${
|
|
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}':
|
|
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: ${
|
|
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.
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
}
|