@checkstack/healthcheck-backend 1.15.0 → 1.16.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/CHANGELOG.md CHANGED
@@ -1,5 +1,82 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c55d7c6: Make collector assertions analyzable: structured per-assertion outcomes on
8
+ every run, pass/fail counts in every aggregate tier, and dedicated analysis
9
+ surfaces. Previously a passing assertion left no trace and only the first
10
+ failure was recorded as a string.
11
+
12
+ - `@checkstack/healthcheck-common` adds the assertion-analytics contract:
13
+ `AssertionOutcomeSchema`, per-bucket `BucketAssertionStats` (stored under
14
+ the platform-owned top-level `assertions` key of `aggregatedResult`), and
15
+ the canonical assertion identity key (`computeAssertionKey` /
16
+ `parseAssertionKey`, a JSON tuple of field/jsonPath/operator/value).
17
+ Editing an assertion starts a new series; identical duplicates collapse.
18
+ - The executor evaluates ALL assertions (no first-failure short-circuit) and
19
+ stores `_assertions` on each collector entry alongside the unchanged
20
+ `_assertionFailed` compatibility string. Pass/fail counts are folded into
21
+ the hourly realtime aggregation, the on-read raw tier, cross-tier bucket
22
+ re-merges, and the daily retention rollup (assertion counts are the only
23
+ `aggregatedResult` content that survives the rollup - they are purely
24
+ additive), so assertion analytics do not silently end at the hourly
25
+ retention horizon.
26
+ - Satellite ingest now evaluates assertions on the core
27
+ (`ingestSatelliteResult`), downgrading a satellite-reported healthy run
28
+ whose assertions fail, and strips ephemeral result fields (e.g. raw HTTP
29
+ bodies) at ingest for parity with local runs. BEHAVIOR CHANGE:
30
+ satellite-executed checks previously never enforced assertions at all;
31
+ they now do, with no satellite upgrade or wire-protocol change. Buffered
32
+ satellite results are evaluated against the configuration current at
33
+ ingest time.
34
+ - The run detail gains an Assertions tab (per-collector groups, pass AND
35
+ fail rows with expected vs actual, a legacy fallback for pre-feature
36
+ runs), and the drawer's auto-chart grid leads each collector group with
37
+ per-assertion pass-rate tiles (sparkline of per-bucket pass rate,
38
+ expandable to a pass/fail StackedTimeline; currently-configured assertions
39
+ appear before any data exists, historical-only series are flagged).
40
+
41
+ State & scale: all new state lives in the existing `healthCheckRuns.result`
42
+ and `healthCheckAggregates.aggregated_result` jsonb columns (durable, shared
43
+ Postgres - no new tables, no pod-local state); reads resolve identically on
44
+ every pod; the run-vs-bucket duplication is the platform's existing
45
+ raw-vs-aggregate tiering with the existing single-writer upsert paths.
46
+
47
+ ### Patch Changes
48
+
49
+ - Updated dependencies [c55d7c6]
50
+ - Updated dependencies [c55d7c6]
51
+ - Updated dependencies [a83bcc2]
52
+ - Updated dependencies [c55d7c6]
53
+ - @checkstack/ai-backend@0.10.7
54
+ - @checkstack/healthcheck-common@1.13.0
55
+ - @checkstack/common@0.21.0
56
+ - @checkstack/automation-backend@0.10.9
57
+ - @checkstack/catalog-backend@1.6.7
58
+ - @checkstack/incident-backend@1.9.5
59
+ - @checkstack/satellite-backend@0.8.1
60
+ - @checkstack/backend-api@0.29.1
61
+ - @checkstack/sdk@0.123.1
62
+ - @checkstack/ai-common@0.6.5
63
+ - @checkstack/cache-api@0.3.18
64
+ - @checkstack/catalog-common@2.6.2
65
+ - @checkstack/command-backend@0.2.19
66
+ - @checkstack/gitops-backend@0.5.19
67
+ - @checkstack/gitops-common@0.7.2
68
+ - @checkstack/incident-common@1.7.2
69
+ - @checkstack/maintenance-common@1.8.2
70
+ - @checkstack/notification-common@1.5.2
71
+ - @checkstack/queue-api@0.3.18
72
+ - @checkstack/script-packages-backend@0.3.23
73
+ - @checkstack/secrets-backend@0.3.1
74
+ - @checkstack/secrets-common@0.3.1
75
+ - @checkstack/signal-common@0.2.16
76
+ - @checkstack/status-page-backend@0.4.6
77
+ - @checkstack/status-page-common@0.5.2
78
+ - @checkstack/cache-utils@0.2.23
79
+
3
80
  ## 1.15.0
