@qawolf/ci-sdk 1.3.2 → 2.1.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 CHANGED
@@ -113,8 +113,14 @@ const { attemptNotifyDeploy } = makeQaWolfSdk({
113
113
 
114
114
  (async () => {
115
115
  const result = await attemptNotifyDeploy(deployConfig);
116
+ if (result.outcome === "skipped") {
117
+ // No trigger matched this deployment, a deterministic no-op rather than a
118
+ // failure. Let the job pass (or fail here instead if you would rather
119
+ // catch a trigger misconfiguration).
120
+ return;
121
+ }
116
122
  if (result.outcome !== "success") {
117
- // Fail the job.
123
+ // "failed" or "aborted": fail the job.
118
124
  process.exit(1);
119
125
  }
120
126
  const runId = result.runId;
@@ -461,6 +467,21 @@ This package follows the [SemVer](https://semver.org/) versioning scheme. Additi
461
467
 
462
468
  # Changelog
463
469
 
470
+ ## v2.1.0
471
+
472
+ - `attemptNotifyDeploy`: the `deploy_success` response is now validated at runtime instead of being trusted. A response with a non-JSON body, or a body that does not match the expected shape, returns `outcome: "aborted"` with the new `abortReason: "invalid-response-body"`. This is distinct from `"network-error"`, which now covers only transport and body-stream failures rather than also masking unparseable responses. A result the SDK does not recognize (for example a new result kind sent by a newer backend) returns `outcome: "failed"` with the new `failReason: "unknown-result"` instead of being reported as a success. Recognized results are unchanged, and older backends that omit newer fields remain fully supported.
473
+
474
+ ## v2.0.0
475
+
476
+ - `attemptNotifyDeploy`: the `DeployStatus` response now carries `skippedTriggers` (triggers that matched the deployment but produced no run, each with a `reason` code and human-readable `message`) and `skippedDeployment` (set when no trigger matched at all). The result now distinguishes these cases. A trigger that matched but could not run (e.g. the environment was still syncing) returns `outcome: "failed"` with `failReason: "trigger-skipped"` and a `skippedTriggers` array, since a precondition was not met and retrying may succeed. A deployment that matched no trigger returns `outcome: "skipped"` with `skippedDeployment` (the server's reason, when it provides one). That result is deterministic given the current configuration, so it is reported as a skip rather than a failure and is logged as a warning, not an error. Preview deployments keep `outcome: "success"` but now log the skip reason. `outcome: "success"` results are unchanged.
477
+ - `pollCiGreenlightStatus`: the `canceled` `CiGreenlightStatus` object may now include a `reason` code and a human-readable `message` explaining why the run was canceled. These are read off the status directly (via the `onRunStageChanged` callback or `makePollCiGreenlightStatusIterator`); the top-level `pollCiGreenlightStatus` result and logs are unchanged.
478
+
479
+ BREAKING CHANGES:
480
+
481
+ - `attemptNotifyDeploy`: a deployment that matched no trigger now returns `outcome: "skipped"` instead of `outcome: "failed"` / `failReason: "no-matched-trigger"`. Callers that switch on `outcome` must handle the new `"skipped"` case; code that treated a no-trigger-match as a failure should decide whether a deterministic no-op should still fail the deploy step. `AttemptDeployFailedReason` no longer includes `"no-matched-trigger"`.
482
+ - `pollCiGreenlightStatus`: on the `initializing` and `canceled` run stages, the fields `relevantRunWithBugsUrl`, `workflowsDisabledAfterRunCount`, `workflowsInRunCount`, and `workflowsUnderInvestigationCount` are now optional, aligning the types with the greenlight API. Code that reads these fields on those stages must handle `undefined`.
483
+ - Removed the deprecated `blockingBugUrls` and `nonBlockingBugUrls` fields and the `CiGreenlightCompletedSuiteDetails` type. Use `reproducedBugs` filtered by `isBlocking`, reading each bug's `applicationUrl`.
484
+
464
485
  ## v1.3.2
465
486
 
466
487
  - Bump dependencies to address security alerts.
package/dist/index.cjs CHANGED
@@ -41,7 +41,7 @@ var defaultServiceBase = "https://app.qawolf.com/";
41
41
 
42
42
  // package.json
43
43
  var package_default = {
44
- version: "1.3.2"};
44
+ version: "2.1.0"};
45
45
 
46
46
  // src/lib/sdk/defaults/userAgent.ts
47
47
  var defaultUserAgent = `ci-sdk/${package_default.version}`;
@@ -76,11 +76,195 @@ async function logFetchError({ log, methodName, response }) {
76
76
  }
77
77
  __name(logFetchError, "logFetchError");
78
78
 
79
+ // src/lib/sdk/domain/attemptDeploy/handleSuccessResponse.ts
80
+ async function handleSuccessResponse(data, log) {
81
+ const { environment, results } = data;
82
+ const skippedTriggers = data.skippedTriggers ?? [];
83
+ const skippedDeployment = data.skippedDeployment;
84
+ const isPreviewDeployment = environment && !environment.isStatic;
85
+ if (environment) {
86
+ const logPrefix = environment.isStatic ? "Static environment" : "Preview environment";
87
+ log.info(`${logPrefix}: ${environment.alias} (id: ${environment.id})`);
88
+ }
89
+ if (results.length === 0) {
90
+ const skipMessages = [
91
+ ...skippedTriggers.map((trigger) => trigger.message),
92
+ ...skippedDeployment ? [
93
+ skippedDeployment.message
94
+ ] : []
95
+ ];
96
+ const skipDetail = skipMessages.length > 0 ? ` ${skipMessages.join(" ")}` : "";
97
+ if (isPreviewDeployment) {
98
+ log.info(`\u2705 Deployment notification was successful. No run was created for this deployment.${skipDetail}`);
99
+ return {
100
+ environmentId: environment.id,
101
+ outcome: "success",
102
+ runId: void 0
103
+ };
104
+ }
105
+ if (skippedTriggers.length > 0) {
106
+ log.error(`\u{1F6AB} No run was created for this deployment.${skipDetail}`);
107
+ return {
108
+ failReason: "trigger-skipped",
109
+ outcome: "failed",
110
+ skippedTriggers
111
+ };
112
+ }
113
+ log.warn(skippedDeployment ? `\u26A0\uFE0F No run was created for this deployment.${skipDetail}` : `\u26A0\uFE0F No trigger matched the request. Please make sure there is at least one active test in the QA Wolf environment, or contact support.`);
114
+ return {
115
+ outcome: "skipped",
116
+ skippedDeployment
117
+ };
118
+ }
119
+ if (!environment) {
120
+ log.error(`\u{1F6AB} No environment matched the request. Please contact support.`);
121
+ return {
122
+ failReason: "no-environment",
123
+ outcome: "failed"
124
+ };
125
+ }
126
+ const unrecognizedResult = results.find((result) => "unrecognized" in result);
127
+ if (unrecognizedResult) {
128
+ log.error(`\u{1F6AB} The deployment response contained an unrecognized result. Please upgrade @qawolf/ci-sdk.`);
129
+ return {
130
+ failReason: "unknown-result",
131
+ outcome: "failed"
132
+ };
133
+ }
134
+ const failedTrigger = results.find((result) => "failureReason" in result);
135
+ if (failedTrigger) {
136
+ log.error(`\u{1F6AB} Run could not be created. Reason: "${failedTrigger.failureReason}". Please contact support.`);
137
+ return {
138
+ failReason: "run-could-not-be-created",
139
+ outcome: "failed"
140
+ };
141
+ }
142
+ const duplicateSuite = results.find((result) => "duplicateSuiteId" in result);
143
+ if (duplicateSuite) {
144
+ log.info(`\u2705 Deployment notification was successful. Previous run ID: ${duplicateSuite.duplicateSuiteId} already exists for this deployment.`);
145
+ return {
146
+ environmentId: environment.id,
147
+ outcome: "success",
148
+ runId: duplicateSuite.duplicateSuiteId
149
+ };
150
+ }
151
+ const successfulTrigger = results.find((result) => "createdSuiteId" in result);
152
+ if (!successfulTrigger) {
153
+ log.error(`\u{1F6AB} Run could not be created. Please contact support.`);
154
+ return {
155
+ failReason: "run-could-not-be-created",
156
+ outcome: "failed"
157
+ };
158
+ }
159
+ log.info(`\u2705 Deployment notification was successful. Run created with ID: ${successfulTrigger.createdSuiteId}`);
160
+ return {
161
+ environmentId: environment.id,
162
+ outcome: "success",
163
+ runId: successfulTrigger.createdSuiteId
164
+ };
165
+ }
166
+ __name(handleSuccessResponse, "handleSuccessResponse");
167
+
168
+ // src/lib/sdk/domain/attemptDeploy/mapDeploySuccessResponse.ts
169
+ function mapSuiteResult(result) {
170
+ if ("created_suite_id" in result) {
171
+ return {
172
+ createdSuiteId: result.created_suite_id,
173
+ triggerId: result.trigger_id
174
+ };
175
+ }
176
+ if ("failure_reason" in result) {
177
+ return {
178
+ failureReason: result.failure_reason,
179
+ triggerId: result.trigger_id
180
+ };
181
+ }
182
+ if ("duplicate_suite_id" in result) {
183
+ return {
184
+ duplicateSuiteId: result.duplicate_suite_id,
185
+ skipReason: result.skip_reason,
186
+ triggerId: result.trigger_id
187
+ };
188
+ }
189
+ return {
190
+ triggerId: result.trigger_id,
191
+ unrecognized: true
192
+ };
193
+ }
194
+ __name(mapSuiteResult, "mapSuiteResult");
195
+ function mapDeploySuccessResponse(wire) {
196
+ return {
197
+ environment: wire.environment,
198
+ results: wire.results.map(mapSuiteResult),
199
+ skippedDeployment: wire.skipped_deployment && {
200
+ message: wire.skipped_deployment.message,
201
+ reason: wire.skipped_deployment.reason
202
+ },
203
+ skippedTriggers: wire.skipped_triggers?.map((trigger) => ({
204
+ environmentId: trigger.environment_id,
205
+ message: trigger.message,
206
+ reason: trigger.reason,
207
+ triggerId: trigger.trigger_id
208
+ }))
209
+ };
210
+ }
211
+ __name(mapDeploySuccessResponse, "mapDeploySuccessResponse");
212
+
213
+ // src/lib/sdk/domain/attemptDeploy/parseDeploySuccessWireResponse.ts
214
+ function isRecord(value) {
215
+ return typeof value === "object" && value !== null && !Array.isArray(value);
216
+ }
217
+ __name(isRecord, "isRecord");
218
+ function isWireSuiteResult(value) {
219
+ if (!isRecord(value)) return false;
220
+ if (typeof value.trigger_id !== "string") return false;
221
+ if ("created_suite_id" in value) return typeof value.created_suite_id === "string";
222
+ if ("failure_reason" in value) return typeof value.failure_reason === "string";
223
+ if ("duplicate_suite_id" in value) {
224
+ return typeof value.duplicate_suite_id === "string" && typeof value.skip_reason === "string";
225
+ }
226
+ return true;
227
+ }
228
+ __name(isWireSuiteResult, "isWireSuiteResult");
229
+ function isEnvironment(value) {
230
+ if (!isRecord(value)) return false;
231
+ if (typeof value.id !== "string") return false;
232
+ if (typeof value.isStatic !== "boolean") return false;
233
+ if (value.alias !== void 0 && typeof value.alias !== "string") return false;
234
+ return true;
235
+ }
236
+ __name(isEnvironment, "isEnvironment");
237
+ function isSkippedDeployment(value) {
238
+ if (!isRecord(value)) return false;
239
+ return typeof value.message === "string" && typeof value.reason === "string";
240
+ }
241
+ __name(isSkippedDeployment, "isSkippedDeployment");
242
+ function isSkippedTrigger(value) {
243
+ if (!isRecord(value)) return false;
244
+ return typeof value.environment_id === "string" && typeof value.message === "string" && typeof value.reason === "string" && typeof value.trigger_id === "string";
245
+ }
246
+ __name(isSkippedTrigger, "isSkippedTrigger");
247
+ function isDeploySuccessWireResponse(value) {
248
+ if (!isRecord(value)) return false;
249
+ if (value.environment !== void 0 && !isEnvironment(value.environment)) return false;
250
+ if (!Array.isArray(value.results) || !value.results.every(isWireSuiteResult)) return false;
251
+ if (value.skipped_deployment !== void 0 && !isSkippedDeployment(value.skipped_deployment)) return false;
252
+ if (value.skipped_triggers !== void 0 && (!Array.isArray(value.skipped_triggers) || !value.skipped_triggers.every(isSkippedTrigger))) return false;
253
+ return true;
254
+ }
255
+ __name(isDeploySuccessWireResponse, "isDeploySuccessWireResponse");
256
+ function parseDeploySuccessWireResponse(value) {
257
+ return isDeploySuccessWireResponse(value) ? value : "invalid";
258
+ }
259
+ __name(parseDeploySuccessWireResponse, "parseDeploySuccessWireResponse");
260
+
79
261
  // src/lib/sdk/domain/attemptDeploy/index.ts
80
262
  async function attemptNotifyDeploy(deps, apiConfig, config) {
81
263
  const { fetch: localFetch, log } = deps;
264
+ let response;
265
+ let bodyText;
82
266
  try {
83
- const response = await localFetch(new URL("/api/webhooks/deploy_success", apiConfig.serviceBase), {
267
+ response = await localFetch(new URL("/api/webhooks/deploy_success", apiConfig.serviceBase), {
84
268
  body: JSON.stringify({
85
269
  branch: config.branch,
86
270
  commit_url: config.commitUrl,
@@ -109,8 +293,7 @@ async function attemptNotifyDeploy(deps, apiConfig, config) {
109
293
  signal: AbortSignal.timeout(5 * 60 * 1e3)
110
294
  });
111
295
  if (!response.ok) return handleErrorResponse(response, log);
112
- const data = await response.json();
113
- return handleSuccessResponse(data, log);
296
+ bodyText = await response.text();
114
297
  } catch {
115
298
  log.error(`\u{1F6AB} Network error from deploy_success, aborting.`);
116
299
  return {
@@ -119,8 +302,29 @@ async function attemptNotifyDeploy(deps, apiConfig, config) {
119
302
  outcome: "aborted"
120
303
  };
121
304
  }
305
+ let rawBody;
306
+ try {
307
+ rawBody = JSON.parse(bodyText);
308
+ } catch {
309
+ return abortInvalidResponseBody(log, "the response body was not JSON");
310
+ }
311
+ const wire = parseDeploySuccessWireResponse(rawBody);
312
+ if (wire === "invalid") {
313
+ return abortInvalidResponseBody(log, "the response body did not match the expected shape");
314
+ }
315
+ const data = mapDeploySuccessResponse(wire);
316
+ return handleSuccessResponse(data, log);
122
317
  }
123
318
  __name(attemptNotifyDeploy, "attemptNotifyDeploy");
319
+ function abortInvalidResponseBody(log, detail) {
320
+ log.error(`\u{1F6AB} Invalid response from deploy_success (${detail}), aborting.`);
321
+ return {
322
+ abortReason: "invalid-response-body",
323
+ httpStatus: void 0,
324
+ outcome: "aborted"
325
+ };
326
+ }
327
+ __name(abortInvalidResponseBody, "abortInvalidResponseBody");
124
328
  async function handleErrorResponse(response, log) {
125
329
  const status = response.status;
126
330
  let abortReason;
@@ -141,68 +345,6 @@ async function handleErrorResponse(response, log) {
141
345
  };
142
346
  }
143
347
  __name(handleErrorResponse, "handleErrorResponse");
144
- async function handleSuccessResponse(data, log) {
145
- const { environment, results } = data;
146
- const isPreviewDeployment = environment && !environment.isStatic;
147
- if (environment) {
148
- const logPrefix = environment.isStatic ? "Static environment" : "Preview environment";
149
- log.info(`${logPrefix}: ${environment.alias} (id: ${environment.id})`);
150
- }
151
- if (results.length === 0) {
152
- if (isPreviewDeployment) {
153
- log.info(`\u2705 Deployment notification was successful. No run was created for this deployment.`);
154
- return {
155
- environmentId: environment.id,
156
- outcome: "success",
157
- runId: void 0
158
- };
159
- }
160
- log.error(`\u{1F6AB} No trigger matched the request. Please make sure there is at least one active test in the QA Wolf environment, or contact support.`);
161
- return {
162
- failReason: "no-matched-trigger",
163
- outcome: "failed"
164
- };
165
- }
166
- if (!environment) {
167
- log.error(`\u{1F6AB} No environment matched the request. Please contact support.`);
168
- return {
169
- failReason: "no-environment",
170
- outcome: "failed"
171
- };
172
- }
173
- const failedTrigger = results.find((t) => "failure_reason" in t);
174
- if (failedTrigger) {
175
- log.error(`\u{1F6AB} Run could not be created. Reason: "${failedTrigger.failure_reason}". Please contact support.`);
176
- return {
177
- failReason: "run-could-not-be-created",
178
- outcome: "failed"
179
- };
180
- }
181
- const duplicateSuite = results.find((t) => "duplicate_suite_id" in t);
182
- if (duplicateSuite) {
183
- log.info(`\u2705 Deployment notification was successful. Previous run ID: ${duplicateSuite.duplicate_suite_id} already exists for this deployment.`);
184
- return {
185
- environmentId: environment.id,
186
- outcome: "success",
187
- runId: duplicateSuite.duplicate_suite_id
188
- };
189
- }
190
- const successfulTrigger = results.find((t) => "created_suite_id" in t);
191
- if (!successfulTrigger) {
192
- log.error(`\u{1F6AB} No trigger matched the request. Please contact support.`);
193
- return {
194
- failReason: "no-matched-trigger",
195
- outcome: "failed"
196
- };
197
- }
198
- log.info(`\u2705 Deployment notification was successful. Run created with ID: ${successfulTrigger.created_suite_id}`);
199
- return {
200
- environmentId: environment.id,
201
- outcome: "success",
202
- runId: successfulTrigger.created_suite_id
203
- };
204
- }
205
- __name(handleSuccessResponse, "handleSuccessResponse");
206
348
 
207
349
  // src/lib/api/generate-signed-url-for-run-inputs-executables.ts
208
350
  async function callGenerateSignedUrlForRunInputsExecutablesStorage({ apiKey, serviceBase, userAgent }, { destinationFilePath }, { fetch: localFetch, log }) {
@@ -607,7 +749,9 @@ ${urlInfo}`;
607
749
  }
608
750
  __name(buildSuccessLogMessage, "buildSuccessLogMessage");
609
751
  function buildFailedLogMessage(status) {
610
- const { blockingBugsCount, blockingBugUrls = [], nonBlockingBugsCount, nonBlockingBugUrls = [], relevantRunWithBugsUrl } = status;
752
+ const { blockingBugsCount, nonBlockingBugsCount, relevantRunWithBugsUrl, reproducedBugs } = status;
753
+ const blockingBugUrls = reproducedBugs.filter((bug) => bug.isBlocking).map((bug) => bug.applicationUrl);
754
+ const nonBlockingBugUrls = reproducedBugs.filter((bug) => !bug.isBlocking).map((bug) => bug.applicationUrl);
611
755
  const blockingBugList = blockingBugUrls.map((url) => `
612
756
  \u2022 ${url}`).join("");
613
757
  const nonBlockingBugList = nonBlockingBugUrls.map((url) => `