@riddledc/riddle-proof 0.7.126 → 0.7.127

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
@@ -592,13 +592,35 @@ JSON, YAML, robots, sitemap, or other machine-readable endpoint:
592
592
  }
593
593
  ```
594
594
 
595
+ For JSON responses, prefer `body_json_assertions` when the durable contract is
596
+ a field value rather than a raw substring:
597
+
598
+ ```json
599
+ {
600
+ "type": "http_status",
601
+ "label": "proof artifact",
602
+ "url": "/proof/good-catches/artifacts/job_1234/proof.json.json",
603
+ "expected_status": 200,
604
+ "allowed_content_types": ["application/json"],
605
+ "body_json_assertions": [
606
+ { "path": "status", "equals": "passed" },
607
+ { "path": "checks[0].status", "equals": "passed" },
608
+ { "path": "environment_blocker", "exists": false }
609
+ ]
610
+ }
611
+ ```
612
+
613
+ JSON paths support dot keys and array indexes such as `checks[0].status`, with
614
+ `$` as the root. Each assertion supports `exists`, `equals`, `not_equals`,
615
+ `contains`, and `type`.
616
+
595
617
  `body_contains`, `body_patterns`, `body_not_contains`, and
596
618
  `body_not_patterns` match the raw HTTP response body, not rendered browser
597
619
  text. Use `text_visible` or `selector_text_visible` when CSS transforms,
598
620
  hydration, client rendering, hidden elements, or layout-specific copy should be
599
621
  judged exactly as the browser exposes it to users.
600
- Hosted `summary.md` includes `http_status` body assertion pass counts so a
601
- reviewer can see raw body proof coverage without opening `proof.json`.
622
+ Hosted `summary.md` includes `http_status` body and JSON assertion pass counts
623
+ so a reviewer can see raw response proof coverage without opening `proof.json`.
602
624
 
603
625
  When the profile target is a mounted Riddle static Preview such as
604
626
  `https://preview.riddledc.com/s/ps_1234abcd/docs/`, root-relative
@@ -139,6 +139,9 @@ function valueFromOwn(input, ...keys) {
139
139
  function numberValue(value) {
140
140
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
141
141
  }
142
+ function booleanValue(value) {
143
+ return typeof value === "boolean" ? value : void 0;
144
+ }
142
145
  function horizontalBoundsOverflowPx(value) {
143
146
  if (!isRecord(value)) return 0;
144
147
  let max = maxPositiveNumber(
@@ -233,6 +236,156 @@ function toJsonValue(value) {
233
236
  if (isRecord(value)) return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, toJsonValue(child)]));
234
237
  return String(value);
235
238
  }