4
81
 
5
82
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,32 +14,32 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/ai-backend": "0.10.6",
18
- "@checkstack/ai-common": "0.6.4",
19
- "@checkstack/automation-backend": "0.10.8",
20
- "@checkstack/backend-api": "0.29.0",
21
- "@checkstack/cache-api": "0.3.17",
22
- "@checkstack/cache-utils": "0.2.22",
23
- "@checkstack/catalog-backend": "1.6.6",
24
- "@checkstack/catalog-common": "2.6.1",
25
- "@checkstack/command-backend": "0.2.18",
26
- "@checkstack/common": "0.20.0",
27
- "@checkstack/gitops-backend": "0.5.18",
28
- "@checkstack/gitops-common": "0.7.1",
29
- "@checkstack/healthcheck-common": "1.12.0",
30
- "@checkstack/incident-backend": "1.9.4",
31
- "@checkstack/incident-common": "1.7.1",
32
- "@checkstack/maintenance-common": "1.8.1",
33
- "@checkstack/notification-common": "1.5.1",
34
- "@checkstack/queue-api": "0.3.17",
35
- "@checkstack/satellite-backend": "0.8.0",
36
- "@checkstack/script-packages-backend": "0.3.22",
37
- "@checkstack/sdk": "0.122.1",
38
- "@checkstack/secrets-backend": "0.3.0",
39
- "@checkstack/secrets-common": "0.3.0",
40
- "@checkstack/signal-common": "0.2.15",
41
- "@checkstack/status-page-backend": "0.4.5",
42
- "@checkstack/status-page-common": "0.5.1",
17
+ "@checkstack/ai-backend": "0.10.7",
18
+ "@checkstack/ai-common": "0.6.5",
19
+ "@checkstack/automation-backend": "0.10.9",
20
+ "@checkstack/backend-api": "0.29.1",
21
+ "@checkstack/cache-api": "0.3.18",
22
+ "@checkstack/cache-utils": "0.2.23",
23
+ "@checkstack/catalog-backend": "1.6.7",
24
+ "@checkstack/catalog-common": "2.6.2",
25
+ "@checkstack/command-backend": "0.2.19",
26
+ "@checkstack/common": "0.21.0",
27
+ "@checkstack/gitops-backend": "0.5.19",
28
+ "@checkstack/gitops-common": "0.7.2",
29
+ "@checkstack/healthcheck-common": "1.13.0",
30
+ "@checkstack/incident-backend": "1.9.5",
31
+ "@checkstack/incident-common": "1.7.2",
32
+ "@checkstack/maintenance-common": "1.8.2",
33
+ "@checkstack/notification-common": "1.5.2",
34
+ "@checkstack/queue-api": "0.3.18",
35
+ "@checkstack/satellite-backend": "0.8.1",
36
+ "@checkstack/script-packages-backend": "0.3.23",
37
+ "@checkstack/sdk": "0.123.1",
38
+ "@checkstack/secrets-backend": "0.3.1",
39
+ "@checkstack/secrets-common": "0.3.1",
40
+ "@checkstack/signal-common": "0.2.16",
41
+ "@checkstack/status-page-backend": "0.4.6",
42
+ "@checkstack/status-page-common": "0.5.2",
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@orpc/contract": "^1.14.4",
45
45
  "@orpc/server": "^1.14.4",
@@ -52,8 +52,8 @@
52
52
  },
