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 +211 -82
- package/dist/index.d.ts +6 -1
- package/dist/index.js +505 -35
- package/package.json +40 -38
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
|
-
##
|
|
54
|
-
|
|
55
|
-
If your
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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(
|
|
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(
|
|
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
|
|
199
|
-
filePath
|
|
200
|
-
status
|
|
445
|
+
testName,
|
|
446
|
+
filePath,
|
|
447
|
+
status,
|
|
201
448
|
durationMs: result.duration,
|
|
202
449
|
retryCount: result.retry,
|
|
203
|
-
errorMessage: error?.message
|
|
204
|
-
errorStack: error?.stack
|
|
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.
|
|
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
|
|
234
|
-
//
|
|
235
|
-
// this
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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.
|
|
653
|
+
console.warn("[squally-reporter] Artifact presign request failed:", describeRequestError(err, API_REQUEST_TIMEOUT_MS));
|
|
654
|
+
return;
|
|
262
655
|
}
|
|
263
|
-
|
|
264
|
-
|
|
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.
|
|
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
|
+
}
|