239
+ function jsonValueType(value) {
240
+ if (value === null) return "null";
241
+ if (Array.isArray(value)) return "array";
242
+ if (typeof value === "boolean") return "boolean";
243
+ if (typeof value === "number") return "number";
244
+ if (typeof value === "string") return "string";
245
+ return "object";
246
+ }
247
+ function deepJsonEqual(left, right) {
248
+ if (left === right) return true;
249
+ if (typeof left !== typeof right) return false;
250
+ if (left === null || right === null) return left === right;
251
+ if (typeof left !== "object" || typeof right !== "object") return false;
252
+ if (Array.isArray(left) || Array.isArray(right)) {
253
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
254
+ return left.every((item, index) => deepJsonEqual(item, right[index]));
255
+ }
256
+ if (!isRecord(left) || !isRecord(right)) return false;
257
+ const leftKeys = Object.keys(left).sort();
258
+ const rightKeys = Object.keys(right).sort();
259
+ if (!deepJsonEqual(leftKeys, rightKeys)) return false;
260
+ return leftKeys.every((key) => deepJsonEqual(left[key], right[key]));
261
+ }
262
+ function jsonContains(observed, expected) {
263
+ if (typeof observed === "string" && typeof expected === "string") {
264
+ return observed.includes(expected);
265
+ }
266
+ if (Array.isArray(observed)) {
267
+ return observed.some((item) => deepJsonEqual(item, expected));
268
+ }
269
+ if (isRecord(observed) && isRecord(expected)) {
270
+ return Object.entries(expected).every(([key, value]) => hasOwn(observed, key) && deepJsonEqual(observed[key], value));
271
+ }
272
+ return false;
273
+ }
274
+ function parseJsonPathSegments(path) {
275
+ let input = path.trim();
276
+ if (!input) throw new Error("path is empty");
277
+ if (input === "$") return [];
278
+ if (input.startsWith("$.")) input = input.slice(2);
279
+ else if (input.startsWith("$[")) input = input.slice(1);
280
+ const segments = [];
281
+ let token = "";
282
+ const pushToken = () => {
283
+ if (!token) return;
284
+ segments.push(token);
285
+ token = "";
286
+ };
287
+ for (let index = 0; index < input.length; index += 1) {
288
+ const char = input[index];
289
+ if (char === ".") {
290
+ pushToken();
291
+ continue;
292
+ }
293
+ if (char !== "[") {
294
+ token += char;
295
+ continue;
296
+ }
297
+ pushToken();
298
+ const closeIndex = input.indexOf("]", index + 1);
299
+ if (closeIndex === -1) throw new Error(`unterminated bracket at ${index}`);
300
+ const bracket = input.slice(index + 1, closeIndex).trim();
301
+ if (!bracket) throw new Error(`empty bracket at ${index}`);
302
+ if (/^\d+$/.test(bracket)) {
303
+ segments.push(Number(bracket));
304
+ } else if (bracket.startsWith('"') && bracket.endsWith('"') || bracket.startsWith("'") && bracket.endsWith("'")) {
305
+ const quoted = bracket.startsWith("'") ? `"${bracket.slice(1, -1).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : bracket;
306
+ segments.push(String(JSON.parse(quoted)));
307
+ } else {
308
+ segments.push(bracket);
309
+ }
310
+ index = closeIndex;
311
+ }
312
+ pushToken();
313
+ return segments;
314
+ }
315
+ function resolveJsonPath(root, path) {
316
+ let segments;
317
+ try {
318
+ segments = parseJsonPathSegments(path);
319
+ } catch (error) {
320
+ return { exists: false, error: String(error instanceof Error ? error.message : error) };
321
+ }
322
+ let current = root;
323
+ for (const segment of segments) {
324
+ if (typeof segment === "number") {
325
+ if (!Array.isArray(current) || segment < 0 || segment >= current.length) return { exists: false };
326
+ current = current[segment];
327
+ continue;
328
+ }
329
+ if (!isRecord(current) || !hasOwn(current, segment)) return { exists: false };
330
+ current = current[segment];
331
+ }
332
+ return { exists: true, value: current };
333
+ }
334
+ function evaluateHttpStatusBodyJsonAssertion(root, assertion) {
335
+ const resolved = resolveJsonPath(root, assertion.path);
336
+ const errors = [];
337
+ const result = {
338
+ label: assertion.label || assertion.path,
339
+ path: assertion.path,
340
+ ok: true,
341
+ exists: resolved.exists,
342
+ observed_type: resolved.exists ? jsonValueType(resolved.value) : "missing"
343
+ };
344
+ if (resolved.exists) result.observed = toJsonValue(resolved.value);
345
+ if (resolved.error) errors.push(resolved.error);
346
+ if (hasOwn(assertion, "exists")) {
347
+ result.expected_exists = assertion.exists;
348
+ if (resolved.exists !== assertion.exists) errors.push(`expected exists=${assertion.exists}`);
349
+ }
350
+ if (hasOwn(assertion, "type")) {
351
+ result.type = assertion.type;
352
+ if (!resolved.exists || jsonValueType(resolved.value) !== assertion.type) errors.push(`expected type ${assertion.type}`);
353
+ }
354
+ if (hasOwn(assertion, "equals")) {
355
+ result.equals = assertion.equals;
356
+ if (!resolved.exists || !deepJsonEqual(resolved.value, assertion.equals)) errors.push("expected JSON value equality");
357
+ }
358
+ if (hasOwn(assertion, "not_equals")) {
359
+ result.not_equals = assertion.not_equals;
360
+ if (resolved.exists && deepJsonEqual(resolved.value, assertion.not_equals)) errors.push("expected JSON value inequality");
361
+ }
362
+ if (hasOwn(assertion, "contains")) {
363
+ result.contains = assertion.contains;
364
+ if (!resolved.exists || !jsonContains(resolved.value, assertion.contains)) errors.push("expected JSON value containment");
365
+ }
366
+ result.ok = errors.length === 0;
367
+ if (errors.length) result.errors = errors;
368
+ return result;
369
+ }
370
+ function evaluateHttpStatusBodyJsonAssertions(bodyText, assertions) {
371
+ const expected = assertions?.filter((assertion) => assertion.path) ?? [];
372
+ if (!expected.length) return [];
373
+ let parsed;
374
+ try {
375
+ parsed = JSON.parse(bodyText);
376
+ } catch (error) {
377
+ const message = `response body is not valid JSON: ${String(error instanceof Error ? error.message : error).slice(0, 200)}`;
378
+ return expected.map((assertion) => ({
379
+ label: assertion.label || assertion.path,
380
+ path: assertion.path,
381
+ ok: false,
382
+ exists: false,
383
+ observed_type: "missing",
384
+ errors: [message]
385
+ }));
386
+ }
387
+ return expected.map((assertion) => evaluateHttpStatusBodyJsonAssertion(parsed, assertion));
388
+ }
236
389
  function compactProfileSetupSummaryText(value, limit = 160) {
237
390
  const text = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
238
391
  if (!text) return void 0;
@@ -887,6 +1040,46 @@ function validateRegexPatterns(patterns, label) {
887
1040
  }
888
1041
  }
889
1042
  }
1043
+ function normalizeHttpStatusBodyJsonAssertions(value, label) {
1044
+ if (value === void 0) return void 0;
1045
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
1046
+ if (!value.length) throw new Error(`${label} must not be empty.`);
1047
+ return value.map((item, index) => {
1048
+ const itemLabel = `${label}[${index}]`;
1049
+ if (typeof item === "string") {
1050
+ const path2 = stringValue(item);
1051
+ if (!path2) throw new Error(`${itemLabel} path must not be empty.`);
1052
+ return { path: path2, exists: true };
1053
+ }
1054
+ if (!isRecord(item)) throw new Error(`${itemLabel} must be an object or JSON path string.`);
1055
+ const path = stringFromOwn(item, "path", "json_path", "jsonPath", "key");
1056
+ if (!path) throw new Error(`${itemLabel}.path is required.`);
1057
+ const assertion = {
1058
+ label: stringValue(item.label),
1059
+ path
1060
+ };
1061
+ const exists = booleanValue(valueFromOwn(item, "exists", "present"));
1062
+ if (exists !== void 0) assertion.exists = exists;
1063
+ const type = stringValue(valueFromOwn(item, "type", "value_type", "valueType"));
1064
+ if (type !== void 0) {
1065
+ const allowedTypes = ["array", "boolean", "null", "number", "object", "string"];
1066
+ if (!allowedTypes.includes(type)) {
1067
+ throw new Error(`${itemLabel}.type must be one of ${allowedTypes.join(", ")}.`);
1068
+ }
1069
+ assertion.type = type;
1070
+ }
1071
+ const equalsValue = valueFromOwn(item, "equals", "expected", "expected_value", "expectedValue", "value");
1072
+ if (equalsValue !== void 0) assertion.equals = toJsonValue(equalsValue);
1073
+ const notEqualsValue = valueFromOwn(item, "not_equals", "notEquals", "forbidden", "forbidden_value", "forbiddenValue");
1074
+ if (notEqualsValue !== void 0) assertion.not_equals = toJsonValue(notEqualsValue);
1075
+ const containsValue = valueFromOwn(item, "contains", "includes", "contains_value", "containsValue", "include");
1076
+ if (containsValue !== void 0) assertion.contains = toJsonValue(containsValue);
1077
+ if (assertion.exists === void 0 && assertion.type === void 0 && !hasOwn(assertion, "equals") && !hasOwn(assertion, "not_equals") && !hasOwn(assertion, "contains")) {
1078
+ assertion.exists = true;
1079
+ }
1080
+ return assertion;
1081
+ });
1082
+ }
890
1083
  function isDialogCountCheckType(type) {
891
1084
  return type === "dialog_count_equals" || type === "dialog_accept_count_equals" || type === "dialog_dismiss_count_equals";
892
1085
  }
@@ -986,6 +1179,10 @@ function normalizeCheck(input, index) {
986
1179
  `checks[${index}] body_not_patterns`
987
1180
  ) : void 0;
988
1181
  if (bodyNotPatterns?.length) validateRegexPatterns(bodyNotPatterns, `checks[${index}] body_not_patterns`);
1182
+ const bodyJsonAssertions = isHttpStatusCheck ? normalizeHttpStatusBodyJsonAssertions(
1183
+ input.body_json_assertions ?? input.bodyJsonAssertions ?? input.json_body_assertions ?? input.jsonBodyAssertions ?? input.json_assertions ?? input.jsonAssertions ?? input.response_json_assertions ?? input.responseJsonAssertions,
1184
+ `checks[${index}] body_json_assertions`
1185
+ ) : void 0;
989
1186
  if (isLinkStatusCheck) {
990
1187
  if (minCount !== void 0 && (!Number.isInteger(minCount) || minCount < 0)) {
991
1188
  throw new Error(`checks[${index}] ${type} min_count must be a non-negative integer.`);
@@ -1011,6 +1208,7 @@ function normalizeCheck(input, index) {
1011
1208
  body_contains: bodyContains,
1012
1209
  body_not_contains: bodyNotContains,
1013
1210
  body_not_patterns: bodyNotPatterns,
1211
+ body_json_assertions: bodyJsonAssertions,
1014
1212
  expected_texts: expectedTexts,
1015
1213
  link_selector: stringValue(input.link_selector) || stringValue(input.linkSelector),
1016
1214
  source_selector: stringValue(input.source_selector) || stringValue(input.sourceSelector),
@@ -1217,6 +1415,34 @@ function httpStatusBodyNotPatternFailures(result, check) {
1217
1415
  const observed = isRecord(result.body_not_patterns) ? result.body_not_patterns : {};
1218
1416
  return forbidden.filter((pattern) => observed[pattern] !== false);
1219
1417
  }
1418
+ function httpStatusBodyJsonAssertionFailures(result, check) {
1419
+ const expected = check.body_json_assertions?.filter((assertion) => assertion.path) ?? [];
1420
+ if (!expected.length) return [];
1421
+ if (!Array.isArray(result.body_json_assertions)) {
1422
+ return expected.map((assertion) => ({
1423
+ label: assertion.label || assertion.path,
1424
+ path: assertion.path,
1425
+ ok: false,
1426
+ exists: false,
1427
+ observed_type: "missing",
1428
+ errors: ["body_json_assertions evidence missing"]
1429
+ }));
1430
+ }
1431
+ return result.body_json_assertions.filter((assertion) => isRecord(assertion) && assertion.ok !== true).map((assertion) => ({
1432
+ label: stringValue(assertion.label) || stringValue(assertion.path) || "json assertion",
1433
+ path: stringValue(assertion.path) || "",
1434
+ ok: false,
1435
+ exists: assertion.exists === true,
1436
+ observed: hasOwn(assertion, "observed") ? toJsonValue(assertion.observed) : void 0,
1437
+ observed_type: stringValue(assertion.observed_type) || "missing",
1438
+ expected_exists: booleanValue(assertion.expected_exists),
1439
+ equals: hasOwn(assertion, "equals") ? toJsonValue(assertion.equals) : void 0,
1440
+ not_equals: hasOwn(assertion, "not_equals") ? toJsonValue(assertion.not_equals) : void 0,
1441
+ contains: hasOwn(assertion, "contains") ? toJsonValue(assertion.contains) : void 0,
1442
+ type: stringValue(assertion.type),
1443
+ errors: Array.isArray(assertion.errors) ? assertion.errors.map(String) : void 0
1444
+ }));
1445
+ }
1220
1446
  function linkStatusResultOk(result, check) {
1221
1447
  const status = numberValue(result.status);
1222
1448
  if (!httpStatusIsAllowed(status, check)) return false;
@@ -1234,6 +1460,7 @@ function linkStatusResultOk(result, check) {
1234
1460
  if (httpStatusBodyContainsFailures(result, check).length) return false;
1235
1461
  if (httpStatusBodyNotContainsFailures(result, check).length) return false;
1236
1462
  if (httpStatusBodyNotPatternFailures(result, check).length) return false;
1463
+ if (httpStatusBodyJsonAssertionFailures(result, check).length) return false;
1237
1464
  return true;
1238
1465
  }
1239
1466
  function responseHeader(response, name) {
@@ -1302,7 +1529,7 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1302
1529
  statusText = typeof response.statusText === "string" ? response.statusText : "";
1303
1530
  result.content_type = responseHeader(response, "content-type");
1304
1531
  result.content_length = responseContentLength(response);
1305
- const shouldReadBody = check.require_nonzero_bytes === true || typeof check.min_bytes === "number" || Boolean(check.body_contains?.length) || Boolean(check.body_not_contains?.length) || Boolean(check.body_not_patterns?.length);
1532
+ const shouldReadBody = check.require_nonzero_bytes === true || typeof check.min_bytes === "number" || Boolean(check.body_contains?.length) || Boolean(check.body_not_contains?.length) || Boolean(check.body_not_patterns?.length) || Boolean(check.body_json_assertions?.length);
1306
1533
  if (shouldReadBody && method !== "HEAD") {
1307
1534
  const body = await responseBodyText(response);
1308
1535
  result.bytes = body.bytes;
@@ -1315,6 +1542,9 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1315
1542
  if (check.body_not_patterns?.length) {
1316
1543
  result.body_not_patterns = Object.fromEntries(check.body_not_patterns.filter(Boolean).map((pattern) => [pattern, new RegExp(pattern).test(body.text)]));
1317
1544
  }
1545
+ if (check.body_json_assertions?.length) {
1546
+ result.body_json_assertions = evaluateHttpStatusBodyJsonAssertions(body.text, check.body_json_assertions);
1547
+ }
1318
1548
  }
1319
1549
  } catch (caught) {
1320
1550
  error = String(caught instanceof Error ? caught.message : caught).slice(0, 500);
@@ -1323,6 +1553,7 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1323
1553
  const bodyContainsMissing = httpStatusBodyContainsFailures(result, check);
1324
1554
  const bodyNotContainsFound = httpStatusBodyNotContainsFailures(result, check);
1325
1555
  const bodyNotPatternsFound = httpStatusBodyNotPatternFailures(result, check);
1556
+ const bodyJsonAssertionsFailed = httpStatusBodyJsonAssertionFailures(result, check);
1326
1557
  const ok = !error && linkStatusResultOk(result, check);
1327
1558
  return {
1328
1559
  index,
@@ -1341,7 +1572,9 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1341
1572
  body_not_contains: isRecord(result.body_not_contains) ? Object.fromEntries(Object.entries(result.body_not_contains).map(([key, value]) => [key, value === true])) : null,
1342
1573
  body_not_contains_found: bodyNotContainsFound,
1343
1574
  body_not_patterns: isRecord(result.body_not_patterns) ? Object.fromEntries(Object.entries(result.body_not_patterns).map(([key, value]) => [key, value === true])) : null,
1344
- body_not_patterns_found: bodyNotPatternsFound
1575
+ body_not_patterns_found: bodyNotPatternsFound,
1576
+ body_json_assertions: Array.isArray(result.body_json_assertions) ? result.body_json_assertions : null,
1577
+ body_json_assertions_failed: bodyJsonAssertionsFailed
1345
1578
  };
1346
1579
  }
1347
1580
  async function preflightRiddleProofProfileHttpStatusChecks(profile, options = {}) {
@@ -1386,6 +1619,7 @@ function summarizeHttpStatusEvidence(viewport, check) {
1386
1619
  const bodyContainsMissing = httpStatusBodyContainsFailures(statusEvidence, check);
1387
1620
  const bodyNotContainsFound = httpStatusBodyNotContainsFailures(statusEvidence, check);
1388
1621
  const bodyNotPatternsFound = httpStatusBodyNotPatternFailures(statusEvidence, check);
1622
+ const bodyJsonAssertionsFailed = httpStatusBodyJsonAssertionFailures(statusEvidence, check);
1389
1623
  if (!linkStatusResultOk(statusEvidence, check)) {
1390
1624
  failures.push({
1391
1625
  code: "http_status_failed",
@@ -1404,6 +1638,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
1404
1638
  body_not_contains_found: bodyNotContainsFound,
1405
1639
  body_not_patterns: check.body_not_patterns ?? null,
1406
1640
  body_not_patterns_found: bodyNotPatternsFound,
1641
+ body_json_assertions: check.body_json_assertions ?? null,
1642
+ body_json_assertions_failed: bodyJsonAssertionsFailed.map((assertion) => toJsonValue(assertion)),
1407
1643
  body_sample: stringValue(statusEvidence.body_sample) ?? null
1408
1644
  });
1409
1645
  }
@@ -1425,6 +1661,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
1425
1661
  body_not_contains_found: bodyNotContainsFound,
1426
1662
  body_not_patterns: isRecord(statusEvidence.body_not_patterns) ? toJsonValue(statusEvidence.body_not_patterns) : null,
1427
1663
  body_not_patterns_found: bodyNotPatternsFound,
1664
+ body_json_assertions: Array.isArray(statusEvidence.body_json_assertions) ? toJsonValue(statusEvidence.body_json_assertions) : null,
1665
+ body_json_assertions_failed: bodyJsonAssertionsFailed.map((assertion) => toJsonValue(assertion)),
1428
1666
  body_sample: stringValue(statusEvidence.body_sample) ?? null,
1429
1667
  failures
1430
1668
  };
@@ -2061,6 +2299,7 @@ function assessCheckFromEvidence(check, evidence) {
2061
2299
  body_contains: check.body_contains ?? [],
2062
2300
  body_not_contains: check.body_not_contains ?? [],
2063
2301
  body_not_patterns: check.body_not_patterns ?? [],
2302
+ body_json_assertions: toJsonValue(check.body_json_assertions ?? []),
2064
2303
  viewports: summaries.map((summary) => toJsonValue(summary)),
2065
2304
  failures: failed.flatMap((summary) => Array.isArray(summary.failures) ? summary.failures.map((failure) => toJsonValue({ viewport: stringValue(summary.viewport) ?? null, failure })) : [])
2066
2305
  },
@@ -2804,6 +3043,36 @@ function httpStatusBodyNotPatternFailures(result, check) {
2804
3043
  : {};
2805
3044
  return forbidden.filter((pattern) => observed[pattern] !== false);
2806
3045
  }
3046
+ function httpStatusBodyJsonAssertionFailures(result, check) {
3047
+ const expected = Array.isArray(check.body_json_assertions) ? check.body_json_assertions.filter((assertion) => assertion && assertion.path) : [];
3048
+ if (!expected.length) return [];
3049
+ if (!Array.isArray(result.body_json_assertions)) {
3050
+ return expected.map((assertion) => ({
3051
+ label: assertion.label || assertion.path,
3052
+ path: assertion.path,
3053
+ ok: false,
3054
+ exists: false,
3055
+ observed_type: "missing",
3056
+ errors: ["body_json_assertions evidence missing"],
3057
+ }));
3058
+ }
3059
+ return result.body_json_assertions
3060
+ .filter((assertion) => assertion && typeof assertion === "object" && assertion.ok !== true)
3061
+ .map((assertion) => ({
3062
+ label: typeof assertion.label === "string" && assertion.label ? assertion.label : typeof assertion.path === "string" && assertion.path ? assertion.path : "json assertion",
3063
+ path: typeof assertion.path === "string" ? assertion.path : "",
3064
+ ok: false,
3065
+ exists: assertion.exists === true,
3066
+ observed: Object.hasOwn(assertion, "observed") ? assertion.observed : undefined,
3067
+ observed_type: typeof assertion.observed_type === "string" && assertion.observed_type ? assertion.observed_type : "missing",
3068
+ expected_exists: typeof assertion.expected_exists === "boolean" ? assertion.expected_exists : undefined,
3069
+ equals: Object.hasOwn(assertion, "equals") ? assertion.equals : undefined,
3070
+ not_equals: Object.hasOwn(assertion, "not_equals") ? assertion.not_equals : undefined,
3071
+ contains: Object.hasOwn(assertion, "contains") ? assertion.contains : undefined,
3072
+ type: typeof assertion.type === "string" ? assertion.type : undefined,
3073
+ errors: Array.isArray(assertion.errors) ? assertion.errors.map(String) : undefined,
3074
+ }));
3075
+ }
2807
3076
  function linkStatusResultOk(result, check) {
2808
3077
  if (!result || typeof result !== "object" || Array.isArray(result)) return false;
2809
3078
  if (!httpStatusIsAllowed(result.status, check)) return false;
@@ -2821,6 +3090,7 @@ function linkStatusResultOk(result, check) {
2821
3090
  if (httpStatusBodyContainsFailures(result, check).length) return false;
2822
3091
  if (httpStatusBodyNotContainsFailures(result, check).length) return false;
2823
3092
  if (httpStatusBodyNotPatternFailures(result, check).length) return false;
3093
+ if (httpStatusBodyJsonAssertionFailures(result, check).length) return false;
2824
3094
  return true;
2825
3095
  }
2826
3096
  function summarizeHttpStatusEvidence(viewport, check) {
@@ -2841,6 +3111,7 @@ function summarizeHttpStatusEvidence(viewport, check) {
2841
3111
  const bodyContainsMissing = httpStatusBodyContainsFailures(statusEvidence, check);
2842
3112
  const bodyNotContainsFound = httpStatusBodyNotContainsFailures(statusEvidence, check);
2843
3113
  const bodyNotPatternsFound = httpStatusBodyNotPatternFailures(statusEvidence, check);
3114
+ const bodyJsonAssertionsFailed = httpStatusBodyJsonAssertionFailures(statusEvidence, check);
2844
3115
  if (!linkStatusResultOk(statusEvidence, check)) {
2845
3116
  failures.push({
2846
3117
  code: "http_status_failed",
@@ -2859,6 +3130,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
2859
3130
  body_not_contains_found: bodyNotContainsFound,
2860
3131
  body_not_patterns: Array.isArray(check.body_not_patterns) ? check.body_not_patterns : null,
2861
3132
  body_not_patterns_found: bodyNotPatternsFound,
3133
+ body_json_assertions: Array.isArray(check.body_json_assertions) ? check.body_json_assertions : null,
3134
+ body_json_assertions_failed: bodyJsonAssertionsFailed,
2862
3135
  body_sample: typeof statusEvidence.body_sample === "string" ? statusEvidence.body_sample : null,
2863
3136
  });
2864
3137
  }
@@ -2886,6 +3159,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
2886
3159
  ? statusEvidence.body_not_patterns
2887
3160
  : null,
2888
3161
  body_not_patterns_found: bodyNotPatternsFound,
3162
+ body_json_assertions: Array.isArray(statusEvidence.body_json_assertions) ? statusEvidence.body_json_assertions : null,
3163
+ body_json_assertions_failed: bodyJsonAssertionsFailed,
2889
3164
  body_sample: typeof statusEvidence.body_sample === "string" ? statusEvidence.body_sample : null,
2890
3165
  failures,
2891
3166
  };
@@ -4988,6 +5263,155 @@ function linkProbeResponseFields(response, method) {
4988
5263
  content_length: contentLength,
4989
5264
  };
4990
5265
  }
5266
+ function jsonProbeValueType(value) {
5267
+ if (value === null) return "null";
5268
+ if (Array.isArray(value)) return "array";
5269
+ if (typeof value === "boolean") return "boolean";
5270
+ if (typeof value === "number") return "number";
5271
+ if (typeof value === "string") return "string";
5272
+ return "object";
5273
+ }
5274
+ function jsonProbeDeepEqual(left, right) {
5275
+ if (left === right) return true;
5276
+ if (typeof left !== typeof right) return false;
5277
+ if (left === null || right === null) return left === right;
5278
+ if (typeof left !== "object" || typeof right !== "object") return false;
5279
+ if (Array.isArray(left) || Array.isArray(right)) {
5280
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
5281
+ return left.every((item, index) => jsonProbeDeepEqual(item, right[index]));
5282
+ }
5283
+ const leftKeys = Object.keys(left).sort();
5284
+ const rightKeys = Object.keys(right).sort();
5285
+ if (!jsonProbeDeepEqual(leftKeys, rightKeys)) return false;
5286
+ return leftKeys.every((key) => jsonProbeDeepEqual(left[key], right[key]));
5287
+ }
5288
+ function jsonProbeContains(observed, expected) {
5289
+ if (typeof observed === "string" && typeof expected === "string") return observed.includes(expected);
5290
+ if (Array.isArray(observed)) return observed.some((item) => jsonProbeDeepEqual(item, expected));
5291
+ if (observed && expected && typeof observed === "object" && typeof expected === "object" && !Array.isArray(observed) && !Array.isArray(expected)) {
5292
+ return Object.entries(expected).every(([key, value]) => Object.hasOwn(observed, key) && jsonProbeDeepEqual(observed[key], value));
5293
+ }
5294
+ return false;
5295
+ }
5296
+ function parseJsonProbePathSegments(path) {
5297
+ let input = String(path || "").trim();
5298
+ if (!input) throw new Error("path is empty");
5299
+ if (input === "$") return [];
5300
+ if (input.startsWith("$.")) input = input.slice(2);
5301
+ else if (input.startsWith("$[")) input = input.slice(1);
5302
+ const segments = [];
5303
+ let token = "";
5304
+ const pushToken = () => {
5305
+ if (!token) return;
5306
+ segments.push(token);
5307
+ token = "";
5308
+ };
5309
+ for (let index = 0; index < input.length; index += 1) {
5310
+ const char = input[index];
5311
+ if (char === ".") {
5312
+ pushToken();
5313
+ continue;
5314
+ }
5315
+ if (char !== "[") {
5316
+ token += char;
5317
+ continue;
5318
+ }
5319
+ pushToken();
5320
+ const closeIndex = input.indexOf("]", index + 1);
5321
+ if (closeIndex === -1) throw new Error("unterminated bracket at " + index);
5322
+ const bracket = input.slice(index + 1, closeIndex).trim();
5323
+ if (!bracket) throw new Error("empty bracket at " + index);
5324
+ if (/^\d+$/.test(bracket)) {
5325
+ segments.push(Number(bracket));
5326
+ } else if ((bracket.startsWith('"') && bracket.endsWith('"')) || (bracket.startsWith("'") && bracket.endsWith("'"))) {
5327
+ const quoted = bracket.startsWith("'")
5328
+ ? '"' + bracket.slice(1, -1).replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + '"'
5329
+ : bracket;
5330
+ segments.push(String(JSON.parse(quoted)));
5331
+ } else {
5332
+ segments.push(bracket);
5333
+ }
5334
+ index = closeIndex;
5335
+ }
5336
+ pushToken();
5337
+ return segments;
5338
+ }
5339
+ function resolveJsonProbePath(root, path) {
5340
+ let segments;
5341
+ try {
5342
+ segments = parseJsonProbePathSegments(path);
5343
+ } catch (error) {
5344
+ return { exists: false, error: String(error && error.message ? error.message : error) };
5345
+ }
5346
+ let current = root;
5347
+ for (const segment of segments) {
5348
+ if (typeof segment === "number") {
5349
+ if (!Array.isArray(current) || segment < 0 || segment >= current.length) return { exists: false };
5350
+ current = current[segment];
5351
+ continue;
5352
+ }
5353
+ if (!current || typeof current !== "object" || Array.isArray(current) || !Object.hasOwn(current, segment)) {
5354
+ return { exists: false };
5355
+ }
5356
+ current = current[segment];
5357
+ }
5358
+ return { exists: true, value: current };
5359
+ }
5360
+ function evaluateJsonProbeAssertion(root, assertion) {
5361
+ const resolved = resolveJsonProbePath(root, assertion.path);
5362
+ const errors = [];
5363
+ const result = {
5364
+ label: assertion.label || assertion.path,
5365
+ path: assertion.path,
5366
+ ok: true,
5367
+ exists: resolved.exists,
5368
+ observed_type: resolved.exists ? jsonProbeValueType(resolved.value) : "missing",
5369
+ };
5370
+ if (resolved.exists) result.observed = resolved.value;
5371
+ if (resolved.error) errors.push(resolved.error);
5372
+ if (Object.hasOwn(assertion, "exists")) {
5373
+ result.expected_exists = assertion.exists;
5374
+ if (resolved.exists !== assertion.exists) errors.push("expected exists=" + assertion.exists);
5375
+ }
5376
+ if (Object.hasOwn(assertion, "type")) {
5377
+ result.type = assertion.type;
5378
+ if (!resolved.exists || jsonProbeValueType(resolved.value) !== assertion.type) errors.push("expected type " + assertion.type);
5379
+ }
5380
+ if (Object.hasOwn(assertion, "equals")) {
5381
+ result.equals = assertion.equals;
5382
+ if (!resolved.exists || !jsonProbeDeepEqual(resolved.value, assertion.equals)) errors.push("expected JSON value equality");
5383
+ }
5384
+ if (Object.hasOwn(assertion, "not_equals")) {
5385
+ result.not_equals = assertion.not_equals;
5386
+ if (resolved.exists && jsonProbeDeepEqual(resolved.value, assertion.not_equals)) errors.push("expected JSON value inequality");
5387
+ }
5388
+ if (Object.hasOwn(assertion, "contains")) {
5389
+ result.contains = assertion.contains;
5390
+ if (!resolved.exists || !jsonProbeContains(resolved.value, assertion.contains)) errors.push("expected JSON value containment");
5391
+ }
5392
+ result.ok = errors.length === 0;
5393
+ if (errors.length) result.errors = errors;
5394
+ return result;
5395
+ }
5396
+ function evaluateJsonProbeAssertions(text, assertions) {
5397
+ const expected = Array.isArray(assertions) ? assertions.filter((assertion) => assertion && assertion.path) : [];
5398
+ if (!expected.length) return [];
5399
+ let parsed;
5400
+ try {
5401
+ parsed = JSON.parse(text);
5402
+ } catch (error) {
5403
+ const message = "response body is not valid JSON: " + String(error && error.message ? error.message : error).slice(0, 200);
5404
+ return expected.map((assertion) => ({
5405
+ label: assertion.label || assertion.path,
5406
+ path: assertion.path,
5407
+ ok: false,
5408
+ exists: false,
5409
+ observed_type: "missing",
5410
+ errors: [message],
5411
+ }));
5412
+ }
5413
+ return expected.map((assertion) => evaluateJsonProbeAssertion(parsed, assertion));
5414
+ }
4991
5415
  async function collectHttpStatus(check) {
4992
5416
  const url = httpStatusRequestUrl(check, page.url() || targetUrl);
4993
5417
  const method = httpStatusMethod(check);
@@ -5004,6 +5428,7 @@ async function collectHttpStatus(check) {
5004
5428
  const bodyContains = Array.isArray(check.body_contains) ? check.body_contains.filter(Boolean) : [];
5005
5429
  const bodyNotContains = Array.isArray(check.body_not_contains) ? check.body_not_contains.filter(Boolean) : [];
5006
5430
  const bodyNotPatterns = Array.isArray(check.body_not_patterns) ? check.body_not_patterns.filter(Boolean) : [];
5431
+ const bodyJsonAssertions = Array.isArray(check.body_json_assertions) ? check.body_json_assertions.filter((assertion) => assertion && assertion.path) : [];
5007
5432
  const options = {
5008
5433
  method,
5009
5434
  redirect: "follow",
@@ -5029,17 +5454,18 @@ async function collectHttpStatus(check) {
5029
5454
  Object.assign(result, linkProbeResponseFields(response, method));
5030
5455
  result.url = url;
5031
5456
  result.status_text = response.statusText || "";
5032
- const shouldReadBody = check.require_nonzero_bytes === true || (typeof check.min_bytes === "number" && Number.isFinite(check.min_bytes)) || bodyContains.length > 0 || bodyNotContains.length > 0 || bodyNotPatterns.length > 0;
5457
+ const shouldReadBody = check.require_nonzero_bytes === true || (typeof check.min_bytes === "number" && Number.isFinite(check.min_bytes)) || bodyContains.length > 0 || bodyNotContains.length > 0 || bodyNotPatterns.length > 0 || bodyJsonAssertions.length > 0;
5033
5458
  if (shouldReadBody) {
5034
5459
  try {
5035
5460
  const buffer = await response.arrayBuffer();
5036
5461
  result.bytes = buffer.byteLength;
5037
- if (bodyContains.length || bodyNotContains.length || bodyNotPatterns.length) {
5462
+ if (bodyContains.length || bodyNotContains.length || bodyNotPatterns.length || bodyJsonAssertions.length) {
5038
5463
  const text = new TextDecoder().decode(buffer);
5039
5464
  result.body_sample = text.slice(0, 1000);
5040
5465
  if (bodyContains.length) result.body_contains = Object.fromEntries(bodyContains.map((expected) => [expected, text.includes(expected)]));
5041
5466
  if (bodyNotContains.length) result.body_not_contains = Object.fromEntries(bodyNotContains.map((forbidden) => [forbidden, text.includes(forbidden)]));
5042
5467
  if (bodyNotPatterns.length) result.body_not_patterns = Object.fromEntries(bodyNotPatterns.map((pattern) => [pattern, new RegExp(pattern).test(text)]));
5468
+ if (bodyJsonAssertions.length) result.body_json_assertions = evaluateJsonProbeAssertions(text, bodyJsonAssertions);
5043
5469
  }
5044
5470
  } catch (error) {
5045
5471
  result.error = String(error && error.message ? error.message : error).slice(0, 500);
@@ -5052,6 +5478,7 @@ async function collectHttpStatus(check) {
5052
5478
  && (!bodyContains.length || bodyContains.every((expected) => result.body_contains && result.body_contains[expected] === true))
5053
5479
  && (!bodyNotContains.length || bodyNotContains.every((forbidden) => result.body_not_contains && result.body_not_contains[forbidden] === false))
5054
5480
  && (!bodyNotPatterns.length || bodyNotPatterns.every((pattern) => result.body_not_patterns && result.body_not_patterns[pattern] === false))
5481
+ && (!bodyJsonAssertions.length || (Array.isArray(result.body_json_assertions) && result.body_json_assertions.every((assertion) => assertion.ok === true)))
5055
5482
  && !result.error;
5056
5483
  return result;
5057
5484
  } catch (error) {