squally-reporter 0.10.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -50,8 +50,30 @@ const V2_FIELD_LIMITS = {
50
50
  playwrightVersion: 50,
51
51
  os: 100,
52
52
  osVersion: 50,
53
+ // 0.11.0 - squally-app's own limits for the three begin fields
54
+ // (lib/ingest-v2-contract.ts, V2_FIELD_LIMITS).
55
+ ciJobKey: 64,
56
+ beginToken: 100,
57
+ ciRunUrl: 2048,
53
58
  };
59
+ /**
60
+ * SQUALLY_CI_JOB_KEY's alphabet - the server's CI_JOB_KEY_PATTERN, exactly. It
61
+ * becomes part of the CI run id and of a GitHub check name, which is why it is
62
+ * this narrow.
63
+ */
64
+ const CI_JOB_KEY_PATTERN = /^[A-Za-z0-9._-]+$/;
65
+ /** The highest attempt the server accepts (MAX_CI_RUN_ATTEMPT in squally-app). */
66
+ const MAX_CI_RUN_ATTEMPT = 1000;
54
67
  const MAX_TAGS = 20;
68
+ // Steps (0.12.0) - squally-app lib/attempt-steps.ts holds the same limits.
69
+ const MAX_STEPS = 300;
70
+ const MAX_STEP_TITLE = 200;
71
+ const MAX_STEP_CATEGORY = 32;
72
+ const MAX_STEP_LOCATION = 300;
73
+ // Byte ceiling of one results request (0.12.0). Vercel refuses a body over
74
+ // 4.5 MB before squally-app sees it; with steps, 500 results could reach
75
+ // ~20 MB. 3 MB leaves room for the JSON around the entries.
76
+ const BATCH_MAX_BYTES = 3000000;
55
77
  const MAX_ERRORS_PER_RESULT = 10;
56
78
  const MAX_ERROR_SNIPPET = 2000;
57
79
  // ASCII only, like everything else in this file, and short: it has to fit
@@ -138,40 +160,37 @@ const RULES_REQUEST_TIMEOUT_MS = 5000;
138
160
  // limit, not a size limit (BATCH_MAX_RESULTS above is the size limit) - it
139
161
  // keeps a normal suite's "running" view within a few seconds of the suite,
140
162
  // while a very large run needs many requests spread over its whole duration
141
- // rather than one enormous one. 2 seconds is the shortest window that
142
- // cannot, on its own, exceed the ingest firewall rule (60 requests/60 s per
143
- // API key): 60 s / 2 s = 30 requests/minute, half of 60.
163
+ // rather than one enormous one. 2 seconds keeps the live view a couple of
164
+ // seconds behind the suite, and one reporter at that cadence sends 30
165
+ // requests/minute - a tenth of the ingest rule (300 requests/60 s per API
166
+ // key since squally-app's B2-0, 10.10.2026; it was 60 when this was written).
144
167
  const FLUSH_THRESHOLD_RESULTS = 25;
145
168
  const FLUSH_INTERVAL_MS = 2000;
146
- // Scaled by the shard count (0.6.0), because the budget the 2 s above was
147
- // derived from is NOT per reporter - it is per API key, and every shard of a
148
- // run carries the same key. The firewall counts `header:authorization`, so
149
- // eight shards each flushing every 2 s produce 8 x 30 = 240 requests/minute
150
- // against a 60/minute rule. The "half of 60" margin the constant above claims
151
- // was computed for a single reporter and silently stopped holding the day
152
- // sharding arrived.
169
+ // Scaled by the shard count (0.6.0), because the budget is NOT per reporter -
170
+ // it is per API key, and every shard of a run carries the same key. The
171
+ // firewall counts `header:authorization`, so eight shards each flushing every
172
+ // 2 s produce 8 x 30 = 240 requests/minute.
153
173
  //
154
- // interval x shards keeps the AGGREGATE rate at 30/minute for any shard count:
155
- // S shards x (60 s / 2S s) = 30. The live view does not get slower for it
156
- // either - it shows the RUN, and a run made of S shards receives an update
157
- // from one shard or another every 2 s on average regardless of S.
174
+ // THE BUDGET IS 300/minute SINCE 0.12.0 (it was 60, which made 0.6.0 stretch
175
+ // every sharded run to a 30/minute aggregate - a fifth of what the rule now
176
+ // allows, and up to 30 s of results waiting in each shard). Each shard now
177
+ // takes SHARD_INTERVAL_MS per shard, never less than the 2 s base: the
178
+ // AGGREGATE is S x (60 s / (S x 0.4 s)) = 150/minute, half the rule, for any
179
+ // shard count from 5 up; below 5 the 2 s base keeps it lower still.
158
180
  //
159
181
  // CAPPED, and the cap is about crash exposure rather than rate. Everything
160
182
  // buffered is lost if the runner is killed (CI cancellation, OOM), so the
161
183
  // interval is also the size of the window at risk. 30 s is the same figure
162
- // RATE_LIMIT_BACKOFF_CAP_MS uses for "the longest we ever wait", and it keeps
163
- // the aggregate inside the budget up to 30 shards (S x 60/30 <= 60), which
164
- // covers the 4-to-16 range the app's own shard documentation describes with
165
- // room to spare.
166
- //
167
- // Above 30 shards the aggregate creeps over the rule again. That is accepted:
168
- // it is outside the documented range, the retry path already handles a 429,
169
- // and since 0.6.0 any batch actually lost to it is visible in the
170
- // resultsProduced diff rather than silent.
184
+ // RATE_LIMIT_BACKOFF_CAP_MS uses for "the longest we ever wait"; it is reached
185
+ // at 75 shards, and the aggregate stays inside the rule up to 150 shards
186
+ // (S x 60/30 <= 300) - far beyond the 4-to-16 the app's shard documentation
187
+ // describes. Past that, the retry path handles a 429, and any batch lost to it
188
+ // is visible in the resultsProduced diff rather than silent.
189
+ const SHARD_INTERVAL_MS = 400;
171
190
  const FLUSH_INTERVAL_CAP_MS = 30000;