53
53
  "devDependencies": {
54
54
  "@checkstack/drizzle-helper": "0.0.6",
55
- "@checkstack/scripts": "0.7.1",
56
- "@checkstack/test-utils-backend": "0.1.52",
55
+ "@checkstack/scripts": "0.7.2",
56
+ "@checkstack/test-utils-backend": "0.1.53",
57
57
  "@checkstack/tsconfig": "0.0.7",
58
58
  "@types/bun": "^1.0.0",
59
59
  "@types/tdigest": "^0.1.5",
@@ -4,12 +4,14 @@ import {
4
4
  calculateLatencyStats,
5
5
  countStatuses,
6
6
  extractLatencies,
7
+ foldRunAssertionStats,
7
8
  mergeTieredBuckets,
8
9
  combineBuckets,
9
10
  reaggregateBuckets,
10
11
  mergeAggregatedBucketResults,
11
12
  type NormalizedBucket,
12
13
  } from "./aggregation-utils";
14
+ import { computeAssertionKey } from "@checkstack/healthcheck-common";
13
15
  import {
14
16
  VersionedAggregated,
15
17
  aggregatedCounter,
@@ -982,3 +984,133 @@ describe("aggregation-utils", () => {
982
984
  });
983
985
  });
984
986
  });
987
+
988
+ describe("assertion stats in aggregation", () => {
989
+ const KEY = computeAssertionKey({
990
+ assertion: { field: "statusCode", operator: "equals", value: 200 },
991
+ });
992
+
993
+ const outcome = (passed: boolean) => ({
994
+ key: KEY,
995
+ field: "statusCode",
996
+ operator: "equals",
997
+ value: "200",
998
+ passed,
999
+ });
1000
+
1001
+ describe("foldRunAssertionStats", () => {
1002
+ it("folds outcomes across a bucket's runs per collector entry", () => {
1003
+ const runs = [
1004
+ {
1005
+ metadata: {
1006
+ collectors: {
1007
+ "uuid-1": { _collectorId: "c", _assertions: [outcome(true)] },
1008
+ },
1009
+ },
1010
+ },
1011
+ {
1012
+ metadata: {
1013
+ collectors: {
1014
+ "uuid-1": { _collectorId: "c", _assertions: [outcome(false)] },
1015
+ },
1016
+ },
1017
+ },
1018
+ // Pre-feature run without outcomes is tolerated.
1019
+ { metadata: { collectors: { "uuid-1": { _collectorId: "c" } } } },
1020
+ ];
1021
+ expect(foldRunAssertionStats(runs)).toEqual({
1022
+ "uuid-1": { [KEY]: { passCount: 1, failCount: 1 } },
1023
+ });
1024
+ });
1025
+
1026
+ it("returns undefined when no run carries outcomes", () => {
1027
+ expect(foldRunAssertionStats([{ metadata: {} }, {}])).toBeUndefined();
1028
+ });
1029
+
1030
+ it("ignores malformed outcome entries", () => {
1031
+ const runs = [
1032
+ {
1033
+ metadata: {
1034
+ collectors: {
1035
+ "uuid-1": {
1036
+ _collectorId: "c",
1037
+ _assertions: ["garbage", outcome(true)],
1038
+ },
1039
+ },
1040
+ },
1041
+ },
1042
+ ];
1043
+ expect(foldRunAssertionStats(runs)).toEqual({
1044
+ "uuid-1": { [KEY]: { passCount: 1, failCount: 0 } },
1045
+ });
1046
+ });
1047
+ });
1048
+
1049
+ describe("mergeAggregatedBucketResults with assertion stats", () => {
1050
+ it("merges counts additively and keeps them out of strategy merging", () => {
1051
+ const mergeAggregatedStates = mock(
1052
+ (a: Record<string, unknown>, b: Record<string, unknown>) => ({
1053
+ ...a,
1054
+ ...b,
1055
+ }),
1056
+ );
1057
+ const registry = {
1058
+ getStrategy: mock(() => ({
1059
+ aggregatedResult: { mergeAggregatedStates },
1060
+ })),
1061
+ register: mock(() => {}),
1062
+ getStrategies: mock(() => []),
1063
+ getStrategiesWithMeta: mock(() => []),
1064
+ } as unknown as HealthCheckRegistry;
1065
+ const collectorRegistry = {
1066
+ register: mock(() => {}),
1067
+ getCollector: mock(() => undefined),
1068
+ getCollectors: mock(() => []),
1069
+ } as unknown as CollectorRegistry;
1070
+
1071
+ const merged = mergeAggregatedBucketResults({
1072
+ aggregatedResults: [
1073
+ {
1074
+ uptime: 1,
1075
+ assertions: { "uuid-1": { [KEY]: { passCount: 3, failCount: 1 } } },
1076
+ },
1077
+ {
1078
+ uptime: 2,
1079
+ assertions: { "uuid-1": { [KEY]: { passCount: 2, failCount: 0 } } },
1080
+ },
1081
+ ],
1082
+ collectorRegistry,
1083
+ registry,
1084
+ strategyId: "test-strategy",
1085
+ });
1086
+
1087
+ expect(merged?.assertions).toEqual({
1088
+ "uuid-1": { [KEY]: { passCount: 5, failCount: 1 } },
1089
+ });
1090
+ // The strategy merger only ever saw its own fields.
1091
+ for (const call of mergeAggregatedStates.mock.calls) {
1092
+ for (const arg of call) {
1093
+ expect(
1094
+ (arg as Record<string, unknown>).assertions,
1095
+ ).toBeUndefined();
1096
+ }
1097
+ }
1098
+ });
1099
+
1100
+ it("single-bucket results pass through with their stats intact", () => {
1101
+ const { collectorRegistry, registry, strategyId } =
1102
+ createMockRegistries();
1103
+ const only = {
1104
+ assertions: { "uuid-1": { [KEY]: { passCount: 7, failCount: 0 } } },
1105
+ };
1106
+ expect(
1107
+ mergeAggregatedBucketResults({
1108
+ aggregatedResults: [only],
1109
+ collectorRegistry,
1110
+ registry,
1111
+ strategyId,
1112
+ }),
1113
+ ).toBe(only);
1114
+ });
1115
+ });
1116
+ });
@@ -2,6 +2,15 @@ import type {
2
2
  CollectorRegistry,
3
3
  HealthCheckRegistry,
4
4
  } from "@checkstack/backend-api";
