squally-reporter 0.3.0 → 0.5.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 +182 -121
- package/dist/index.d.ts +95 -4
- package/dist/index.js +940 -115
- package/package.json +41 -38
package/dist/index.js
CHANGED
|
@@ -3,11 +3,179 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.__internal = void 0;
|
|
6
7
|
const node_child_process_1 = require("node:child_process");
|
|
8
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
9
|
const node_fs_1 = require("node:fs");
|
|
8
10
|
const promises_1 = require("node:fs/promises");
|
|
11
|
+
const node_os_1 = require("node:os");
|
|
9
12
|
const node_path_1 = __importDefault(require("node:path"));
|
|
10
13
|
const undici_1 = require("undici");
|
|
14
|
+
// Mirrors squally-app's ingest contract (lib/ingest-contract.ts,
|
|
15
|
+
// FIELD_LIMITS). Kept as a copy on purpose - the reporter ships independently
|
|
16
|
+
// of the app and cannot import from it - which means this table has to be
|
|
17
|
+
// updated when that one changes. It is deliberately not "roughly right": the
|
|
18
|
+
// server rejects the ENTIRE payload on the FIRST field that is too long
|
|
19
|
+
// (v1) or the ENTIRE BATCH (v2), so one over-long value used to cost a whole
|
|
20
|
+
// run, and under v2 still costs a whole batch, with a bare "400 Bad Request"
|
|
21
|
+
// as the only clue if this table drifts.
|
|
22
|
+
//
|
|
23
|
+
// ciProvider is absent because the server does not length-check it, and this
|
|
24
|
+
// reporter only ever sends one of four fixed strings for it.
|
|
25
|
+
const FIELD_LIMITS = {
|
|
26
|
+
branch: 250,
|
|
27
|
+
commitSha: 100,
|
|
28
|
+
commitMessage: 500,
|
|
29
|
+
authorName: 100,
|
|
30
|
+
testedRevision: 200,
|
|
31
|
+
eventType: 100,
|
|
32
|
+
testName: 500,
|
|
33
|
+
filePath: 500,
|
|
34
|
+
errorMessage: 5000,
|
|
35
|
+
errorStack: 20000,
|
|
36
|
+
};
|
|
37
|
+
// v2-only limits (squally-app/lib/ingest-v2-contract.ts, V2_FIELD_LIMITS).
|
|
38
|
+
// Mirrored the same way as FIELD_LIMITS above, for the same reason.
|
|
39
|
+
const V2_FIELD_LIMITS = {
|
|
40
|
+
ciRunId: 200,
|
|
41
|
+
projectName: 200,
|
|
42
|
+
browserName: 50,
|
|
43
|
+
errorContext: 20000,
|
|
44
|
+
tag: 100,
|
|
45
|
+
playwrightVersion: 50,
|
|
46
|
+
os: 100,
|
|
47
|
+
osVersion: 50,
|
|
48
|
+
};
|
|
49
|
+
const MAX_TAGS = 20;
|
|
50
|
+
const MAX_ERRORS_PER_RESULT = 10;
|
|
51
|
+
const MAX_ERROR_SNIPPET = 2000;
|
|
52
|
+
// ASCII only, like everything else in this file, and short: it has to fit
|
|
53
|
+
// INSIDE the limit it is protecting, and authorName only has 100 characters
|
|
54
|
+
// to give away.
|
|
55
|
+
const TRUNCATION_MARKER = "... [truncated]";
|
|
56
|
+
// Squally rejects a payload with more than this many entries outright - the
|
|
57
|
+
// whole run, not the surplus. Mirrored here so the reporter can say so up
|
|
58
|
+
// front instead of the customer finding out from a 400 after the suite has
|
|
59
|
+
// already finished.
|
|
60
|
+
//
|
|
61
|
+
// Deliberately NOT enforced by trimming: unlike an over-long error message,
|
|
62
|
+
// the surplus here is test results, and dropping them would turn a loud
|
|
63
|
+
// failure into a run that quietly looks like those tests never existed. The
|
|
64
|
+
// reporter reports what happened or says why it could not - it does not
|
|
65
|
+
// invent a smaller truth.
|
|
66
|
+
//
|
|
67
|
+
// Retries count as separate entries, so a suite of 2 000 tests reaches this
|
|
68
|
+
// faster than the number suggests. Under v2 this is a soft ceiling in a
|
|
69
|
+
// different sense than under v1: it no longer discards the WHOLE run (results
|
|
70
|
+
// stream progressively, and the app itself has no total-per-run cap in v2 -
|
|
71
|
+
// see docs/ingest-v2-spec.md section 0, "the payload is bounded twice over"
|
|
72
|
+
// describes v1's problem specifically). Kept here anyway as an early,
|
|
73
|
+
// friendly warning for a suite that large, since it usually indicates a
|
|
74
|
+
// configuration problem (e.g. retries left cranked up) worth knowing about
|
|
75
|
+
// regardless of whether v2 would technically accept it in smaller pieces.
|
|
76
|
+
const MAX_TEST_RESULTS_WARNING = 5000;
|
|
77
|
+
// Squally accepts at most this many results per APPEND request (spec section
|
|
78
|
+
// 1.3). A batch larger than this is split into sequential requests rather
|
|
79
|
+
// than rejected - see flush() below.
|
|
80
|
+
const BATCH_MAX_RESULTS = 500;
|
|
81
|
+
// Visible rather than silent: a value that was cut says so, so nobody debugs
|
|
82
|
+
// a "wrong" commit message that is merely shortened. For testName/filePath
|
|
83
|
+
// this also alters Squally's test identity - unavoidable, since the
|
|
84
|
+
// alternative is losing the run outright, and two names sharing their first
|
|
85
|
+
// 500 characters would collide either way.
|
|
86
|
+
function truncate(value, max) {
|
|
87
|
+
if (value.length <= max)
|
|
88
|
+
return value;
|
|
89
|
+
if (max <= TRUNCATION_MARKER.length)
|
|
90
|
+
return value.slice(0, max);
|
|
91
|
+
return value.slice(0, max - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
|
|
92
|
+
}
|
|
93
|
+
function truncateOrNull(value, max) {
|
|
94
|
+
return value === null ? null : truncate(value, max);
|
|
95
|
+
}
|
|
96
|
+
// How much of a server error body to put in the log. Enough for the server's
|
|
97
|
+
// own one-line explanation, bounded so that an unexpected HTML error page
|
|
98
|
+
// from a proxy cannot dump kilobytes into the customer's CI output.
|
|
99
|
+
const ERROR_BODY_LOG_LIMIT = 500;
|
|
100
|
+
// Sent on every request to squally-app (not on the artifact PUT to R2's
|
|
101
|
+
// presigned URL - that goes straight to Cloudflare storage, not through the
|
|
102
|
+
// app, and R2 has no use for it). Server-side, a customer's reporter version
|
|
103
|
+
// was previously not determinable at all - every request looked identical
|
|
104
|
+
// regardless of which release sent it, which made both support ("what
|
|
105
|
+
// version is this customer on") and a future deprecation notice
|
|
106
|
+
// impossible to target.
|
|
107
|
+
//
|
|
108
|
+
// Read from this package's own package.json rather than hardcoded, so it can
|
|
109
|
+
// never drift from the version actually being published - package.json is
|
|
110
|
+
// always included in a published npm tarball regardless of the "files"
|
|
111
|
+
// field, and dist/index.js's parent directory is the package root.
|
|
112
|
+
const REPORTER_VERSION = require("../package.json").version;
|
|
113
|
+
const USER_AGENT = `squally-reporter/${REPORTER_VERSION}`;
|
|
114
|
+
// undici leaves headersTimeout and bodyTimeout at 300 000 ms each when they
|
|
115
|
+
// are not set. A hung server would otherwise cost 5 minutes per request -
|
|
116
|
+
// with v2's streaming appends that could mean many such waits spread across
|
|
117
|
+
// one run, not just a handful at the very end as in v1.
|
|
118
|
+
//
|
|
119
|
+
// Two budgets, because the two kinds of request are not comparable. The JSON
|
|
120
|
+
// calls (begin/append/complete/rules) exchange kilobytes and have no business
|
|
121
|
+
// taking longer than half a minute. An upload can be a 50 MB trace over a CI
|
|
122
|
+
// runner's modest uplink, where two minutes is tight-ish but still far short
|
|
123
|
+
// of "hung".
|
|
124
|
+
const API_REQUEST_TIMEOUT_MS = 30000;
|
|
125
|
+
const UPLOAD_REQUEST_TIMEOUT_MS = 120000;
|
|
126
|
+
// Shorter than API_REQUEST_TIMEOUT_MS on purpose (spec section 6): the rules
|
|
127
|
+
// call is best-effort and its result is not yet used for anything, so it has
|
|
128
|
+
// no business holding anything up as long as the other calls do.
|
|
129
|
+
const RULES_REQUEST_TIMEOUT_MS = 5000;
|
|
130
|
+
// --- Batching (spec section 3.2) --------------------------------------------
|
|
131
|
+
//
|
|
132
|
+
// Flush on whichever comes first: 25 results, or 2 seconds. 25 is a LATENCY
|
|
133
|
+
// limit, not a size limit (BATCH_MAX_RESULTS above is the size limit) - it
|
|
134
|
+
// keeps a normal suite's "running" view within a few seconds of the suite,
|
|
135
|
+
// while a very large run needs many requests spread over its whole duration
|
|
136
|
+
// rather than one enormous one. 2 seconds is the shortest window that
|
|
137
|
+
// cannot, on its own, exceed the ingest firewall rule (60 requests/60 s per
|
|
138
|
+
// API key): 60 s / 2 s = 30 requests/minute, half of 60.
|
|
139
|
+
const FLUSH_THRESHOLD_RESULTS = 25;
|
|
140
|
+
const FLUSH_INTERVAL_MS = 2000;
|
|
141
|
+
// --- Retry policy (spec section 3.2) ----------------------------------------
|
|
142
|
+
//
|
|
143
|
+
// Two independent policies, both applied by requestJson() below via separate
|
|
144
|
+
// counters (not one shared counter) - a run of alternating 429s and 5xxs
|
|
145
|
+
// should not let an early 429 retry eat into the budget a LATER, unrelated
|
|
146
|
+
// 5xx is entitled to, or vice versa.
|
|
147
|
+
const RATE_LIMIT_MAX_ATTEMPTS = 5;
|
|
148
|
+
const RATE_LIMIT_BACKOFF_BASE_MS = 1000;
|
|
149
|
+
const RATE_LIMIT_BACKOFF_CAP_MS = 30000;
|
|
150
|
+
const SERVER_ERROR_MAX_ATTEMPTS = 2; // i.e. one retry
|
|
151
|
+
const SERVER_ERROR_RETRY_DELAY_MS = 1000;
|
|
152
|
+
const NETWORK_ERROR_MAX_ATTEMPTS = 2; // treated the same as a 5xx - one retry
|
|
153
|
+
// (decision, spec section 3.2 closes 8.7) Drain budget at onEnd: 5 seconds,
|
|
154
|
+
// ON TOP OF EXIT_DRAIN_MS below. At the flush cadence above, at most one or
|
|
155
|
+
// two batches are ever in flight when onEnd fires; 5 s comfortably covers a
|
|
156
|
+
// single retry cycle. Also covers waiting for onBegin itself to resolve, if
|
|
157
|
+
// it is still pending - see drainAndComplete().
|
|
158
|
+
const DRAIN_BUDGET_MS = 5000;
|
|
159
|
+
function describeRequestError(err, timeoutMs) {
|
|
160
|
+
const name = err instanceof Error ? err.name : "";
|
|
161
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
162
|
+
return `no response within ${timeoutMs} ms - giving up`;
|
|
163
|
+
}
|
|
164
|
+
return String(err);
|
|
165
|
+
}
|
|
166
|
+
async function describeResponseError(response) {
|
|
167
|
+
let detail = "";
|
|
168
|
+
try {
|
|
169
|
+
const body = (await response.text()).trim();
|
|
170
|
+
if (body)
|
|
171
|
+
detail = ` - ${truncate(body, ERROR_BODY_LOG_LIMIT)}`;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// Reading the body is best-effort: a body that cannot be read must not
|
|
175
|
+
// turn a logged warning into a thrown error.
|
|
176
|
+
}
|
|
177
|
+
return `${response.status} ${response.statusText}${detail}`;
|
|
178
|
+
}
|
|
11
179
|
// Maps Playwright's more granular statuses down to the three
|
|
12
180
|
// statuses Squally stores (passed | failed | skipped).
|
|
13
181
|
// "timedOut" and "interrupted" both count as failures for reporting purposes.
|
|
@@ -31,29 +199,46 @@ function detectCiProvider() {
|
|
|
31
199
|
return "unknown-ci";
|
|
32
200
|
return null;
|
|
33
201
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
202
|
+
// Bounded and with stderr suppressed, for the four git fallbacks below. Two
|
|
203
|
+
// separate problems, one fix:
|
|
204
|
+
// - No timeout meant a hung `git` (e.g. an interactive credential helper
|
|
205
|
+
// prompting for input that will never come) could block the process
|
|
206
|
+
// indefinitely with no recovery.
|
|
207
|
+
// - Inheriting stderr meant a failure printed straight into the customer's
|
|
208
|
+
// CI log (e.g. "fatal: not a git repository", up to four times per run -
|
|
209
|
+
// one per detect* function) even though the failure is already handled
|
|
210
|
+
// gracefully by the caller and never surfaces as a warning. A customer
|
|
211
|
+
// seeing that text has no way to tell it apart from a real reporter
|
|
212
|
+
// problem.
|
|
213
|
+
// stdio: ["ignore", "pipe", "ignore"] keeps stdout (the actual value being
|
|
214
|
+
// read) piped back, discards stdin and stderr entirely.
|
|
215
|
+
const GIT_COMMAND_TIMEOUT_MS = 5000;
|
|
216
|
+
function runGitCommand(command) {
|
|
39
217
|
try {
|
|
40
|
-
return (0, node_child_process_1.execSync)(
|
|
218
|
+
return (0, node_child_process_1.execSync)(command, {
|
|
219
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
220
|
+
timeout: GIT_COMMAND_TIMEOUT_MS,
|
|
221
|
+
})
|
|
222
|
+
.toString()
|
|
223
|
+
.trim();
|
|
41
224
|
}
|
|
42
225
|
catch {
|
|
43
226
|
return null;
|
|
44
227
|
}
|
|
45
228
|
}
|
|
229
|
+
function detectBranch() {
|
|
230
|
+
if (process.env.GITHUB_REF_NAME)
|
|
231
|
+
return process.env.GITHUB_REF_NAME;
|
|
232
|
+
if (process.env.CI_COMMIT_BRANCH)
|
|
233
|
+
return process.env.CI_COMMIT_BRANCH;
|
|
234
|
+
return runGitCommand("git rev-parse --abbrev-ref HEAD");
|
|
235
|
+
}
|
|
46
236
|
function detectCommitSha() {
|
|
47
237
|
if (process.env.GITHUB_SHA)
|
|
48
238
|
return process.env.GITHUB_SHA;
|
|
49
239
|
if (process.env.CI_COMMIT_SHA)
|
|
50
240
|
return process.env.CI_COMMIT_SHA;
|
|
51
|
-
|
|
52
|
-
return (0, node_child_process_1.execSync)("git rev-parse HEAD").toString().trim();
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
241
|
+
return runGitCommand("git rev-parse HEAD");
|
|
57
242
|
}
|
|
58
243
|
// GitLab CI exposes the full commit message directly (CI_COMMIT_MESSAGE).
|
|
59
244
|
// GitHub Actions has no equivalent env var, so it falls through to the git
|
|
@@ -62,12 +247,7 @@ function detectCommitSha() {
|
|
|
62
247
|
function detectCommitMessage() {
|
|
63
248
|
if (process.env.CI_COMMIT_MESSAGE)
|
|
64
249
|
return process.env.CI_COMMIT_MESSAGE;
|
|
65
|
-
|
|
66
|
-
return (0, node_child_process_1.execSync)("git log -1 --pretty=%B").toString().trim();
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
250
|
+
return runGitCommand("git log -1 --pretty=%B");
|
|
71
251
|
}
|
|
72
252
|
// GitHub Actions' GITHUB_ACTOR is the user who triggered the workflow run
|
|
73
253
|
// (a username, not necessarily the commit author, but the closest thing
|
|
@@ -81,12 +261,74 @@ function detectAuthorName() {
|
|
|
81
261
|
const match = process.env.CI_COMMIT_AUTHOR.match(/^(.*?)\s*<[^>]*>\s*$/);
|
|
82
262
|
return match ? match[1] : process.env.CI_COMMIT_AUTHOR;
|
|
83
263
|
}
|
|
84
|
-
|
|
85
|
-
|
|
264
|
+
return runGitCommand("git log -1 --pretty=%an");
|
|
265
|
+
}
|
|
266
|
+
// --- ciRunId (spec sections 1.2, 2.1, 5.3) ----------------------------------
|
|
267
|
+
//
|
|
268
|
+
// This is new in v2 and has no v1 equivalent to imitate: it is what binds
|
|
269
|
+
// several shards of one CI run together into one group (spec section 1.5),
|
|
270
|
+
// and what makes a retried onBegin idempotent (spec section 2.1). It has to
|
|
271
|
+
// be something SHARED across the several separate shard PROCESSES of one CI
|
|
272
|
+
// run, which rules out anything generated inside this process alone for that
|
|
273
|
+
// case - it has to come from the CI environment.
|
|
274
|
+
//
|
|
275
|
+
// One id per provider, chosen because it is shared across a job's shards and
|
|
276
|
+
// distinct across separate runs:
|
|
277
|
+
// - GitHub Actions: GITHUB_RUN_ID, shared by every matrix/shard job of one
|
|
278
|
+
// workflow run. GITHUB_RUN_ATTEMPT is appended when present, because a
|
|
279
|
+
// manual re-run of a failed job REUSES GITHUB_RUN_ID - without the
|
|
280
|
+
// attempt suffix, re-running a job whose shard had already completed
|
|
281
|
+
// would immediately hit 409 (append after completion) instead of
|
|
282
|
+
// starting a fresh, trackable run.
|
|
283
|
+
// - GitLab CI: CI_PIPELINE_ID, shared by every job in a `parallel:` group -
|
|
284
|
+
// unlike CI_JOB_ID, which is per-job.
|
|
285
|
+
// - CircleCI: CIRCLE_WORKFLOW_ID, shared by every job in a workflow -
|
|
286
|
+
// unlike CIRCLE_BUILD_NUM, which is per-job.
|
|
287
|
+
//
|
|
288
|
+
// No id available (an unrecognised CI provider, or a local run): falls back
|
|
289
|
+
// to a fresh id generated for this process. That id is STABLE across a
|
|
290
|
+
// retried onBegin call within this same process (idempotency still holds
|
|
291
|
+
// locally), but it cannot correlate with a SEPARATE shard process, because
|
|
292
|
+
// there is no shared environment variable to read for those providers. A
|
|
293
|
+
// user on such a provider - or running --shard locally across several
|
|
294
|
+
// invocations - can set SQUALLY_CI_RUN_ID explicitly to restore correlation,
|
|
295
|
+
// the same escape-hatch pattern this file already uses for
|
|
296
|
+
// SAME_REPO_AS_APP/TESTED_REVISION below.
|
|
297
|
+
//
|
|
298
|
+
// env and generateFallback are parameters, not process.env/randomUUID read
|
|
299
|
+
// directly, so this is unit-testable without mocking global state.
|
|
300
|
+
function detectCiRunId(env, generateFallback) {
|
|
301
|
+
if (env.SQUALLY_CI_RUN_ID)
|
|
302
|
+
return env.SQUALLY_CI_RUN_ID;
|
|
303
|
+
if (env.GITHUB_RUN_ID) {
|
|
304
|
+
return env.GITHUB_RUN_ATTEMPT
|
|
305
|
+
? `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`
|
|
306
|
+
: env.GITHUB_RUN_ID;
|
|
86
307
|
}
|
|
87
|
-
|
|
308
|
+
if (env.CI_PIPELINE_ID)
|
|
309
|
+
return env.CI_PIPELINE_ID;
|
|
310
|
+
if (env.CIRCLE_WORKFLOW_ID)
|
|
311
|
+
return env.CIRCLE_WORKFLOW_ID;
|
|
312
|
+
return generateFallback();
|
|
313
|
+
}
|
|
314
|
+
function resolveProjectName(projectSuites) {
|
|
315
|
+
if (projectSuites.length !== 1)
|
|
88
316
|
return null;
|
|
89
|
-
|
|
317
|
+
const name = projectSuites[0].project()?.name;
|
|
318
|
+
return name ? name : null;
|
|
319
|
+
}
|
|
320
|
+
// The per-RESULT counterpart to resolveProjectName above: which project did
|
|
321
|
+
// THIS SPECIFIC test run under, inside a process that may cover several.
|
|
322
|
+
// Playwright's own convention - visible in the app's own reading of
|
|
323
|
+
// titlePath() (squally-app/docs/ingest-v2-spec.md section 5.1) - is that the
|
|
324
|
+
// project name IS what test authors already call their browser/config
|
|
325
|
+
// variant (the FullProject config example in Playwright's own types uses
|
|
326
|
+
// exactly "chromium"/"firefox"/"webkit" as project names), so this reuses
|
|
327
|
+
// that value rather than trying to resolve an underlying browser engine name
|
|
328
|
+
// through a separate, less stable path.
|
|
329
|
+
function resolveBrowserName(test) {
|
|
330
|
+
const name = test.parent.project()?.name;
|
|
331
|
+
return name ? truncate(name, V2_FIELD_LIMITS.browserName) : null;
|
|
90
332
|
}
|
|
91
333
|
// --- Flakiness Engine v2 (Phase A): tested application revision ---
|
|
92
334
|
//
|
|
@@ -152,20 +394,18 @@ function toPosixPath(p) {
|
|
|
152
394
|
// via chalk/picocolors) can do the same to stdout/stderr. Stripped here,
|
|
153
395
|
// before anything is ever sent - not server/UI-side - so the raw escape
|
|
154
396
|
// codes never land in the database in the first place, and every later
|
|
155
|
-
// consumer
|
|
156
|
-
// AI analysis, a fingerprint cache over normalized error signatures)
|
|
157
|
-
// does not have to strip them again itself.
|
|
397
|
+
// consumer does not have to strip them again itself.
|
|
158
398
|
//
|
|
159
399
|
// Matches the CSI form ANSI color/style codes actually take: an ESC
|
|
160
400
|
// character (code point 27), then an opening bracket, then any run of
|
|
161
401
|
// digits/semicolons, then one letter (e.g. ESC, bracket, 3, 1, m for red).
|
|
162
|
-
// No new dependency (e.g. strip-ansi) for a one-line regex
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
402
|
+
// No new dependency (e.g. strip-ansi) for a one-line regex. Built via
|
|
403
|
+
// String.fromCharCode rather than a hex escape directly in the source: this
|
|
404
|
+
// file has to stay plain, readable ASCII - a raw ESC byte pasted into source
|
|
405
|
+
// code is invisible in most editors/diffs and easy to mistake for empty
|
|
406
|
+
// content.
|
|
167
407
|
function stripAnsi(value) {
|
|
168
|
-
const ansiPattern = new RegExp(String.fromCharCode(27) +
|
|
408
|
+
const ansiPattern = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*[a-zA-Z]", "g");
|
|
169
409
|
return value.replace(ansiPattern, "");
|
|
170
410
|
}
|
|
171
411
|
// Playwright captures stdout/stderr as two separate arrays (each internally
|
|
@@ -184,6 +424,134 @@ function formatConsoleOutput(result) {
|
|
|
184
424
|
}
|
|
185
425
|
return lines.length > 0 ? lines.join("\n") : null;
|
|
186
426
|
}
|
|
427
|
+
// --- Console masking (spec section 1.3) -------------------------------------
|
|
428
|
+
//
|
|
429
|
+
// Mirrors squally-app's lib/ingest-v2-core.ts MASK_PATTERNS exactly - same
|
|
430
|
+
// four patterns, same deliberate absence of a fifth. `password=` was dropped
|
|
431
|
+
// there (and is not added here) because it matched ordinary test output far
|
|
432
|
+
// more often than a real secret (an assertion on a validation message about
|
|
433
|
+
// a password field), and masking is not reversible: a false positive
|
|
434
|
+
// destroys the diagnostic value console output exists for in the first
|
|
435
|
+
// place. The app masks again server-side - this copy exists so the token
|
|
436
|
+
// never travels over the wire AT ALL, not merely so it never reaches
|
|
437
|
+
// storage (spec section 1.3: "the field is the one most likely to carry a
|
|
438
|
+
// token").
|
|
439
|
+
const MASK_PATTERNS = [
|
|
440
|
+
// The token after the scheme, not the word "Bearer" itself - keeping the
|
|
441
|
+
// scheme is what makes a masked line still readable as an auth header.
|
|
442
|
+
{ pattern: /(Bearer\s+)[A-Za-z0-9\-._~+/]{8,}={0,2}/gi, replace: "$1[redacted]" },
|
|
443
|
+
{ pattern: /\bsk_[A-Za-z0-9_]{8,}/g, replace: "[redacted]" },
|
|
444
|
+
{ pattern: /\bghp_[A-Za-z0-9]{8,}/g, replace: "[redacted]" },
|
|
445
|
+
{ pattern: /\bAKIA[0-9A-Z]{16}\b/g, replace: "[redacted]" },
|
|
446
|
+
];
|
|
447
|
+
function maskSecrets(value) {
|
|
448
|
+
let masked = value;
|
|
449
|
+
for (const { pattern, replace } of MASK_PATTERNS) {
|
|
450
|
+
// Fresh lastIndex per call: these are module-level /g regexes and would
|
|
451
|
+
// otherwise carry state between inputs (the same bug once landed in
|
|
452
|
+
// squally-app's own copy of this function).
|
|
453
|
+
pattern.lastIndex = 0;
|
|
454
|
+
masked = masked.replace(pattern, replace);
|
|
455
|
+
}
|
|
456
|
+
return masked;
|
|
457
|
+
}
|
|
458
|
+
// Console output cap, spec section 1.3 - 16 KB, down from the 20 000
|
|
459
|
+
// CHARACTER limit this file used to enforce for the old flat consoleOutput
|
|
460
|
+
// field. Masks, THEN caps: capping first could cut a token in half and leave
|
|
461
|
+
// a prefix that no longer matches its pattern, storing the first half of a
|
|
462
|
+
// secret. Cutting on a byte boundary can still split a multi-byte character,
|
|
463
|
+
// so the tail is trimmed back to a valid boundary rather than left broken -
|
|
464
|
+
// the exact same logic squally-app applies server-side (belt and braces: the
|
|
465
|
+
// server enforces this regardless of what the reporter already did, but
|
|
466
|
+
// doing it here too means a run never sends more than it needs to).
|
|
467
|
+
const CONSOLE_OUTPUT_MAX_BYTES = 16 * 1024;
|
|
468
|
+
function prepareConsoleOutput(value) {
|
|
469
|
+
if (value === null)
|
|
470
|
+
return null;
|
|
471
|
+
const masked = maskSecrets(value);
|
|
472
|
+
const bytes = Buffer.from(masked, "utf8");
|
|
473
|
+
if (bytes.byteLength <= CONSOLE_OUTPUT_MAX_BYTES)
|
|
474
|
+
return masked;
|
|
475
|
+
const cut = bytes.subarray(0, CONSOLE_OUTPUT_MAX_BYTES).toString("utf8");
|
|
476
|
+
return cut.endsWith("�") ? cut.slice(0, -1) : cut;
|
|
477
|
+
}
|
|
478
|
+
// --- errors[] (spec section 1.3) --------------------------------------------
|
|
479
|
+
//
|
|
480
|
+
// Replaces the old errors[0]-only mapping: Playwright supplies an array,
|
|
481
|
+
// v2's wire contract accepts one (up to MAX_ERRORS_PER_RESULT), stored as a
|
|
482
|
+
// child row per element server-side (squally-app's TestResultError).
|
|
483
|
+
//
|
|
484
|
+
// message is REQUIRED on the wire (unlike the old flat errorMessage, which
|
|
485
|
+
// could be null) because a stored error row with no message at all is not
|
|
486
|
+
// useful information. Playwright's own TestError.message is itself OPTIONAL
|
|
487
|
+
// though - it is unset when something other than an Error was thrown (e.g.
|
|
488
|
+
// `throw "a string"`), in which case .value holds what was thrown instead.
|
|
489
|
+
// Falls back to that, then to a fixed string, so this function always
|
|
490
|
+
// produces a message even when Playwright's own error object did not have
|
|
491
|
+
// one to give.
|
|
492
|
+
//
|
|
493
|
+
// Masking is deliberately NOT applied here. The spec scopes masking to
|
|
494
|
+
// console output specifically (section 1.3's own heading), and the app's
|
|
495
|
+
// server-side implementation (squally-app/lib/ingest-v2.ts) only calls its
|
|
496
|
+
// masking function on consoleOutput, nowhere near error text - matching that
|
|
497
|
+
// scope rather than inventing a wider one.
|
|
498
|
+
function mapErrors(errors) {
|
|
499
|
+
if (errors.length === 0)
|
|
500
|
+
return undefined;
|
|
501
|
+
return errors.slice(0, MAX_ERRORS_PER_RESULT).map((error) => {
|
|
502
|
+
const rawMessage = error.message ?? (error.value !== undefined ? String(error.value) : "Unknown error");
|
|
503
|
+
const entry = {
|
|
504
|
+
message: truncate(stripAnsi(rawMessage), FIELD_LIMITS.errorMessage),
|
|
505
|
+
};
|
|
506
|
+
if (error.stack)
|
|
507
|
+
entry.stack = truncate(stripAnsi(error.stack), FIELD_LIMITS.errorStack);
|
|
508
|
+
// The code-frame snippet Playwright prints can itself carry ANSI
|
|
509
|
+
// highlighting for the failing line, same reasoning as message/stack.
|
|
510
|
+
if (error.snippet)
|
|
511
|
+
entry.snippet = truncate(stripAnsi(error.snippet), MAX_ERROR_SNIPPET);
|
|
512
|
+
if (error.location) {
|
|
513
|
+
entry.location = {
|
|
514
|
+
file: truncate(toPosixPath(node_path_1.default.relative(process.cwd(), error.location.file)), FIELD_LIMITS.filePath),
|
|
515
|
+
line: error.location.line,
|
|
516
|
+
column: error.location.column,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
return entry;
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
// --- error-context attachment (spec section 1.2/1.3, "the error-context
|
|
523
|
+
// attachment") -------------------------------------------------------------
|
|
524
|
+
//
|
|
525
|
+
// Playwright attaches a page-state snapshot named "error-context" to a
|
|
526
|
+
// failed attempt (playwright-core's errorContext.js), used by AI debugging
|
|
527
|
+
// tools to understand what the page looked like at the moment of failure.
|
|
528
|
+
// It is either inline (attachment.body) or file-backed (attachment.path),
|
|
529
|
+
// same shape as the screenshot/trace attachments already handled below.
|
|
530
|
+
//
|
|
531
|
+
// Read synchronously, same justification as the existing sizeBytes stat
|
|
532
|
+
// call in onTestEnd: this hook's return value is not awaited by Playwright
|
|
533
|
+
// (Reporter['onTestEnd'] is typed void), so an async read here would race
|
|
534
|
+
// with onEnd() reading collected state before it resolved. These files are
|
|
535
|
+
// small (a text/YAML outline of visible page elements), so a synchronous
|
|
536
|
+
// read costs microseconds, not milliseconds - unlike a screenshot/trace
|
|
537
|
+
// upload, which is genuinely large and stays fully async, uploaded later.
|
|
538
|
+
function readErrorContext(attachment) {
|
|
539
|
+
if (!attachment)
|
|
540
|
+
return null;
|
|
541
|
+
try {
|
|
542
|
+
const raw = attachment.body !== undefined
|
|
543
|
+
? attachment.body.toString("utf8")
|
|
544
|
+
: attachment.path !== undefined
|
|
545
|
+
? (0, node_fs_1.readFileSync)(attachment.path, "utf8")
|
|
546
|
+
: null;
|
|
547
|
+
return raw === null ? null : truncate(stripAnsi(raw), V2_FIELD_LIMITS.errorContext);
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
// File already gone (e.g. cleaned up by a parallel worker) - the same
|
|
551
|
+
// expected, harmless race the sizeBytes stat below already tolerates.
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
187
555
|
// Presigned upload URLs are valid for 15 minutes (server-side, see
|
|
188
556
|
// squally-app's lib/r2.ts). Uploading fully sequentially could get close to
|
|
189
557
|
// that window for a suite with a very large number of failing/retried
|
|
@@ -194,6 +562,28 @@ function formatConsoleOutput(result) {
|
|
|
194
562
|
// fast enough that even a few hundred artifacts finish in well under 15
|
|
195
563
|
// minutes, bounded enough not to overwhelm a typical CI runner.
|
|
196
564
|
const UPLOAD_CONCURRENCY = 5;
|
|
565
|
+
// Playwright does not let the process end on its own: the runner calls
|
|
566
|
+
// gracefullyCloseAll().then(() => process.exit(code)) (playwright-core's
|
|
567
|
+
// gracefullyProcessExitDoNotHang). process.exit() tears the event loop down
|
|
568
|
+
// immediately, and on Windows that aborts the whole process with
|
|
569
|
+
// Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src/win/async.c
|
|
570
|
+
// if any of our own I/O is still winding down at that moment - the exit code
|
|
571
|
+
// becomes 127 instead of 0, i.e. a green test run reports as failed.
|
|
572
|
+
//
|
|
573
|
+
// Measured (0.3.0/0.4.0), so the number is not a guess: with the run POST
|
|
574
|
+
// alone the process exits cleanly; from the second request on (presign) it
|
|
575
|
+
// aborts reproducibly. A 0 ms or 10 ms wait does not help, 100 ms survives
|
|
576
|
+
// two requests, and 250 ms survives the full v1 path (ingest -> presign ->
|
|
577
|
+
// upload -> confirm) over repeated runs. Under v2's streaming path there are
|
|
578
|
+
// MORE sequential/concurrent requests over the run's lifetime than v1 ever
|
|
579
|
+
// had, which is exactly why this session's exit-code guard (test/exit-code)
|
|
580
|
+
// was updated rather than assumed to still cover the new path automatically.
|
|
581
|
+
//
|
|
582
|
+
// This is a mitigation, not a cure: it widens the window rather than
|
|
583
|
+
// removing the race, because nothing in Node's public API reports "all my
|
|
584
|
+
// handles are really gone". Do not shrink it without re-running the Windows
|
|
585
|
+
// job in CI - that job exists specifically to catch this.
|
|
586
|
+
const EXIT_DRAIN_MS = 250;
|
|
197
587
|
// No new dependency for this - a fixed-size worker pool that keeps pulling
|
|
198
588
|
// the next item until the list is exhausted is ~10 lines, not worth pulling
|
|
199
589
|
// in e.g. p-limit for.
|
|
@@ -226,7 +616,8 @@ function attemptQualifiesForArtifacts(mappedStatus, retryCount) {
|
|
|
226
616
|
// testName/filePath can contain arbitrary characters, and this key only
|
|
227
617
|
// needs to be unique, not human-readable (it is purely an internal lookup
|
|
228
618
|
// back to the local CollectedArtifact once the server round-trips these
|
|
229
|
-
// identity fields).
|
|
619
|
+
// identity fields). Unchanged from v1 - see the CollectedArtifact comment
|
|
620
|
+
// above for why this stays a 4-part key under v2.
|
|
230
621
|
function artifactKey(testName, filePath, retryCount, type) {
|
|
231
622
|
return [testName, filePath, String(retryCount), type].join(String.fromCharCode(1));
|
|
232
623
|
}
|
|
@@ -249,6 +640,55 @@ function confirmReasonMessage(reason) {
|
|
|
249
640
|
function artifactLabel(type) {
|
|
250
641
|
return type === "screenshot" ? "Screenshot" : "Trace";
|
|
251
642
|
}
|
|
643
|
+
function delay(ms) {
|
|
644
|
+
return new Promise((resolve) => {
|
|
645
|
+
const handle = setTimeout(resolve, ms);
|
|
646
|
+
handle.unref();
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
// Retry-After per RFC 9110: either an integer number of seconds, or an
|
|
650
|
+
// HTTP-date. Returns null (never throws) when the header is absent or
|
|
651
|
+
// unparseable as either form, so the caller can fall back to its own
|
|
652
|
+
// backoff formula.
|
|
653
|
+
function parseRetryAfterMs(headerValue, nowMs) {
|
|
654
|
+
if (!headerValue)
|
|
655
|
+
return null;
|
|
656
|
+
const asSeconds = Number(headerValue);
|
|
657
|
+
if (Number.isFinite(asSeconds) && asSeconds >= 0)
|
|
658
|
+
return asSeconds * 1000;
|
|
659
|
+
const asDate = Date.parse(headerValue);
|
|
660
|
+
if (!Number.isNaN(asDate))
|
|
661
|
+
return Math.max(0, asDate - nowMs);
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
// retryNumber is 1-indexed (the first retry passes 1, not 0).
|
|
665
|
+
function computeBackoffMs(retryNumber) {
|
|
666
|
+
return Math.min(RATE_LIMIT_BACKOFF_CAP_MS, RATE_LIMIT_BACKOFF_BASE_MS * 2 ** (retryNumber - 1));
|
|
667
|
+
}
|
|
668
|
+
const TIMED_OUT = Symbol("timed-out");
|
|
669
|
+
// Races a promise against an absolute deadline (a timestamp, not a
|
|
670
|
+
// duration) - absolute so that several sequential awaits inside one drain
|
|
671
|
+
// pass (see drainAndComplete()) can all share the SAME overall budget
|
|
672
|
+
// instead of each getting a fresh one.
|
|
673
|
+
function withDeadline(promise, deadlineMs) {
|
|
674
|
+
const remaining = Math.max(0, deadlineMs - Date.now());
|
|
675
|
+
return Promise.race([
|
|
676
|
+
promise,
|
|
677
|
+
new Promise((resolve) => {
|
|
678
|
+
const handle = setTimeout(() => resolve(TIMED_OUT), remaining);
|
|
679
|
+
handle.unref();
|
|
680
|
+
}),
|
|
681
|
+
]);
|
|
682
|
+
}
|
|
683
|
+
// module-level, not per-instance: spec section 6 says the rules response is
|
|
684
|
+
// "cached in memory for ttlSeconds, per process", and a Playwright process
|
|
685
|
+
// normally constructs exactly one Reporter instance for the run, so this
|
|
686
|
+
// mostly matters as a statement of intent rather than something exercised
|
|
687
|
+
// today - but "per process" is what the spec says, so this is where it
|
|
688
|
+
// lives. v2's reporter only ever FETCHES and stores this; nothing reads it
|
|
689
|
+
// yet (spec: "fetches and ignores... keeps the call path proven before the
|
|
690
|
+
// first behaviour hangs off it").
|
|
691
|
+
let rulesCache = null;
|
|
252
692
|
class SquallyReporter {
|
|
253
693
|
constructor(options = {}) {
|
|
254
694
|
this.apiUrl = "";
|
|
@@ -258,8 +698,65 @@ class SquallyReporter {
|
|
|
258
698
|
// Every onXxx hook checks this first and no-ops if it's false.
|
|
259
699
|
this.enabled = false;
|
|
260
700
|
this.startedAt = new Date();
|
|
261
|
-
this.
|
|
701
|
+
this.ciRunId = "";
|
|
702
|
+
this.shardIndex = null;
|
|
703
|
+
this.shardTotal = null;
|
|
704
|
+
this.projectName = null;
|
|
705
|
+
this.buffer = [];
|
|
262
706
|
this.artifacts = [];
|
|
707
|
+
// True once nothing more will ever be sent for this run - a permanent
|
|
708
|
+
// give-up (402/404/409 on append, or onBegin never getting a runId at
|
|
709
|
+
// all). onTestEnd stops collecting further results/artifacts once this is
|
|
710
|
+
// set (see giveUp() below for why that is safe).
|
|
711
|
+
this.sendingDisabled = false;
|
|
712
|
+
this.flushTimerHandle = null;
|
|
713
|
+
this.inFlightFlushes = new Set();
|
|
714
|
+
// Resolves to the runId once onBegin succeeds, or to null on any permanent
|
|
715
|
+
// failure (never rejects - every failure path here is a warning, not an
|
|
716
|
+
// exception, same principle as the rest of this file).
|
|
717
|
+
this.runIdPromise = Promise.resolve(null);
|
|
718
|
+
// Set once runIdPromise resolves to a real id - read by onEnd's artifact
|
|
719
|
+
// upload step, which needs a plain value rather than a promise it would
|
|
720
|
+
// otherwise have to await a second time.
|
|
721
|
+
this.runId = null;
|
|
722
|
+
// onBegin's timestamp is normally the right answer, and under v2 it is the
|
|
723
|
+
// ONLY answer available at send time: startedAt has to be sent to the
|
|
724
|
+
// server IMMEDIATELY in onBegin's own request, before a single test has
|
|
725
|
+
// run - unlike v1, where the whole payload (startedAt included) was built
|
|
726
|
+
// once at onEnd, after every test's start time was already known.
|
|
727
|
+
//
|
|
728
|
+
// v1 corrected this timestamp against the earliest test's own start time,
|
|
729
|
+
// specifically for `npx playwright merge-reports`, where onBegin fires on
|
|
730
|
+
// the merging machine minutes or hours after the tests actually ran. That
|
|
731
|
+
// correction CANNOT be reproduced under v2's streaming model - there is no
|
|
732
|
+
// moment between "onBegin fired" and "the first test started" at which an
|
|
733
|
+
// earliest-start value could be known, because onBegin's own network call
|
|
734
|
+
// has to go out before any test has even begun. This is not a discretionary
|
|
735
|
+
// simplification; the value the old correction needed does not exist yet
|
|
736
|
+
// at the point v2 requires startedAt to be sent.
|
|
737
|
+
//
|
|
738
|
+
// The one case this used to fix is also the one case v2 no longer supports
|
|
739
|
+
// for reporting to Squally at all: merge-reports is explicitly unsupported
|
|
740
|
+
// under ingest v2 (spec section 5.3, closes question 8.8) - ciRunId is now
|
|
741
|
+
// the one supported sharding path. Dropping the correction therefore loses
|
|
742
|
+
// nothing for any path v2 actually supports.
|
|
743
|
+
this.onEnd = async (result) => {
|
|
744
|
+
if (!this.enabled)
|
|
745
|
+
return;
|
|
746
|
+
try {
|
|
747
|
+
await this.drainAndComplete(result);
|
|
748
|
+
if (this.runId && this.artifacts.length > 0) {
|
|
749
|
+
await this.uploadArtifacts(this.runId);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
finally {
|
|
753
|
+
await this.dispatcher.close();
|
|
754
|
+
// See EXIT_DRAIN_MS - closing the dispatcher resolves before the
|
|
755
|
+
// underlying sockets are fully gone, and Playwright exits the process
|
|
756
|
+
// the moment onEnd() resolves.
|
|
757
|
+
await delay(EXIT_DRAIN_MS);
|
|
758
|
+
}
|
|
759
|
+
};
|
|
263
760
|
const apiUrl = options.apiUrl ?? process.env.SQUALLY_API_URL;
|
|
264
761
|
const apiKey = options.apiKey ?? process.env.SQUALLY_API_KEY;
|
|
265
762
|
if (!apiUrl || !apiKey) {
|
|
@@ -276,28 +773,106 @@ class SquallyReporter {
|
|
|
276
773
|
this.apiKey = apiKey;
|
|
277
774
|
this.enabled = true;
|
|
278
775
|
}
|
|
279
|
-
onBegin(
|
|
776
|
+
onBegin(config, suite) {
|
|
280
777
|
if (!this.enabled)
|
|
281
778
|
return;
|
|
282
779
|
this.startedAt = new Date();
|
|
780
|
+
this.dispatcher = new undici_1.Agent({
|
|
781
|
+
keepAliveTimeout: 4000,
|
|
782
|
+
keepAliveMaxTimeout: 4000,
|
|
783
|
+
headersTimeout: UPLOAD_REQUEST_TIMEOUT_MS,
|
|
784
|
+
bodyTimeout: UPLOAD_REQUEST_TIMEOUT_MS,
|
|
785
|
+
});
|
|
786
|
+
this.ciRunId = truncate(detectCiRunId(process.env, () => (0, node_crypto_1.randomUUID)()), V2_FIELD_LIMITS.ciRunId);
|
|
787
|
+
// Playwright's shard.current/total are already 1-based, matching the
|
|
788
|
+
// server's own shardIndex >= 1 constraint (docs/ingest-v2-spec.md
|
|
789
|
+
// section 1.2) - no adjustment needed.
|
|
790
|
+
this.shardIndex = config.shard?.current ?? null;
|
|
791
|
+
this.shardTotal = config.shard?.total ?? null;
|
|
792
|
+
this.projectName = resolveProjectName(suite.suites);
|
|
793
|
+
// config.tags is a newer Playwright feature (test tags) than this
|
|
794
|
+
// package's peer dependency floor (@playwright/test >= 1.30.0) - guarded
|
|
795
|
+
// rather than assumed present, unlike the well-established fields below
|
|
796
|
+
// (config.shard, config.workers) which have existed since early
|
|
797
|
+
// versions and are read unguarded.
|
|
798
|
+
const tags = Array.isArray(config.tags) ? config.tags : [];
|
|
799
|
+
this.runTags =
|
|
800
|
+
tags.length > 0 ? tags.slice(0, MAX_TAGS).map((tag) => truncate(tag, V2_FIELD_LIMITS.tag)) : undefined;
|
|
801
|
+
this.runConfig = this.resolveRunConfig(config);
|
|
802
|
+
this.runIdPromise = this.beginRun();
|
|
803
|
+
void this.fetchRules();
|
|
804
|
+
this.armFlushTimer();
|
|
805
|
+
}
|
|
806
|
+
// config.browsers ({name, version}[]) is deliberately OMITTED here, not
|
|
807
|
+
// sent as an empty/placeholder array. Getting REAL installed browser
|
|
808
|
+
// versions would mean either launching each browser type just to query it
|
|
809
|
+
// (expensive, and a new way for this reporter to fail a customer's CI that
|
|
810
|
+
// has nothing to do with reporting) or reaching into playwright-core's
|
|
811
|
+
// private browser-registry internals (undocumented, and liable to break
|
|
812
|
+
// silently across the wide @playwright/test >= 1.30.0 peer range this
|
|
813
|
+
// package supports). The field has zero consumers today either way (spec
|
|
814
|
+
// section 5.2: added "so that later work has real data instead of a
|
|
815
|
+
// parsed prefix") - sending an invented placeholder version string would
|
|
816
|
+
// be worse than sending nothing, because it would look like real data to
|
|
817
|
+
// whatever reads it later. Flagged in this session's report as a decision
|
|
818
|
+
// the spec left open.
|
|
819
|
+
resolveRunConfig(config) {
|
|
820
|
+
return {
|
|
821
|
+
playwrightVersion: typeof config.version === "string" ? truncate(config.version, V2_FIELD_LIMITS.playwrightVersion) : null,
|
|
822
|
+
workers: typeof config.workers === "number" ? config.workers : null,
|
|
823
|
+
os: truncate((0, node_os_1.platform)(), V2_FIELD_LIMITS.os),
|
|
824
|
+
osVersion: truncate((0, node_os_1.release)(), V2_FIELD_LIMITS.osVersion),
|
|
825
|
+
};
|
|
283
826
|
}
|
|
284
827
|
onTestEnd(test, result) {
|
|
285
|
-
if (!this.enabled)
|
|
828
|
+
if (!this.enabled || this.sendingDisabled)
|
|
286
829
|
return;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
830
|
+
// Truncated HERE, once, not when the payload is built: testName/filePath
|
|
831
|
+
// are Squally's test identity, and /api/artifacts/presign resolves each
|
|
832
|
+
// artifact back to a TestResult through exactly these two values plus
|
|
833
|
+
// retryCount. Cutting them in two places, or cutting them only for the
|
|
834
|
+
// ingest payload, would make every artifact of a long-named test
|
|
835
|
+
// unmatchable server-side.
|
|
836
|
+
//
|
|
837
|
+
// slice(1), NOT slice(2) - the project name has to stay in the title.
|
|
838
|
+
// See squally-app/docs/ingest-v2-spec.md section 5.1/5.2: testName is
|
|
839
|
+
// part of @@unique([projectId, testName, filePath]), and the identity
|
|
840
|
+
// key is UNCHANGED in v2 (a deliberately conservative decision - see the
|
|
841
|
+
// spec's own section 5.2). "chromium > a.spec.ts > t" and
|
|
842
|
+
// "webkit > a.spec.ts > t" stay two independent identities; merging them
|
|
843
|
+
// would make the same-commit-flip signal treat a browser-specific bug as
|
|
844
|
+
// proven flakiness across the whole suite at once.
|
|
845
|
+
const testName = truncate(test.titlePath().slice(1).join(" > "), FIELD_LIMITS.testName);
|
|
846
|
+
const filePath = truncate(toPosixPath(node_path_1.default.relative(process.cwd(), test.location.file)), FIELD_LIMITS.filePath);
|
|
290
847
|
const status = mapStatus(result.status);
|
|
291
|
-
|
|
848
|
+
// Guarded the same way runTags is above: an older installed Playwright
|
|
849
|
+
// may not have this field at all.
|
|
850
|
+
const repeatIndex = typeof test.repeatEachIndex === "number" ? test.repeatEachIndex : 0;
|
|
851
|
+
const testTags = Array.isArray(test.tags) ? test.tags : [];
|
|
852
|
+
const errorContextAttachment = result.attachments.find((attachment) => attachment.name === "error-context");
|
|
853
|
+
this.buffer.push({
|
|
292
854
|
testName,
|
|
293
855
|
filePath,
|
|
294
856
|
status,
|
|
295
|
-
durationMs: result.duration,
|
|
296
857
|
retryCount: result.retry,
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
858
|
+
repeatIndex,
|
|
859
|
+
durationMs: result.duration,
|
|
860
|
+
errors: mapErrors(result.errors),
|
|
861
|
+
consoleOutput: prepareConsoleOutput(formatConsoleOutput(result)),
|
|
862
|
+
tags: testTags.length > 0
|
|
863
|
+
? testTags.slice(0, MAX_TAGS).map((tag) => truncate(tag, V2_FIELD_LIMITS.tag))
|
|
864
|
+
: undefined,
|
|
865
|
+
browserName: resolveBrowserName(test),
|
|
866
|
+
errorContext: readErrorContext(errorContextAttachment),
|
|
300
867
|
});
|
|
868
|
+
if (this.buffer.length > MAX_TEST_RESULTS_WARNING && this.buffer.length % MAX_TEST_RESULTS_WARNING === 1) {
|
|
869
|
+
console.warn(`[squally-reporter] This run has sent well over ${MAX_TEST_RESULTS_WARNING} test results so far. ` +
|
|
870
|
+
"That usually means retries are configured much higher than intended, or the suite is " +
|
|
871
|
+
"unusually large - results are still being sent normally.");
|
|
872
|
+
}
|
|
873
|
+
if (this.buffer.length >= FLUSH_THRESHOLD_RESULTS) {
|
|
874
|
+
this.triggerFlush();
|
|
875
|
+
}
|
|
301
876
|
if (!attemptQualifiesForArtifacts(status, result.retry))
|
|
302
877
|
return;
|
|
303
878
|
for (const attachment of result.attachments) {
|
|
@@ -332,98 +907,320 @@ class SquallyReporter {
|
|
|
332
907
|
});
|
|
333
908
|
}
|
|
334
909
|
}
|
|
335
|
-
|
|
336
|
-
|
|
910
|
+
// (decision, spec section 3.2, closes 8.7) At onEnd: flush the remaining
|
|
911
|
+
// buffer, wait for in-flight batches, then call /complete - bounded by
|
|
912
|
+
// DRAIN_BUDGET_MS overall, covering waiting for onBegin itself if it has
|
|
913
|
+
// not resolved yet. On expiry, gives up on draining and calls /complete
|
|
914
|
+
// anyway with whatever WAS sent - a hung append must not hold the
|
|
915
|
+
// customer's CI process open indefinitely.
|
|
916
|
+
//
|
|
917
|
+
// (deliberate divergence from v1) Once a runId exists, /complete is ALWAYS
|
|
918
|
+
// called - there is no "this run had zero results, skip reporting it
|
|
919
|
+
// entirely" special case, unlike v1 (`if (this.results.length === 0)
|
|
920
|
+
// return;`, guarding the whole of onEnd there). That check cannot be
|
|
921
|
+
// reproduced here: onBegin already created the run server-side, before a
|
|
922
|
+
// single test could have told us the eventual count would be zero.
|
|
923
|
+
// Skipping /complete at this point would not undo the run's existence - it
|
|
924
|
+
// would leave an already-created run stuck in `running` until the timeout
|
|
925
|
+
// sweep notices (currently up to 18 hours, see docs/status.md's run-timeout
|
|
926
|
+
// entry). Completing it immediately with zero results is strictly better: a
|
|
927
|
+
// short-lived empty run in the dashboard costs less than an
|
|
928
|
+
// apparently-stuck one for the better part of a day.
|
|
929
|
+
async drainAndComplete(result) {
|
|
930
|
+
this.stopFlushTimer();
|
|
931
|
+
const deadline = Date.now() + DRAIN_BUDGET_MS;
|
|
932
|
+
const runId = await withDeadline(this.runIdPromise, deadline);
|
|
933
|
+
if (runId === TIMED_OUT) {
|
|
934
|
+
console.warn("[squally-reporter] Squally did not confirm the start of this run in time - nothing was sent.");
|
|
337
935
|
return;
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
936
|
+
}
|
|
937
|
+
if (runId === null) {
|
|
938
|
+
// Already warned inside beginRun() - nothing more to say here, and
|
|
939
|
+
// nothing to complete without a runId.
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
this.runId = runId;
|
|
943
|
+
if (this.buffer.length > 0)
|
|
944
|
+
this.triggerFlush();
|
|
945
|
+
// Result intentionally unused: on expiry we proceed to /complete anyway,
|
|
946
|
+
// per the decision above - a hung append must not block it.
|
|
947
|
+
await withDeadline(Promise.allSettled([...this.inFlightFlushes]), deadline);
|
|
948
|
+
const finalStatus = result.status === "passed" ? "passed" : "failed";
|
|
949
|
+
await this.completeRun(runId, finalStatus);
|
|
950
|
+
}
|
|
951
|
+
armFlushTimer() {
|
|
952
|
+
if (!this.enabled || this.sendingDisabled)
|
|
953
|
+
return;
|
|
954
|
+
const handle = setTimeout(() => {
|
|
955
|
+
this.triggerFlush();
|
|
956
|
+
this.armFlushTimer();
|
|
957
|
+
}, FLUSH_INTERVAL_MS);
|
|
958
|
+
handle.unref();
|
|
959
|
+
this.flushTimerHandle = handle;
|
|
960
|
+
}
|
|
961
|
+
stopFlushTimer() {
|
|
962
|
+
if (this.flushTimerHandle) {
|
|
963
|
+
clearTimeout(this.flushTimerHandle);
|
|
964
|
+
this.flushTimerHandle = null;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
// Permanent give-up: nothing more will ever be sent for this run. Clears
|
|
968
|
+
// the UNSENT buffer (those results can never be delivered) and the flush
|
|
969
|
+
// timer, but deliberately leaves this.artifacts untouched - artifacts
|
|
970
|
+
// collected BEFORE this point correspond to test results that WERE
|
|
971
|
+
// already sent and exist server-side, and are still uploaded normally at
|
|
972
|
+
// onEnd.
|
|
973
|
+
giveUp() {
|
|
974
|
+
this.sendingDisabled = true;
|
|
975
|
+
this.buffer.length = 0;
|
|
976
|
+
this.stopFlushTimer();
|
|
977
|
+
}
|
|
978
|
+
triggerFlush() {
|
|
979
|
+
const promise = this.flush().catch((err) => {
|
|
980
|
+
console.warn("[squally-reporter] Unexpected error while sending results:", err);
|
|
981
|
+
});
|
|
982
|
+
this.inFlightFlushes.add(promise);
|
|
983
|
+
void promise.finally(() => this.inFlightFlushes.delete(promise));
|
|
984
|
+
}
|
|
985
|
+
async flush() {
|
|
986
|
+
if (this.buffer.length === 0)
|
|
987
|
+
return;
|
|
988
|
+
const runId = await this.runIdPromise;
|
|
989
|
+
if (!runId)
|
|
344
990
|
return;
|
|
991
|
+
while (this.buffer.length > 0 && !this.sendingDisabled) {
|
|
992
|
+
const chunk = this.buffer.splice(0, BATCH_MAX_RESULTS);
|
|
993
|
+
await this.sendBatch(runId, chunk);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// (spec section 3, the error table) Six outcomes, and they do not all
|
|
997
|
+
// behave the same way:
|
|
998
|
+
// 400 (malformed) - drop THIS batch only, keep going. The spec's
|
|
999
|
+
// own wording for 404/409 explicitly says "stop
|
|
1000
|
+
// sending"; 400 does not, and section 3.4 says
|
|
1001
|
+
// the server itself "rejects only the batch,
|
|
1002
|
+
// not the run" - a local bug producing one bad
|
|
1003
|
+
// batch does not mean the next one would also
|
|
1004
|
+
// be malformed.
|
|
1005
|
+
// 402 (quota) - warn once, stop sending for this run.
|
|
1006
|
+
// 404 (unknown runId) - warn, stop sending for this run.
|
|
1007
|
+
// 409 (already complete) - warn, stop sending for this run.
|
|
1008
|
+
// 5xx / network error - already retried once inside requestJson();
|
|
1009
|
+
// on final failure, drop this batch, keep
|
|
1010
|
+
// going. The spec's table lists no explicit
|
|
1011
|
+
// "stop sending" for this case, and a
|
|
1012
|
+
// transient server hiccup should not
|
|
1013
|
+
// permanently disable reporting for the rest
|
|
1014
|
+
// of the run.
|
|
1015
|
+
async sendBatch(runId, batch) {
|
|
1016
|
+
const outcome = await this.requestJson("POST", `${this.apiUrl}/api/ingest/v2/runs/${runId}/results`, { results: batch });
|
|
1017
|
+
if (outcome.kind === "ok")
|
|
1018
|
+
return;
|
|
1019
|
+
if (outcome.kind === "http_error") {
|
|
1020
|
+
switch (outcome.status) {
|
|
1021
|
+
case 400:
|
|
1022
|
+
console.warn(`[squally-reporter] A batch of ${batch.length} result(s) was rejected: ${outcome.detail} - ` +
|
|
1023
|
+
"dropping this batch, reporting continues.");
|
|
1024
|
+
return;
|
|
1025
|
+
case 402:
|
|
1026
|
+
console.warn(`[squally-reporter] Monthly plan quota exceeded: ${outcome.detail} - no further results will ` +
|
|
1027
|
+
"be sent for this run. The run will still be marked complete.");
|
|
1028
|
+
this.giveUp();
|
|
1029
|
+
return;
|
|
1030
|
+
case 404:
|
|
1031
|
+
console.warn(`[squally-reporter] Squally no longer has a record of this run: ${outcome.detail} - no ` +
|
|
1032
|
+
"further results will be sent.");
|
|
1033
|
+
this.giveUp();
|
|
1034
|
+
return;
|
|
1035
|
+
case 409:
|
|
1036
|
+
console.warn(`[squally-reporter] This run was already marked complete on Squally: ${outcome.detail} - no ` +
|
|
1037
|
+
"further results will be sent.");
|
|
1038
|
+
this.giveUp();
|
|
1039
|
+
return;
|
|
1040
|
+
default:
|
|
1041
|
+
console.warn(`[squally-reporter] Failed to send ${batch.length} result(s) to Squally: ${outcome.status} - ` +
|
|
1042
|
+
`${outcome.detail} - dropping this batch, reporting continues.`);
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
console.warn(`[squally-reporter] Failed to send ${batch.length} result(s) to Squally: ${outcome.detail} - ` +
|
|
1047
|
+
"dropping this batch, reporting continues.");
|
|
1048
|
+
}
|
|
1049
|
+
async beginRun() {
|
|
345
1050
|
const resolvedRevision = resolveTestedRevision();
|
|
346
1051
|
const payload = {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
1052
|
+
ciRunId: this.ciRunId,
|
|
1053
|
+
shardIndex: this.shardIndex,
|
|
1054
|
+
shardTotal: this.shardTotal,
|
|
1055
|
+
projectName: this.projectName,
|
|
1056
|
+
branch: truncateOrNull(detectBranch(), FIELD_LIMITS.branch),
|
|
1057
|
+
commitSha: truncateOrNull(detectCommitSha(), FIELD_LIMITS.commitSha),
|
|
1058
|
+
commitMessage: truncateOrNull(detectCommitMessage(), FIELD_LIMITS.commitMessage),
|
|
1059
|
+
authorName: truncateOrNull(detectAuthorName(), FIELD_LIMITS.authorName),
|
|
351
1060
|
ciProvider: detectCiProvider(),
|
|
352
|
-
status: result.status === "passed" ? "passed" : "failed",
|
|
353
1061
|
startedAt: this.startedAt.toISOString(),
|
|
354
|
-
|
|
355
|
-
testResults: this.results,
|
|
356
|
-
testedRevision: resolvedRevision?.revision ?? null,
|
|
1062
|
+
testedRevision: truncateOrNull(resolvedRevision?.revision ?? null, FIELD_LIMITS.testedRevision),
|
|
357
1063
|
revisionSource: resolvedRevision?.source ?? null,
|
|
358
|
-
eventType: detectEventType(),
|
|
1064
|
+
eventType: truncateOrNull(detectEventType(), FIELD_LIMITS.eventType),
|
|
1065
|
+
runTags: this.runTags,
|
|
1066
|
+
config: this.runConfig,
|
|
1067
|
+
};
|
|
1068
|
+
const outcome = await this.requestJson("POST", `${this.apiUrl}/api/ingest/v2/runs`, payload);
|
|
1069
|
+
if (outcome.kind === "ok")
|
|
1070
|
+
return outcome.json.runId;
|
|
1071
|
+
// (spec section 7, "New reporter against old app") A 404 here means the
|
|
1072
|
+
// TARGET SERVER does not have the v2 routes at all - an old squally-app.
|
|
1073
|
+
// Cannot happen in production (the app deploys before any reporter that
|
|
1074
|
+
// calls it), but will happen locally while both sides are in
|
|
1075
|
+
// development. Treated as a distinct, permanent condition with its own
|
|
1076
|
+
// message, not retried, and NOT downgraded to a v1 POST - the spec is
|
|
1077
|
+
// explicit that a silent protocol downgrade would make "my streaming
|
|
1078
|
+
// isn't working" undiagnosable. The reporter falls back to sending
|
|
1079
|
+
// nothing for this run instead.
|
|
1080
|
+
if (outcome.kind === "http_error" && outcome.status === 404) {
|
|
1081
|
+
console.warn("[squally-reporter] This Squally instance does not support the v2 ingest API yet (404 on " +
|
|
1082
|
+
"/api/ingest/v2/runs). No results from this run will be sent - update squally-app, or check " +
|
|
1083
|
+
"SQUALLY_API_URL if this is unexpected.");
|
|
1084
|
+
}
|
|
1085
|
+
else {
|
|
1086
|
+
const detail = outcome.kind === "http_error" ? `${outcome.status} - ${outcome.detail}` : outcome.detail;
|
|
1087
|
+
console.error(`[squally-reporter] Failed to start reporting to Squally: ${detail}`);
|
|
1088
|
+
}
|
|
1089
|
+
this.giveUp();
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
async completeRun(runId, status) {
|
|
1093
|
+
const payload = {
|
|
1094
|
+
shardIndex: this.shardIndex,
|
|
1095
|
+
status,
|
|
1096
|
+
finishedAt: new Date().toISOString(),
|
|
359
1097
|
};
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
1098
|
+
const outcome = await this.requestJson("POST", `${this.apiUrl}/api/ingest/v2/runs/${runId}/complete`, payload);
|
|
1099
|
+
if (outcome.kind !== "ok") {
|
|
1100
|
+
const detail = outcome.kind === "http_error" ? `${outcome.status} - ${outcome.detail}` : outcome.detail;
|
|
1101
|
+
console.error(`[squally-reporter] Failed to mark the run complete on Squally: ${detail}`);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
// (spec section 6) Called once, fired alongside beginRun() rather than
|
|
1105
|
+
// awaited before it - onBegin cannot itself be awaited by Playwright
|
|
1106
|
+
// (Reporter['onBegin'] is typed void), so "before any test runs" is
|
|
1107
|
+
// best-effort here regardless of internal sequencing: tests may already be
|
|
1108
|
+
// executing by the time either promise settles. Deliberately its OWN
|
|
1109
|
+
// request, not routed through requestJson()'s retry machinery: the spec's
|
|
1110
|
+
// failure handling for this call is simpler by design ("on any error,
|
|
1111
|
+
// timeout, or non-200, proceeds with an empty ruleset and warns once") -
|
|
1112
|
+
// a single attempt, no retries, because nothing depends on this succeeding
|
|
1113
|
+
// yet.
|
|
1114
|
+
async fetchRules() {
|
|
1115
|
+
if (rulesCache && rulesCache.expiresAt > Date.now())
|
|
1116
|
+
return;
|
|
371
1117
|
try {
|
|
372
|
-
|
|
1118
|
+
const response = await (0, undici_1.fetch)(`${this.apiUrl}/api/ingest/v2/rules`, {
|
|
1119
|
+
method: "GET",
|
|
1120
|
+
headers: { Authorization: `Bearer ${this.apiKey}`, "User-Agent": USER_AGENT },
|
|
1121
|
+
dispatcher: this.dispatcher,
|
|
1122
|
+
signal: AbortSignal.timeout(RULES_REQUEST_TIMEOUT_MS),
|
|
1123
|
+
});
|
|
1124
|
+
if (!response.ok) {
|
|
1125
|
+
console.warn(`[squally-reporter] Could not fetch quarantine rules from Squally: ` +
|
|
1126
|
+
`${await describeResponseError(response)} - continuing without them.`);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
const data = (await response.json());
|
|
1130
|
+
rulesCache = { data, expiresAt: Date.now() + data.ttlSeconds * 1000 };
|
|
1131
|
+
}
|
|
1132
|
+
catch (err) {
|
|
1133
|
+
console.warn("[squally-reporter] Could not fetch quarantine rules from Squally:", describeRequestError(err, RULES_REQUEST_TIMEOUT_MS));
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
// Shared low-level request/retry machinery for begin/append/complete.
|
|
1137
|
+
//
|
|
1138
|
+
// Two INDEPENDENT retry policies with separate counters (spec section
|
|
1139
|
+
// 3.2): 429 gets up to RATE_LIMIT_MAX_ATTEMPTS total tries, honouring
|
|
1140
|
+
// Retry-After when present and otherwise backing off exponentially; a 5xx
|
|
1141
|
+
// or network failure gets exactly one retry (SERVER_ERROR_MAX_ATTEMPTS=2
|
|
1142
|
+
// total tries) after a short fixed delay. Any other non-2xx status is
|
|
1143
|
+
// returned immediately, uninterpreted - the CALLER (beginRun/sendBatch/
|
|
1144
|
+
// completeRun) decides what a given status code means for ITS route,
|
|
1145
|
+
// since the same code means different things on different routes (404 on
|
|
1146
|
+
// /runs means "no v2 support"; 404 on /results means "unknown runId").
|
|
1147
|
+
//
|
|
1148
|
+
// Every request here is idempotent server-side (spec section 2:
|
|
1149
|
+
// "Idempotency" - onBegin keyed on ciRunId+shardIndex, append keyed on the
|
|
1150
|
+
// attempt tuple, complete keyed on shardIndex), which is what makes
|
|
1151
|
+
// retrying safely possible in the first place.
|
|
1152
|
+
async requestJson(method, url, body) {
|
|
1153
|
+
let rateLimitAttempts = 0;
|
|
1154
|
+
let serverErrorAttempts = 0;
|
|
1155
|
+
let networkAttempts = 0;
|
|
1156
|
+
for (;;) {
|
|
1157
|
+
let response;
|
|
373
1158
|
try {
|
|
374
|
-
|
|
375
|
-
method
|
|
1159
|
+
response = await (0, undici_1.fetch)(url, {
|
|
1160
|
+
method,
|
|
376
1161
|
headers: {
|
|
377
1162
|
"Content-Type": "application/json",
|
|
378
1163
|
Authorization: `Bearer ${this.apiKey}`,
|
|
1164
|
+
"User-Agent": USER_AGENT,
|
|
379
1165
|
},
|
|
380
|
-
body: JSON.stringify(
|
|
381
|
-
dispatcher,
|
|
1166
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
1167
|
+
dispatcher: this.dispatcher,
|
|
1168
|
+
signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
|
|
382
1169
|
});
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
1170
|
+
}
|
|
1171
|
+
catch (err) {
|
|
1172
|
+
networkAttempts++;
|
|
1173
|
+
if (networkAttempts >= NETWORK_ERROR_MAX_ATTEMPTS) {
|
|
1174
|
+
return { kind: "network_error", detail: describeRequestError(err, API_REQUEST_TIMEOUT_MS) };
|
|
387
1175
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
1176
|
+
await delay(SERVER_ERROR_RETRY_DELAY_MS);
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
if (response.status === 429) {
|
|
1180
|
+
rateLimitAttempts++;
|
|
1181
|
+
if (rateLimitAttempts >= RATE_LIMIT_MAX_ATTEMPTS) {
|
|
1182
|
+
return { kind: "http_error", status: 429, detail: await describeResponseError(response) };
|
|
391
1183
|
}
|
|
1184
|
+
const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"), Date.now());
|
|
1185
|
+
const waitMs = Math.min(RATE_LIMIT_BACKOFF_CAP_MS, retryAfterMs ?? computeBackoffMs(rateLimitAttempts));
|
|
1186
|
+
await delay(waitMs);
|
|
1187
|
+
continue;
|
|
392
1188
|
}
|
|
393
|
-
|
|
394
|
-
|
|
1189
|
+
if (response.status >= 500) {
|
|
1190
|
+
serverErrorAttempts++;
|
|
1191
|
+
if (serverErrorAttempts >= SERVER_ERROR_MAX_ATTEMPTS) {
|
|
1192
|
+
return { kind: "http_error", status: response.status, detail: await describeResponseError(response) };
|
|
1193
|
+
}
|
|
1194
|
+
await delay(SERVER_ERROR_RETRY_DELAY_MS);
|
|
1195
|
+
continue;
|
|
395
1196
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
1197
|
+
if (!response.ok) {
|
|
1198
|
+
return { kind: "http_error", status: response.status, detail: await describeResponseError(response) };
|
|
1199
|
+
}
|
|
1200
|
+
try {
|
|
1201
|
+
const json = (await response.json());
|
|
1202
|
+
return { kind: "ok", status: response.status, json };
|
|
1203
|
+
}
|
|
1204
|
+
catch (err) {
|
|
1205
|
+
return { kind: "parse_error", detail: String(err) };
|
|
405
1206
|
}
|
|
406
|
-
}
|
|
407
|
-
finally {
|
|
408
|
-
await dispatcher.close();
|
|
409
1207
|
}
|
|
410
1208
|
}
|
|
411
1209
|
// Top-level safety net - presignAndUpload already handles presign/
|
|
412
|
-
// upload/confirm failures individually
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
|
|
418
|
-
async uploadArtifacts(runId, dispatcher) {
|
|
1210
|
+
// upload/confirm failures individually, but this catch still exists in
|
|
1211
|
+
// case something in there throws in a way that wasn't anticipated. The
|
|
1212
|
+
// whole artifact feature must be additive: worst case, some screenshots/
|
|
1213
|
+
// traces don't make it to Squally, but the customer's CI run itself is
|
|
1214
|
+
// never at risk.
|
|
1215
|
+
async uploadArtifacts(runId) {
|
|
419
1216
|
try {
|
|
420
|
-
await this.presignAndUpload(runId
|
|
1217
|
+
await this.presignAndUpload(runId);
|
|
421
1218
|
}
|
|
422
1219
|
catch (err) {
|
|
423
1220
|
console.warn("[squally-reporter] Artifact upload failed unexpectedly:", err);
|
|
424
1221
|
}
|
|
425
1222
|
}
|
|
426
|
-
async presignAndUpload(runId
|
|
1223
|
+
async presignAndUpload(runId) {
|
|
427
1224
|
const presignArtifacts = this.artifacts.map((artifact) => ({
|
|
428
1225
|
testName: artifact.testName,
|
|
429
1226
|
filePath: artifact.filePath,
|
|
@@ -438,19 +1235,22 @@ class SquallyReporter {
|
|
|
438
1235
|
headers: {
|
|
439
1236
|
"Content-Type": "application/json",
|
|
440
1237
|
Authorization: `Bearer ${this.apiKey}`,
|
|
1238
|
+
"User-Agent": USER_AGENT,
|
|
441
1239
|
},
|
|
442
1240
|
body: JSON.stringify({ runId, artifacts: presignArtifacts }),
|
|
443
|
-
dispatcher,
|
|
1241
|
+
dispatcher: this.dispatcher,
|
|
1242
|
+
signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
|
|
444
1243
|
});
|
|
445
1244
|
if (!response.ok) {
|
|
446
|
-
console.warn(`[squally-reporter] Artifact presign failed: ${
|
|
1245
|
+
console.warn(`[squally-reporter] Artifact presign failed: ${await describeResponseError(response)}` +
|
|
1246
|
+
" - skipping artifact upload for this run");
|
|
447
1247
|
return;
|
|
448
1248
|
}
|
|
449
1249
|
const body = (await response.json());
|
|
450
1250
|
presignResults = body.results;
|
|
451
1251
|
}
|
|
452
1252
|
catch (err) {
|
|
453
|
-
console.warn("[squally-reporter] Artifact presign request failed:", err);
|
|
1253
|
+
console.warn("[squally-reporter] Artifact presign request failed:", describeRequestError(err, API_REQUEST_TIMEOUT_MS));
|
|
454
1254
|
return;
|
|
455
1255
|
}
|
|
456
1256
|
// Maps a presign result back to the local file it came from -
|
|
@@ -482,14 +1282,16 @@ class SquallyReporter {
|
|
|
482
1282
|
const response = await (0, undici_1.fetch)(uploadUrl, {
|
|
483
1283
|
method: "PUT",
|
|
484
1284
|
body: fileBody,
|
|
485
|
-
dispatcher,
|
|
1285
|
+
dispatcher: this.dispatcher,
|
|
1286
|
+
signal: AbortSignal.timeout(UPLOAD_REQUEST_TIMEOUT_MS),
|
|
486
1287
|
});
|
|
487
1288
|
if (!response.ok) {
|
|
488
|
-
console.warn(`[squally-reporter] Upload failed for ${artifact.type} of '${artifact.testName}':
|
|
1289
|
+
console.warn(`[squally-reporter] Upload failed for ${artifact.type} of '${artifact.testName}': ` +
|
|
1290
|
+
(await describeResponseError(response)));
|
|
489
1291
|
}
|
|
490
1292
|
}
|
|
491
1293
|
catch (err) {
|
|
492
|
-
console.warn(`[squally-reporter] Upload failed for ${artifact.type} of '${artifact.testName}':`, err);
|
|
1294
|
+
console.warn(`[squally-reporter] Upload failed for ${artifact.type} of '${artifact.testName}':`, describeRequestError(err, UPLOAD_REQUEST_TIMEOUT_MS));
|
|
493
1295
|
}
|
|
494
1296
|
});
|
|
495
1297
|
// Confirm every artifact that was actually presigned (not the ones
|
|
@@ -512,12 +1314,14 @@ class SquallyReporter {
|
|
|
512
1314
|
headers: {
|
|
513
1315
|
"Content-Type": "application/json",
|
|
514
1316
|
Authorization: `Bearer ${this.apiKey}`,
|
|
1317
|
+
"User-Agent": USER_AGENT,
|
|
515
1318
|
},
|
|
516
1319
|
body: JSON.stringify({ runId, artifacts: confirmArtifacts }),
|
|
517
|
-
dispatcher,
|
|
1320
|
+
dispatcher: this.dispatcher,
|
|
1321
|
+
signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
|
|
518
1322
|
});
|
|
519
1323
|
if (!response.ok) {
|
|
520
|
-
console.warn(`[squally-reporter] Artifact confirm failed: ${
|
|
1324
|
+
console.warn(`[squally-reporter] Artifact confirm failed: ${await describeResponseError(response)}`);
|
|
521
1325
|
return;
|
|
522
1326
|
}
|
|
523
1327
|
const body = (await response.json());
|
|
@@ -528,8 +1332,29 @@ class SquallyReporter {
|
|
|
528
1332
|
}
|
|
529
1333
|
}
|
|
530
1334
|
catch (err) {
|
|
531
|
-
console.warn("[squally-reporter] Artifact confirm request failed:", err);
|
|
1335
|
+
console.warn("[squally-reporter] Artifact confirm request failed:", describeRequestError(err, API_REQUEST_TIMEOUT_MS));
|
|
532
1336
|
}
|
|
533
1337
|
}
|
|
534
1338
|
}
|
|
535
1339
|
exports.default = SquallyReporter;
|
|
1340
|
+
// Exported for unit tests only (test/unit) - not part of the package's public
|
|
1341
|
+
// API surface (see package.json "files": ["dist"], which ships everything in
|
|
1342
|
+
// dist regardless, but these are undocumented and may change without notice).
|
|
1343
|
+
exports.__internal = {
|
|
1344
|
+
truncate,
|
|
1345
|
+
truncateOrNull,
|
|
1346
|
+
mapStatus,
|
|
1347
|
+
stripAnsi,
|
|
1348
|
+
normalizeRevision,
|
|
1349
|
+
resolveTestedRevision,
|
|
1350
|
+
toPosixPath,
|
|
1351
|
+
detectCiRunId,
|
|
1352
|
+
resolveProjectName,
|
|
1353
|
+
maskSecrets,
|
|
1354
|
+
prepareConsoleOutput,
|
|
1355
|
+
mapErrors,
|
|
1356
|
+
parseRetryAfterMs,
|
|
1357
|
+
computeBackoffMs,
|
|
1358
|
+
attemptQualifiesForArtifacts,
|
|
1359
|
+
artifactKey,
|
|
1360
|
+
};
|