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.
Files changed (4) hide show
  1. package/README.md +182 -121
  2. package/dist/index.d.ts +95 -4
  3. package/dist/index.js +940 -115
  4. 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
- function detectBranch() {
35
- if (process.env.GITHUB_REF_NAME)
36
- return process.env.GITHUB_REF_NAME;
37
- if (process.env.CI_COMMIT_BRANCH)
38
- return process.env.CI_COMMIT_BRANCH;
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)("git rev-parse --abbrev-ref HEAD").toString().trim();
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
- try {
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
- try {
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
- try {
85
- return (0, node_child_process_1.execSync)("git log -1 --pretty=%an").toString().trim();
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
- catch {
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 (the existing keyword-based error categorization, the planned
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, same reasoning
163
- // as toPosixPath/the upload worker pool above. Built via String.fromCharCode
164
- // rather than a hex escape directly in the source: this file has to stay
165
- // plain, readable ASCII - a raw ESC byte pasted into source code is
166
- // invisible in most editors/diffs and easy to mistake for empty content.
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) + String.fromCharCode(92) + "[[0-9;]*[a-zA-Z]", "g");
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.results = [];
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(_config, _suite) {
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
- const error = result.errors[0];
288
- const testName = test.titlePath().slice(1).join(" > ");
289
- const filePath = toPosixPath(node_path_1.default.relative(process.cwd(), test.location.file));
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
- this.results.push({
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
- errorMessage: error?.message ? stripAnsi(error.message) : null,
298
- errorStack: error?.stack ? stripAnsi(error.stack) : null,
299
- consoleOutput: formatConsoleOutput(result),
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
- async onEnd(result) {
336
- if (!this.enabled)
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
- // A run with 0 test results is meaningless for Squally's flakiness
339
- // calculation and would just be dashboard clutter - regardless of why
340
- // there were none (npx playwright test --list, an empty testDir, a
341
- // --grep filter that matched nothing). Bail out before building/
342
- // sending anything.
343
- if (this.results.length === 0)
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
- branch: detectBranch(),
348
- commitSha: detectCommitSha(),
349
- commitMessage: detectCommitMessage(),
350
- authorName: detectAuthorName(),
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
- finishedAt: new Date().toISOString(),
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
- // Node's global fetch() pools connections through a shared, long-lived
361
- // keep-alive dispatcher by default. In a short-lived CLI process like
362
- // this reporter, that pooled socket can still be mid-teardown when
363
- // Playwright exits the process, which crashes with a libuv assertion on
364
- // Windows ("UV_HANDLE_CLOSING", src/win/async.c). Using our own Agent
365
- // for this one request and closing it explicitly guarantees no open
366
- // handle is left behind by the time onEnd() resolves. Importing fetch
367
- // from undici too (not Node's global one) avoids a possible instanceof
368
- // mismatch between Node's internal bundled undici and this installed
369
- // copy when passing our Agent as the dispatcher.
370
- const dispatcher = new undici_1.Agent({ keepAliveTimeout: 1, keepAliveMaxTimeout: 1 });
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
- let runId = null;
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
- const response = await (0, undici_1.fetch)(`${this.apiUrl}/api/ingest`, {
375
- method: "POST",
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(payload),
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
- if (!response.ok) {
384
- // Never throw here - a failing reporter upload should not fail the
385
- // whole CI run just because the dashboard was unreachable.
386
- console.error(`[squally-reporter] Ingest failed: ${response.status} ${response.statusText}`);
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
- else {
389
- const body = (await response.json());
390
- runId = body.runId ?? null;
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
- catch (err) {
394
- console.error("[squally-reporter] Failed to reach Squally API:", err);
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
- // Artifact upload is a separate, best-effort step layered on top of
397
- // ingest, never a reason to fail it: it needs a runId (presign looks
398
- // TestResults up by runId + identity fields, which only exist once
399
- // ingest has actually inserted them), and there's nothing to do if
400
- // this run had no failing/retried attempts with a screenshot/trace
401
- // attached (attemptQualifiesForArtifacts already filtered that in
402
- // onTestEnd) - no reason to make an empty presign call.
403
- if (runId && this.artifacts.length > 0) {
404
- await this.uploadArtifacts(runId, dispatcher);
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 (see Aufgabe 5's requirement that
413
- // none of this ever affects the test suite's exit code), but this catch
414
- // still exists in case something in there throws in a way that wasn't
415
- // anticipated. The whole artifact feature must be additive: worst case,
416
- // some screenshots/traces don't make it to Squally, but the customer's
417
- // CI run itself is never at risk.
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, dispatcher);
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, dispatcher) {
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: ${response.status} ${response.statusText} - skipping artifact upload for this run`);
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}': ${response.status} ${response.statusText}`);
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: ${response.status} ${response.statusText}`);
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
+ };