5
+ import {
6
+ ASSERTIONS_AGG_KEY,
7
+ AssertionOutcomeSchema,
8
+ foldOutcomesIntoStats,
9
+ mergeAssertionStats,
10
+ readAssertionStats,
11
+ type AssertionOutcome,
12
+ type BucketAssertionStats,
13
+ } from "@checkstack/healthcheck-common";
5
14
 
6
15
  // ===== Percentile Calculation =====
7
16
 
@@ -138,7 +147,8 @@ export function aggregateCollectorData(
138
147
  const existing = aggregatedByUuid.get(uuid)?.aggregated;
139
148
 
140
149
  // Strip internal fields from collector data
141
- const { _collectorId, _assertionFailed, ...collectorMetadata } = data;
150
+ const { _collectorId, _assertionFailed, _assertions, ...collectorMetadata } =
151
+ data;
142
152
 
143
153
  // Call mergeResult to incrementally aggregate
144
154
  const merged = registered.collector.mergeResult(existing, {
@@ -166,6 +176,39 @@ export function aggregateCollectorData(
166
176
  return result;
167
177
  }
168
178
 
179
+ /**
180
+ * Fold the structured assertion outcomes of a bucket's raw runs into the
181
+ * per-assertion pass/fail stats (the on-read raw tier's counterpart of the
182
+ * realtime hourly fold). Returns undefined when no run carries outcomes.
183
+ */
184
+ export function foldRunAssertionStats(
185
+ runs: Array<{ metadata?: Record<string, unknown> }>,
186
+ ): BucketAssertionStats | undefined {
187
+ let stats: BucketAssertionStats | undefined;
188
+ for (const run of runs) {
189
+ const collectors = run.metadata?.collectors as
190
+ | Record<string, Record<string, unknown>>
191
+ | undefined;
192
+ if (!collectors) continue;
193
+ for (const [uuid, data] of Object.entries(collectors)) {
194
+ const rawOutcomes = data._assertions;
195
+ if (!Array.isArray(rawOutcomes) || rawOutcomes.length === 0) continue;
196
+ const outcomes: AssertionOutcome[] = [];
197
+ for (const raw of rawOutcomes) {
198
+ const parsed = AssertionOutcomeSchema.safeParse(raw);
199
+ if (parsed.success) outcomes.push(parsed.data);
200
+ }
201
+ if (outcomes.length === 0) continue;
202
+ stats = foldOutcomesIntoStats({
203
+ stats,
204
+ collectorEntryId: uuid,
205
+ outcomes,
206
+ });
207
+ }
208
+ }
209
+ return stats;
210
+ }
211
+
169
212
  // ===== Bucket Result Merging =====
170
213
 
171
214
  /**
@@ -201,15 +244,29 @@ export function mergeAggregatedBucketResults(params: {
201
244
  return validResults[0];
202
245
  }
203
246
 
247
+ // === Platform-level assertion stats (additive, never strategy-merged) ===
248
+ let mergedAssertionStats: BucketAssertionStats | undefined;
249
+ for (const result of validResults) {
250
+ mergedAssertionStats = mergeAssertionStats({
251
+ a: mergedAssertionStats,
252
+ b: readAssertionStats({ aggregatedResult: result }),
253
+ });
254
+ }
255
+
204
256
  // === Strategy-level field merging ===
205
257
  let mergedStrategyFields: Record<string, unknown> = {};
206
258
 
207
259
  const registeredStrategy = registry.getStrategy(strategyId);
208
260
  if (registeredStrategy?.aggregatedResult) {
209
- // Extract strategy-level fields (everything except 'collectors')
261
+ // Extract strategy-level fields (everything except the platform-owned
262
+ // 'collectors' and 'assertions' keys)
210
263
  const strategyDataSets: Array<Record<string, unknown>> = [];
211
264
  for (const result of validResults) {
212
- const { collectors: _collectors, ...strategyFields } = result;
265
+ const {
266
+ collectors: _collectors,
267
+ [ASSERTIONS_AGG_KEY]: _assertions,
268
+ ...strategyFields
269
+ } = result;
213
270
  if (Object.keys(strategyFields).length > 0) {
214
271
  strategyDataSets.push(strategyFields);
215
272
  }
@@ -233,7 +290,11 @@ export function mergeAggregatedBucketResults(params: {
233
290
  }
234
291
  } else {
235
292
  // Strategy not found - preserve strategy fields from first result
236
- const { collectors: _collectors, ...firstStrategyFields } = validResults[0];
293
+ const {
294
+ collectors: _collectors,
295
+ [ASSERTIONS_AGG_KEY]: _assertions,
296
+ ...firstStrategyFields
297
+ } = validResults[0];
237
298
  mergedStrategyFields = firstStrategyFields;
238
299
  }
239
300
 
@@ -302,17 +363,20 @@ export function mergeAggregatedBucketResults(params: {
302
363
  }
303
364
  }
304
365
 
305
- // Combine strategy fields and collector fields
366
+ // Combine strategy fields, collector fields, and assertion stats
306
367
  const hasCollectors = Object.keys(mergedCollectors).length > 0;
307
368
  const hasStrategyFields = Object.keys(mergedStrategyFields).length > 0;
308
369
 
309
- if (!hasCollectors && !hasStrategyFields) {
370
+ if (!hasCollectors && !hasStrategyFields && mergedAssertionStats === undefined) {
310
371
  return undefined;
311
372
  }
312
373
 
313
374
  return {
314
375
  ...mergedStrategyFields,
315
376
  ...(hasCollectors ? { collectors: mergedCollectors } : {}),
377
+ ...(mergedAssertionStats === undefined
378
+ ? {}
379
+ : { [ASSERTIONS_AGG_KEY]: mergedAssertionStats }),
316
380
  };
317
381
  }
318
382
 
@@ -1,6 +1,13 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import type { CollectorAssertion } from "@checkstack/healthcheck-common";
3
- import { evaluateCollectorAssertions } from "./collector-assertions";
3
+ import {
4
+ ASSERTION_ACTUAL_MAX_LENGTH,
5
+ computeAssertionKey,
6
+ } from "@checkstack/healthcheck-common";
7
+ import {
8
+ evaluateCollectorAssertionOutcomes,
9
+ evaluateCollectorAssertions,
10
+ } from "./collector-assertions";
4
11
 
5
12
  /** The HTTP Request collector's result shape, as the executor sees it. */
6
13
  const httpResult = (body: string): Record<string, unknown> => ({
@@ -274,3 +281,92 @@ describe("evaluateCollectorAssertions", () => {
274
281
  });
275
282
  });
276
283
  });
284
+
285
+ describe("evaluateCollectorAssertionOutcomes", () => {
286
+ it("evaluates ALL assertions instead of short-circuiting on failure", () => {
287
+ const result = httpResult('{"status":"degraded"}');
288
+ const { outcomes, firstFailureMessage } =
289
+ evaluateCollectorAssertionOutcomes({
290
+ assertions: [
291
+ { field: "statusCode", operator: "equals", value: 500 }, // fails
292
+ { field: "success", operator: "isTrue" }, // passes
293
+ jsonPathAssertion("$.status", "equals", "ok"), // fails
294
+ ],
295
+ result,
296
+ });
297
+ expect(outcomes.length).toBe(3);
298
+ expect(outcomes.map((o) => o.passed)).toEqual([false, true, false]);
299
+ // The legacy string still reports the FIRST failure only.
300
+ expect(firstFailureMessage).toBe("statusCode equals 500");
301
+ });
302
+
303
+ it("captures observed values for plain and JSONPath assertions", () => {
304
+ const result = httpResult('{"status":"ok","items":[1,2]}');
305
+ const { outcomes } = evaluateCollectorAssertionOutcomes({
306
+ assertions: [
307
+ { field: "statusCode", operator: "equals", value: 200 },
308
+ jsonPathAssertion("$.status", "equals", "ok"),
309
+ jsonPathAssertion("$.items", "lengthEquals", 2),
310
+ ],
311
+ result,
312
+ });
313
+ expect(outcomes[0].actual).toBe("200");
314
+ expect(outcomes[1].actual).toBe("ok");
315
+ expect(outcomes[2].actual).toBe("[1,2]");
316
+ expect(outcomes.every((o) => o.passed)).toBe(true);
317
+ });
318
+
319
+ it("truncates long observed values", () => {
320
+ const longBody = JSON.stringify({ blob: "x".repeat(5000) });
321
+ const { outcomes } = evaluateCollectorAssertionOutcomes({
322
+ assertions: [
323
+ { field: "body", operator: "contains", value: "x" },
324
+ ],
325
+ result: httpResult(longBody),
326
+ });
327
+ expect(outcomes[0].passed).toBe(true);
328
+ expect(outcomes[0].actual?.length).toBe(ASSERTION_ACTUAL_MAX_LENGTH);
329
+ });
330
+
331
+ it("fail-closed diagnostics become failed outcomes with messages", () => {
332
+ const { outcomes, firstFailureMessage } =
333
+ evaluateCollectorAssertionOutcomes({
334
+ assertions: [
335
+ { field: "body.$", jsonPath: " ", operator: "exists" },
336
+ jsonPathAssertion("$.status", "equals", "ok"),
337
+ ],
338
+ result: httpResult("not json"),
339
+ });
340
+ expect(outcomes[0].passed).toBe(false);
341
+ expect(outcomes[0].message).toBe("missing JSONPath expression");
342
+ expect(outcomes[1].passed).toBe(false);
343
+ expect(outcomes[1].message).toBe('field "body" is not valid JSON');
344
+ expect(firstFailureMessage).toBe(
345
+ "body.$ exists (missing JSONPath expression)",
346
+ );
347
+ });
348
+
349
+ it("outcomes carry the canonical assertion identity key", () => {
350
+ const assertion: CollectorAssertion = {
351
+ field: "statusCode",
352
+ operator: "equals",
353
+ value: 200,
354
+ };
355
+ const { outcomes } = evaluateCollectorAssertionOutcomes({
356
+ assertions: [assertion],
357
+ result: httpResult("{}"),
358
+ });
359
+ expect(outcomes[0].key).toBe(computeAssertionKey({ assertion }));
360
+ expect(outcomes[0].value).toBe("200");
361
+ });
362
+
363
+ it("no assertions yields an empty evaluation", () => {
364
+ const { outcomes, firstFailureMessage } =
365
+ evaluateCollectorAssertionOutcomes({
366
+ assertions: undefined,
367
+ result: httpResult("{}"),
368
+ });
369
+ expect(outcomes).toEqual([]);
370
+ expect(firstFailureMessage).toBeUndefined();
371
+ });
372
+ });