172
191
  /**
173
- * This shard's flush interval: the base cadence stretched by how many shards
174
- * share the rate budget with it.
192
+ * This shard's flush interval: the base cadence, stretched once enough shards
193
+ * share the rate budget to need it.
175
194
  *
176
195
  * Only the TIMER is scaled. FLUSH_THRESHOLD_RESULTS is not, and that is
177
196
  * deliberate: it is a latency bound on a fast suite, not a rate control, and
@@ -182,7 +201,7 @@ const FLUSH_INTERVAL_CAP_MS = 30000;
182
201
  */
183
202
  function flushIntervalFor(shardTotal) {
184
203
  const shards = Math.max(1, shardTotal ?? 1);
185
- return Math.min(FLUSH_INTERVAL_CAP_MS, FLUSH_INTERVAL_MS * shards);
204
+ return Math.min(FLUSH_INTERVAL_CAP_MS, Math.max(FLUSH_INTERVAL_MS, SHARD_INTERVAL_MS * shards));
186
205
  }
187
206
  // --- Retry policy (spec section 3.2) ----------------------------------------
188
207
  //
@@ -252,6 +271,34 @@ function describeRequestError(err, timeoutMs) {
252
271
  }
253
272
  return String(err);
254
273
  }
274
+ /**
275
+ * An error response read once: the log line describeResponseError makes, and -
276
+ * when the body is squally-app's JSON `{ error, code }` - the code and the
277
+ * server's own sentence (0.11.0: begin's 409 carries `code`).
278
+ */
279
+ async function readResponseError(response) {
280
+ let body = "";
281
+ try {
282
+ body = (await response.text()).trim();
283
+ }
284
+ catch {
285
+ // Best-effort, as in describeResponseError.
286
+ }
287
+ let code = null;
288
+ let message = null;
289
+ try {
290
+ const parsed = JSON.parse(body);
291
+ if (typeof parsed.code === "string")
292
+ code = parsed.code;
293
+ if (typeof parsed.error === "string")
294
+ message = parsed.error;
295
+ }
296
+ catch {
297
+ // Not JSON - the detail line still carries the text.
298
+ }
299
+ const detail = `${response.status} ${response.statusText}${body ? ` - ${truncate(body, ERROR_BODY_LOG_LIMIT)}` : ""}`;
300
+ return { detail, code, message };
301
+ }
255
302
  async function describeResponseError(response) {
256
303
  let detail = "";
257
304
  try {
@@ -802,15 +849,17 @@ function providerRefs(provider, env, io) {
802
849
  return { branch: undefined, commitSha: undefined, describe: "checkout", checkout: undefined };
803
850
  }
804
851
  }
805
- // GitHub Actions' GITHUB_ACTOR is the user who triggered the workflow run
806
- // (a username, not necessarily the commit author, but the closest thing
807
- // GitHub exposes without shelling out). GitLab's CI_COMMIT_AUTHOR is
808
- // "Name <email>" - only the name part is kept. Unchanged in 0.10.0, and used
809
- // only when the reported commit is the checked-out one ("checkout" above).
810
- function authorFromEnv(env) {
811
- const actor = envValue(env, "GITHUB_ACTOR");
812
- if (actor !== undefined)
813
- return actor;
852
+ // The commit's author, as a CI hands it over: GitLab's CI_COMMIT_AUTHOR is
853
+ // "Name <email>" - only the name part is kept. Used only when the reported
854
+ // commit is the checked-out one ("checkout" above).
855
+ //
856
+ // GITHUB_ACTOR IS NO AUTHOR (0.12.0, squally-app #22): it is whoever
857
+ // triggered the workflow - the person who pushed, merged, or clicked "Run
858
+ // workflow", or a bot - and until 0.11.0 it was reported as the commit's
859
+ // author outside pull requests. The checkout knows the real one (`git log`),
860
+ // so that comes first; the actor stays only as the last resort, for a
861
+ // checkout git cannot read.
862
+ function commitAuthorFromEnv(env) {
814
863
  const gitlabAuthor = envValue(env, "CI_COMMIT_AUTHOR");
815
864
  if (gitlabAuthor === undefined)
816
865
  return undefined;
@@ -911,7 +960,7 @@ function gitContextParts(env, io) {
911
960
  branch,
912
961
  commitSha,
913
962
  commitMessage: envValue(env, "CI_COMMIT_MESSAGE") ?? io.git("git log -1 --pretty=%B"),
914
- authorName: authorFromEnv(env) ?? io.git("git log -1 --pretty=%an"),
963
+ authorName: commitAuthorFromEnv(env) ?? io.git("git log -1 --pretty=%an") ?? envValue(env, "GITHUB_ACTOR") ?? null,
915
964
  },
916
965
  missingHeadCommit: null,
917
966
  };
@@ -1010,37 +1059,215 @@ function isListOnlyInvocation(config) {
1010
1059
  }
1011
1060
  // env and generateFallback are parameters, not process.env/randomUUID read
1012
1061
  // directly, so this is unit-testable without mocking global state.
1013
- function detectCiRunId(env, generateFallback) {
1062
+ //
1063
+ // THE JOB KEY (0.11.0, SQUALLY_CI_JOB_KEY). Several jobs of one CI run that are
1064
+ // not shards of each other - a browser matrix, an "api" and a "ui" suite -
1065
+ // share the provider's id, and squally-app grouped them into one run: the
1066
+ // second job was handed the first one's run, and its results were refused once
1067
+ // that run completed. With a key each job gets an id of its own, shared only
1068
+ // by its own shards: the key goes right after the provider's id and BEFORE
1069
+ // GitHub's attempt suffix - "<run_id>-<key>-<attempt>" - so squally-app still
1070
+ // reads the attempt off the end and links a re-run to the job's own earlier
1071
+ // attempt. Without a key every id is byte-identical to 0.10.2. An explicit
1072
+ // SQUALLY_CI_RUN_ID still wins outright; the key is then only sent on its own
1073
+ // (ciJobKey). The random fallback is already unique to this process and stays
1074
+ // as it was.
1075
+ function detectCiRunId(env, generateFallback, jobKey = null) {
1014
1076
  if (env.SQUALLY_CI_RUN_ID)
1015
1077
  return env.SQUALLY_CI_RUN_ID;
1078
+ const key = jobKey ? `-${jobKey}` : "";
1016
1079
  if (env.GITHUB_RUN_ID) {
1017
1080
  return env.GITHUB_RUN_ATTEMPT
1018
- ? `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`
1019
- : env.GITHUB_RUN_ID;
1081
+ ? `${env.GITHUB_RUN_ID}${key}-${env.GITHUB_RUN_ATTEMPT}`
1082
+ : `${env.GITHUB_RUN_ID}${key}`;
1020
1083
  }
1021
1084
  if (env.CI_PIPELINE_ID)
1022
- return env.CI_PIPELINE_ID;
1085
+ return `${env.CI_PIPELINE_ID}${key}`;
1023
1086
  if (env.CIRCLE_WORKFLOW_ID)
1024
- return env.CIRCLE_WORKFLOW_ID;
1087
+ return `${env.CIRCLE_WORKFLOW_ID}${key}`;
1025
1088
  // 0.10.0 - the same shape as the three above: the provider's own variable,
1026
1089
  // read directly. BUILD_TAG only in Jenkins' own format, since the name is
1027
1090
  // generic enough for something else to have set it.
1028
1091
  if (env.BUILD_TAG?.startsWith("jenkins-"))
1029
- return env.BUILD_TAG;
1092
+ return `${env.BUILD_TAG}${key}`;
1030
1093
  if (env.BUILD_BUILDID)
1031
- return env.BUILD_BUILDID;
1094
+ return `${env.BUILD_BUILDID}${key}`;
1032
1095
  if (env.BITBUCKET_BUILD_NUMBER)
1033
- return env.BITBUCKET_BUILD_NUMBER;
1096
+ return `${env.BITBUCKET_BUILD_NUMBER}${key}`;
1034
1097
  if (env.BUILDKITE_BUILD_ID)
1035
- return env.BUILDKITE_BUILD_ID;
1098
+ return `${env.BUILDKITE_BUILD_ID}${key}`;
1036
1099
  if (env.CODEBUILD_BUILD_ID)
1037
- return env.CODEBUILD_BUILD_ID;
1100
+ return `${env.CODEBUILD_BUILD_ID}${key}`;
1038
1101
  if (env.TRAVIS_BUILD_ID)
1039
- return env.TRAVIS_BUILD_ID;
1102
+ return `${env.TRAVIS_BUILD_ID}${key}`;
1040
1103
  if (env.DRONE_BUILD_NUMBER)
1041
- return env.DRONE_BUILD_NUMBER;
1104
+ return `${env.DRONE_BUILD_NUMBER}${key}`;
1042
1105
  return generateFallback();
1043
1106
  }
1107
+ /**
1108
+ * SQUALLY_CI_JOB_KEY, checked by squally-app's own rule (letters, digits,
1109
+ * '.', '_' and '-', at most 64). An invalid key is a warning, never a failed
1110
+ * test run: the reporter continues WITHOUT a key - the id it would have built
1111
+ * without one, and no ciJobKey - rather than send a value the server refuses
1112
+ * or guess a cleaned-up one. Unset or empty: no key, no warning.
1113
+ */
1114
+ function resolveCiJobKey(env) {
1115
+ const raw = env.SQUALLY_CI_JOB_KEY;
1116
+ if (raw === undefined || raw.trim() === "")
1117
+ return { key: null, warning: null };
1118
+ const key = raw.trim();
1119
+ if (key.length > V2_FIELD_LIMITS.ciJobKey || !CI_JOB_KEY_PATTERN.test(key)) {
1120
+ return {
1121
+ key: null,
1122
+ warning: `[squally-reporter] SQUALLY_CI_JOB_KEY ${JSON.stringify(truncate(raw, 80))} is not used: it may hold only ` +
1123
+ `letters, digits, '.', '_' and '-', at most ${V2_FIELD_LIMITS.ciJobKey} characters. This job reports ` +
1124
+ "without a key - like before - so jobs sharing this CI run's id may still be refused as a collision.",
1125
+ };
1126
+ }
1127
+ return { key, warning: null };
1128
+ }
1129
+ /**
1130
+ * The attempt to send as ciRunAttempt (0.11.0) - only when THIS reporter built
1131
+ * the CI run id from GITHUB_RUN_ATTEMPT, so the id ends in "-<attempt>" and
1132
+ * squally-app can link a re-run to the attempt before it (detectCiAttempt
1133
+ * there reads the stem off that suffix). Never with SQUALLY_CI_RUN_ID: only
1134
+ * the reporter knows the id is GitHub's, and an id the user wrote proves
1135
+ * nothing about its suffix.
1136
+ *
1137
+ * GITHUB ONLY, by the providers' documentation: GitHub's attempt counts runs
1138
+ * of the whole workflow run and is part of its id. GitLab's
1139
+ * CI_JOB_RETRY_COUNT, Buildkite's BUILDKITE_RETRY_COUNT and Azure's job
1140
+ * attempt count retries of ONE JOB inside a build whose id stays the same - not
1141
+ * an attempt of the run squally-app groups - and Jenkins, Travis and Drone
1142
+ * document no counter at all.
1143
+ */
1144
+ function detectCiRunAttempt(env) {
1145
+ if (env.SQUALLY_CI_RUN_ID || !env.GITHUB_RUN_ID || !env.GITHUB_RUN_ATTEMPT)
1146
+ return null;
1147
+ if (!/^[1-9]\d*$/.test(env.GITHUB_RUN_ATTEMPT))
1148
+ return null;
1149
+ const attempt = Number(env.GITHUB_RUN_ATTEMPT);
1150
+ return attempt <= MAX_CI_RUN_ATTEMPT ? attempt : null;
1151
+ }
1152
+ /** An absolute https: URL with a host - squally-app's isHttpsUrl. */
1153
+ function isHttpsUrl(value) {
1154
+ try {
1155
+ const url = new URL(value);
1156
+ return url.protocol === "https:" && url.hostname.length > 0;
1157
+ }
1158
+ catch {
1159
+ return false;
1160
+ }
1161
+ }
1162
+ /**
1163
+ * The CI run's page, sent as ciRunUrl (0.11.0) so Squally can link a run to
1164
+ * its CI run. One builder per provider, from each provider's documented
1165
+ * variable:
1166
+ * - GitHub Actions: GITHUB_SERVER_URL/GITHUB_REPOSITORY/actions/runs/
1167
+ * GITHUB_RUN_ID - the URL GitHub's own docs give - and /attempts/
1168
+ * GITHUB_RUN_ATTEMPT when set, the page of exactly this attempt;
1169
+ * - GitLab CI: CI_PIPELINE_URL ("The URL for the pipeline details");
1170
+ * - Jenkins: BUILD_URL ("The URL where the results of this build can be
1171
+ * found");
1172
+ * - Buildkite: BUILDKITE_BUILD_URL ("The url for this build on Buildkite");
1173
+ * - Travis CI: TRAVIS_BUILD_WEB_URL ("URL to the build log");
1174
+ * - Drone: DRONE_BUILD_LINK ("a deep link the Drone build results").
1175
+ * The run, never the job: the id groups a whole build's jobs, so its link is
1176
+ * the build's. Independent of SQUALLY_CI_RUN_ID - the page is the same.
1177
+ *
1178
+ * ONLY HTTPS, AT MOST 2048 CHARACTERS (squally-app's rule). Anything else - a
1179
+ * Jenkins without TLS, a value that is not a URL - is left out silently, with
1180
+ * a debug line: a missing link is no reason to warn on every run.
1181
+ */
1182
+ function detectCiRunUrl(env) {
1183
+ const url = ciRunUrlCandidate(env);
1184
+ if (url === null)
1185
+ return null;
1186
+ if (url.length > V2_FIELD_LIMITS.ciRunUrl || !isHttpsUrl(url)) {
1187
+ debugLog(`ciRunUrl not sent: not an https URL of at most ${V2_FIELD_LIMITS.ciRunUrl} characters`);
1188
+ return null;
1189
+ }
1190
+ return url;
1191
+ }
1192
+ function ciRunUrlCandidate(env) {
1193
+ const value = (name) => envValue(env, name) ?? null;
1194
+ switch (detectCiProvider(env)) {
1195
+ case "github-actions": {
1196
+ const server = value("GITHUB_SERVER_URL");
1197
+ const repository = value("GITHUB_REPOSITORY");
1198
+ const runId = value("GITHUB_RUN_ID");
1199
+ if (!server || !repository || !runId)
1200
+ return null;
1201
+ const attempt = value("GITHUB_RUN_ATTEMPT");
1202
+ return `${server.replace(/\/+$/, "")}/${repository}/actions/runs/${runId}${attempt ? `/attempts/${attempt}` : ""}`;
1203
+ }
1204
+ case "gitlab-ci":
1205
+ return value("CI_PIPELINE_URL");
1206
+ case "jenkins":
1207
+ return value("BUILD_URL");
1208
+ case "buildkite":
1209
+ return value("BUILDKITE_BUILD_URL");
1210
+ case "travis":
1211
+ return value("TRAVIS_BUILD_WEB_URL");
1212
+ case "drone":
1213
+ return value("DRONE_BUILD_LINK");
1214
+ default:
1215
+ return null;
1216
+ }
1217
+ }
1218
+ /** The YAML a refused begin shows: one key per matrix job. */
1219
+ const JOB_KEY_YAML_EXAMPLE = [
1220
+ " strategy:",
1221
+ " matrix:",
1222
+ " browser: [chromium, firefox]",
1223
+ " env:",
1224
+ " SQUALLY_CI_JOB_KEY: ${{ matrix.browser }}",
1225
+ ].join("\n");
1226
+ /**
1227
+ * What the reporter prints when squally-app refuses this job's begin with a
1228
+ * 409 (0.11.0). One block per code; nothing is retried and nothing more is
1229
+ * sent for this job - the test run itself is untouched.
1230
+ * - ci_run_collision: another job already reports this CI run id's slot;
1231
+ * - ci_run_mixed: the id would group an unsharded job with a sharded one,
1232
+ * or shards of different totals;
1233
+ * - anything else: the server's own sentence.
1234
+ */
1235
+ // The app's refusal codes this reporter words itself (0.12.0): a run cancelled
1236
+ // in Squally (409, begin and results), and a read key on the ingest (401).
1237
+ const RUN_CANCELLED_CODE = "run_cancelled";
1238
+ const RUN_CANCELLED_SENTENCE = "This run was cancelled in Squally";
1239
+ const READ_API_KEY_CODE = "read_api_key";
1240
+ function beginRefusalMessage(refusal, where) {
1241
+ const slot = where.shardIndex === null ? "" : ` (shard ${where.shardIndex})`;
1242
+ const tail = [
1243
+ " No results from this job will be sent to Squally. Your test run and its exit code are not affected.",
1244
+ " Give every job of the workflow its own SQUALLY_CI_JOB_KEY - the shards of one job share it. For a matrix:",
1245
+ JOB_KEY_YAML_EXAMPLE,
1246
+ ];
1247
+ if (where.jobKey !== null) {
1248
+ tail.splice(1, 1, ` This job's SQUALLY_CI_JOB_KEY is "${where.jobKey}" - another job uses the same key, or the same SQUALLY_CI_RUN_ID. Give each job its own:`);
1249
+ }
1250
+ if (refusal.code === "ci_run_collision") {
1251
+ return [
1252
+ `[squally-reporter] Squally refused this job (ci_run_collision): another job already reports under the CI run id "${where.ciRunId}"${slot}.`,
1253
+ ...tail,
1254
+ ].join("\n");
1255
+ }
1256
+ if (refusal.code === "ci_run_mixed") {
1257
+ return [
1258
+ `[squally-reporter] Squally refused this job (ci_run_mixed): the CI run id "${where.ciRunId}" would mix sharded and unsharded jobs, or shards of different totals, in one run.`,
1259
+ ...tail,
1260
+ ].join("\n");
1261
+ }
1262
+ // squally-app 19.10.2026 (#44): someone cancelled this run in Squally before
1263
+ // this shard began. Nothing to set - the job is simply too late for the run.
1264
+ if (refusal.code === RUN_CANCELLED_CODE) {
1265
+ return (`[squally-reporter] ${RUN_CANCELLED_SENTENCE} (CI run id "${where.ciRunId}"${slot}) - no results ` +
1266
+ "from this job will be sent. Your test run and its exit code are not affected.");
1267
+ }
1268
+ return (`[squally-reporter] Squally refused this job (409): ${refusal.message ?? refusal.detail} - ` +
1269
+ "no results from this job will be sent. Your test run and its exit code are not affected.");
1270
+ }
1044
1271
  function resolveProjectName(projectSuites) {
1045
1272
  if (projectSuites.length !== 1)
1046
1273
  return null;
@@ -1254,6 +1481,120 @@ function maskSecrets(value) {
1254
1481
  }
1255
1482
  return masked;
1256
1483
  }
1484
+ // THE VALUE HAS NO RELIABLE END. Measured on Playwright 1.62.1: the title is
1485
+ // `Fill "<value>" <locator>` with the value's own quotes NOT escaped -
1486
+ // `Fill "a "b" c" getByLabel('P')` - and pressSequentially reads `Type "…"`.
1487
+ // So nothing between the verb and the locator is trusted: everything up to
1488
+ // the LAST place a locator begins goes, and with no locator in sight,
1489
+ // everything after the verb. The last, not the first: a value may itself
1490
+ // contain " getByX(", and cutting there would keep the rest of it.
1491
+ const TYPED_ACTION = /^(Fill|Type|Press sequentially)\s+"/i;
1492
+ const LOCATOR_START = /\s(?:getBy[A-Za-z]*|locator|frameLocator)\(/g;
1493
+ function stripTypedValue(title) {
1494
+ const action = TYPED_ACTION.exec(title);
1495
+ if (!action)
1496
+ return title;
1497
+ let locatorAt = -1;
1498
+ LOCATOR_START.lastIndex = 0;
1499
+ for (let match = LOCATOR_START.exec(title); match; match = LOCATOR_START.exec(title)) {
1500
+ if (match.index >= action[0].length)
1501
+ locatorAt = match.index;
1502
+ }
1503
+ return `${action[1]} "•••"${locatorAt >= 0 ? title.slice(locatorAt) : ""}`;
1504
+ }
1505
+ /** Whether this attempt sends its steps: a failure, a timeout, or any retry. */
1506
+ function attemptSendsSteps(result) {
1507
+ return result.retry > 0 || result.status === "failed" || result.status === "timedOut";
1508
+ }
1509
+ /**
1510
+ * The attempt's steps for the wire, at most MAX_STEPS, and how many were left
1511
+ * out. Null when there are none.
1512
+ *
1513
+ * FAILED IS MARKED ON THE DEEPEST STEP ONLY: Playwright puts the error on a
1514
+ * failing expect AND on every test.step around it, and the page should point
1515
+ * at the one that broke, not paint its whole ancestry red.
1516
+ *
1517
+ * THE CUT KEEPS THE FAILING PATH (decision 4): every marked step and its
1518
+ * ancestors first, then the rest in order until MAX_STEPS. A kept step whose
1519
+ * parent was cut hangs from its nearest kept ancestor.
1520
+ */
1521
+ function stepsForAttempt(steps, rootDir) {
1522
+ if (!Array.isArray(steps) || steps.length === 0)
1523
+ return null;
1524
+ const flat = [];
1525
+ const hasError = (step) => step.error !== undefined && step.error !== null;
1526
+ const walk = (list, parent) => {
1527
+ for (const step of list) {
1528
+ const index = flat.length;
1529
+ const children = Array.isArray(step.steps) ? step.steps : [];
1530
+ flat.push({ step, parent, failed: hasError(step) && !children.some(hasError) });
1531
+ walk(children, index);
1532
+ }
1533
+ };
1534
+ walk(steps, null);
1535
+ const keep = new Set();
1536
+ for (let i = 0; i < flat.length; i++) {
1537
+ if (!flat[i].failed)
1538
+ continue;
1539
+ for (let at = i; at !== null && !keep.has(at); at = flat[at].parent)
1540
+ keep.add(at);
1541
+ }
1542
+ // A failing path deeper than MAX_STEPS keeps its first MAX_STEPS steps.
1543
+ let kept = [...keep].sort((a, b) => a - b).slice(0, MAX_STEPS);
1544
+ const keptSet = new Set(kept);
1545
+ for (let i = 0; i < flat.length && keptSet.size < MAX_STEPS; i++)
1546
+ keptSet.add(i);
1547
+ kept = [...keptSet].sort((a, b) => a - b);
1548
+ const newIndex = new Map();
1549
+ kept.forEach((old, index) => newIndex.set(old, index));
1550
+ const entries = kept.map((old) => {
1551
+ const { step, failed } = flat[old];
1552
+ let parent = flat[old].parent;
1553
+ while (parent !== null && !newIndex.has(parent))
1554
+ parent = flat[parent].parent;
1555
+ const location = step.location
1556
+ ? truncate(`${toPosixPath(node_path_1.default.relative(rootDir, step.location.file))}:${step.location.line}`, MAX_STEP_LOCATION)
1557
+ : undefined;
1558
+ const entry = {
1559
+ t: truncate(maskSecrets(stripTypedValue(stripAnsi(step.title))), MAX_STEP_TITLE),
1560
+ c: truncate(step.category ?? "", MAX_STEP_CATEGORY),
1561
+ p: parent === null ? null : newIndex.get(parent),
1562
+ d: Number.isFinite(step.duration) && step.duration >= 0 ? Math.round(step.duration) : null,
1563
+ };
1564
+ if (location)
1565
+ entry.l = maskSecrets(location);
1566
+ if (failed)
1567
+ entry.e = true;
1568
+ return entry;
1569
+ });
1570
+ return { steps: entries, omitted: flat.length - kept.length };
1571
+ }
1572
+ /** The result entry's step fields: none at all when there are no steps, no stepsOmitted when nothing was cut. */
1573
+ function stepFields(payload) {
1574
+ if (!payload)
1575
+ return {};
1576
+ return payload.omitted > 0 ? { steps: payload.steps, stepsOmitted: payload.omitted } : { steps: payload.steps };
1577
+ }
1578
+ /**
1579
+ * How many of the buffered results go into the next request: up to
1580
+ * BATCH_MAX_RESULTS and BATCH_MAX_BYTES, and always at least one - a single
1581
+ * result is bounded far below the byte limit by its own field limits.
1582
+ */
1583
+ function takeBatch(buffer, maxResults, maxBytes) {
1584
+ let bytes = 0;
1585
+ let count = 0;
1586
+ for (const entry of buffer) {
1587
+ if (count >= maxResults)
1588
+ break;
1589
+ // +1 for the comma between entries.
1590
+ const size = Buffer.byteLength(JSON.stringify(entry), "utf8") + 1;
1591
+ if (count > 0 && bytes + size > maxBytes)
1592
+ break;
1593
+ bytes += size;
1594
+ count++;
1595
+ }
1596
+ return count;
1597
+ }
1257
1598
  // MASKED FIRST, CUT SECOND - for every piece of free text, and for two
1258
1599
  // reasons. Cutting first can split a token and leave a prefix no pattern
1259
1600
  // recognises. And masking can LENGTHEN a value (a one-character URL password
@@ -1637,13 +1978,26 @@ function attemptQualifiesForArtifacts(mappedStatus, retryCount) {
1637
1978
  function artifactKey(testName, filePath, retryCount, repeatIndex, type) {
1638
1979
  return [testName, filePath, String(retryCount), String(repeatIndex), type].join(String.fromCharCode(1));
1639
1980
  }
1640
- function skipReasonMessage(reason) {
1981
+ // The app's per-kind size limits (squally-app lib/artifact-limits.ts): a
1982
+ // console's is 4 MB since squally-app B2-2 (10.10.2026), everything else 50 MB.
1983
+ // And at most ARTIFACTS_PER_REQUEST_LIMIT per presign request (one shard).
1984
+ const ARTIFACT_SIZE_LIMIT_LABEL = {
1985
+ screenshot: "50 MB",
1986
+ trace: "50 MB",
1987
+ console: "4 MB",
1988
+ };
1989
+ const ARTIFACTS_PER_REQUEST_LIMIT = 250;
1990
+ /** Why the app skipped an artifact, as a sentence (0.12.0: the limit per kind, and `limit`). */
1991
+ function skipReasonMessage(reason, type) {
1641
1992
  if (reason === "file_too_large")
1642
- return "file_too_large (limit 50 MB)";
1993
+ return `file_too_large (limit ${ARTIFACT_SIZE_LIMIT_LABEL[type]})`;
1643
1994
  if (reason === "cap_exceeded")
1644
1995
  return "cap_exceeded (storage limit reached, upgrade your plan)";
1645
1996
  if (reason === "not_found")
1646
1997
  return "not_found (test result not found on server)";
1998
+ if (reason === "limit") {
1999
+ return `limit (Squally keeps at most ${ARTIFACTS_PER_REQUEST_LIMIT} artifacts per shard; the first ${ARTIFACTS_PER_REQUEST_LIMIT} were kept)`;
2000
+ }
1647
2001
  return reason;
1648
2002
  }
1649
2003
  function confirmReasonMessage(reason) {
@@ -1654,6 +2008,9 @@ function confirmReasonMessage(reason) {
1654
2008
  return reason;
1655
2009
  }
1656
2010
  function artifactLabel(type) {
2011
+ // "console" read "Trace" here until 0.12.0 - the label predated the kind.
2012
+ if (type === "console")
2013
+ return "Browser console";
1657
2014
  return type === "screenshot" ? "Screenshot" : "Trace";
1658
2015
  }
1659
2016
  /**
@@ -1760,6 +2117,19 @@ let rulesCache = null;
1760
2117
  // Explicit options win over all of it, as before.
1761
2118
  //
1762
2119
  // Every warning names variables, never a value.
2120
+ //
2121
+ // THE DEFAULT URL (0.12.0): a key without a URL used to send nothing. Now it
2122
+ // reports to https://app.squally.dev - the only Squally there is - so the
2123
+ // setup is one secret, not two. NOT when SQUALLY_API_URL is set but unused
2124
+ // (a key from the new name or the options): that variable says someone meant
2125
+ // another target, and replacing it silently with production would be the
2126
+ // worst reading of it. That case still skips, with the warning it had.
2127
+ //
2128
+ // A READ KEY IS REFUSED FROM EVERY SOURCE (0.12.0, squally-app #23): the
2129
+ // option and SQUALLY_INGEST_KEY too, not only the legacy variable. The app
2130
+ // refuses it as well (401 read_api_key), but saying so here costs no request
2131
+ // and names the variable to fix.
2132
+ const DEFAULT_INGEST_URL = "https://app.squally.dev";
1763
2133
  const READ_KEY_PREFIX = "sqly_ro_";
1764
2134
  // One deprecation warning per variable per run: the reporter is constructed
1765
2135
  // once per run, but a config that builds it twice must not say it twice.
@@ -1794,24 +2164,34 @@ function resolveConnection(options, env, warned = deprecationsWarned) {
1794
2164
  }
1795
2165
  }
1796
2166
  }
2167
+ // A read key from the option or SQUALLY_INGEST_KEY (0.12.0) - the legacy
2168
+ // variable said so above already.
2169
+ if (apiKey?.startsWith(READ_KEY_PREFIX)) {
2170
+ const source = options.apiKey !== undefined ? "The apiKey option" : "SQUALLY_INGEST_KEY";
2171
+ console.warn(`[squally-reporter] ${source} holds a read key (sqly_ro_…); reporting needs the project's ` +
2172
+ "ingest key (Settings › Project › Ingest key). Nothing from this run will be sent.");
2173
+ return {};
2174
+ }
1797
2175
  let apiUrl = options.apiUrl ?? envValue(env, "SQUALLY_INGEST_URL");
1798
2176
  const legacyUrl = envValue(env, "SQUALLY_API_URL");
1799
2177
  if (apiUrl === undefined && keyFromLegacy && legacyUrl !== undefined) {
1800
2178
  warnDeprecated("SQUALLY_API_URL", "SQUALLY_INGEST_URL", warned);
1801
2179
  apiUrl = legacyUrl;
1802
2180
  }
1803
- if (!apiUrl || !apiKey) {
1804
- const ignoredUrl = !apiUrl && legacyUrl !== undefined;
1805
- console.warn("[squally-reporter] no ingest key or URL configured, skipping report - set " +
1806
- "SQUALLY_INGEST_URL/SQUALLY_INGEST_KEY as env vars, or pass apiUrl/apiKey " +
1807
- "in playwright.config.ts" +
2181
+ // Set but not taken (see above): someone meant another target - never
2182
+ // fall back to production in its place.
2183
+ const ignoredUrl = apiUrl === undefined && legacyUrl !== undefined;
2184
+ if (!apiKey || ignoredUrl) {
2185
+ console.warn("[squally-reporter] no ingest key configured, skipping report - set SQUALLY_INGEST_KEY " +
2186
+ `(and SQUALLY_INGEST_URL only for a Squally other than ${DEFAULT_INGEST_URL}), or pass ` +
2187
+ "apiKey in playwright.config.ts" +
1808
2188
  (ignoredUrl
1809
2189
  ? ". SQUALLY_API_URL is set but not used for ingest unless the key comes from " +
1810
2190
  "SQUALLY_API_KEY too - set SQUALLY_INGEST_URL."
1811
2191
  : ""));
1812
2192
  return {};
1813
2193
  }
1814
- return { apiUrl, apiKey };
2194
+ return { apiUrl: apiUrl ?? DEFAULT_INGEST_URL, apiKey };
1815
2195
  }
1816
2196
  class SquallyReporter {
1817
2197
  constructor(options = {}) {
@@ -1826,6 +2206,14 @@ class SquallyReporter {
1826
2206
  this.startedAt = new Date();
1827
2207
  this.ciRunId = "";
1828
2208
  this.shardIndex = null;
2209
+ /**
2210
+ * 0.11.0: one per reporter process, the same on every retry of begin - a
2211
+ * retried begin is the same job, another process with the same CI run id
2212
+ * and shard is not (squally-app answers that one 409 ci_run_collision).
2213
+ */
2214
+ this.beginToken = (0, node_crypto_1.randomUUID)();
2215
+ /** SQUALLY_CI_JOB_KEY, validated (resolveCiJobKey) - or null. */
2216
+ this.ciJobKey = null;
1829
2217
  this.shardTotal = null;
1830
2218
  this.projectName = null;
1831
2219
  this.buffer = [];
@@ -1975,7 +2363,13 @@ class SquallyReporter {
1975
2363
  headersTimeout: UPLOAD_REQUEST_TIMEOUT_MS,
1976
2364
  bodyTimeout: UPLOAD_REQUEST_TIMEOUT_MS,
1977
2365
  });
1978
- this.ciRunId = truncate(detectCiRunId(process.env, () => (0, node_crypto_1.randomUUID)()), V2_FIELD_LIMITS.ciRunId);
2366
+ // The job key first (0.11.0): the id is built with it. An invalid key is
2367
+ // one warning and no key - never a failed run.
2368
+ const jobKey = resolveCiJobKey(process.env);
2369
+ if (jobKey.warning)
2370
+ console.warn(jobKey.warning);
2371
+ this.ciJobKey = jobKey.key;
2372
+ this.ciRunId = truncate(detectCiRunId(process.env, () => (0, node_crypto_1.randomUUID)(), this.ciJobKey), V2_FIELD_LIMITS.ciRunId);
1979
2373
  // Playwright's shard.current/total are already 1-based, matching the
1980
2374
  // server's own shardIndex >= 1 constraint (docs/ingest-v2-spec.md
1981
2375
  // section 1.2) - no adjustment needed.
@@ -2139,6 +2533,8 @@ class SquallyReporter {
2139
2533
  : undefined,
2140
2534
  browserName: resolveBrowserName(test),
2141
2535
  errorContext: text.errorContext,
2536
+ // 0.12.0: the steps, only for an attempt that did not pass first time.
2537
+ ...stepFields(attemptSendsSteps(result) ? stepsForAttempt(result.steps, process.cwd()) : null),
2142
2538
  });
2143
2539
  if (this.buffer.length > MAX_TEST_RESULTS_WARNING && this.buffer.length % MAX_TEST_RESULTS_WARNING === 1) {
2144
2540
  console.warn(`[squally-reporter] This run has sent well over ${MAX_TEST_RESULTS_WARNING} test results so far. ` +
@@ -2340,7 +2736,9 @@ class SquallyReporter {
2340
2736
  if (!runId)
2341
2737
  return;
2342
2738
  while (this.buffer.length > 0 && !this.sendingDisabled) {
2343
- const chunk = this.buffer.splice(0, BATCH_MAX_RESULTS);
2739
+ // By count AND bytes since 0.12.0 - steps made a 500-result batch able
2740
+ // to outgrow the request body limit (BATCH_MAX_BYTES).
2741
+ const chunk = this.buffer.splice(0, takeBatch(this.buffer, BATCH_MAX_RESULTS, BATCH_MAX_BYTES));
2344
2742
  await this.sendBatch(runId, chunk);
2345
2743
  }
2346
2744
  }
@@ -2392,13 +2790,25 @@ class SquallyReporter {
2392
2790
  // everything this job sends is refused. The warning says that, and
2393
2791
  // what to set, once per run. Same give-up as before: nothing about
2394
2792
  // the customer's own test run changes.
2793
+ //
2794
+ // CANCELLED IN SQUALLY (0.12.0, #44) is the other 409, with its own
2795
+ // code: someone stopped the run in the app. The CI job keeps
2796
+ // running - Squally does not stop it - and what it sends from now on
2797
+ // is discarded. Saying "another job used the same CI run id" there
2798
+ // would send the reader after a misconfiguration that does not exist.
2799
+ if (!this.warnedAlreadyComplete && outcome.code === RUN_CANCELLED_CODE) {
2800
+ this.warnedAlreadyComplete = true;
2801
+ console.warn(`[squally-reporter] ${RUN_CANCELLED_SENTENCE} - results sent after the cancel are discarded, ` +
2802
+ "and no further results from this job will be sent. Your test run and its exit code are not affected.");
2803
+ }
2395
2804
  if (!this.warnedAlreadyComplete) {
2396
2805
  this.warnedAlreadyComplete = true;
2397
2806
  console.warn(`[squally-reporter] Squally has already completed run ${runId} for CI run id "${this.ciRunId}"` +
2398
2807
  (this.shardIndex !== null ? ` (shard ${this.shardIndex})` : "") +
2399
2808
  " - no further results from this job will be sent. Most likely another job used the same " +
2400
- "CI run id and finished first: a matrix job, or a re-run. Set SQUALLY_CI_RUN_ID to a value " +
2401
- "unique per job - shared only by the shards of one job - to report each job as its own run.");
2809
+ "CI run id and finished first: a matrix job, or a re-run. Set SQUALLY_CI_JOB_KEY to a value " +
2810
+ "unique per job - shared only by the shards of one job, for example ${{ matrix.browser }} - " +
2811
+ "to report each job as its own run.");
2402
2812
  }
2403
2813
  this.giveUp();
2404
2814
  return;
@@ -2456,6 +2866,16 @@ class SquallyReporter {
2456
2866
  runTags: this.runTags,
2457
2867
  config: this.runConfig,
2458
2868
  };
2869
+ // 0.11.0 (squally-app block 1b): sent only when known. The token always.
2870
+ const ciRunAttempt = detectCiRunAttempt(process.env);
2871
+ const ciRunUrl = detectCiRunUrl(process.env);
2872
+ payload.beginToken = this.beginToken;
2873
+ if (this.ciJobKey !== null)
2874
+ payload.ciJobKey = this.ciJobKey;
2875
+ if (ciRunAttempt !== null)
2876
+ payload.ciRunAttempt = ciRunAttempt;
2877
+ if (ciRunUrl !== null)
2878
+ payload.ciRunUrl = ciRunUrl;
2459
2879
  const outcome = await this.requestJson("POST", `${this.apiUrl}/api/ingest/v2/runs`, payload);
2460
2880
  if (outcome.kind === "ok")
2461
2881
  return outcome.json.runId;
@@ -2468,11 +2888,27 @@ class SquallyReporter {
2468
2888
  // explicit that a silent protocol downgrade would make "my streaming
2469
2889
  // isn't working" undiagnosable. The reporter falls back to sending
2470
2890
  // nothing for this run instead.
2891
+ // A REFUSED JOB (0.11.0, squally-app block 1b): 409 at begin means another
2892
+ // job holds this CI run id's slot (ci_run_collision) or the id would mix
2893
+ // kinds of job (ci_run_mixed). Not retried - requestJson never retries a
2894
+ // 4xx - and nothing more is sent for this job; the block says what to set.
2895
+ if (outcome.kind === "http_error" && outcome.status === 409) {
2896
+ console.warn(beginRefusalMessage({ code: outcome.code ?? null, message: outcome.message ?? null, detail: outcome.detail }, { ciRunId: this.ciRunId, shardIndex: this.shardIndex, jobKey: this.ciJobKey }));
2897
+ this.giveUp();
2898
+ return null;
2899
+ }
2471
2900
  if (outcome.kind === "http_error" && outcome.status === 404) {
2472
2901
  console.warn("[squally-reporter] This Squally instance does not support the v2 ingest API yet (404 on " +
2473
2902
  "/api/ingest/v2/runs). No results from this run will be sent - update squally-app, or check " +
2474
2903
  "SQUALLY_INGEST_URL (or the apiUrl option) if this is unexpected.");
2475
2904
  }
2905
+ else if (outcome.kind === "http_error" && outcome.status === 401 && outcome.code === READ_API_KEY_CODE) {
2906
+ // resolveConnection refuses a sqly_ro_ key before any request; this is
2907
+ // the app saying the same about a key that does not carry the prefix
2908
+ // locally - a read key set through some other path (#23, 0.12.0).
2909
+ console.error("[squally-reporter] Squally refused the key: it is a read API key. Reporting needs the project's " +
2910
+ "ingest key (Settings › Project › Ingest key) in SQUALLY_INGEST_KEY. No results from this run will be sent.");
2911
+ }
2476
2912
  else {
2477
2913
  const detail = outcome.kind === "http_error" ? `${outcome.status} - ${outcome.detail}` : outcome.detail;
2478
2914
  console.error(`[squally-reporter] Failed to start reporting to Squally: ${detail}`);
@@ -2590,7 +3026,10 @@ class SquallyReporter {
2590
3026
  continue;
2591
3027
  }
2592
3028
  if (!response.ok) {
2593
- return { kind: "http_error", status: response.status, detail: await describeResponseError(response) };
3029
+ // Read once, kept whole: the log line, and squally-app's `code` and
3030
+ // `error` when it sent them (begin's 409, 0.11.0).
3031
+ const error = await readResponseError(response);
3032
+ return { kind: "http_error", status: response.status, ...error };
2594
3033
  }
2595
3034
  try {
2596
3035
  const json = (await response.json());
@@ -2666,7 +3105,7 @@ class SquallyReporter {
2666
3105
  if (!local)
2667
3106
  continue;
2668
3107
  if (item.skip) {
2669
- console.warn(`[squally-reporter] ${artifactLabel(item.type)} for '${item.testName}' skipped: ${skipReasonMessage(item.reason)}`);
3108
+ console.warn(`[squally-reporter] ${artifactLabel(item.type)} for '${item.testName}' skipped: ${skipReasonMessage(item.reason, item.type)}`);
2670
3109
  continue;
2671
3110
  }
2672
3111
  uploads.push({ artifact: local, uploadUrl: item.uploadUrl });
@@ -2756,6 +3195,11 @@ exports.__internal = {
2756
3195
  toPosixPath,
2757
3196
  detectCiRunId,
2758
3197
  detectCiProvider,
3198
+ resolveCiJobKey,
3199
+ detectCiRunAttempt,
3200
+ detectCiRunUrl,
3201
+ isHttpsUrl,
3202
+ beginRefusalMessage,
2759
3203
  resolveGitContext,
2760
3204
  resolveGitContextForRun,
2761
3205
  fetchCommitFromOrigin,
@@ -2774,5 +3218,17 @@ exports.__internal = {
2774
3218
  flushIntervalFor,
2775
3219
  attemptQualifiesForArtifacts,
2776
3220
  artifactKey,
3221
+ // 0.12.0
3222
+ stepsForAttempt,
3223
+ stepFields,
3224
+ stripTypedValue,
3225
+ attemptSendsSteps,
3226
+ takeBatch,
3227
+ skipReasonMessage,
3228
+ artifactLabel,
3229
+ MAX_STEPS,
3230
+ MAX_STEP_TITLE,
3231
+ BATCH_MAX_BYTES,
3232
+ DEFAULT_INGEST_URL,
2777
3233
  };
2778
3234
  //# sourceMappingURL=index.js.map