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/README.md +145 -42
- package/dist/index.d.ts +155 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +518 -62
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
|
142
|
-
//
|
|
143
|
-
//
|
|
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
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
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
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
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"
|
|
163
|
-
// the aggregate inside the
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
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
|
|
174
|
-
* share the rate budget
|
|
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
|
-
//
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2401
|
-
"unique per job - shared only by the shards of one job
|
|
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
|
-
|
|
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
|