@qawolf/ci-sdk 3.1.0 → 3.3.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
@@ -13,6 +13,7 @@ to determine the status of your CI/CD step/job/action.
13
13
 
14
14
  - [API Keys and Workspaces](#api-keys)
15
15
  - [Report a Deployment](#report-deployment)
16
+ - [Wait for a Verdict](#wait-for-verdict)
16
17
  - [Notify Deployment (deprecated)](#notify-deployment)
17
18
  - [Notify Preview Deployment (Pull Request / Merge Request Testing)](#notify-preview)
18
19
  - [Poll for CI Greenlight Status (deprecated)](#ci-greenlight)
@@ -249,7 +250,90 @@ The response carries the deployment only, not the resulting runs. Waiting for a
249
250
 
250
251
  `detectProviderDeploymentId()` composes the run, attempt and job, so two jobs in one workflow do not report into the same deployment. Compose the id yourself only if you would rather not call it, and include the job.
251
252
 
252
- Four things move: `deploymentType` becomes `environment.name`; `branch` / `sha` become `metadata`; `providerDeploymentId` is new and required (`deduplicationKey` is subsumed by it); `hostingService` disappears, since the code host comes from your integration. There is no `skipped` outcome: whether a trigger matched is not known at report time. `pollCiGreenlightStatus` takes a run id that only `attemptNotifyDeploy` returns, so a pipeline that waits for a verdict stays on the legacy pair until the successor to polling ships.
253
+ Four things move: `deploymentType` becomes `environment.name`; `branch` / `sha` become `metadata`; `providerDeploymentId` is new and required (`deduplicationKey` is subsumed by it); `hostingService` disappears, since the code host comes from your integration. There is no `skipped` outcome: whether a trigger matched is not known at report time. `pollCiGreenlightStatus` takes a run id that only `attemptNotifyDeploy` returns; a pipeline that waits for a verdict moves to [`deployment.waitForVerdict`](#wait-for-verdict), which takes the deployment id this call returns.
254
+
255
+ <a id="wait-for-verdict"></a>
256
+
257
+ ## Wait for a Verdict
258
+
259
+ `deployment.waitForVerdict` blocks until QA Wolf can say whether a deployment's tests passed. Give it the deployment id [the report](#report-deployment) returned; working out which runs the deployment produced happens inside.
260
+
261
+ > ⚠️ Set `timeout` below your CI job's own ceiling. When the job's ceiling fires first, the job is killed with no run link and no explanation. When ours fires first, you get `timed-out` with the stage, the elapsed time, and every run URL we know about.
262
+
263
+ ### Example
264
+
265
+ ```ts
266
+ const { deployment } = makeQaWolfSdk({ apiKey: "qawolf_xxxxx" });
267
+
268
+ const report = await deployment.reportStatus({ /* ... */ status: "success" });
269
+ if (report.outcome !== "reported") process.exit(1);
270
+
271
+ const verdict = await deployment.waitForVerdict({
272
+ deploymentId: report.deployment.id,
273
+ timeout: 45 * 60 * 1000,
274
+ });
275
+
276
+ process.exit(["passed", "not-tested"].includes(verdict.outcome) ? 0 : 1);
277
+ ```
278
+
279
+ ### Result
280
+
281
+ Like every function here, it never throws. Branch on `outcome` alone:
282
+
283
+ | `outcome` | Meaning |
284
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
285
+ | `passed` | Every run QA Wolf started passed. Carries `runs`. |
286
+ | `failed` | A run found a bug that is still open and serious enough to block. Carries `runs` and `blockingBugCount`. |
287
+ | `not-tested` | Nothing tested this deployment. Carries `reason` and a `triggers` note per trigger saying why. |
288
+ | `run-canceled` | A run was canceled, so there is no verdict. This is not a pass. Carries `runs`. |
289
+ | `superseded` | A newer deployment to the same branch and environment replaced this run before it finished, so there is no verdict for _this_ deployment. Not a pass. Carries `runs` and `supersededBy` with the replacement's id and URL. The replacement may be testing a later commit. |
290
+ | `timed-out` | The budget ran out. Carries `elapsedMs`, `lastStage` and any `runs` it had already looked up. |
291
+ | `canceled-by-caller` | The `signal` you passed was aborted. |
292
+ | `unauthorized` | The API key is unknown, or may not read this deployment. Carries `message`. |
293
+ | `not-found` | No such deployment. Carries `message`. |
294
+ | `server-error` | QA Wolf kept failing after every retry. Carries `httpStatus`. |
295
+ | `unexpected-status` | A status this SDK version does not map. Carries `httpStatus` and `message`. |
296
+ | `network-error` | The connection never came back within the retries. |
297
+ | `invalid-response-body` | QA Wolf answered with a body the contract rejects. |
298
+
299
+ `not-tested` is a separate answer from `passed` on purpose. A deployment that no trigger matched was not tested, which is a different fact from tests passing, and you decide what it is worth: exit 0 and keep the log line, or fail the build when you expect a run every time. `run-canceled` likewise means we cannot tell you.
300
+
301
+ `superseded` is what a `cancel-in-progress` job looks like in your CI: the example above ends within one poll of the supersession, logs the replacement's URL and exits 1, and the later deployment's job carries the verdict. Reserve a distinct exit code for it when your CI should treat the job as skipped rather than failed, for example `continue-on-error` on GitHub Actions, `allow_failure: { exit_codes: [3] }` on GitLab or `soft_fail: [{ exit_status: 3 }]` on Buildkite:
302
+
303
+ ```ts
304
+ if (verdict.outcome === "superseded") process.exit(3);
305
+ process.exit(["passed", "not-tested"].includes(verdict.outcome) ? 0 : 1);
306
+ ```
307
+
308
+ The wait never follows `supersededBy`, because the replacement is chosen by branch and environment, not by commit, and a pipeline promoting the artifact it just built must not promote it on tests of a later one. A release train that promotes whatever is on the environment can follow the pointer itself. The SDK has no run-keyed wait yet, so the follow step is your own poll of the public API's `run.get` with the replacement's id until its `status` is terminal. That answer is a run summary, with `status` and `supersededBy` rather than `outcome`, and the replacement can itself be superseded:
309
+
310
+ ```ts
311
+ const verdict = await deployment.waitForVerdict({ deploymentId });
312
+ let replacement =
313
+ verdict.outcome === "superseded" ? verdict.supersededBy : undefined;
314
+ while (replacement) {
315
+ console.log(`superseded by ${replacement.url}, following`);
316
+ const run = await waitForRun(replacement.runId); // your poll of run.get until status is terminal
317
+ replacement = run.supersededBy;
318
+ }
319
+ ```
320
+
321
+ The shape is `deploymentVerdictSchema`, published in `@qawolf/api-contracts`, so the same answer parses the same way wherever you read it.
322
+
323
+ ### Options
324
+
325
+ Every field below is optional and carries the default shown.
326
+
327
+ | Option | Default | Meaning |
328
+ | ---------------------- | ------- | ------------------------------------------------------------------------------------- |
329
+ | `timeout` | `2h` | Whole-operation budget. Set it below your CI job's ceiling. |
330
+ | `runAppearanceTimeout` | `5min` | Give up if no trigger has produced a run by then. Runs that did appear are waited on. |
331
+ | `triggerPollInterval` | `3s` | Between polls while a trigger is still producing a run. |
332
+ | `runPollInterval` | `30s` | Between polls while a run executes. |
333
+ | `maxRetries` | `10` | Network and server-error retries before giving up. |
334
+ | `retryInterval` | `10s` | Between those retries. |
335
+ | `onProgress` | — | Called on every stage change, for your build log. |
336
+ | `signal` | — | An `AbortSignal`. Aborting it yields `canceled-by-caller` instead of a rejection. |
253
337
 
254
338
  <a id="notify-deployment"></a>
255
339
 
@@ -735,6 +819,14 @@ This package follows the [SemVer](https://semver.org/) versioning scheme. Additi
735
819
 
736
820
  # Changelog
737
821
 
822
+ ## v3.3.0
823
+
824
+ - `deployment.waitForVerdict` answers `superseded` when a newer deployment to the same branch and environment replaced the run before it finished. It carries `runs` and `supersededBy` with the replacement's id and URL, is not a pass, and the wait stops there rather than follow the replacement, which may be testing a later commit. A caller switching exhaustively on `outcome` has one more case to handle; the recipe's `["passed", "not-tested"].includes(...)` check needs no change. `runs[].status` now uses the run summary's vocabulary, so it can read `superseded`, and `runs[]` carries `supersededBy` for such a run. See [Wait for a Verdict](#wait-for-verdict).
825
+
826
+ ## v3.2.0
827
+
828
+ - New `deployment.waitForVerdict` function, which blocks until QA Wolf can say whether a deployment's tests passed. It takes the deployment id `deployment.reportStatus` returned and resolves that to runs internally, so no run id crosses the boundary. It answers `passed`, `failed`, `not-tested`, `run-canceled`, `timed-out`, `canceled-by-caller` or one of the transport outcomes, and never throws. `not-tested` is deliberately distinct from `passed`, and `run-canceled` means no verdict rather than a pass. The whole-operation budget stays at 2 hours, matching `pollCiGreenlightStatus`. See [Wait for a Verdict](#wait-for-verdict).
829
+
738
830
  ## v3.1.0
739
831
 
740
832
  - New `deployment.reportStatus` function, which reports a deployment through the public `deployment.reportStatus` API — the one your triggers watch. A deployment's first `success` report is what starts a run. Its parameters are the API's own input, taken from the `@qawolf/api-contracts` package, so the SDK cannot drift from the API, and it returns `{ outcome: "reported", deployment }` carrying the deployment's `id`, `status` and `url`. A refusal comes back as one named outcome you can branch on — `invalid-request`, `unauthorized`, `not-found`, `conflict`, `server-error`, `unexpected-status`, `network-error` or `invalid-response-body` — so `if (result.outcome !== "reported")` is the whole check a CI gate needs. See [Report a Deployment](#report-deployment).
package/dist/index.js CHANGED
@@ -98,6 +98,9 @@ var buildFetchWithTimeout = /* @__PURE__ */ __name((fetch, defaultTimeoutMs) =>
98
98
  return fetch(args[0], { ...args[1], signal });
99
99
  }, "buildFetchWithTimeout");
100
100
 
101
+ // src/lib/sdk/defaults/clock.ts
102
+ var defaultClock = { now: /* @__PURE__ */ __name(() => Date.now(), "now"), sleep };
103
+
101
104
  // src/lib/sdk/defaults/fetch.ts
102
105
  var defaultFetch = globalThis.fetch;
103
106
 
@@ -120,7 +123,7 @@ var defaultServiceBase = "https://app.qawolf.com/";
120
123
  // package.json
121
124
  var package_default = {
122
125
  name: "@qawolf/ci-sdk",
123
- version: "3.1.0",
126
+ version: "3.3.0",
124
127
  private: false,
125
128
  description: "A simple SDK for interacting with QAWolf in CI scripts.",
126
129
  keywords: [],
@@ -149,7 +152,7 @@ var package_default = {
149
152
  "tsc:check": "tsc --noEmit"
150
153
  },
151
154
  dependencies: {
152
- "@qawolf/api-contracts": ">=0.68.0",
155
+ "@qawolf/api-contracts": ">=0.73.0",
153
156
  "@qawolf/ci-utils": "^2.0.0",
154
157
  "@sinonjs/fake-timers": "^10.3.0",
155
158
  tslib: "^2.6.2",
@@ -1273,7 +1276,7 @@ async function callPublicContract({ fetch: localFetch, log }, apiConfig, {
1273
1276
  if (!response.ok) return readHttpError(response, contract.name, log);
1274
1277
  bodyText = await response.text();
1275
1278
  } catch {
1276
- log.error(`\u{1F6AB} Network error from ${contract.name}, aborting.`);
1279
+ log.error(`\u{1F6AB} Network error from ${contract.name}.`);
1277
1280
  return { outcome: "network-error" };
1278
1281
  }
1279
1282
  const parsed = contract.output.safeParse(
@@ -1316,12 +1319,441 @@ function mapFailure(result) {
1316
1319
  }
1317
1320
  __name(mapFailure, "mapFailure");
1318
1321
 
1322
+ // src/lib/sdk/domain/waitForVerdict/awaitRuns.ts
1323
+ import { publicContractsV1 as publicContractsV12 } from "@qawolf/api-contracts";
1324
+
1325
+ // src/lib/sdk/domain/waitForVerdict/failures.ts
1326
+ function handleCallFailure(failure, deploymentId) {
1327
+ if (failure.outcome === "network-error") {
1328
+ return {
1329
+ exhaustedVerdict: { deploymentId, outcome: "network-error" },
1330
+ kind: "retry"
1331
+ };
1332
+ }
1333
+ if (failure.outcome === "invalid-response-body") {
1334
+ return {
1335
+ kind: "stop",
1336
+ verdict: { deploymentId, outcome: "invalid-response-body" }
1337
+ };
1338
+ }
1339
+ const { httpStatus, message } = failure;
1340
+ if (httpStatus >= 500) {
1341
+ return {
1342
+ exhaustedVerdict: { deploymentId, httpStatus, outcome: "server-error" },
1343
+ kind: "retry"
1344
+ };
1345
+ }
1346
+ if (httpStatus === 401 || httpStatus === 403) {
1347
+ return {
1348
+ kind: "stop",
1349
+ verdict: { deploymentId, message, outcome: "unauthorized" }
1350
+ };
1351
+ }
1352
+ if (httpStatus === 404) {
1353
+ return {
1354
+ kind: "stop",
1355
+ verdict: { deploymentId, message, outcome: "not-found" }
1356
+ };
1357
+ }
1358
+ return {
1359
+ kind: "stop",
1360
+ verdict: {
1361
+ deploymentId,
1362
+ httpStatus,
1363
+ message,
1364
+ outcome: "unexpected-status"
1365
+ }
1366
+ };
1367
+ }
1368
+ __name(handleCallFailure, "handleCallFailure");
1369
+
1370
+ // src/lib/sdk/domain/waitForVerdict/poll.ts
1371
+ async function pollUntil(ctx, {
1372
+ deadlineAt,
1373
+ onData,
1374
+ onDeadline,
1375
+ request
1376
+ }) {
1377
+ const { clock, log } = ctx.deps;
1378
+ const { deploymentId } = ctx;
1379
+ const { maxRetries, retryInterval, signal } = ctx.config;
1380
+ let retries = 0;
1381
+ const sleepWithinBudget = /* @__PURE__ */ __name((intervalMs) => clock.sleep(Math.max(0, Math.min(intervalMs, deadlineAt - clock.now()))), "sleepWithinBudget");
1382
+ for (; ; ) {
1383
+ if (signal?.aborted)
1384
+ return { deploymentId, outcome: "canceled-by-caller" };
1385
+ if (clock.now() >= deadlineAt) return onDeadline();
1386
+ const result = await request();
1387
+ if (result.outcome === "ok") {
1388
+ retries = 0;
1389
+ const step = await onData(result.data);
1390
+ if (step.kind === "done") return step.result;
1391
+ await sleepWithinBudget(step.intervalMs);
1392
+ continue;
1393
+ }
1394
+ const handling = handleCallFailure(result, deploymentId);
1395
+ if (handling.kind === "stop") return handling.verdict;
1396
+ retries += 1;
1397
+ if (retries > maxRetries) return handling.exhaustedVerdict;
1398
+ log.warn(
1399
+ `\u23F3 QA Wolf did not answer, trying again... (${retries}/${maxRetries})`
1400
+ );
1401
+ await sleepWithinBudget(retryInterval);
1402
+ }
1403
+ }
1404
+ __name(pollUntil, "pollUntil");
1405
+
1406
+ // src/lib/sdk/domain/waitForVerdict/progress.ts
1407
+ function describeStage(stage) {
1408
+ switch (stage.stage) {
1409
+ case "waiting-for-evaluation":
1410
+ return "\u23F3 Waiting for QA Wolf to evaluate this deployment's triggers...";
1411
+ case "waiting-for-run": {
1412
+ const { matchedTriggerCount } = stage;
1413
+ return `\u23F3 ${matchedTriggerCount} trigger${pluralize(matchedTriggerCount)} matched; waiting for a run to start...`;
1414
+ }
1415
+ case "waiting-for-verdict": {
1416
+ const urls = stage.runs.map((run) => run.runUrl).join(" ");
1417
+ return `\u23F3 ${stage.runs.length} run${pluralize(stage.runs.length)} executing... ${urls}`;
1418
+ }
1419
+ default:
1420
+ throw Error(`Unexpected stage: ${stage}`);
1421
+ }
1422
+ }
1423
+ __name(describeStage, "describeStage");
1424
+ async function reportStage(ctx, previousDescription, stage) {
1425
+ const description = describeStage(stage);
1426
+ if (description === previousDescription) return description;
1427
+ ctx.deps.log.info(description);
1428
+ try {
1429
+ await ctx.config.onProgress?.(stage);
1430
+ } catch (error) {
1431
+ ctx.deps.log.error("The onProgress callback failed.", error);
1432
+ }
1433
+ return description;
1434
+ }
1435
+ __name(reportStage, "reportStage");
1436
+
1437
+ // src/lib/sdk/domain/waitForVerdict/readEvaluations.ts
1438
+ var notConsideredMessages = {
1439
+ "not-a-deployment-trigger": "The trigger has no deployment conditions, so no deployment can fire it.",
1440
+ "trigger-paused": "The trigger was paused when the deployment was evaluated."
1441
+ };
1442
+ function readDidNotMatch(conditionSets) {
1443
+ const reasons = conditionSets.flatMap(
1444
+ (set) => set.conditions.flatMap(
1445
+ (condition) => condition.outcome === "did-not-match" ? [condition.reason] : []
1446
+ )
1447
+ );
1448
+ return reasons.length > 0 ? reasons.join(" ") : "The trigger's conditions did not match this deployment.";
1449
+ }
1450
+ __name(readDidNotMatch, "readDidNotMatch");
1451
+ function toTriggerNote(evaluation) {
1452
+ const { triggerName, verdict } = evaluation;
1453
+ if (verdict.outcome === "did-not-match")
1454
+ return { message: readDidNotMatch(verdict.conditionSets), triggerName };
1455
+ if (verdict.outcome === "not-considered") {
1456
+ return {
1457
+ message: notConsideredMessages[verdict.reason],
1458
+ triggerName
1459
+ };
1460
+ }
1461
+ if (verdict.run.status === "did-not-run") {
1462
+ return {
1463
+ message: verdict.run.message,
1464
+ reason: verdict.run.reason,
1465
+ triggerName
1466
+ };
1467
+ }
1468
+ return void 0;
1469
+ }
1470
+ __name(toTriggerNote, "toTriggerNote");
1471
+ function toMatchedRun(evaluation) {
1472
+ const { triggerName, verdict } = evaluation;
1473
+ if (verdict.outcome !== "matched") return void 0;
1474
+ if (verdict.run.status === "ran")
1475
+ return { runId: verdict.run.runId, triggerName };
1476
+ if (verdict.run.status === "did-not-run" && verdict.run.supersededByRunId)
1477
+ return { runId: verdict.run.supersededByRunId, triggerName };
1478
+ return void 0;
1479
+ }
1480
+ __name(toMatchedRun, "toMatchedRun");
1481
+ function isStillProducingARun(evaluation) {
1482
+ const { verdict } = evaluation;
1483
+ return verdict.outcome === "matched" && (verdict.run.status === "starting" || verdict.run.status === "unknown");
1484
+ }
1485
+ __name(isStillProducingARun, "isStillProducingARun");
1486
+ function readEvaluations(evaluations) {
1487
+ if (evaluations.state === "not-evaluated") return { kind: "not-evaluated" };
1488
+ const runs = evaluations.evaluations.flatMap((evaluation) => {
1489
+ const run = toMatchedRun(evaluation);
1490
+ return run ? [run] : [];
1491
+ });
1492
+ const matched = evaluations.evaluations.filter(
1493
+ (evaluation) => evaluation.verdict.outcome === "matched"
1494
+ );
1495
+ if (evaluations.evaluations.some(isStillProducingARun))
1496
+ return { kind: "awaiting-run", matchedTriggerCount: matched.length, runs };
1497
+ if (runs.length > 0) return { kind: "runs", runs };
1498
+ return {
1499
+ kind: "not-tested",
1500
+ reason: matched.length > 0 ? "no-run-created" : "no-trigger-matched",
1501
+ triggers: evaluations.evaluations.flatMap((evaluation) => {
1502
+ const note = toTriggerNote(evaluation);
1503
+ return note ? [note] : [];
1504
+ })
1505
+ };
1506
+ }
1507
+ __name(readEvaluations, "readEvaluations");
1508
+
1509
+ // src/lib/sdk/domain/waitForVerdict/awaitRuns.ts
1510
+ async function awaitRuns(ctx) {
1511
+ const { clock } = ctx.deps;
1512
+ const { deploymentId } = ctx;
1513
+ const { runAppearanceTimeout, timeout, triggerPollInterval } = ctx.config;
1514
+ const overallDeadlineAt = ctx.startedAt + timeout;
1515
+ let knownRuns = [];
1516
+ let lastStage = { stage: "waiting-for-evaluation" };
1517
+ let lastDescription = void 0;
1518
+ return pollUntil(ctx, {
1519
+ deadlineAt: Math.min(
1520
+ overallDeadlineAt,
1521
+ ctx.startedAt + runAppearanceTimeout
1522
+ ),
1523
+ async onData(evaluations) {
1524
+ const reading = readEvaluations(evaluations);
1525
+ if (reading.kind === "runs")
1526
+ return { kind: "done", result: { kind: "runs", runs: reading.runs } };
1527
+ if (reading.kind === "not-tested") {
1528
+ return {
1529
+ kind: "done",
1530
+ result: {
1531
+ deploymentId,
1532
+ outcome: "not-tested",
1533
+ reason: reading.reason,
1534
+ triggers: reading.triggers
1535
+ }
1536
+ };
1537
+ }
1538
+ knownRuns = reading.kind === "awaiting-run" ? reading.runs : knownRuns;
1539
+ lastStage = reading.kind === "awaiting-run" ? {
1540
+ matchedTriggerCount: reading.matchedTriggerCount,
1541
+ stage: "waiting-for-run"
1542
+ } : { stage: "waiting-for-evaluation" };
1543
+ lastDescription = await reportStage(ctx, lastDescription, lastStage);
1544
+ return { intervalMs: triggerPollInterval, kind: "continue" };
1545
+ },
1546
+ onDeadline() {
1547
+ const now = clock.now();
1548
+ if (knownRuns.length > 0 && now < overallDeadlineAt)
1549
+ return { kind: "runs", runs: knownRuns };
1550
+ return {
1551
+ deploymentId,
1552
+ elapsedMs: now - ctx.startedAt,
1553
+ lastStage,
1554
+ outcome: "timed-out",
1555
+ runs: []
1556
+ };
1557
+ },
1558
+ request: /* @__PURE__ */ __name(() => callPublicContract(ctx.deps, ctx.apiConfig, {
1559
+ contract: publicContractsV12.deployment.listTriggerEvaluations,
1560
+ input: { deploymentId }
1561
+ }), "request")
1562
+ });
1563
+ }
1564
+ __name(awaitRuns, "awaitRuns");
1565
+
1566
+ // src/lib/sdk/domain/waitForVerdict/awaitVerdict.ts
1567
+ import { publicContractsV1 as publicContractsV13 } from "@qawolf/api-contracts";
1568
+ var settledStatuses = ["passed", "failed", "canceled", "superseded"];
1569
+ function isSettled(run) {
1570
+ return settledStatuses.includes(run.status);
1571
+ }
1572
+ __name(isSettled, "isSettled");
1573
+ function aggregateRuns(deploymentId, runs) {
1574
+ if (runs.some((run) => run.status === "failed" || run.blockingBugCount > 0)) {
1575
+ return {
1576
+ blockingBugCount: runs.reduce(
1577
+ (total, run) => total + run.blockingBugCount,
1578
+ 0
1579
+ ),
1580
+ deploymentId,
1581
+ outcome: "failed",
1582
+ runs
1583
+ };
1584
+ }
1585
+ const supersededBy = runs.find(
1586
+ (run) => run.status === "superseded"
1587
+ )?.supersededBy;
1588
+ if (supersededBy)
1589
+ return { deploymentId, outcome: "superseded", runs, supersededBy };
1590
+ if (runs.some((run) => run.status === "canceled"))
1591
+ return { deploymentId, outcome: "run-canceled", runs };
1592
+ return { deploymentId, outcome: "passed", runs };
1593
+ }
1594
+ __name(aggregateRuns, "aggregateRuns");
1595
+ function keepOnePerRun(runs) {
1596
+ return runs.filter(
1597
+ (run, index) => runs.findIndex((other) => other.runId === run.runId) === index
1598
+ );
1599
+ }
1600
+ __name(keepOnePerRun, "keepOnePerRun");
1601
+ async function requestRuns(ctx, matched) {
1602
+ const answers = await Promise.all(
1603
+ matched.map(async (run) => ({
1604
+ result: await callPublicContract(ctx.deps, ctx.apiConfig, {
1605
+ contract: publicContractsV13.run.get,
1606
+ // A suite can hold hundreds of flows and the verdict reads none of
1607
+ // them, so ask for the smallest list the contract allows.
1608
+ input: { flowStatuses: ["failed"], runId: run.runId }
1609
+ }),
1610
+ triggerName: run.triggerName
1611
+ }))
1612
+ );
1613
+ const failed = answers.find((answer) => answer.result.outcome !== "ok");
1614
+ if (failed && failed.result.outcome !== "ok") return failed.result;
1615
+ return {
1616
+ data: keepOnePerRun(
1617
+ answers.flatMap(
1618
+ (answer) => answer.result.outcome === "ok" ? [
1619
+ {
1620
+ blockingBugCount: answer.result.data.blockingBugCount,
1621
+ // Differs from the id asked for only when a deploy notification's
1622
+ // pre-allocated id was folded into an earlier run of the same commit.
1623
+ runId: answer.result.data.runId,
1624
+ runUrl: answer.result.data.url,
1625
+ status: answer.result.data.status,
1626
+ supersededBy: answer.result.data.supersededBy,
1627
+ triggerName: answer.triggerName
1628
+ }
1629
+ ] : []
1630
+ )
1631
+ ),
1632
+ outcome: "ok"
1633
+ };
1634
+ }
1635
+ __name(requestRuns, "requestRuns");
1636
+ async function awaitVerdict(ctx, matched) {
1637
+ const { deploymentId } = ctx;
1638
+ let lastRuns = [];
1639
+ let lastDescription = void 0;
1640
+ return pollUntil(ctx, {
1641
+ deadlineAt: ctx.startedAt + ctx.config.timeout,
1642
+ async onData(runs) {
1643
+ lastRuns = runs;
1644
+ if (runs.every(isSettled))
1645
+ return { kind: "done", result: aggregateRuns(deploymentId, runs) };
1646
+ lastDescription = await reportStage(ctx, lastDescription, {
1647
+ runs,
1648
+ stage: "waiting-for-verdict"
1649
+ });
1650
+ return { intervalMs: ctx.config.runPollInterval, kind: "continue" };
1651
+ },
1652
+ onDeadline: /* @__PURE__ */ __name(() => ({
1653
+ deploymentId,
1654
+ elapsedMs: ctx.deps.clock.now() - ctx.startedAt,
1655
+ lastStage: { runs: lastRuns, stage: "waiting-for-verdict" },
1656
+ outcome: "timed-out",
1657
+ runs: lastRuns
1658
+ }), "onDeadline"),
1659
+ request: /* @__PURE__ */ __name(() => requestRuns(ctx, matched), "request")
1660
+ });
1661
+ }
1662
+ __name(awaitVerdict, "awaitVerdict");
1663
+
1664
+ // src/lib/sdk/domain/waitForVerdict/consts.ts
1665
+ var waitForVerdictDefaults = {
1666
+ maxRetries: 10,
1667
+ retryInterval: 10 * 1e3,
1668
+ runAppearanceTimeout: 5 * 60 * 1e3,
1669
+ runPollInterval: 30 * 1e3,
1670
+ timeout: 120 * 60 * 1e3,
1671
+ triggerPollInterval: 3 * 1e3
1672
+ };
1673
+
1674
+ // src/lib/sdk/domain/waitForVerdict/logVerdict.ts
1675
+ function runUrls(runs) {
1676
+ return runs.map((run) => run.runUrl).join(" ");
1677
+ }
1678
+ __name(runUrls, "runUrls");
1679
+ function logVerdict(log, verdict) {
1680
+ switch (verdict.outcome) {
1681
+ case "canceled-by-caller":
1682
+ return log.warn("\u{1F6AB} The wait was canceled before QA Wolf answered.");
1683
+ case "failed":
1684
+ return log.error(
1685
+ `\u274C QA Wolf found ${verdict.blockingBugCount} blocking bug${pluralize(verdict.blockingBugCount)}. ${runUrls(verdict.runs)}`
1686
+ );
1687
+ case "not-tested": {
1688
+ log.info(
1689
+ verdict.reason === "no-trigger-matched" ? "\u26A0\uFE0F QA Wolf: nothing to test for this deployment." : "\u26A0\uFE0F QA Wolf: a trigger matched this deployment but started no run."
1690
+ );
1691
+ for (const trigger of verdict.triggers)
1692
+ log.info(` ${trigger.triggerName}: ${trigger.message}`);
1693
+ return;
1694
+ }
1695
+ case "passed":
1696
+ return log.info(`\u2705 QA Wolf gate: passed. ${runUrls(verdict.runs)}`);
1697
+ case "run-canceled":
1698
+ return log.error(
1699
+ `\u{1F6AB} The run was canceled, so QA Wolf reached no verdict. ${runUrls(verdict.runs)}`
1700
+ );
1701
+ case "superseded":
1702
+ return log.error(
1703
+ `\u{1F6AB} superseded \u2014 replaced by ${verdict.supersededBy.url}, so QA Wolf reached no verdict for this deployment. ${runUrls(verdict.runs)}`
1704
+ );
1705
+ case "timed-out":
1706
+ return log.error(
1707
+ `\u{1F6AB} Gave up after ${Math.round(verdict.elapsedMs / 1e3)}s waiting for QA Wolf. ${runUrls(verdict.runs)}`
1708
+ );
1709
+ default:
1710
+ return void 0;
1711
+ }
1712
+ }
1713
+ __name(logVerdict, "logVerdict");
1714
+
1715
+ // src/lib/sdk/domain/waitForVerdict/index.ts
1716
+ async function waitForVerdict(deps, apiConfig, {
1717
+ deploymentId,
1718
+ maxRetries = waitForVerdictDefaults.maxRetries,
1719
+ onProgress,
1720
+ retryInterval = waitForVerdictDefaults.retryInterval,
1721
+ runAppearanceTimeout = waitForVerdictDefaults.runAppearanceTimeout,
1722
+ runPollInterval = waitForVerdictDefaults.runPollInterval,
1723
+ signal,
1724
+ timeout = waitForVerdictDefaults.timeout,
1725
+ triggerPollInterval = waitForVerdictDefaults.triggerPollInterval
1726
+ }) {
1727
+ const ctx = {
1728
+ apiConfig,
1729
+ config: {
1730
+ maxRetries,
1731
+ onProgress,
1732
+ retryInterval,
1733
+ runAppearanceTimeout,
1734
+ runPollInterval,
1735
+ signal,
1736
+ timeout,
1737
+ triggerPollInterval
1738
+ },
1739
+ deploymentId,
1740
+ deps,
1741
+ startedAt: deps.clock.now()
1742
+ };
1743
+ const phase = await awaitRuns(ctx);
1744
+ const verdict = "kind" in phase ? await awaitVerdict(ctx, phase.runs) : phase;
1745
+ logVerdict(deps.log, verdict);
1746
+ return verdict;
1747
+ }
1748
+ __name(waitForVerdict, "waitForVerdict");
1749
+
1319
1750
  // src/lib/sdk/index.ts
1320
1751
  function makeQaWolfSdk({
1321
1752
  apiKey,
1322
1753
  serviceBase = defaultServiceBase,
1323
1754
  userAgent = defaultUserAgent
1324
1755
  }, {
1756
+ clock = defaultClock,
1325
1757
  fetch = defaultFetch,
1326
1758
  log = defaultLogDriver
1327
1759
  } = {}) {
@@ -1332,6 +1764,7 @@ We recommend 'undici' package for that purpose. See the Requirement section of o
1332
1764
  );
1333
1765
  }
1334
1766
  const deps = {
1767
+ clock,
1335
1768
  // Versions of fetch may have different default timeouts. For consistency, we explicitly set it to 2 minutes here.
1336
1769
  // The increased timeout helps avoid edge cases where we need to search for a commit against multiple
1337
1770
  // code hosting service repositories as a fallback when not finding the repository based on organization/name.
@@ -1346,7 +1779,8 @@ We recommend 'undici' package for that purpose. See the Requirement section of o
1346
1779
  */
1347
1780
  attemptNotifyDeploy: attemptNotifyDeploy.bind(null, deps, apiConfig),
1348
1781
  deployment: {
1349
- reportStatus: reportDeployment.bind(null, deps, apiConfig)
1782
+ reportStatus: reportDeployment.bind(null, deps, apiConfig),
1783
+ waitForVerdict: waitForVerdict.bind(null, deps, apiConfig)
1350
1784
  },
1351
1785
  generateSignedUrlForRunInputsExecutablesStorage: generateSignedUrlForRunInputsExecutablesStorage.bind(
1352
1786
  null,