@riddledc/riddle-proof 0.7.126 → 0.7.128

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.
@@ -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,187 @@ 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 compactJsonAssertionSample(value, depth = 0) {
248
+ if (typeof value === "string") return value.length > 240 ? `${value.slice(0, 237)}...` : value;
249
+ if (value === null || typeof value === "boolean" || typeof value === "number") return toJsonValue(value);
250
+ if (Array.isArray(value)) {
251
+ if (depth >= 2) return `[array:${value.length}]`;
252
+ return value.slice(0, 3).map((item) => compactJsonAssertionSample(item, depth + 1));
253
+ }
254
+ if (isRecord(value)) {
255
+ const entries = Object.entries(value).slice(0, 8);
256
+ if (depth >= 2) return `[object:${Object.keys(value).length} keys]`;
257
+ return Object.fromEntries(entries.map(([key, child]) => [key, compactJsonAssertionSample(child, depth + 1)]));
258
+ }
259
+ return String(value);
260
+ }
261
+ function attachJsonAssertionObservedValue(result, value) {
262
+ const type = jsonValueType(value);
263
+ if (type === "array" && Array.isArray(value)) {
264
+ result.observed_length = value.length;
265
+ result.observed_omitted_count = Math.max(0, value.length - 3);
266
+ result.observed_sample = compactJsonAssertionSample(value);
267
+ return;
268
+ }
269
+ if (type === "object" && isRecord(value)) {
270
+ const keyCount = Object.keys(value).length;
271
+ result.observed_key_count = keyCount;
272
+ result.observed_omitted_count = Math.max(0, keyCount - 8);
273
+ result.observed_sample = compactJsonAssertionSample(value);
274
+ return;
275
+ }
276
+ result.observed = toJsonValue(value);
277
+ }
278
+ function deepJsonEqual(left, right) {
279
+ if (left === right) return true;
280
+ if (typeof left !== typeof right) return false;
281
+ if (left === null || right === null) return left === right;
282
+ if (typeof left !== "object" || typeof right !== "object") return false;
283
+ if (Array.isArray(left) || Array.isArray(right)) {
284
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
285
+ return left.every((item, index) => deepJsonEqual(item, right[index]));
286
+ }
287
+ if (!isRecord(left) || !isRecord(right)) return false;
288
+ const leftKeys = Object.keys(left).sort();
289
+ const rightKeys = Object.keys(right).sort();
290
+ if (!deepJsonEqual(leftKeys, rightKeys)) return false;
291
+ return leftKeys.every((key) => deepJsonEqual(left[key], right[key]));
292
+ }
293
+ function jsonContains(observed, expected) {
294
+ if (typeof observed === "string" && typeof expected === "string") {
295
+ return observed.includes(expected);
296
+ }
297
+ if (Array.isArray(observed)) {
298
+ return observed.some((item) => deepJsonEqual(item, expected));
299
+ }
300
+ if (isRecord(observed) && isRecord(expected)) {
301
+ return Object.entries(expected).every(([key, value]) => hasOwn(observed, key) && deepJsonEqual(observed[key], value));
302
+ }
303
+ return false;
304
+ }
305
+ function parseJsonPathSegments(path) {
306
+ let input = path.trim();
307
+ if (!input) throw new Error("path is empty");
308
+ if (input === "$") return [];
309
+ if (input.startsWith("$.")) input = input.slice(2);
310
+ else if (input.startsWith("$[")) input = input.slice(1);
311
+ const segments = [];
312
+ let token = "";
313
+ const pushToken = () => {
314
+ if (!token) return;
315
+ segments.push(token);
316
+ token = "";
317
+ };
318
+ for (let index = 0; index < input.length; index += 1) {
319
+ const char = input[index];
320
+ if (char === ".") {
321
+ pushToken();
322
+ continue;
323
+ }
324
+ if (char !== "[") {
325
+ token += char;
326
+ continue;
327
+ }
328
+ pushToken();
329
+ const closeIndex = input.indexOf("]", index + 1);
330
+ if (closeIndex === -1) throw new Error(`unterminated bracket at ${index}`);
331
+ const bracket = input.slice(index + 1, closeIndex).trim();
332
+ if (!bracket) throw new Error(`empty bracket at ${index}`);
333
+ if (/^\d+$/.test(bracket)) {
334
+ segments.push(Number(bracket));
335
+ } else if (bracket.startsWith('"') && bracket.endsWith('"') || bracket.startsWith("'") && bracket.endsWith("'")) {
336
+ const quoted = bracket.startsWith("'") ? `"${bracket.slice(1, -1).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : bracket;
337
+ segments.push(String(JSON.parse(quoted)));
338
+ } else {
339
+ segments.push(bracket);
340
+ }
341
+ index = closeIndex;
342
+ }
343
+ pushToken();
344
+ return segments;
345
+ }
346
+ function resolveJsonPath(root, path) {
347
+ let segments;
348
+ try {
349
+ segments = parseJsonPathSegments(path);
350
+ } catch (error) {
351
+ return { exists: false, error: String(error instanceof Error ? error.message : error) };
352
+ }
353
+ let current = root;
354
+ for (const segment of segments) {
355
+ if (typeof segment === "number") {
356
+ if (!Array.isArray(current) || segment < 0 || segment >= current.length) return { exists: false };
357
+ current = current[segment];
358
+ continue;
359
+ }
360
+ if (!isRecord(current) || !hasOwn(current, segment)) return { exists: false };
361
+ current = current[segment];
362
+ }
363
+ return { exists: true, value: current };
364
+ }
365
+ function evaluateHttpStatusBodyJsonAssertion(root, assertion) {
366
+ const resolved = resolveJsonPath(root, assertion.path);
367
+ const errors = [];
368
+ const result = {
369
+ label: assertion.label || assertion.path,
370
+ path: assertion.path,
371
+ ok: true,
372
+ exists: resolved.exists,
373
+ observed_type: resolved.exists ? jsonValueType(resolved.value) : "missing"
374
+ };
375
+ if (resolved.exists) attachJsonAssertionObservedValue(result, resolved.value);
376
+ if (resolved.error) errors.push(resolved.error);
377
+ if (hasOwn(assertion, "exists")) {
378
+ result.expected_exists = assertion.exists;
379
+ if (resolved.exists !== assertion.exists) errors.push(`expected exists=${assertion.exists}`);
380
+ }
381
+ if (hasOwn(assertion, "type")) {
382
+ result.type = assertion.type;
383
+ if (!resolved.exists || jsonValueType(resolved.value) !== assertion.type) errors.push(`expected type ${assertion.type}`);
384
+ }
385
+ if (hasOwn(assertion, "equals")) {
386
+ result.equals = assertion.equals;
387
+ if (!resolved.exists || !deepJsonEqual(resolved.value, assertion.equals)) errors.push("expected JSON value equality");
388
+ }
389
+ if (hasOwn(assertion, "not_equals")) {
390
+ result.not_equals = assertion.not_equals;
391
+ if (resolved.exists && deepJsonEqual(resolved.value, assertion.not_equals)) errors.push("expected JSON value inequality");
392
+ }
393
+ if (hasOwn(assertion, "contains")) {
394
+ result.contains = assertion.contains;
395
+ if (!resolved.exists || !jsonContains(resolved.value, assertion.contains)) errors.push("expected JSON value containment");
396
+ }
397
+ result.ok = errors.length === 0;
398
+ if (errors.length) result.errors = errors;
399
+ return result;
400
+ }
401
+ function evaluateHttpStatusBodyJsonAssertions(bodyText, assertions) {
402
+ const expected = assertions?.filter((assertion) => assertion.path) ?? [];
403
+ if (!expected.length) return [];
404
+ let parsed;
405
+ try {
406
+ parsed = JSON.parse(bodyText);
407
+ } catch (error) {
408
+ const message = `response body is not valid JSON: ${String(error instanceof Error ? error.message : error).slice(0, 200)}`;
409
+ return expected.map((assertion) => ({
410
+ label: assertion.label || assertion.path,
411
+ path: assertion.path,
412
+ ok: false,
413
+ exists: false,
414
+ observed_type: "missing",
415
+ errors: [message]
416
+ }));
417
+ }
418
+ return expected.map((assertion) => evaluateHttpStatusBodyJsonAssertion(parsed, assertion));
419
+ }
236
420
  function compactProfileSetupSummaryText(value, limit = 160) {
237
421
  const text = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
238
422
  if (!text) return void 0;
@@ -887,6 +1071,46 @@ function validateRegexPatterns(patterns, label) {
887
1071
  }
888
1072
  }
889
1073
  }
1074
+ function normalizeHttpStatusBodyJsonAssertions(value, label) {
1075
+ if (value === void 0) return void 0;
1076
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
1077
+ if (!value.length) throw new Error(`${label} must not be empty.`);
1078
+ return value.map((item, index) => {
1079
+ const itemLabel = `${label}[${index}]`;
1080
+ if (typeof item === "string") {
1081
+ const path2 = stringValue(item);
1082
+ if (!path2) throw new Error(`${itemLabel} path must not be empty.`);
1083
+ return { path: path2, exists: true };
1084
+ }
1085
+ if (!isRecord(item)) throw new Error(`${itemLabel} must be an object or JSON path string.`);
1086
+ const path = stringFromOwn(item, "path", "json_path", "jsonPath", "key");
1087
+ if (!path) throw new Error(`${itemLabel}.path is required.`);
1088
+ const assertion = {
1089
+ label: stringValue(item.label),
1090
+ path
1091
+ };
1092
+ const exists = booleanValue(valueFromOwn(item, "exists", "present"));
1093
+ if (exists !== void 0) assertion.exists = exists;
1094
+ const type = stringValue(valueFromOwn(item, "type", "value_type", "valueType"));
1095
+ if (type !== void 0) {
1096
+ const allowedTypes = ["array", "boolean", "null", "number", "object", "string"];
1097
+ if (!allowedTypes.includes(type)) {
1098
+ throw new Error(`${itemLabel}.type must be one of ${allowedTypes.join(", ")}.`);
1099
+ }
1100
+ assertion.type = type;
1101
+ }
1102
+ const equalsValue = valueFromOwn(item, "equals", "expected", "expected_value", "expectedValue", "value");
1103
+ if (equalsValue !== void 0) assertion.equals = toJsonValue(equalsValue);
1104
+ const notEqualsValue = valueFromOwn(item, "not_equals", "notEquals", "forbidden", "forbidden_value", "forbiddenValue");
1105
+ if (notEqualsValue !== void 0) assertion.not_equals = toJsonValue(notEqualsValue);
1106
+ const containsValue = valueFromOwn(item, "contains", "includes", "contains_value", "containsValue", "include");
1107
+ if (containsValue !== void 0) assertion.contains = toJsonValue(containsValue);
1108
+ if (assertion.exists === void 0 && assertion.type === void 0 && !hasOwn(assertion, "equals") && !hasOwn(assertion, "not_equals") && !hasOwn(assertion, "contains")) {
1109
+ assertion.exists = true;
1110
+ }
1111
+ return assertion;
1112
+ });
1113
+ }
890
1114
  function isDialogCountCheckType(type) {
891
1115
  return type === "dialog_count_equals" || type === "dialog_accept_count_equals" || type === "dialog_dismiss_count_equals";
892
1116
  }
@@ -986,6 +1210,10 @@ function normalizeCheck(input, index) {
986
1210
  `checks[${index}] body_not_patterns`
987
1211
  ) : void 0;
988
1212
  if (bodyNotPatterns?.length) validateRegexPatterns(bodyNotPatterns, `checks[${index}] body_not_patterns`);
1213
+ const bodyJsonAssertions = isHttpStatusCheck ? normalizeHttpStatusBodyJsonAssertions(
1214
+ input.body_json_assertions ?? input.bodyJsonAssertions ?? input.json_body_assertions ?? input.jsonBodyAssertions ?? input.json_assertions ?? input.jsonAssertions ?? input.response_json_assertions ?? input.responseJsonAssertions,
1215
+ `checks[${index}] body_json_assertions`
1216
+ ) : void 0;
989
1217
  if (isLinkStatusCheck) {
990
1218
  if (minCount !== void 0 && (!Number.isInteger(minCount) || minCount < 0)) {
991
1219
  throw new Error(`checks[${index}] ${type} min_count must be a non-negative integer.`);
@@ -1011,6 +1239,7 @@ function normalizeCheck(input, index) {
1011
1239
  body_contains: bodyContains,
1012
1240
  body_not_contains: bodyNotContains,
1013
1241
  body_not_patterns: bodyNotPatterns,
1242
+ body_json_assertions: bodyJsonAssertions,
1014
1243
  expected_texts: expectedTexts,
1015
1244
  link_selector: stringValue(input.link_selector) || stringValue(input.linkSelector),
1016
1245
  source_selector: stringValue(input.source_selector) || stringValue(input.sourceSelector),
@@ -1217,6 +1446,38 @@ function httpStatusBodyNotPatternFailures(result, check) {
1217
1446
  const observed = isRecord(result.body_not_patterns) ? result.body_not_patterns : {};
1218
1447
  return forbidden.filter((pattern) => observed[pattern] !== false);
1219
1448
  }
1449
+ function httpStatusBodyJsonAssertionFailures(result, check) {
1450
+ const expected = check.body_json_assertions?.filter((assertion) => assertion.path) ?? [];
1451
+ if (!expected.length) return [];
1452
+ if (!Array.isArray(result.body_json_assertions)) {
1453
+ return expected.map((assertion) => ({
1454
+ label: assertion.label || assertion.path,
1455
+ path: assertion.path,
1456
+ ok: false,
1457
+ exists: false,
1458
+ observed_type: "missing",
1459
+ errors: ["body_json_assertions evidence missing"]
1460
+ }));
1461
+ }
1462
+ return result.body_json_assertions.filter((assertion) => isRecord(assertion) && assertion.ok !== true).map((assertion) => ({
1463
+ label: stringValue(assertion.label) || stringValue(assertion.path) || "json assertion",
1464
+ path: stringValue(assertion.path) || "",
1465
+ ok: false,
1466
+ exists: assertion.exists === true,
1467
+ observed: hasOwn(assertion, "observed") ? toJsonValue(assertion.observed) : void 0,
1468
+ observed_sample: hasOwn(assertion, "observed_sample") ? toJsonValue(assertion.observed_sample) : void 0,
1469
+ observed_length: numberValue(assertion.observed_length),
1470
+ observed_key_count: numberValue(assertion.observed_key_count),
1471
+ observed_omitted_count: numberValue(assertion.observed_omitted_count),
1472
+ observed_type: stringValue(assertion.observed_type) || "missing",
1473
+ expected_exists: booleanValue(assertion.expected_exists),
1474
+ equals: hasOwn(assertion, "equals") ? toJsonValue(assertion.equals) : void 0,
1475
+ not_equals: hasOwn(assertion, "not_equals") ? toJsonValue(assertion.not_equals) : void 0,
1476
+ contains: hasOwn(assertion, "contains") ? toJsonValue(assertion.contains) : void 0,
1477
+ type: stringValue(assertion.type),
1478
+ errors: Array.isArray(assertion.errors) ? assertion.errors.map(String) : void 0
1479
+ }));
1480
+ }
1220
1481
  function linkStatusResultOk(result, check) {
1221
1482
  const status = numberValue(result.status);
1222
1483
  if (!httpStatusIsAllowed(status, check)) return false;
@@ -1234,6 +1495,7 @@ function linkStatusResultOk(result, check) {
1234
1495
  if (httpStatusBodyContainsFailures(result, check).length) return false;
1235
1496
  if (httpStatusBodyNotContainsFailures(result, check).length) return false;
1236
1497
  if (httpStatusBodyNotPatternFailures(result, check).length) return false;
1498
+ if (httpStatusBodyJsonAssertionFailures(result, check).length) return false;
1237
1499
  return true;
1238
1500
  }
1239
1501
  function responseHeader(response, name) {
@@ -1302,7 +1564,7 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1302
1564
  statusText = typeof response.statusText === "string" ? response.statusText : "";
1303
1565
  result.content_type = responseHeader(response, "content-type");
1304
1566
  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);
1567
+ 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
1568
  if (shouldReadBody && method !== "HEAD") {
1307
1569
  const body = await responseBodyText(response);
1308
1570
  result.bytes = body.bytes;
@@ -1315,6 +1577,9 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1315
1577
  if (check.body_not_patterns?.length) {
1316
1578
  result.body_not_patterns = Object.fromEntries(check.body_not_patterns.filter(Boolean).map((pattern) => [pattern, new RegExp(pattern).test(body.text)]));
1317
1579
  }
1580
+ if (check.body_json_assertions?.length) {
1581
+ result.body_json_assertions = evaluateHttpStatusBodyJsonAssertions(body.text, check.body_json_assertions);
1582
+ }
1318
1583
  }
1319
1584
  } catch (caught) {
1320
1585
  error = String(caught instanceof Error ? caught.message : caught).slice(0, 500);
@@ -1323,6 +1588,7 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1323
1588
  const bodyContainsMissing = httpStatusBodyContainsFailures(result, check);
1324
1589
  const bodyNotContainsFound = httpStatusBodyNotContainsFailures(result, check);
1325
1590
  const bodyNotPatternsFound = httpStatusBodyNotPatternFailures(result, check);
1591
+ const bodyJsonAssertionsFailed = httpStatusBodyJsonAssertionFailures(result, check);
1326
1592
  const ok = !error && linkStatusResultOk(result, check);
1327
1593
  return {
1328
1594
  index,
@@ -1341,7 +1607,9 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1341
1607
  body_not_contains: isRecord(result.body_not_contains) ? Object.fromEntries(Object.entries(result.body_not_contains).map(([key, value]) => [key, value === true])) : null,
1342
1608
  body_not_contains_found: bodyNotContainsFound,
1343
1609
  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
1610
+ body_not_patterns_found: bodyNotPatternsFound,
1611
+ body_json_assertions: Array.isArray(result.body_json_assertions) ? result.body_json_assertions : null,
1612
+ body_json_assertions_failed: bodyJsonAssertionsFailed
1345
1613
  };
1346
1614
  }
1347
1615
  async function preflightRiddleProofProfileHttpStatusChecks(profile, options = {}) {
@@ -1386,6 +1654,7 @@ function summarizeHttpStatusEvidence(viewport, check) {
1386
1654
  const bodyContainsMissing = httpStatusBodyContainsFailures(statusEvidence, check);
1387
1655
  const bodyNotContainsFound = httpStatusBodyNotContainsFailures(statusEvidence, check);
1388
1656
  const bodyNotPatternsFound = httpStatusBodyNotPatternFailures(statusEvidence, check);
1657
+ const bodyJsonAssertionsFailed = httpStatusBodyJsonAssertionFailures(statusEvidence, check);
1389
1658
  if (!linkStatusResultOk(statusEvidence, check)) {
1390
1659
  failures.push({
1391
1660
  code: "http_status_failed",
@@ -1404,6 +1673,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
1404
1673
  body_not_contains_found: bodyNotContainsFound,
1405
1674
  body_not_patterns: check.body_not_patterns ?? null,
1406
1675
  body_not_patterns_found: bodyNotPatternsFound,
1676
+ body_json_assertions: check.body_json_assertions ?? null,
1677
+ body_json_assertions_failed: bodyJsonAssertionsFailed.map((assertion) => toJsonValue(assertion)),
1407
1678
  body_sample: stringValue(statusEvidence.body_sample) ?? null
1408
1679
  });
1409
1680
  }
@@ -1425,6 +1696,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
1425
1696
  body_not_contains_found: bodyNotContainsFound,
1426
1697
  body_not_patterns: isRecord(statusEvidence.body_not_patterns) ? toJsonValue(statusEvidence.body_not_patterns) : null,
1427
1698
  body_not_patterns_found: bodyNotPatternsFound,
1699
+ body_json_assertions: Array.isArray(statusEvidence.body_json_assertions) ? toJsonValue(statusEvidence.body_json_assertions) : null,
1700
+ body_json_assertions_failed: bodyJsonAssertionsFailed.map((assertion) => toJsonValue(assertion)),
1428
1701
  body_sample: stringValue(statusEvidence.body_sample) ?? null,
1429
1702
  failures
1430
1703
  };
@@ -2061,6 +2334,7 @@ function assessCheckFromEvidence(check, evidence) {
2061
2334
  body_contains: check.body_contains ?? [],
2062
2335
  body_not_contains: check.body_not_contains ?? [],
2063
2336
  body_not_patterns: check.body_not_patterns ?? [],
2337
+ body_json_assertions: toJsonValue(check.body_json_assertions ?? []),
2064
2338
  viewports: summaries.map((summary) => toJsonValue(summary)),
2065
2339
  failures: failed.flatMap((summary) => Array.isArray(summary.failures) ? summary.failures.map((failure) => toJsonValue({ viewport: stringValue(summary.viewport) ?? null, failure })) : [])
2066
2340
  },
@@ -2804,6 +3078,40 @@ function httpStatusBodyNotPatternFailures(result, check) {
2804
3078
  : {};
2805
3079
  return forbidden.filter((pattern) => observed[pattern] !== false);
2806
3080
  }
3081
+ function httpStatusBodyJsonAssertionFailures(result, check) {
3082
+ const expected = Array.isArray(check.body_json_assertions) ? check.body_json_assertions.filter((assertion) => assertion && assertion.path) : [];
3083
+ if (!expected.length) return [];
3084
+ if (!Array.isArray(result.body_json_assertions)) {
3085
+ return expected.map((assertion) => ({
3086
+ label: assertion.label || assertion.path,
3087
+ path: assertion.path,
3088
+ ok: false,
3089
+ exists: false,
3090
+ observed_type: "missing",
3091
+ errors: ["body_json_assertions evidence missing"],
3092
+ }));
3093
+ }
3094
+ return result.body_json_assertions
3095
+ .filter((assertion) => assertion && typeof assertion === "object" && assertion.ok !== true)
3096
+ .map((assertion) => ({
3097
+ label: typeof assertion.label === "string" && assertion.label ? assertion.label : typeof assertion.path === "string" && assertion.path ? assertion.path : "json assertion",
3098
+ path: typeof assertion.path === "string" ? assertion.path : "",
3099
+ ok: false,
3100
+ exists: assertion.exists === true,
3101
+ observed: Object.hasOwn(assertion, "observed") ? assertion.observed : undefined,
3102
+ observed_sample: Object.hasOwn(assertion, "observed_sample") ? assertion.observed_sample : undefined,
3103
+ observed_length: typeof assertion.observed_length === "number" && Number.isFinite(assertion.observed_length) ? assertion.observed_length : undefined,
3104
+ observed_key_count: typeof assertion.observed_key_count === "number" && Number.isFinite(assertion.observed_key_count) ? assertion.observed_key_count : undefined,
3105
+ observed_omitted_count: typeof assertion.observed_omitted_count === "number" && Number.isFinite(assertion.observed_omitted_count) ? assertion.observed_omitted_count : undefined,
3106
+ observed_type: typeof assertion.observed_type === "string" && assertion.observed_type ? assertion.observed_type : "missing",
3107
+ expected_exists: typeof assertion.expected_exists === "boolean" ? assertion.expected_exists : undefined,
3108
+ equals: Object.hasOwn(assertion, "equals") ? assertion.equals : undefined,
3109
+ not_equals: Object.hasOwn(assertion, "not_equals") ? assertion.not_equals : undefined,
3110
+ contains: Object.hasOwn(assertion, "contains") ? assertion.contains : undefined,
3111
+ type: typeof assertion.type === "string" ? assertion.type : undefined,
3112
+ errors: Array.isArray(assertion.errors) ? assertion.errors.map(String) : undefined,
3113
+ }));
3114
+ }
2807
3115
  function linkStatusResultOk(result, check) {
2808
3116
  if (!result || typeof result !== "object" || Array.isArray(result)) return false;
2809
3117
  if (!httpStatusIsAllowed(result.status, check)) return false;
@@ -2821,6 +3129,7 @@ function linkStatusResultOk(result, check) {
2821
3129
  if (httpStatusBodyContainsFailures(result, check).length) return false;
2822
3130
  if (httpStatusBodyNotContainsFailures(result, check).length) return false;
2823
3131
  if (httpStatusBodyNotPatternFailures(result, check).length) return false;
3132
+ if (httpStatusBodyJsonAssertionFailures(result, check).length) return false;
2824
3133
  return true;
2825
3134
  }
2826
3135
  function summarizeHttpStatusEvidence(viewport, check) {
@@ -2841,6 +3150,7 @@ function summarizeHttpStatusEvidence(viewport, check) {
2841
3150
  const bodyContainsMissing = httpStatusBodyContainsFailures(statusEvidence, check);
2842
3151
  const bodyNotContainsFound = httpStatusBodyNotContainsFailures(statusEvidence, check);
2843
3152
  const bodyNotPatternsFound = httpStatusBodyNotPatternFailures(statusEvidence, check);
3153
+ const bodyJsonAssertionsFailed = httpStatusBodyJsonAssertionFailures(statusEvidence, check);
2844
3154
  if (!linkStatusResultOk(statusEvidence, check)) {
2845
3155
  failures.push({
2846
3156
  code: "http_status_failed",
@@ -2859,6 +3169,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
2859
3169
  body_not_contains_found: bodyNotContainsFound,
2860
3170
  body_not_patterns: Array.isArray(check.body_not_patterns) ? check.body_not_patterns : null,
2861
3171
  body_not_patterns_found: bodyNotPatternsFound,
3172
+ body_json_assertions: Array.isArray(check.body_json_assertions) ? check.body_json_assertions : null,
3173
+ body_json_assertions_failed: bodyJsonAssertionsFailed,
2862
3174
  body_sample: typeof statusEvidence.body_sample === "string" ? statusEvidence.body_sample : null,
2863
3175
  });
2864
3176
  }
@@ -2886,6 +3198,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
2886
3198
  ? statusEvidence.body_not_patterns
2887
3199
  : null,
2888
3200
  body_not_patterns_found: bodyNotPatternsFound,
3201
+ body_json_assertions: Array.isArray(statusEvidence.body_json_assertions) ? statusEvidence.body_json_assertions : null,
3202
+ body_json_assertions_failed: bodyJsonAssertionsFailed,
2889
3203
  body_sample: typeof statusEvidence.body_sample === "string" ? statusEvidence.body_sample : null,
2890
3204
  failures,
2891
3205
  };
@@ -4988,6 +5302,187 @@ function linkProbeResponseFields(response, method) {
4988
5302
  content_length: contentLength,
4989
5303
  };
4990
5304
  }
5305
+ function jsonProbeValueType(value) {
5306
+ if (value === null) return "null";
5307
+ if (Array.isArray(value)) return "array";
5308
+ if (typeof value === "boolean") return "boolean";
5309
+ if (typeof value === "number") return "number";
5310
+ if (typeof value === "string") return "string";
5311
+ return "object";
5312
+ }
5313
+ function compactJsonProbeSample(value, depth) {
5314
+ const level = typeof depth === "number" ? depth : 0;
5315
+ if (typeof value === "string") return value.length > 240 ? value.slice(0, 237) + "..." : value;
5316
+ if (value === null || typeof value === "boolean" || typeof value === "number") return value;
5317
+ if (Array.isArray(value)) {
5318
+ if (level >= 2) return "[array:" + value.length + "]";
5319
+ return value.slice(0, 3).map((item) => compactJsonProbeSample(item, level + 1));
5320
+ }
5321
+ if (value && typeof value === "object") {
5322
+ const entries = Object.entries(value);
5323
+ if (level >= 2) return "[object:" + entries.length + " keys]";
5324
+ return Object.fromEntries(entries.slice(0, 8).map(([key, child]) => [key, compactJsonProbeSample(child, level + 1)]));
5325
+ }
5326
+ return String(value);
5327
+ }
5328
+ function attachJsonProbeObservedValue(result, value) {
5329
+ const type = jsonProbeValueType(value);
5330
+ if (type === "array" && Array.isArray(value)) {
5331
+ result.observed_length = value.length;
5332
+ result.observed_omitted_count = Math.max(0, value.length - 3);
5333
+ result.observed_sample = compactJsonProbeSample(value, 0);
5334
+ return;
5335
+ }
5336
+ if (type === "object" && value && typeof value === "object" && !Array.isArray(value)) {
5337
+ const keyCount = Object.keys(value).length;
5338
+ result.observed_key_count = keyCount;
5339
+ result.observed_omitted_count = Math.max(0, keyCount - 8);
5340
+ result.observed_sample = compactJsonProbeSample(value, 0);
5341
+ return;
5342
+ }
5343
+ result.observed = value;
5344
+ }
5345
+ function jsonProbeDeepEqual(left, right) {
5346
+ if (left === right) return true;
5347
+ if (typeof left !== typeof right) return false;
5348
+ if (left === null || right === null) return left === right;
5349
+ if (typeof left !== "object" || typeof right !== "object") return false;
5350
+ if (Array.isArray(left) || Array.isArray(right)) {
5351
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
5352
+ return left.every((item, index) => jsonProbeDeepEqual(item, right[index]));
5353
+ }
5354
+ const leftKeys = Object.keys(left).sort();
5355
+ const rightKeys = Object.keys(right).sort();
5356
+ if (!jsonProbeDeepEqual(leftKeys, rightKeys)) return false;
5357
+ return leftKeys.every((key) => jsonProbeDeepEqual(left[key], right[key]));
5358
+ }
5359
+ function jsonProbeContains(observed, expected) {
5360
+ if (typeof observed === "string" && typeof expected === "string") return observed.includes(expected);
5361
+ if (Array.isArray(observed)) return observed.some((item) => jsonProbeDeepEqual(item, expected));
5362
+ if (observed && expected && typeof observed === "object" && typeof expected === "object" && !Array.isArray(observed) && !Array.isArray(expected)) {
5363
+ return Object.entries(expected).every(([key, value]) => Object.hasOwn(observed, key) && jsonProbeDeepEqual(observed[key], value));
5364
+ }
5365
+ return false;
5366
+ }
5367
+ function parseJsonProbePathSegments(path) {
5368
+ let input = String(path || "").trim();
5369
+ if (!input) throw new Error("path is empty");
5370
+ if (input === "$") return [];
5371
+ if (input.startsWith("$.")) input = input.slice(2);
5372
+ else if (input.startsWith("$[")) input = input.slice(1);
5373
+ const segments = [];
5374
+ let token = "";
5375
+ const pushToken = () => {
5376
+ if (!token) return;
5377
+ segments.push(token);
5378
+ token = "";
5379
+ };
5380
+ for (let index = 0; index < input.length; index += 1) {
5381
+ const char = input[index];
5382
+ if (char === ".") {
5383
+ pushToken();
5384
+ continue;
5385
+ }
5386
+ if (char !== "[") {
5387
+ token += char;
5388
+ continue;
5389
+ }
5390
+ pushToken();
5391
+ const closeIndex = input.indexOf("]", index + 1);
5392
+ if (closeIndex === -1) throw new Error("unterminated bracket at " + index);
5393
+ const bracket = input.slice(index + 1, closeIndex).trim();
5394
+ if (!bracket) throw new Error("empty bracket at " + index);
5395
+ if (/^\d+$/.test(bracket)) {
5396
+ segments.push(Number(bracket));
5397
+ } else if ((bracket.startsWith('"') && bracket.endsWith('"')) || (bracket.startsWith("'") && bracket.endsWith("'"))) {
5398
+ const quoted = bracket.startsWith("'")
5399
+ ? '"' + bracket.slice(1, -1).replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + '"'
5400
+ : bracket;
5401
+ segments.push(String(JSON.parse(quoted)));
5402
+ } else {
5403
+ segments.push(bracket);
5404
+ }
5405
+ index = closeIndex;
5406
+ }
5407
+ pushToken();
5408
+ return segments;
5409
+ }
5410
+ function resolveJsonProbePath(root, path) {
5411
+ let segments;
5412
+ try {
5413
+ segments = parseJsonProbePathSegments(path);
5414
+ } catch (error) {
5415
+ return { exists: false, error: String(error && error.message ? error.message : error) };
5416
+ }
5417
+ let current = root;
5418
+ for (const segment of segments) {
5419
+ if (typeof segment === "number") {
5420
+ if (!Array.isArray(current) || segment < 0 || segment >= current.length) return { exists: false };
5421
+ current = current[segment];
5422
+ continue;
5423
+ }
5424
+ if (!current || typeof current !== "object" || Array.isArray(current) || !Object.hasOwn(current, segment)) {
5425
+ return { exists: false };
5426
+ }
5427
+ current = current[segment];
5428
+ }
5429
+ return { exists: true, value: current };
5430
+ }
5431
+ function evaluateJsonProbeAssertion(root, assertion) {
5432
+ const resolved = resolveJsonProbePath(root, assertion.path);
5433
+ const errors = [];
5434
+ const result = {
5435
+ label: assertion.label || assertion.path,
5436
+ path: assertion.path,
5437
+ ok: true,
5438
+ exists: resolved.exists,
5439
+ observed_type: resolved.exists ? jsonProbeValueType(resolved.value) : "missing",
5440
+ };
5441
+ if (resolved.exists) attachJsonProbeObservedValue(result, resolved.value);
5442
+ if (resolved.error) errors.push(resolved.error);
5443
+ if (Object.hasOwn(assertion, "exists")) {
5444
+ result.expected_exists = assertion.exists;
5445
+ if (resolved.exists !== assertion.exists) errors.push("expected exists=" + assertion.exists);
5446
+ }
5447
+ if (Object.hasOwn(assertion, "type")) {
5448
+ result.type = assertion.type;
5449
+ if (!resolved.exists || jsonProbeValueType(resolved.value) !== assertion.type) errors.push("expected type " + assertion.type);
5450
+ }
5451
+ if (Object.hasOwn(assertion, "equals")) {
5452
+ result.equals = assertion.equals;
5453
+ if (!resolved.exists || !jsonProbeDeepEqual(resolved.value, assertion.equals)) errors.push("expected JSON value equality");
5454
+ }
5455
+ if (Object.hasOwn(assertion, "not_equals")) {
5456
+ result.not_equals = assertion.not_equals;
5457
+ if (resolved.exists && jsonProbeDeepEqual(resolved.value, assertion.not_equals)) errors.push("expected JSON value inequality");
5458
+ }
5459
+ if (Object.hasOwn(assertion, "contains")) {
5460
+ result.contains = assertion.contains;
5461
+ if (!resolved.exists || !jsonProbeContains(resolved.value, assertion.contains)) errors.push("expected JSON value containment");
5462
+ }
5463
+ result.ok = errors.length === 0;
5464
+ if (errors.length) result.errors = errors;
5465
+ return result;
5466
+ }
5467
+ function evaluateJsonProbeAssertions(text, assertions) {
5468
+ const expected = Array.isArray(assertions) ? assertions.filter((assertion) => assertion && assertion.path) : [];
5469
+ if (!expected.length) return [];
5470
+ let parsed;
5471
+ try {
5472
+ parsed = JSON.parse(text);
5473
+ } catch (error) {
5474
+ const message = "response body is not valid JSON: " + String(error && error.message ? error.message : error).slice(0, 200);
5475
+ return expected.map((assertion) => ({
5476
+ label: assertion.label || assertion.path,
5477
+ path: assertion.path,
5478
+ ok: false,
5479
+ exists: false,
5480
+ observed_type: "missing",
5481
+ errors: [message],
5482
+ }));
5483
+ }
5484
+ return expected.map((assertion) => evaluateJsonProbeAssertion(parsed, assertion));
5485
+ }
4991
5486
  async function collectHttpStatus(check) {
4992
5487
  const url = httpStatusRequestUrl(check, page.url() || targetUrl);
4993
5488
  const method = httpStatusMethod(check);
@@ -5004,6 +5499,7 @@ async function collectHttpStatus(check) {
5004
5499
  const bodyContains = Array.isArray(check.body_contains) ? check.body_contains.filter(Boolean) : [];
5005
5500
  const bodyNotContains = Array.isArray(check.body_not_contains) ? check.body_not_contains.filter(Boolean) : [];
5006
5501
  const bodyNotPatterns = Array.isArray(check.body_not_patterns) ? check.body_not_patterns.filter(Boolean) : [];
5502
+ const bodyJsonAssertions = Array.isArray(check.body_json_assertions) ? check.body_json_assertions.filter((assertion) => assertion && assertion.path) : [];
5007
5503
  const options = {
5008
5504
  method,
5009
5505
  redirect: "follow",
@@ -5029,17 +5525,18 @@ async function collectHttpStatus(check) {
5029
5525
  Object.assign(result, linkProbeResponseFields(response, method));
5030
5526
  result.url = url;
5031
5527
  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;
5528
+ 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
5529
  if (shouldReadBody) {
5034
5530
  try {
5035
5531
  const buffer = await response.arrayBuffer();
5036
5532
  result.bytes = buffer.byteLength;
5037
- if (bodyContains.length || bodyNotContains.length || bodyNotPatterns.length) {
5533
+ if (bodyContains.length || bodyNotContains.length || bodyNotPatterns.length || bodyJsonAssertions.length) {
5038
5534
  const text = new TextDecoder().decode(buffer);
5039
5535
  result.body_sample = text.slice(0, 1000);
5040
5536
  if (bodyContains.length) result.body_contains = Object.fromEntries(bodyContains.map((expected) => [expected, text.includes(expected)]));
5041
5537
  if (bodyNotContains.length) result.body_not_contains = Object.fromEntries(bodyNotContains.map((forbidden) => [forbidden, text.includes(forbidden)]));
5042
5538
  if (bodyNotPatterns.length) result.body_not_patterns = Object.fromEntries(bodyNotPatterns.map((pattern) => [pattern, new RegExp(pattern).test(text)]));
5539
+ if (bodyJsonAssertions.length) result.body_json_assertions = evaluateJsonProbeAssertions(text, bodyJsonAssertions);
5043
5540
  }
5044
5541
  } catch (error) {
5045
5542
  result.error = String(error && error.message ? error.message : error).slice(0, 500);
@@ -5052,6 +5549,7 @@ async function collectHttpStatus(check) {
5052
5549
  && (!bodyContains.length || bodyContains.every((expected) => result.body_contains && result.body_contains[expected] === true))
5053
5550
  && (!bodyNotContains.length || bodyNotContains.every((forbidden) => result.body_not_contains && result.body_not_contains[forbidden] === false))
5054
5551
  && (!bodyNotPatterns.length || bodyNotPatterns.every((pattern) => result.body_not_patterns && result.body_not_patterns[pattern] === false))
5552
+ && (!bodyJsonAssertions.length || (Array.isArray(result.body_json_assertions) && result.body_json_assertions.every((assertion) => assertion.ok === true)))
5055
5553
  && !result.error;
5056
5554
  return result;
5057
5555
  } catch (error) {