squally-reporter 0.4.0 → 0.5.1

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/dist/index.js CHANGED
@@ -3,18 +3,22 @@ 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");
11
- // Mirrors squally-app's ingest contract (app/api/ingest/route.ts,
12
- // FIELD_LIMITS / checkStringField). Kept as a copy on purpose - the reporter
13
- // ships independently of the app and cannot import from it - which means this
14
- // table has to be updated when that one changes. It is deliberately not
15
- // "roughly right": the server rejects the ENTIRE payload on the FIRST field
16
- // that is too long, so one over-long value used to cost the whole run, every
17
- // test result in it, with a bare "400 Bad Request" as the only clue.
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.
18
22
  //
19
23
  // ciProvider is absent because the server does not length-check it, and this
20
24
  // reporter only ever sends one of four fixed strings for it.
@@ -29,8 +33,22 @@ const FIELD_LIMITS = {
29
33
  filePath: 500,
30
34
  errorMessage: 5000,
31
35
  errorStack: 20000,
32
- consoleOutput: 20000,
33
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;
34
52
  // ASCII only, like everything else in this file, and short: it has to fit
35
53
  // INSIDE the limit it is protecting, and authorName only has 100 characters
36
54
  // to give away.
@@ -47,8 +65,19 @@ const TRUNCATION_MARKER = "... [truncated]";
47
65
  // invent a smaller truth.
48
66
  //
49
67
  // Retries count as separate entries, so a suite of 2 000 tests reaches this
50
- // faster than the number suggests.
51
- const MAX_TEST_RESULTS = 5000;
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;
52
81
  // Visible rather than silent: a value that was cut says so, so nobody debugs
53
82
  // a "wrong" commit message that is merely shortened. For testName/filePath
54
83
  // this also alters Squally's test identity - unavoidable, since the
@@ -67,30 +96,66 @@ function truncateOrNull(value, max) {
67
96
  // How much of a server error body to put in the log. Enough for the server's
68
97
  // own one-line explanation, bounded so that an unexpected HTML error page
69
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}`;
70
114
  // undici leaves headersTimeout and bodyTimeout at 300 000 ms each when they
71
- // are not set, and nothing here used to set them. A hung server therefore cost
72
- // 5 minutes for the run POST, 5 for presign, 5 for confirm and 5 per upload
73
- // round - with a hundred artifacts at five in parallel, well over an hour of a
74
- // CI job sitting there with no output explaining what it is waiting for. That
75
- // is worse for a customer than an error: an error at least says something.
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.
76
118
  //
77
119
  // Two budgets, because the two kinds of request are not comparable. The JSON
78
- // calls exchange kilobytes and have no business taking longer than half a
79
- // minute. An upload can be a 50 MB trace over a CI runner's modest uplink,
80
- // where two minutes is tight-ish but still far short of "hung".
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".
81
124
  const API_REQUEST_TIMEOUT_MS = 30000;
82
125
  const UPLOAD_REQUEST_TIMEOUT_MS = 120000;
83
- const ERROR_BODY_LOG_LIMIT = 500;
84
- // Turns a failed response into something a customer can act on. Today the
85
- // caller only sees "400 Bad Request"; the reason ("commitMessage exceeds max
86
- // length of 500 characters") sits in the body and was being thrown away.
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) ----------------------------------------
87
142
  //
88
- // Nothing from the REQUEST is logged, only the server's own response - the
89
- // Authorization header must never reach the CI log.
90
- // A timed-out request throws like any other network failure, and is handled
91
- // like one - warn, carry on, never touch the exit code. It only gets its own
92
- // wording because "fetch failed" would leave the customer guessing whether the
93
- // server said no or simply never answered.
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;
94
159
  function describeRequestError(err, timeoutMs) {
95
160
  const name = err instanceof Error ? err.name : "";
96
161
  if (name === "TimeoutError" || name === "AbortError") {
@@ -134,29 +199,46 @@ function detectCiProvider() {
134
199
  return "unknown-ci";
135
200
  return null;
136
201
  }
137
- function detectBranch() {
138
- if (process.env.GITHUB_REF_NAME)
139
- return process.env.GITHUB_REF_NAME;
140
- if (process.env.CI_COMMIT_BRANCH)
141
- 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) {
142
217
  try {
143
- 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();
144
224
  }
145
225
  catch {
146
226
  return null;
147
227
  }
148
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
+ }
149
236
  function detectCommitSha() {
150
237
  if (process.env.GITHUB_SHA)
151
238
  return process.env.GITHUB_SHA;
152
239
  if (process.env.CI_COMMIT_SHA)
153
240
  return process.env.CI_COMMIT_SHA;
154
- try {
155
- return (0, node_child_process_1.execSync)("git rev-parse HEAD").toString().trim();
156
- }
157
- catch {
158
- return null;
159
- }
241
+ return runGitCommand("git rev-parse HEAD");
160
242
  }
161
243
  // GitLab CI exposes the full commit message directly (CI_COMMIT_MESSAGE).
162
244
  // GitHub Actions has no equivalent env var, so it falls through to the git
@@ -165,12 +247,7 @@ function detectCommitSha() {
165
247
  function detectCommitMessage() {
166
248
  if (process.env.CI_COMMIT_MESSAGE)
167
249
  return process.env.CI_COMMIT_MESSAGE;
168
- try {
169
- return (0, node_child_process_1.execSync)("git log -1 --pretty=%B").toString().trim();
170
- }
171
- catch {
172
- return null;
173
- }
250
+ return runGitCommand("git log -1 --pretty=%B");
174
251
  }
175
252
  // GitHub Actions' GITHUB_ACTOR is the user who triggered the workflow run
176
253
  // (a username, not necessarily the commit author, but the closest thing
@@ -184,12 +261,74 @@ function detectAuthorName() {
184
261
  const match = process.env.CI_COMMIT_AUTHOR.match(/^(.*?)\s*<[^>]*>\s*$/);
185
262
  return match ? match[1] : process.env.CI_COMMIT_AUTHOR;
186
263
  }
187
- try {
188
- 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;
189
307
  }
190
- 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)
191
316
  return null;
192
- }
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;
193
332
  }
194
333
  // --- Flakiness Engine v2 (Phase A): tested application revision ---
195
334
  //
@@ -255,20 +394,18 @@ function toPosixPath(p) {
255
394
  // via chalk/picocolors) can do the same to stdout/stderr. Stripped here,
256
395
  // before anything is ever sent - not server/UI-side - so the raw escape
257
396
  // codes never land in the database in the first place, and every later
258
- // consumer (the existing keyword-based error categorization, the planned
259
- // AI analysis, a fingerprint cache over normalized error signatures)
260
- // does not have to strip them again itself.
397
+ // consumer does not have to strip them again itself.
261
398
  //
262
399
  // Matches the CSI form ANSI color/style codes actually take: an ESC
263
400
  // character (code point 27), then an opening bracket, then any run of
264
401
  // digits/semicolons, then one letter (e.g. ESC, bracket, 3, 1, m for red).
265
- // No new dependency (e.g. strip-ansi) for a one-line regex, same reasoning
266
- // as toPosixPath/the upload worker pool above. Built via String.fromCharCode
267
- // rather than a hex escape directly in the source: this file has to stay
268
- // plain, readable ASCII - a raw ESC byte pasted into source code is
269
- // invisible in most editors/diffs and easy to mistake for empty content.
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.
270
407
  function stripAnsi(value) {
271
- 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");
272
409
  return value.replace(ansiPattern, "");
273
410
  }
274
411
  // Playwright captures stdout/stderr as two separate arrays (each internally
@@ -287,6 +424,134 @@ function formatConsoleOutput(result) {
287
424
  }
288
425
  return lines.length > 0 ? lines.join("\n") : null;
289
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
+ }
290
555
  // Presigned upload URLs are valid for 15 minutes (server-side, see
291
556
  // squally-app's lib/r2.ts). Uploading fully sequentially could get close to
292
557
  // that window for a suite with a very large number of failing/retried
@@ -305,18 +570,19 @@ const UPLOAD_CONCURRENCY = 5;
305
570
  // if any of our own I/O is still winding down at that moment - the exit code
306
571
  // becomes 127 instead of 0, i.e. a green test run reports as failed.
307
572
  //
308
- // Measured, so the number is not a guess: with the run POST alone the process
309
- // exits cleanly; from the second request on (presign) it aborts reproducibly.
310
- // A 0 ms or 10 ms wait does not help, 100 ms survives two requests, and 250 ms
311
- // survives the full path (ingest -> presign -> upload -> confirm) over
312
- // repeated runs.
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.
313
581
  //
314
- // This is a mitigation, not a cure: it widens the window rather than removing
315
- // the race, because nothing in Node's public API reports "all my handles are
316
- // really gone". Do not shrink it without re-running the Windows job in CI -
317
- // that job exists specifically to catch this (see .github/workflows/ci.yml).
318
- // The same crash was fixed once before for the single-request path and came
319
- // back when 0.3.0 added the artifact requests.
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.
320
586
  const EXIT_DRAIN_MS = 250;
321
587
  // No new dependency for this - a fixed-size worker pool that keeps pulling
322
588
  // the next item until the list is exhausted is ~10 lines, not worth pulling
@@ -350,7 +616,8 @@ function attemptQualifiesForArtifacts(mappedStatus, retryCount) {
350
616
  // testName/filePath can contain arbitrary characters, and this key only
351
617
  // needs to be unique, not human-readable (it is purely an internal lookup
352
618
  // back to the local CollectedArtifact once the server round-trips these
353
- // identity fields).
619
+ // identity fields). Unchanged from v1 - see the CollectedArtifact comment
620
+ // above for why this stays a 4-part key under v2.
354
621
  function artifactKey(testName, filePath, retryCount, type) {
355
622
  return [testName, filePath, String(retryCount), type].join(String.fromCharCode(1));
356
623
  }
@@ -373,6 +640,90 @@ function confirmReasonMessage(reason) {
373
640
  function artifactLabel(type) {
374
641
  return type === "screenshot" ? "Screenshot" : "Trace";
375
642
  }
643
+ /**
644
+ * Wait, WITHOUT letting the process die during the wait.
645
+ *
646
+ * The timer is deliberately NOT unref'd, and that is the whole content of this
647
+ * function. An unref'd timer does not hold Node's event loop open, so a promise
648
+ * that only an unref'd timer can resolve is a promise the process may never
649
+ * wait for: if nothing else is pending, Node treats the loop as empty and exits
650
+ * THERE, with whatever `process.exitCode` happens to be - which is 0.
651
+ *
652
+ * That shipped in 0.5.0 and made a FAILING test suite report a GREEN CI job:
653
+ *
654
+ * 0.4.0 await new Promise((resolve) => setTimeout(resolve, EXIT_DRAIN_MS));
655
+ * 0.5.0 await delay(EXIT_DRAIN_MS); // helper below, which unref'd
656
+ *
657
+ * Playwright computes the exit code only AFTER awaiting every reporter's
658
+ * onEnd - the runner multiplexer awaits `reporter.onEnd(result)` and the CLI
659
+ * then calls `gracefullyProcessExitDoNotHang(status === "passed" ? 0 : 1)`.
660
+ * Exiting during onEnd skips the line that would have set 1. Measured: the same
661
+ * 250 ms wait exits 0 when unref'd and 1 when not, on a suite with a failing
662
+ * test.
663
+ *
664
+ * EVERY caller is awaited from inside onEnd, directly or through
665
+ * drainAndComplete's retries, so every one of them needs this. There is no
666
+ * "this wait is unimportant" case: a wait nobody needs should not be awaited at
667
+ * all. The first fix for the bug above kept an opt-out for the retry backoffs
668
+ * and was still wrong - a server returning 503 on /complete reproduced the
669
+ * green-on-red exit through exactly that path (measured, 10.09.).
670
+ *
671
+ * Ref'ing costs nothing here: Playwright ends the process with `process.exit()`,
672
+ * which does not wait for pending timers, so a still-running backoff cannot
673
+ * delay anyone's CI.
674
+ *
675
+ * NOT to be confused with the two unref'd timers elsewhere in this file, both
676
+ * of which are correct: withDeadline's timer races a real promise, and the
677
+ * periodic flush timer is never awaited by anything.
678
+ */
679
+ function delay(ms) {
680
+ return new Promise((resolve) => {
681
+ setTimeout(resolve, ms);
682
+ });
683
+ }
684
+ // Retry-After per RFC 9110: either an integer number of seconds, or an
685
+ // HTTP-date. Returns null (never throws) when the header is absent or
686
+ // unparseable as either form, so the caller can fall back to its own
687
+ // backoff formula.
688
+ function parseRetryAfterMs(headerValue, nowMs) {
689
+ if (!headerValue)
690
+ return null;
691
+ const asSeconds = Number(headerValue);
692
+ if (Number.isFinite(asSeconds) && asSeconds >= 0)
693
+ return asSeconds * 1000;
694
+ const asDate = Date.parse(headerValue);
695
+ if (!Number.isNaN(asDate))
696
+ return Math.max(0, asDate - nowMs);
697
+ return null;
698
+ }
699
+ // retryNumber is 1-indexed (the first retry passes 1, not 0).
700
+ function computeBackoffMs(retryNumber) {
701
+ return Math.min(RATE_LIMIT_BACKOFF_CAP_MS, RATE_LIMIT_BACKOFF_BASE_MS * 2 ** (retryNumber - 1));
702
+ }
703
+ const TIMED_OUT = Symbol("timed-out");
704
+ // Races a promise against an absolute deadline (a timestamp, not a
705
+ // duration) - absolute so that several sequential awaits inside one drain
706
+ // pass (see drainAndComplete()) can all share the SAME overall budget
707
+ // instead of each getting a fresh one.
708
+ function withDeadline(promise, deadlineMs) {
709
+ const remaining = Math.max(0, deadlineMs - Date.now());
710
+ return Promise.race([
711
+ promise,
712
+ new Promise((resolve) => {
713
+ const handle = setTimeout(() => resolve(TIMED_OUT), remaining);
714
+ handle.unref();
715
+ }),
716
+ ]);
717
+ }
718
+ // module-level, not per-instance: spec section 6 says the rules response is
719
+ // "cached in memory for ttlSeconds, per process", and a Playwright process
720
+ // normally constructs exactly one Reporter instance for the run, so this
721
+ // mostly matters as a statement of intent rather than something exercised
722
+ // today - but "per process" is what the spec says, so this is where it
723
+ // lives. v2's reporter only ever FETCHES and stores this; nothing reads it
724
+ // yet (spec: "fetches and ignores... keeps the call path proven before the
725
+ // first behaviour hangs off it").
726
+ let rulesCache = null;
376
727
  class SquallyReporter {
377
728
  constructor(options = {}) {
378
729
  this.apiUrl = "";
@@ -382,10 +733,68 @@ class SquallyReporter {
382
733
  // Every onXxx hook checks this first and no-ops if it's false.
383
734
  this.enabled = false;
384
735
  this.startedAt = new Date();
385
- this.results = [];
736
+ this.ciRunId = "";
737
+ this.shardIndex = null;
738
+ this.shardTotal = null;
739
+ this.projectName = null;
740
+ this.buffer = [];
386
741
  this.artifacts = [];
387
- // Earliest TestResult.startTime seen, see the startedAt note in onEnd().
388
- this.earliestTestStart = null;
742
+ // True once nothing more will ever be sent for this run - a permanent
743
+ // give-up (402/404/409 on append, or onBegin never getting a runId at
744
+ // all). onTestEnd stops collecting further results/artifacts once this is
745
+ // set (see giveUp() below for why that is safe).
746
+ this.sendingDisabled = false;
747
+ this.flushTimerHandle = null;
748
+ this.inFlightFlushes = new Set();
749
+ // Resolves to the runId once onBegin succeeds, or to null on any permanent
750
+ // failure (never rejects - every failure path here is a warning, not an
751
+ // exception, same principle as the rest of this file).
752
+ this.runIdPromise = Promise.resolve(null);
753
+ // Set once runIdPromise resolves to a real id - read by onEnd's artifact
754
+ // upload step, which needs a plain value rather than a promise it would
755
+ // otherwise have to await a second time.
756
+ this.runId = null;
757
+ // onBegin's timestamp is normally the right answer, and under v2 it is the
758
+ // ONLY answer available at send time: startedAt has to be sent to the
759
+ // server IMMEDIATELY in onBegin's own request, before a single test has
760
+ // run - unlike v1, where the whole payload (startedAt included) was built
761
+ // once at onEnd, after every test's start time was already known.
762
+ //
763
+ // v1 corrected this timestamp against the earliest test's own start time,
764
+ // specifically for `npx playwright merge-reports`, where onBegin fires on
765
+ // the merging machine minutes or hours after the tests actually ran. That
766
+ // correction CANNOT be reproduced under v2's streaming model - there is no
767
+ // moment between "onBegin fired" and "the first test started" at which an
768
+ // earliest-start value could be known, because onBegin's own network call
769
+ // has to go out before any test has even begun. This is not a discretionary
770
+ // simplification; the value the old correction needed does not exist yet
771
+ // at the point v2 requires startedAt to be sent.
772
+ //
773
+ // The one case this used to fix is also the one case v2 no longer supports
774
+ // for reporting to Squally at all: merge-reports is explicitly unsupported
775
+ // under ingest v2 (spec section 5.3, closes question 8.8) - ciRunId is now
776
+ // the one supported sharding path. Dropping the correction therefore loses
777
+ // nothing for any path v2 actually supports.
778
+ this.onEnd = async (result) => {
779
+ if (!this.enabled)
780
+ return;
781
+ try {
782
+ await this.drainAndComplete(result);
783
+ if (this.runId && this.artifacts.length > 0) {
784
+ await this.uploadArtifacts(this.runId);
785
+ }
786
+ }
787
+ finally {
788
+ await this.dispatcher.close();
789
+ // See EXIT_DRAIN_MS - closing the dispatcher resolves before the
790
+ // underlying sockets are fully gone, and Playwright exits the process
791
+ // the moment onEnd() resolves.
792
+ // This wait exists precisely to keep the process alive while sockets
793
+ // finish closing - see delay(), and test/exit-code's failing-suite
794
+ // scenario, which is what catches it if the unref ever comes back.
795
+ await delay(EXIT_DRAIN_MS);
796
+ }
797
+ };
389
798
  const apiUrl = options.apiUrl ?? process.env.SQUALLY_API_URL;
390
799
  const apiKey = options.apiKey ?? process.env.SQUALLY_API_KEY;
391
800
  if (!apiUrl || !apiKey) {
@@ -402,55 +811,106 @@ class SquallyReporter {
402
811
  this.apiKey = apiKey;
403
812
  this.enabled = true;
404
813
  }
405
- onBegin(config, _suite) {
814
+ onBegin(config, suite) {
406
815
  if (!this.enabled)
407
816
  return;
408
817
  this.startedAt = new Date();
409
- // Each shard is its own process with its own reporter instance, so each
410
- // one reports a run of its own. Measured: two shards over two spec files
411
- // produce two runs holding two results each; a shard that receives no
412
- // tests produces none at all.
413
- //
414
- // Worth saying out loud what this does and does not break: per-test
415
- // history is unaffected, because a test lives in exactly one shard and so
416
- // still contributes one data point per CI run. What gets distorted is the
417
- // run level - the number of runs, each run's pass/fail (it only covers
418
- // its own slice), and anything counted per run in the dashboard.
419
- if (config.shard) {
420
- console.warn(`[squally-reporter] Sharding detected (--shard=${config.shard.current}/${config.shard.total}). ` +
421
- `Each shard reports separately, so this CI run will show up in Squally as up to ` +
422
- `${config.shard.total} runs, each holding only its own slice of the suite. ` +
423
- "Per-test flakiness stays correct; run counts and per-run pass/fail do not. " +
424
- "For a single run, write a blob report per shard (--reporter=blob) and merge " +
425
- "afterwards: npx playwright merge-reports --reporter=squally-reporter ./blob-report");
426
- }
818
+ this.dispatcher = new undici_1.Agent({
819
+ keepAliveTimeout: 4000,
820
+ keepAliveMaxTimeout: 4000,
821
+ headersTimeout: UPLOAD_REQUEST_TIMEOUT_MS,
822
+ bodyTimeout: UPLOAD_REQUEST_TIMEOUT_MS,
823
+ });
824
+ this.ciRunId = truncate(detectCiRunId(process.env, () => (0, node_crypto_1.randomUUID)()), V2_FIELD_LIMITS.ciRunId);
825
+ // Playwright's shard.current/total are already 1-based, matching the
826
+ // server's own shardIndex >= 1 constraint (docs/ingest-v2-spec.md
827
+ // section 1.2) - no adjustment needed.
828
+ this.shardIndex = config.shard?.current ?? null;
829
+ this.shardTotal = config.shard?.total ?? null;
830
+ this.projectName = resolveProjectName(suite.suites);
831
+ // config.tags is a newer Playwright feature (test tags) than this
832
+ // package's peer dependency floor (@playwright/test >= 1.30.0) - guarded
833
+ // rather than assumed present, unlike the well-established fields below
834
+ // (config.shard, config.workers) which have existed since early
835
+ // versions and are read unguarded.
836
+ const tags = Array.isArray(config.tags) ? config.tags : [];
837
+ this.runTags =
838
+ tags.length > 0 ? tags.slice(0, MAX_TAGS).map((tag) => truncate(tag, V2_FIELD_LIMITS.tag)) : undefined;
839
+ this.runConfig = this.resolveRunConfig(config);
840
+ this.runIdPromise = this.beginRun();
841
+ void this.fetchRules();
842
+ this.armFlushTimer();
843
+ }
844
+ // config.browsers ({name, version}[]) is deliberately OMITTED here, not
845
+ // sent as an empty/placeholder array. Getting REAL installed browser
846
+ // versions would mean either launching each browser type just to query it
847
+ // (expensive, and a new way for this reporter to fail a customer's CI that
848
+ // has nothing to do with reporting) or reaching into playwright-core's
849
+ // private browser-registry internals (undocumented, and liable to break
850
+ // silently across the wide @playwright/test >= 1.30.0 peer range this
851
+ // package supports). The field has zero consumers today either way (spec
852
+ // section 5.2: added "so that later work has real data instead of a
853
+ // parsed prefix") - sending an invented placeholder version string would
854
+ // be worse than sending nothing, because it would look like real data to
855
+ // whatever reads it later. Flagged in this session's report as a decision
856
+ // the spec left open.
857
+ resolveRunConfig(config) {
858
+ return {
859
+ playwrightVersion: typeof config.version === "string" ? truncate(config.version, V2_FIELD_LIMITS.playwrightVersion) : null,
860
+ workers: typeof config.workers === "number" ? config.workers : null,
861
+ os: truncate((0, node_os_1.platform)(), V2_FIELD_LIMITS.os),
862
+ osVersion: truncate((0, node_os_1.release)(), V2_FIELD_LIMITS.osVersion),
863
+ };
427
864
  }
428
865
  onTestEnd(test, result) {
429
- if (!this.enabled)
866
+ if (!this.enabled || this.sendingDisabled)
430
867
  return;
431
- const error = result.errors[0];
432
868
  // Truncated HERE, once, not when the payload is built: testName/filePath
433
869
  // are Squally's test identity, and /api/artifacts/presign resolves each
434
870
  // artifact back to a TestResult through exactly these two values plus
435
871
  // retryCount. Cutting them in two places, or cutting them only for the
436
872
  // ingest payload, would make every artifact of a long-named test
437
873
  // unmatchable server-side.
874
+ //
875
+ // slice(1), NOT slice(2) - the project name has to stay in the title.
876
+ // See squally-app/docs/ingest-v2-spec.md section 5.1/5.2: testName is
877
+ // part of @@unique([projectId, testName, filePath]), and the identity
878
+ // key is UNCHANGED in v2 (a deliberately conservative decision - see the
879
+ // spec's own section 5.2). "chromium > a.spec.ts > t" and
880
+ // "webkit > a.spec.ts > t" stay two independent identities; merging them
881
+ // would make the same-commit-flip signal treat a browser-specific bug as
882
+ // proven flakiness across the whole suite at once.
438
883
  const testName = truncate(test.titlePath().slice(1).join(" > "), FIELD_LIMITS.testName);
439
884
  const filePath = truncate(toPosixPath(node_path_1.default.relative(process.cwd(), test.location.file)), FIELD_LIMITS.filePath);
440
885
  const status = mapStatus(result.status);
441
- if (this.earliestTestStart === null || result.startTime < this.earliestTestStart) {
442
- this.earliestTestStart = result.startTime;
443
- }
444
- this.results.push({
886
+ // Guarded the same way runTags is above: an older installed Playwright
887
+ // may not have this field at all.
888
+ const repeatIndex = typeof test.repeatEachIndex === "number" ? test.repeatEachIndex : 0;
889
+ const testTags = Array.isArray(test.tags) ? test.tags : [];
890
+ const errorContextAttachment = result.attachments.find((attachment) => attachment.name === "error-context");
891
+ this.buffer.push({
445
892
  testName,
446
893
  filePath,
447
894
  status,
448
- durationMs: result.duration,
449
895
  retryCount: result.retry,
450
- errorMessage: truncateOrNull(error?.message ? stripAnsi(error.message) : null, FIELD_LIMITS.errorMessage),
451
- errorStack: truncateOrNull(error?.stack ? stripAnsi(error.stack) : null, FIELD_LIMITS.errorStack),
452
- consoleOutput: truncateOrNull(formatConsoleOutput(result), FIELD_LIMITS.consoleOutput),
896
+ repeatIndex,
897
+ durationMs: result.duration,
898
+ errors: mapErrors(result.errors),
899
+ consoleOutput: prepareConsoleOutput(formatConsoleOutput(result)),
900
+ tags: testTags.length > 0
901
+ ? testTags.slice(0, MAX_TAGS).map((tag) => truncate(tag, V2_FIELD_LIMITS.tag))
902
+ : undefined,
903
+ browserName: resolveBrowserName(test),
904
+ errorContext: readErrorContext(errorContextAttachment),
453
905
  });
906
+ if (this.buffer.length > MAX_TEST_RESULTS_WARNING && this.buffer.length % MAX_TEST_RESULTS_WARNING === 1) {
907
+ console.warn(`[squally-reporter] This run has sent well over ${MAX_TEST_RESULTS_WARNING} test results so far. ` +
908
+ "That usually means retries are configured much higher than intended, or the suite is " +
909
+ "unusually large - results are still being sent normally.");
910
+ }
911
+ if (this.buffer.length >= FLUSH_THRESHOLD_RESULTS) {
912
+ this.triggerFlush();
913
+ }
454
914
  if (!attemptQualifiesForArtifacts(status, result.retry))
455
915
  return;
456
916
  for (const attachment of result.attachments) {
@@ -485,143 +945,320 @@ class SquallyReporter {
485
945
  });
486
946
  }
487
947
  }
488
- async onEnd(result) {
489
- if (!this.enabled)
948
+ // (decision, spec section 3.2, closes 8.7) At onEnd: flush the remaining
949
+ // buffer, wait for in-flight batches, then call /complete - bounded by
950
+ // DRAIN_BUDGET_MS overall, covering waiting for onBegin itself if it has
951
+ // not resolved yet. On expiry, gives up on draining and calls /complete
952
+ // anyway with whatever WAS sent - a hung append must not hold the
953
+ // customer's CI process open indefinitely.
954
+ //
955
+ // (deliberate divergence from v1) Once a runId exists, /complete is ALWAYS
956
+ // called - there is no "this run had zero results, skip reporting it
957
+ // entirely" special case, unlike v1 (`if (this.results.length === 0)
958
+ // return;`, guarding the whole of onEnd there). That check cannot be
959
+ // reproduced here: onBegin already created the run server-side, before a
960
+ // single test could have told us the eventual count would be zero.
961
+ // Skipping /complete at this point would not undo the run's existence - it
962
+ // would leave an already-created run stuck in `running` until the timeout
963
+ // sweep notices (currently up to 18 hours, see docs/status.md's run-timeout
964
+ // entry). Completing it immediately with zero results is strictly better: a
965
+ // short-lived empty run in the dashboard costs less than an
966
+ // apparently-stuck one for the better part of a day.
967
+ async drainAndComplete(result) {
968
+ this.stopFlushTimer();
969
+ const deadline = Date.now() + DRAIN_BUDGET_MS;
970
+ const runId = await withDeadline(this.runIdPromise, deadline);
971
+ if (runId === TIMED_OUT) {
972
+ console.warn("[squally-reporter] Squally did not confirm the start of this run in time - nothing was sent.");
490
973
  return;
491
- // A run with 0 test results is meaningless for Squally's flakiness
492
- // calculation and would just be dashboard clutter - regardless of why
493
- // there were none (npx playwright test --list, an empty testDir, a
494
- // --grep filter that matched nothing). Bail out before building/
495
- // sending anything.
496
- if (this.results.length === 0)
974
+ }
975
+ if (runId === null) {
976
+ // Already warned inside beginRun() - nothing more to say here, and
977
+ // nothing to complete without a runId.
497
978
  return;
498
- // Checked before the request, not after: the server would answer 400 and
499
- // discard the entire run, and the customer would be left correlating a
500
- // status code with a suite that finished minutes ago.
501
- if (this.results.length > MAX_TEST_RESULTS) {
502
- console.warn(`[squally-reporter] This run has ${this.results.length} test results, and Squally ` +
503
- `accepts at most ${MAX_TEST_RESULTS} per run - nothing was sent. Note that retries ` +
504
- "count separately. Split the suite across several Playwright projects or " +
505
- "configurations so each reports its own run, or get in touch if you need a " +
506
- "single run this size.");
979
+ }
980
+ this.runId = runId;
981
+ if (this.buffer.length > 0)
982
+ this.triggerFlush();
983
+ // Result intentionally unused: on expiry we proceed to /complete anyway,
984
+ // per the decision above - a hung append must not block it.
985
+ await withDeadline(Promise.allSettled([...this.inFlightFlushes]), deadline);
986
+ const finalStatus = result.status === "passed" ? "passed" : "failed";
987
+ await this.completeRun(runId, finalStatus);
988
+ }
989
+ armFlushTimer() {
990
+ if (!this.enabled || this.sendingDisabled)
507
991
  return;
992
+ const handle = setTimeout(() => {
993
+ this.triggerFlush();
994
+ this.armFlushTimer();
995
+ }, FLUSH_INTERVAL_MS);
996
+ handle.unref();
997
+ this.flushTimerHandle = handle;
998
+ }
999
+ stopFlushTimer() {
1000
+ if (this.flushTimerHandle) {
1001
+ clearTimeout(this.flushTimerHandle);
1002
+ this.flushTimerHandle = null;
508
1003
  }
1004
+ }
1005
+ // Permanent give-up: nothing more will ever be sent for this run. Clears
1006
+ // the UNSENT buffer (those results can never be delivered) and the flush
1007
+ // timer, but deliberately leaves this.artifacts untouched - artifacts
1008
+ // collected BEFORE this point correspond to test results that WERE
1009
+ // already sent and exist server-side, and are still uploaded normally at
1010
+ // onEnd.
1011
+ giveUp() {
1012
+ this.sendingDisabled = true;
1013
+ this.buffer.length = 0;
1014
+ this.stopFlushTimer();
1015
+ }
1016
+ triggerFlush() {
1017
+ const promise = this.flush().catch((err) => {
1018
+ console.warn("[squally-reporter] Unexpected error while sending results:", err);
1019
+ });
1020
+ this.inFlightFlushes.add(promise);
1021
+ void promise.finally(() => this.inFlightFlushes.delete(promise));
1022
+ }
1023
+ async flush() {
1024
+ if (this.buffer.length === 0)
1025
+ return;
1026
+ const runId = await this.runIdPromise;
1027
+ if (!runId)
1028
+ return;
1029
+ while (this.buffer.length > 0 && !this.sendingDisabled) {
1030
+ const chunk = this.buffer.splice(0, BATCH_MAX_RESULTS);
1031
+ await this.sendBatch(runId, chunk);
1032
+ }
1033
+ }
1034
+ // (spec section 3, the error table) Six outcomes, and they do not all
1035
+ // behave the same way:
1036
+ // 400 (malformed) - drop THIS batch only, keep going. The spec's
1037
+ // own wording for 404/409 explicitly says "stop
1038
+ // sending"; 400 does not, and section 3.4 says
1039
+ // the server itself "rejects only the batch,
1040
+ // not the run" - a local bug producing one bad
1041
+ // batch does not mean the next one would also
1042
+ // be malformed.
1043
+ // 402 (quota) - warn once, stop sending for this run.
1044
+ // 404 (unknown runId) - warn, stop sending for this run.
1045
+ // 409 (already complete) - warn, stop sending for this run.
1046
+ // 5xx / network error - already retried once inside requestJson();
1047
+ // on final failure, drop this batch, keep
1048
+ // going. The spec's table lists no explicit
1049
+ // "stop sending" for this case, and a
1050
+ // transient server hiccup should not
1051
+ // permanently disable reporting for the rest
1052
+ // of the run.
1053
+ async sendBatch(runId, batch) {
1054
+ const outcome = await this.requestJson("POST", `${this.apiUrl}/api/ingest/v2/runs/${runId}/results`, { results: batch });
1055
+ if (outcome.kind === "ok")
1056
+ return;
1057
+ if (outcome.kind === "http_error") {
1058
+ switch (outcome.status) {
1059
+ case 400:
1060
+ console.warn(`[squally-reporter] A batch of ${batch.length} result(s) was rejected: ${outcome.detail} - ` +
1061
+ "dropping this batch, reporting continues.");
1062
+ return;
1063
+ case 402:
1064
+ console.warn(`[squally-reporter] Monthly plan quota exceeded: ${outcome.detail} - no further results will ` +
1065
+ "be sent for this run. The run will still be marked complete.");
1066
+ this.giveUp();
1067
+ return;
1068
+ case 404:
1069
+ console.warn(`[squally-reporter] Squally no longer has a record of this run: ${outcome.detail} - no ` +
1070
+ "further results will be sent.");
1071
+ this.giveUp();
1072
+ return;
1073
+ case 409:
1074
+ console.warn(`[squally-reporter] This run was already marked complete on Squally: ${outcome.detail} - no ` +
1075
+ "further results will be sent.");
1076
+ this.giveUp();
1077
+ return;
1078
+ default:
1079
+ console.warn(`[squally-reporter] Failed to send ${batch.length} result(s) to Squally: ${outcome.status} - ` +
1080
+ `${outcome.detail} - dropping this batch, reporting continues.`);
1081
+ return;
1082
+ }
1083
+ }
1084
+ console.warn(`[squally-reporter] Failed to send ${batch.length} result(s) to Squally: ${outcome.detail} - ` +
1085
+ "dropping this batch, reporting continues.");
1086
+ }
1087
+ async beginRun() {
509
1088
  const resolvedRevision = resolveTestedRevision();
510
- // commitMessage is the one that bites in practice: `git log -1 %B` is the
511
- // full body, and a squash merge carrying a PR description is routinely
512
- // past 500 characters. Before this, that single value discarded every test
513
- // result in the run.
514
1089
  const payload = {
1090
+ ciRunId: this.ciRunId,
1091
+ shardIndex: this.shardIndex,
1092
+ shardTotal: this.shardTotal,
1093
+ projectName: this.projectName,
515
1094
  branch: truncateOrNull(detectBranch(), FIELD_LIMITS.branch),
516
1095
  commitSha: truncateOrNull(detectCommitSha(), FIELD_LIMITS.commitSha),
517
1096
  commitMessage: truncateOrNull(detectCommitMessage(), FIELD_LIMITS.commitMessage),
518
1097
  authorName: truncateOrNull(detectAuthorName(), FIELD_LIMITS.authorName),
519
1098
  ciProvider: detectCiProvider(),
520
- status: result.status === "passed" ? "passed" : "failed",
521
- startedAt: this.runStartedAt().toISOString(),
522
- finishedAt: new Date().toISOString(),
523
- testResults: this.results,
1099
+ startedAt: this.startedAt.toISOString(),
524
1100
  testedRevision: truncateOrNull(resolvedRevision?.revision ?? null, FIELD_LIMITS.testedRevision),
525
1101
  revisionSource: resolvedRevision?.source ?? null,
526
1102
  eventType: truncateOrNull(detectEventType(), FIELD_LIMITS.eventType),
1103
+ runTags: this.runTags,
1104
+ config: this.runConfig,
527
1105
  };
528
- // Our own Agent rather than the global one: Node's global fetch() pools
529
- // connections through a shared, long-lived keep-alive dispatcher, and a
530
- // pooled socket outliving this run is exactly what we do not want in a
531
- // short-lived CLI process. Importing fetch from undici too (not Node's
532
- // global one) avoids a possible instanceof mismatch between Node's
533
- // internal bundled undici and this installed copy when passing our Agent
534
- // as the dispatcher.
535
- //
536
- // NOTE: this does NOT on its own prevent the Windows exit crash - that
537
- // claim used to stand here and was wrong. Closing the dispatcher resolves
538
- // before the sockets are actually gone; see EXIT_DRAIN_MS above. Measured
539
- // with global fetch as well: dropping undici entirely does not help
540
- // either, so the client is not what makes the difference.
541
- // The per-request AbortSignals below are the real bound; these are a
542
- // backstop so that nothing can outlive the longest budget even if a signal
543
- // never fires.
544
- const dispatcher = new undici_1.Agent({
545
- keepAliveTimeout: 1,
546
- keepAliveMaxTimeout: 1,
547
- headersTimeout: UPLOAD_REQUEST_TIMEOUT_MS,
548
- bodyTimeout: UPLOAD_REQUEST_TIMEOUT_MS,
549
- });
1106
+ const outcome = await this.requestJson("POST", `${this.apiUrl}/api/ingest/v2/runs`, payload);
1107
+ if (outcome.kind === "ok")
1108
+ return outcome.json.runId;
1109
+ // (spec section 7, "New reporter against old app") A 404 here means the
1110
+ // TARGET SERVER does not have the v2 routes at all - an old squally-app.
1111
+ // Cannot happen in production (the app deploys before any reporter that
1112
+ // calls it), but will happen locally while both sides are in
1113
+ // development. Treated as a distinct, permanent condition with its own
1114
+ // message, not retried, and NOT downgraded to a v1 POST - the spec is
1115
+ // explicit that a silent protocol downgrade would make "my streaming
1116
+ // isn't working" undiagnosable. The reporter falls back to sending
1117
+ // nothing for this run instead.
1118
+ if (outcome.kind === "http_error" && outcome.status === 404) {
1119
+ console.warn("[squally-reporter] This Squally instance does not support the v2 ingest API yet (404 on " +
1120
+ "/api/ingest/v2/runs). No results from this run will be sent - update squally-app, or check " +
1121
+ "SQUALLY_API_URL if this is unexpected.");
1122
+ }
1123
+ else {
1124
+ const detail = outcome.kind === "http_error" ? `${outcome.status} - ${outcome.detail}` : outcome.detail;
1125
+ console.error(`[squally-reporter] Failed to start reporting to Squally: ${detail}`);
1126
+ }
1127
+ this.giveUp();
1128
+ return null;
1129
+ }
1130
+ async completeRun(runId, status) {
1131
+ const payload = {
1132
+ shardIndex: this.shardIndex,
1133
+ status,
1134
+ finishedAt: new Date().toISOString(),
1135
+ };
1136
+ const outcome = await this.requestJson("POST", `${this.apiUrl}/api/ingest/v2/runs/${runId}/complete`, payload);
1137
+ if (outcome.kind !== "ok") {
1138
+ const detail = outcome.kind === "http_error" ? `${outcome.status} - ${outcome.detail}` : outcome.detail;
1139
+ console.error(`[squally-reporter] Failed to mark the run complete on Squally: ${detail}`);
1140
+ }
1141
+ }
1142
+ // (spec section 6) Called once, fired alongside beginRun() rather than
1143
+ // awaited before it - onBegin cannot itself be awaited by Playwright
1144
+ // (Reporter['onBegin'] is typed void), so "before any test runs" is
1145
+ // best-effort here regardless of internal sequencing: tests may already be
1146
+ // executing by the time either promise settles. Deliberately its OWN
1147
+ // request, not routed through requestJson()'s retry machinery: the spec's
1148
+ // failure handling for this call is simpler by design ("on any error,
1149
+ // timeout, or non-200, proceeds with an empty ruleset and warns once") -
1150
+ // a single attempt, no retries, because nothing depends on this succeeding
1151
+ // yet.
1152
+ async fetchRules() {
1153
+ if (rulesCache && rulesCache.expiresAt > Date.now())
1154
+ return;
550
1155
  try {
551
- let runId = null;
1156
+ const response = await (0, undici_1.fetch)(`${this.apiUrl}/api/ingest/v2/rules`, {
1157
+ method: "GET",
1158
+ headers: { Authorization: `Bearer ${this.apiKey}`, "User-Agent": USER_AGENT },
1159
+ dispatcher: this.dispatcher,
1160
+ signal: AbortSignal.timeout(RULES_REQUEST_TIMEOUT_MS),
1161
+ });
1162
+ if (!response.ok) {
1163
+ console.warn(`[squally-reporter] Could not fetch quarantine rules from Squally: ` +
1164
+ `${await describeResponseError(response)} - continuing without them.`);
1165
+ return;
1166
+ }
1167
+ const data = (await response.json());
1168
+ rulesCache = { data, expiresAt: Date.now() + data.ttlSeconds * 1000 };
1169
+ }
1170
+ catch (err) {
1171
+ console.warn("[squally-reporter] Could not fetch quarantine rules from Squally:", describeRequestError(err, RULES_REQUEST_TIMEOUT_MS));
1172
+ }
1173
+ }
1174
+ // Shared low-level request/retry machinery for begin/append/complete.
1175
+ //
1176
+ // Two INDEPENDENT retry policies with separate counters (spec section
1177
+ // 3.2): 429 gets up to RATE_LIMIT_MAX_ATTEMPTS total tries, honouring
1178
+ // Retry-After when present and otherwise backing off exponentially; a 5xx
1179
+ // or network failure gets exactly one retry (SERVER_ERROR_MAX_ATTEMPTS=2
1180
+ // total tries) after a short fixed delay. Any other non-2xx status is
1181
+ // returned immediately, uninterpreted - the CALLER (beginRun/sendBatch/
1182
+ // completeRun) decides what a given status code means for ITS route,
1183
+ // since the same code means different things on different routes (404 on
1184
+ // /runs means "no v2 support"; 404 on /results means "unknown runId").
1185
+ //
1186
+ // Every request here is idempotent server-side (spec section 2:
1187
+ // "Idempotency" - onBegin keyed on ciRunId+shardIndex, append keyed on the
1188
+ // attempt tuple, complete keyed on shardIndex), which is what makes
1189
+ // retrying safely possible in the first place.
1190
+ async requestJson(method, url, body) {
1191
+ let rateLimitAttempts = 0;
1192
+ let serverErrorAttempts = 0;
1193
+ let networkAttempts = 0;
1194
+ for (;;) {
1195
+ let response;
552
1196
  try {
553
- const response = await (0, undici_1.fetch)(`${this.apiUrl}/api/ingest`, {
554
- method: "POST",
1197
+ response = await (0, undici_1.fetch)(url, {
1198
+ method,
555
1199
  headers: {
556
1200
  "Content-Type": "application/json",
557
1201
  Authorization: `Bearer ${this.apiKey}`,
1202
+ "User-Agent": USER_AGENT,
558
1203
  },
559
- body: JSON.stringify(payload),
560
- dispatcher,
1204
+ body: body === undefined ? undefined : JSON.stringify(body),
1205
+ dispatcher: this.dispatcher,
561
1206
  signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
562
1207
  });
563
- if (!response.ok) {
564
- // Never throw here - a failing reporter upload should not fail the
565
- // whole CI run just because the dashboard was unreachable.
566
- console.error(`[squally-reporter] Ingest failed: ${await describeResponseError(response)}`);
1208
+ }
1209
+ catch (err) {
1210
+ networkAttempts++;
1211
+ if (networkAttempts >= NETWORK_ERROR_MAX_ATTEMPTS) {
1212
+ return { kind: "network_error", detail: describeRequestError(err, API_REQUEST_TIMEOUT_MS) };
567
1213
  }
568
- else {
569
- const body = (await response.json());
570
- runId = body.runId ?? null;
1214
+ await delay(SERVER_ERROR_RETRY_DELAY_MS);
1215
+ continue;
1216
+ }
1217
+ if (response.status === 429) {
1218
+ rateLimitAttempts++;
1219
+ if (rateLimitAttempts >= RATE_LIMIT_MAX_ATTEMPTS) {
1220
+ return { kind: "http_error", status: 429, detail: await describeResponseError(response) };
571
1221
  }
1222
+ const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"), Date.now());
1223
+ const waitMs = Math.min(RATE_LIMIT_BACKOFF_CAP_MS, retryAfterMs ?? computeBackoffMs(rateLimitAttempts));
1224
+ await delay(waitMs);
1225
+ continue;
572
1226
  }
573
- catch (err) {
574
- console.error("[squally-reporter] Failed to reach Squally API:", describeRequestError(err, API_REQUEST_TIMEOUT_MS));
1227
+ if (response.status >= 500) {
1228
+ serverErrorAttempts++;
1229
+ if (serverErrorAttempts >= SERVER_ERROR_MAX_ATTEMPTS) {
1230
+ return { kind: "http_error", status: response.status, detail: await describeResponseError(response) };
1231
+ }
1232
+ await delay(SERVER_ERROR_RETRY_DELAY_MS);
1233
+ continue;
575
1234
  }
576
- // Artifact upload is a separate, best-effort step layered on top of
577
- // ingest, never a reason to fail it: it needs a runId (presign looks
578
- // TestResults up by runId + identity fields, which only exist once
579
- // ingest has actually inserted them), and there's nothing to do if
580
- // this run had no failing/retried attempts with a screenshot/trace
581
- // attached (attemptQualifiesForArtifacts already filtered that in
582
- // onTestEnd) - no reason to make an empty presign call.
583
- if (runId && this.artifacts.length > 0) {
584
- await this.uploadArtifacts(runId, dispatcher);
1235
+ if (!response.ok) {
1236
+ return { kind: "http_error", status: response.status, detail: await describeResponseError(response) };
1237
+ }
1238
+ try {
1239
+ const json = (await response.json());
1240
+ return { kind: "ok", status: response.status, json };
1241
+ }
1242
+ catch (err) {
1243
+ return { kind: "parse_error", detail: String(err) };
585
1244
  }
586
1245
  }
587
- finally {
588
- await dispatcher.close();
589
- // See EXIT_DRAIN_MS - closing the dispatcher resolves before the
590
- // underlying sockets are fully gone, and Playwright exits the process
591
- // the moment onEnd() resolves.
592
- await new Promise((resolve) => setTimeout(resolve, EXIT_DRAIN_MS));
593
- }
594
- }
595
- // onBegin's timestamp is normally the right answer: it fires before the
596
- // first test and therefore includes global setup, webServer startup and so
597
- // on. It is wrong in exactly one case - `npx playwright merge-reports`,
598
- // where onBegin fires on the merging machine, minutes or hours after the
599
- // tests actually ran, and would date the whole run to the merge.
600
- //
601
- // The earlier of the two is right in both situations: during a normal run
602
- // onBegin precedes every test, during a merge it follows all of them. No
603
- // detection of which mode we are in is needed.
604
- runStartedAt() {
605
- if (this.earliestTestStart === null)
606
- return this.startedAt;
607
- return this.earliestTestStart < this.startedAt ? this.earliestTestStart : this.startedAt;
608
1246
  }
609
1247
  // Top-level safety net - presignAndUpload already handles presign/
610
- // upload/confirm failures individually (see Aufgabe 5's requirement that
611
- // none of this ever affects the test suite's exit code), but this catch
612
- // still exists in case something in there throws in a way that wasn't
613
- // anticipated. The whole artifact feature must be additive: worst case,
614
- // some screenshots/traces don't make it to Squally, but the customer's
615
- // CI run itself is never at risk.
616
- async uploadArtifacts(runId, dispatcher) {
1248
+ // upload/confirm failures individually, but this catch still exists in
1249
+ // case something in there throws in a way that wasn't anticipated. The
1250
+ // whole artifact feature must be additive: worst case, some screenshots/
1251
+ // traces don't make it to Squally, but the customer's CI run itself is
1252
+ // never at risk.
1253
+ async uploadArtifacts(runId) {
617
1254
  try {
618
- await this.presignAndUpload(runId, dispatcher);
1255
+ await this.presignAndUpload(runId);
619
1256
  }
620
1257
  catch (err) {
621
1258
  console.warn("[squally-reporter] Artifact upload failed unexpectedly:", err);
622
1259
  }
623
1260
  }
624
- async presignAndUpload(runId, dispatcher) {
1261
+ async presignAndUpload(runId) {
625
1262
  const presignArtifacts = this.artifacts.map((artifact) => ({
626
1263
  testName: artifact.testName,
627
1264
  filePath: artifact.filePath,
@@ -636,9 +1273,10 @@ class SquallyReporter {
636
1273
  headers: {
637
1274
  "Content-Type": "application/json",
638
1275
  Authorization: `Bearer ${this.apiKey}`,
1276
+ "User-Agent": USER_AGENT,
639
1277
  },
640
1278
  body: JSON.stringify({ runId, artifacts: presignArtifacts }),
641
- dispatcher,
1279
+ dispatcher: this.dispatcher,
642
1280
  signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
643
1281
  });
644
1282
  if (!response.ok) {
@@ -682,7 +1320,7 @@ class SquallyReporter {
682
1320
  const response = await (0, undici_1.fetch)(uploadUrl, {
683
1321
  method: "PUT",
684
1322
  body: fileBody,
685
- dispatcher,
1323
+ dispatcher: this.dispatcher,
686
1324
  signal: AbortSignal.timeout(UPLOAD_REQUEST_TIMEOUT_MS),
687
1325
  });
688
1326
  if (!response.ok) {
@@ -714,9 +1352,10 @@ class SquallyReporter {
714
1352
  headers: {
715
1353
  "Content-Type": "application/json",
716
1354
  Authorization: `Bearer ${this.apiKey}`,
1355
+ "User-Agent": USER_AGENT,
717
1356
  },
718
1357
  body: JSON.stringify({ runId, artifacts: confirmArtifacts }),
719
- dispatcher,
1358
+ dispatcher: this.dispatcher,
720
1359
  signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
721
1360
  });
722
1361
  if (!response.ok) {
@@ -736,3 +1375,24 @@ class SquallyReporter {
736
1375
  }
737
1376
  }
738
1377
  exports.default = SquallyReporter;
1378
+ // Exported for unit tests only (test/unit) - not part of the package's public
1379
+ // API surface (see package.json "files": ["dist"], which ships everything in
1380
+ // dist regardless, but these are undocumented and may change without notice).
1381
+ exports.__internal = {
1382
+ truncate,
1383
+ truncateOrNull,
1384
+ mapStatus,
1385
+ stripAnsi,
1386
+ normalizeRevision,
1387
+ resolveTestedRevision,
1388
+ toPosixPath,
1389
+ detectCiRunId,
1390
+ resolveProjectName,
1391
+ maskSecrets,
1392
+ prepareConsoleOutput,
1393
+ mapErrors,
1394
+ parseRetryAfterMs,
1395
+ computeBackoffMs,
1396
+ attemptQualifiesForArtifacts,
1397
+ artifactKey,
1398
+ };