@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.
package/dist/profile.cjs CHANGED
@@ -186,6 +186,9 @@ function valueFromOwn(input, ...keys) {
186
186
  function numberValue(value) {
187
187
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
188
188
  }
189
+ function booleanValue(value) {
190
+ return typeof value === "boolean" ? value : void 0;
191
+ }
189
192
  function horizontalBoundsOverflowPx(value) {
190
193
  if (!isRecord(value)) return 0;
191
194
  let max = maxPositiveNumber(
@@ -280,6 +283,187 @@ function toJsonValue(value) {
280
283
  if (isRecord(value)) return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, toJsonValue(child)]));
281
284
  return String(value);
282
285
  }
286
+ function jsonValueType(value) {
287
+ if (value === null) return "null";
288
+ if (Array.isArray(value)) return "array";
289
+ if (typeof value === "boolean") return "boolean";
290
+ if (typeof value === "number") return "number";
291
+ if (typeof value === "string") return "string";
292
+ return "object";
293
+ }
294
+ function compactJsonAssertionSample(value, depth = 0) {
295
+ if (typeof value === "string") return value.length > 240 ? `${value.slice(0, 237)}...` : value;
296
+ if (value === null || typeof value === "boolean" || typeof value === "number") return toJsonValue(value);
297
+ if (Array.isArray(value)) {
298
+ if (depth >= 2) return `[array:${value.length}]`;
299
+ return value.slice(0, 3).map((item) => compactJsonAssertionSample(item, depth + 1));
300
+ }
301
+ if (isRecord(value)) {
302
+ const entries = Object.entries(value).slice(0, 8);
303
+ if (depth >= 2) return `[object:${Object.keys(value).length} keys]`;
304
+ return Object.fromEntries(entries.map(([key, child]) => [key, compactJsonAssertionSample(child, depth + 1)]));
305
+ }
306
+ return String(value);
307
+ }
308
+ function attachJsonAssertionObservedValue(result, value) {
309
+ const type = jsonValueType(value);
310
+ if (type === "array" && Array.isArray(value)) {
311
+ result.observed_length = value.length;
312
+ result.observed_omitted_count = Math.max(0, value.length - 3);
313
+ result.observed_sample = compactJsonAssertionSample(value);
314
+ return;
315
+ }
316
+ if (type === "object" && isRecord(value)) {
317
+ const keyCount = Object.keys(value).length;
318
+ result.observed_key_count = keyCount;
319
+ result.observed_omitted_count = Math.max(0, keyCount - 8);
320
+ result.observed_sample = compactJsonAssertionSample(value);
321
+ return;
322
+ }
323
+ result.observed = toJsonValue(value);
324
+ }
325
+ function deepJsonEqual(left, right) {
326
+ if (left === right) return true;
327
+ if (typeof left !== typeof right) return false;
328
+ if (left === null || right === null) return left === right;
329
+ if (typeof left !== "object" || typeof right !== "object") return false;
330
+ if (Array.isArray(left) || Array.isArray(right)) {
331
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
332
+ return left.every((item, index) => deepJsonEqual(item, right[index]));
333
+ }
334
+ if (!isRecord(left) || !isRecord(right)) return false;
335
+ const leftKeys = Object.keys(left).sort();
336
+ const rightKeys = Object.keys(right).sort();
337
+ if (!deepJsonEqual(leftKeys, rightKeys)) return false;
338
+ return leftKeys.every((key) => deepJsonEqual(left[key], right[key]));
339
+ }
340
+ function jsonContains(observed, expected) {
341
+ if (typeof observed === "string" && typeof expected === "string") {
342
+ return observed.includes(expected);
343
+ }
344
+ if (Array.isArray(observed)) {
345
+ return observed.some((item) => deepJsonEqual(item, expected));
346
+ }
347
+ if (isRecord(observed) && isRecord(expected)) {
348
+ return Object.entries(expected).every(([key, value]) => hasOwn(observed, key) && deepJsonEqual(observed[key], value));
349
+ }
350
+ return false;
351
+ }
352
+ function parseJsonPathSegments(path) {
353
+ let input = path.trim();
354
+ if (!input) throw new Error("path is empty");
355
+ if (input === "$") return [];
356
+ if (input.startsWith("$.")) input = input.slice(2);
357
+ else if (input.startsWith("$[")) input = input.slice(1);
358
+ const segments = [];
359
+ let token = "";
360
+ const pushToken = () => {
361
+ if (!token) return;
362
+ segments.push(token);
363
+ token = "";
364
+ };
365
+ for (let index = 0; index < input.length; index += 1) {
366
+ const char = input[index];
367
+ if (char === ".") {
368
+ pushToken();
369
+ continue;
370
+ }
371
+ if (char !== "[") {
372
+ token += char;
373
+ continue;
374
+ }
375
+ pushToken();
376
+ const closeIndex = input.indexOf("]", index + 1);
377
+ if (closeIndex === -1) throw new Error(`unterminated bracket at ${index}`);
378
+ const bracket = input.slice(index + 1, closeIndex).trim();
379
+ if (!bracket) throw new Error(`empty bracket at ${index}`);
380
+ if (/^\d+$/.test(bracket)) {
381
+ segments.push(Number(bracket));
382
+ } else if (bracket.startsWith('"') && bracket.endsWith('"') || bracket.startsWith("'") && bracket.endsWith("'")) {
383
+ const quoted = bracket.startsWith("'") ? `"${bracket.slice(1, -1).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : bracket;
384
+ segments.push(String(JSON.parse(quoted)));
385
+ } else {
386
+ segments.push(bracket);
387
+ }
388
+ index = closeIndex;
389
+ }
390
+ pushToken();
391
+ return segments;
392
+ }
393
+ function resolveJsonPath(root, path) {
394
+ let segments;
395
+ try {
396
+ segments = parseJsonPathSegments(path);
397
+ } catch (error) {
398
+ return { exists: false, error: String(error instanceof Error ? error.message : error) };
399
+ }
400
+ let current = root;
401
+ for (const segment of segments) {
402
+ if (typeof segment === "number") {
403
+ if (!Array.isArray(current) || segment < 0 || segment >= current.length) return { exists: false };
404
+ current = current[segment];
405
+ continue;
406
+ }
407
+ if (!isRecord(current) || !hasOwn(current, segment)) return { exists: false };
408
+ current = current[segment];
409
+ }
410
+ return { exists: true, value: current };
411
+ }
412
+ function evaluateHttpStatusBodyJsonAssertion(root, assertion) {
413
+ const resolved = resolveJsonPath(root, assertion.path);
414
+ const errors = [];
415
+ const result = {
416
+ label: assertion.label || assertion.path,
417
+ path: assertion.path,
418
+ ok: true,
419
+ exists: resolved.exists,
420
+ observed_type: resolved.exists ? jsonValueType(resolved.value) : "missing"
421
+ };
422
+ if (resolved.exists) attachJsonAssertionObservedValue(result, resolved.value);
423
+ if (resolved.error) errors.push(resolved.error);
424
+ if (hasOwn(assertion, "exists")) {
425
+ result.expected_exists = assertion.exists;
426
+ if (resolved.exists !== assertion.exists) errors.push(`expected exists=${assertion.exists}`);
427
+ }
428
+ if (hasOwn(assertion, "type")) {
429
+ result.type = assertion.type;
430
+ if (!resolved.exists || jsonValueType(resolved.value) !== assertion.type) errors.push(`expected type ${assertion.type}`);
431
+ }
432
+ if (hasOwn(assertion, "equals")) {
433
+ result.equals = assertion.equals;
434
+ if (!resolved.exists || !deepJsonEqual(resolved.value, assertion.equals)) errors.push("expected JSON value equality");
435
+ }
436
+ if (hasOwn(assertion, "not_equals")) {
437
+ result.not_equals = assertion.not_equals;
438
+ if (resolved.exists && deepJsonEqual(resolved.value, assertion.not_equals)) errors.push("expected JSON value inequality");
439
+ }
440
+ if (hasOwn(assertion, "contains")) {
441
+ result.contains = assertion.contains;
442
+ if (!resolved.exists || !jsonContains(resolved.value, assertion.contains)) errors.push("expected JSON value containment");
443
+ }
444
+ result.ok = errors.length === 0;
445
+ if (errors.length) result.errors = errors;
446
+ return result;
447
+ }
448
+ function evaluateHttpStatusBodyJsonAssertions(bodyText, assertions) {
449
+ const expected = assertions?.filter((assertion) => assertion.path) ?? [];
450
+ if (!expected.length) return [];
451
+ let parsed;
452
+ try {
453
+ parsed = JSON.parse(bodyText);
454
+ } catch (error) {
455
+ const message = `response body is not valid JSON: ${String(error instanceof Error ? error.message : error).slice(0, 200)}`;
456
+ return expected.map((assertion) => ({
457
+ label: assertion.label || assertion.path,
458
+ path: assertion.path,
459
+ ok: false,
460
+ exists: false,
461
+ observed_type: "missing",
462
+ errors: [message]
463
+ }));
464
+ }
465
+ return expected.map((assertion) => evaluateHttpStatusBodyJsonAssertion(parsed, assertion));
466
+ }
283
467
  function compactProfileSetupSummaryText(value, limit = 160) {
284
468
  const text = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
285
469
  if (!text) return void 0;
@@ -934,6 +1118,46 @@ function validateRegexPatterns(patterns, label) {
934
1118
  }
935
1119
  }
936
1120
  }
1121
+ function normalizeHttpStatusBodyJsonAssertions(value, label) {
1122
+ if (value === void 0) return void 0;
1123
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
1124
+ if (!value.length) throw new Error(`${label} must not be empty.`);
1125
+ return value.map((item, index) => {
1126
+ const itemLabel = `${label}[${index}]`;
1127
+ if (typeof item === "string") {
1128
+ const path2 = stringValue(item);
1129
+ if (!path2) throw new Error(`${itemLabel} path must not be empty.`);
1130
+ return { path: path2, exists: true };
1131
+ }
1132
+ if (!isRecord(item)) throw new Error(`${itemLabel} must be an object or JSON path string.`);
1133
+ const path = stringFromOwn(item, "path", "json_path", "jsonPath", "key");
1134
+ if (!path) throw new Error(`${itemLabel}.path is required.`);
1135
+ const assertion = {
1136
+ label: stringValue(item.label),
1137
+ path
1138
+ };
1139
+ const exists = booleanValue(valueFromOwn(item, "exists", "present"));
1140
+ if (exists !== void 0) assertion.exists = exists;
1141
+ const type = stringValue(valueFromOwn(item, "type", "value_type", "valueType"));
1142
+ if (type !== void 0) {
1143
+ const allowedTypes = ["array", "boolean", "null", "number", "object", "string"];
1144
+ if (!allowedTypes.includes(type)) {
1145
+ throw new Error(`${itemLabel}.type must be one of ${allowedTypes.join(", ")}.`);
1146
+ }
1147
+ assertion.type = type;
1148
+ }
1149
+ const equalsValue = valueFromOwn(item, "equals", "expected", "expected_value", "expectedValue", "value");
1150
+ if (equalsValue !== void 0) assertion.equals = toJsonValue(equalsValue);
1151
+ const notEqualsValue = valueFromOwn(item, "not_equals", "notEquals", "forbidden", "forbidden_value", "forbiddenValue");
1152
+ if (notEqualsValue !== void 0) assertion.not_equals = toJsonValue(notEqualsValue);
1153
+ const containsValue = valueFromOwn(item, "contains", "includes", "contains_value", "containsValue", "include");
1154
+ if (containsValue !== void 0) assertion.contains = toJsonValue(containsValue);
1155
+ if (assertion.exists === void 0 && assertion.type === void 0 && !hasOwn(assertion, "equals") && !hasOwn(assertion, "not_equals") && !hasOwn(assertion, "contains")) {
1156
+ assertion.exists = true;
1157
+ }
1158
+ return assertion;
1159
+ });
1160
+ }
937
1161
  function isDialogCountCheckType(type) {
938
1162
  return type === "dialog_count_equals" || type === "dialog_accept_count_equals" || type === "dialog_dismiss_count_equals";
939
1163
  }
@@ -1033,6 +1257,10 @@ function normalizeCheck(input, index) {
1033
1257
  `checks[${index}] body_not_patterns`
1034
1258
  ) : void 0;
1035
1259
  if (bodyNotPatterns?.length) validateRegexPatterns(bodyNotPatterns, `checks[${index}] body_not_patterns`);
1260
+ const bodyJsonAssertions = isHttpStatusCheck ? normalizeHttpStatusBodyJsonAssertions(
1261
+ input.body_json_assertions ?? input.bodyJsonAssertions ?? input.json_body_assertions ?? input.jsonBodyAssertions ?? input.json_assertions ?? input.jsonAssertions ?? input.response_json_assertions ?? input.responseJsonAssertions,
1262
+ `checks[${index}] body_json_assertions`
1263
+ ) : void 0;
1036
1264
  if (isLinkStatusCheck) {
1037
1265
  if (minCount !== void 0 && (!Number.isInteger(minCount) || minCount < 0)) {
1038
1266
  throw new Error(`checks[${index}] ${type} min_count must be a non-negative integer.`);
@@ -1058,6 +1286,7 @@ function normalizeCheck(input, index) {
1058
1286
  body_contains: bodyContains,
1059
1287
  body_not_contains: bodyNotContains,
1060
1288
  body_not_patterns: bodyNotPatterns,
1289
+ body_json_assertions: bodyJsonAssertions,
1061
1290
  expected_texts: expectedTexts,
1062
1291
  link_selector: stringValue(input.link_selector) || stringValue(input.linkSelector),
1063
1292
  source_selector: stringValue(input.source_selector) || stringValue(input.sourceSelector),
@@ -1264,6 +1493,38 @@ function httpStatusBodyNotPatternFailures(result, check) {
1264
1493
  const observed = isRecord(result.body_not_patterns) ? result.body_not_patterns : {};
1265
1494
  return forbidden.filter((pattern) => observed[pattern] !== false);
1266
1495
  }
1496
+ function httpStatusBodyJsonAssertionFailures(result, check) {
1497
+ const expected = check.body_json_assertions?.filter((assertion) => assertion.path) ?? [];
1498
+ if (!expected.length) return [];
1499
+ if (!Array.isArray(result.body_json_assertions)) {
1500
+ return expected.map((assertion) => ({
1501
+ label: assertion.label || assertion.path,
1502
+ path: assertion.path,
1503
+ ok: false,
1504
+ exists: false,
1505
+ observed_type: "missing",
1506
+ errors: ["body_json_assertions evidence missing"]
1507
+ }));
1508
+ }
1509
+ return result.body_json_assertions.filter((assertion) => isRecord(assertion) && assertion.ok !== true).map((assertion) => ({
1510
+ label: stringValue(assertion.label) || stringValue(assertion.path) || "json assertion",
1511
+ path: stringValue(assertion.path) || "",
1512
+ ok: false,
1513
+ exists: assertion.exists === true,
1514
+ observed: hasOwn(assertion, "observed") ? toJsonValue(assertion.observed) : void 0,
1515
+ observed_sample: hasOwn(assertion, "observed_sample") ? toJsonValue(assertion.observed_sample) : void 0,
1516
+ observed_length: numberValue(assertion.observed_length),
1517
+ observed_key_count: numberValue(assertion.observed_key_count),
1518
+ observed_omitted_count: numberValue(assertion.observed_omitted_count),
1519
+ observed_type: stringValue(assertion.observed_type) || "missing",
1520
+ expected_exists: booleanValue(assertion.expected_exists),
1521
+ equals: hasOwn(assertion, "equals") ? toJsonValue(assertion.equals) : void 0,
1522
+ not_equals: hasOwn(assertion, "not_equals") ? toJsonValue(assertion.not_equals) : void 0,
1523
+ contains: hasOwn(assertion, "contains") ? toJsonValue(assertion.contains) : void 0,
1524
+ type: stringValue(assertion.type),
1525
+ errors: Array.isArray(assertion.errors) ? assertion.errors.map(String) : void 0
1526
+ }));
1527
+ }
1267
1528
  function linkStatusResultOk(result, check) {
1268
1529
  const status = numberValue(result.status);
1269
1530
  if (!httpStatusIsAllowed(status, check)) return false;
@@ -1281,6 +1542,7 @@ function linkStatusResultOk(result, check) {
1281
1542
  if (httpStatusBodyContainsFailures(result, check).length) return false;
1282
1543
  if (httpStatusBodyNotContainsFailures(result, check).length) return false;
1283
1544
  if (httpStatusBodyNotPatternFailures(result, check).length) return false;
1545
+ if (httpStatusBodyJsonAssertionFailures(result, check).length) return false;
1284
1546
  return true;
1285
1547
  }
1286
1548
  function responseHeader(response, name) {
@@ -1349,7 +1611,7 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1349
1611
  statusText = typeof response.statusText === "string" ? response.statusText : "";
1350
1612
  result.content_type = responseHeader(response, "content-type");
1351
1613
  result.content_length = responseContentLength(response);
1352
- 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);
1614
+ 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);
1353
1615
  if (shouldReadBody && method !== "HEAD") {
1354
1616
  const body = await responseBodyText(response);
1355
1617
  result.bytes = body.bytes;
@@ -1362,6 +1624,9 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1362
1624
  if (check.body_not_patterns?.length) {
1363
1625
  result.body_not_patterns = Object.fromEntries(check.body_not_patterns.filter(Boolean).map((pattern) => [pattern, new RegExp(pattern).test(body.text)]));
1364
1626
  }
1627
+ if (check.body_json_assertions?.length) {
1628
+ result.body_json_assertions = evaluateHttpStatusBodyJsonAssertions(body.text, check.body_json_assertions);
1629
+ }
1365
1630
  }
1366
1631
  } catch (caught) {
1367
1632
  error = String(caught instanceof Error ? caught.message : caught).slice(0, 500);
@@ -1370,6 +1635,7 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1370
1635
  const bodyContainsMissing = httpStatusBodyContainsFailures(result, check);
1371
1636
  const bodyNotContainsFound = httpStatusBodyNotContainsFailures(result, check);
1372
1637
  const bodyNotPatternsFound = httpStatusBodyNotPatternFailures(result, check);
1638
+ const bodyJsonAssertionsFailed = httpStatusBodyJsonAssertionFailures(result, check);
1373
1639
  const ok = !error && linkStatusResultOk(result, check);
1374
1640
  return {
1375
1641
  index,
@@ -1388,7 +1654,9 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1388
1654
  body_not_contains: isRecord(result.body_not_contains) ? Object.fromEntries(Object.entries(result.body_not_contains).map(([key, value]) => [key, value === true])) : null,
1389
1655
  body_not_contains_found: bodyNotContainsFound,
1390
1656
  body_not_patterns: isRecord(result.body_not_patterns) ? Object.fromEntries(Object.entries(result.body_not_patterns).map(([key, value]) => [key, value === true])) : null,
1391
- body_not_patterns_found: bodyNotPatternsFound
1657
+ body_not_patterns_found: bodyNotPatternsFound,
1658
+ body_json_assertions: Array.isArray(result.body_json_assertions) ? result.body_json_assertions : null,
1659
+ body_json_assertions_failed: bodyJsonAssertionsFailed
1392
1660
  };
1393
1661
  }
1394
1662
  async function preflightRiddleProofProfileHttpStatusChecks(profile, options = {}) {
@@ -1433,6 +1701,7 @@ function summarizeHttpStatusEvidence(viewport, check) {
1433
1701
  const bodyContainsMissing = httpStatusBodyContainsFailures(statusEvidence, check);
1434
1702
  const bodyNotContainsFound = httpStatusBodyNotContainsFailures(statusEvidence, check);
1435
1703
  const bodyNotPatternsFound = httpStatusBodyNotPatternFailures(statusEvidence, check);
1704
+ const bodyJsonAssertionsFailed = httpStatusBodyJsonAssertionFailures(statusEvidence, check);
1436
1705
  if (!linkStatusResultOk(statusEvidence, check)) {
1437
1706
  failures.push({
1438
1707
  code: "http_status_failed",
@@ -1451,6 +1720,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
1451
1720
  body_not_contains_found: bodyNotContainsFound,
1452
1721
  body_not_patterns: check.body_not_patterns ?? null,
1453
1722
  body_not_patterns_found: bodyNotPatternsFound,
1723
+ body_json_assertions: check.body_json_assertions ?? null,
1724
+ body_json_assertions_failed: bodyJsonAssertionsFailed.map((assertion) => toJsonValue(assertion)),
1454
1725
  body_sample: stringValue(statusEvidence.body_sample) ?? null
1455
1726
  });
1456
1727
  }
@@ -1472,6 +1743,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
1472
1743
  body_not_contains_found: bodyNotContainsFound,
1473
1744
  body_not_patterns: isRecord(statusEvidence.body_not_patterns) ? toJsonValue(statusEvidence.body_not_patterns) : null,
1474
1745
  body_not_patterns_found: bodyNotPatternsFound,
1746
+ body_json_assertions: Array.isArray(statusEvidence.body_json_assertions) ? toJsonValue(statusEvidence.body_json_assertions) : null,
1747
+ body_json_assertions_failed: bodyJsonAssertionsFailed.map((assertion) => toJsonValue(assertion)),
1475
1748
  body_sample: stringValue(statusEvidence.body_sample) ?? null,
1476
1749
  failures
1477
1750
  };
@@ -2108,6 +2381,7 @@ function assessCheckFromEvidence(check, evidence) {
2108
2381
  body_contains: check.body_contains ?? [],
2109
2382
  body_not_contains: check.body_not_contains ?? [],
2110
2383
  body_not_patterns: check.body_not_patterns ?? [],
2384
+ body_json_assertions: toJsonValue(check.body_json_assertions ?? []),
2111
2385
  viewports: summaries.map((summary) => toJsonValue(summary)),
2112
2386
  failures: failed.flatMap((summary) => Array.isArray(summary.failures) ? summary.failures.map((failure) => toJsonValue({ viewport: stringValue(summary.viewport) ?? null, failure })) : [])
2113
2387
  },
@@ -2851,6 +3125,40 @@ function httpStatusBodyNotPatternFailures(result, check) {
2851
3125
  : {};
2852
3126
  return forbidden.filter((pattern) => observed[pattern] !== false);
2853
3127
  }
3128
+ function httpStatusBodyJsonAssertionFailures(result, check) {
3129
+ const expected = Array.isArray(check.body_json_assertions) ? check.body_json_assertions.filter((assertion) => assertion && assertion.path) : [];
3130
+ if (!expected.length) return [];
3131
+ if (!Array.isArray(result.body_json_assertions)) {
3132
+ return expected.map((assertion) => ({
3133
+ label: assertion.label || assertion.path,
3134
+ path: assertion.path,
3135
+ ok: false,
3136
+ exists: false,
3137
+ observed_type: "missing",
3138
+ errors: ["body_json_assertions evidence missing"],
3139
+ }));
3140
+ }
3141
+ return result.body_json_assertions
3142
+ .filter((assertion) => assertion && typeof assertion === "object" && assertion.ok !== true)
3143
+ .map((assertion) => ({
3144
+ label: typeof assertion.label === "string" && assertion.label ? assertion.label : typeof assertion.path === "string" && assertion.path ? assertion.path : "json assertion",
3145
+ path: typeof assertion.path === "string" ? assertion.path : "",
3146
+ ok: false,
3147
+ exists: assertion.exists === true,
3148
+ observed: Object.hasOwn(assertion, "observed") ? assertion.observed : undefined,
3149
+ observed_sample: Object.hasOwn(assertion, "observed_sample") ? assertion.observed_sample : undefined,
3150
+ observed_length: typeof assertion.observed_length === "number" && Number.isFinite(assertion.observed_length) ? assertion.observed_length : undefined,
3151
+ observed_key_count: typeof assertion.observed_key_count === "number" && Number.isFinite(assertion.observed_key_count) ? assertion.observed_key_count : undefined,
3152
+ observed_omitted_count: typeof assertion.observed_omitted_count === "number" && Number.isFinite(assertion.observed_omitted_count) ? assertion.observed_omitted_count : undefined,
3153
+ observed_type: typeof assertion.observed_type === "string" && assertion.observed_type ? assertion.observed_type : "missing",
3154
+ expected_exists: typeof assertion.expected_exists === "boolean" ? assertion.expected_exists : undefined,
3155
+ equals: Object.hasOwn(assertion, "equals") ? assertion.equals : undefined,
3156
+ not_equals: Object.hasOwn(assertion, "not_equals") ? assertion.not_equals : undefined,
3157
+ contains: Object.hasOwn(assertion, "contains") ? assertion.contains : undefined,
3158
+ type: typeof assertion.type === "string" ? assertion.type : undefined,
3159
+ errors: Array.isArray(assertion.errors) ? assertion.errors.map(String) : undefined,
3160
+ }));
3161
+ }
2854
3162
  function linkStatusResultOk(result, check) {
2855
3163
  if (!result || typeof result !== "object" || Array.isArray(result)) return false;
2856
3164
  if (!httpStatusIsAllowed(result.status, check)) return false;
@@ -2868,6 +3176,7 @@ function linkStatusResultOk(result, check) {
2868
3176
  if (httpStatusBodyContainsFailures(result, check).length) return false;
2869
3177
  if (httpStatusBodyNotContainsFailures(result, check).length) return false;
2870
3178
  if (httpStatusBodyNotPatternFailures(result, check).length) return false;
3179
+ if (httpStatusBodyJsonAssertionFailures(result, check).length) return false;
2871
3180
  return true;
2872
3181
  }
2873
3182
  function summarizeHttpStatusEvidence(viewport, check) {
@@ -2888,6 +3197,7 @@ function summarizeHttpStatusEvidence(viewport, check) {
2888
3197
  const bodyContainsMissing = httpStatusBodyContainsFailures(statusEvidence, check);
2889
3198
  const bodyNotContainsFound = httpStatusBodyNotContainsFailures(statusEvidence, check);
2890
3199
  const bodyNotPatternsFound = httpStatusBodyNotPatternFailures(statusEvidence, check);
3200
+ const bodyJsonAssertionsFailed = httpStatusBodyJsonAssertionFailures(statusEvidence, check);
2891
3201
  if (!linkStatusResultOk(statusEvidence, check)) {
2892
3202
  failures.push({
2893
3203
  code: "http_status_failed",
@@ -2906,6 +3216,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
2906
3216
  body_not_contains_found: bodyNotContainsFound,
2907
3217
  body_not_patterns: Array.isArray(check.body_not_patterns) ? check.body_not_patterns : null,
2908
3218
  body_not_patterns_found: bodyNotPatternsFound,
3219
+ body_json_assertions: Array.isArray(check.body_json_assertions) ? check.body_json_assertions : null,
3220
+ body_json_assertions_failed: bodyJsonAssertionsFailed,
2909
3221
  body_sample: typeof statusEvidence.body_sample === "string" ? statusEvidence.body_sample : null,
2910
3222
  });
2911
3223
  }
@@ -2933,6 +3245,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
2933
3245
  ? statusEvidence.body_not_patterns
2934
3246
  : null,
2935
3247
  body_not_patterns_found: bodyNotPatternsFound,
3248
+ body_json_assertions: Array.isArray(statusEvidence.body_json_assertions) ? statusEvidence.body_json_assertions : null,
3249
+ body_json_assertions_failed: bodyJsonAssertionsFailed,
2936
3250
  body_sample: typeof statusEvidence.body_sample === "string" ? statusEvidence.body_sample : null,
2937
3251
  failures,
2938
3252
  };
@@ -5035,6 +5349,187 @@ function linkProbeResponseFields(response, method) {
5035
5349
  content_length: contentLength,
5036
5350
  };
5037
5351
  }
5352
+ function jsonProbeValueType(value) {
5353
+ if (value === null) return "null";
5354
+ if (Array.isArray(value)) return "array";
5355
+ if (typeof value === "boolean") return "boolean";
5356
+ if (typeof value === "number") return "number";
5357
+ if (typeof value === "string") return "string";
5358
+ return "object";
5359
+ }
5360
+ function compactJsonProbeSample(value, depth) {
5361
+ const level = typeof depth === "number" ? depth : 0;
5362
+ if (typeof value === "string") return value.length > 240 ? value.slice(0, 237) + "..." : value;
5363
+ if (value === null || typeof value === "boolean" || typeof value === "number") return value;
5364
+ if (Array.isArray(value)) {
5365
+ if (level >= 2) return "[array:" + value.length + "]";
5366
+ return value.slice(0, 3).map((item) => compactJsonProbeSample(item, level + 1));
5367
+ }
5368
+ if (value && typeof value === "object") {
5369
+ const entries = Object.entries(value);
5370
+ if (level >= 2) return "[object:" + entries.length + " keys]";
5371
+ return Object.fromEntries(entries.slice(0, 8).map(([key, child]) => [key, compactJsonProbeSample(child, level + 1)]));
5372
+ }
5373
+ return String(value);
5374
+ }
5375
+ function attachJsonProbeObservedValue(result, value) {
5376
+ const type = jsonProbeValueType(value);
5377
+ if (type === "array" && Array.isArray(value)) {
5378
+ result.observed_length = value.length;
5379
+ result.observed_omitted_count = Math.max(0, value.length - 3);
5380
+ result.observed_sample = compactJsonProbeSample(value, 0);
5381
+ return;
5382
+ }
5383
+ if (type === "object" && value && typeof value === "object" && !Array.isArray(value)) {
5384
+ const keyCount = Object.keys(value).length;
5385
+ result.observed_key_count = keyCount;
5386
+ result.observed_omitted_count = Math.max(0, keyCount - 8);
5387
+ result.observed_sample = compactJsonProbeSample(value, 0);
5388
+ return;
5389
+ }
5390
+ result.observed = value;
5391
+ }
5392
+ function jsonProbeDeepEqual(left, right) {
5393
+ if (left === right) return true;
5394
+ if (typeof left !== typeof right) return false;
5395
+ if (left === null || right === null) return left === right;
5396
+ if (typeof left !== "object" || typeof right !== "object") return false;
5397
+ if (Array.isArray(left) || Array.isArray(right)) {
5398
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
5399
+ return left.every((item, index) => jsonProbeDeepEqual(item, right[index]));
5400
+ }
5401
+ const leftKeys = Object.keys(left).sort();
5402
+ const rightKeys = Object.keys(right).sort();
5403
+ if (!jsonProbeDeepEqual(leftKeys, rightKeys)) return false;
5404
+ return leftKeys.every((key) => jsonProbeDeepEqual(left[key], right[key]));
5405
+ }
5406
+ function jsonProbeContains(observed, expected) {
5407
+ if (typeof observed === "string" && typeof expected === "string") return observed.includes(expected);
5408
+ if (Array.isArray(observed)) return observed.some((item) => jsonProbeDeepEqual(item, expected));
5409
+ if (observed && expected && typeof observed === "object" && typeof expected === "object" && !Array.isArray(observed) && !Array.isArray(expected)) {
5410
+ return Object.entries(expected).every(([key, value]) => Object.hasOwn(observed, key) && jsonProbeDeepEqual(observed[key], value));
5411
+ }
5412
+ return false;
5413
+ }
5414
+ function parseJsonProbePathSegments(path) {
5415
+ let input = String(path || "").trim();
5416
+ if (!input) throw new Error("path is empty");
5417
+ if (input === "$") return [];
5418
+ if (input.startsWith("$.")) input = input.slice(2);
5419
+ else if (input.startsWith("$[")) input = input.slice(1);
5420
+ const segments = [];
5421
+ let token = "";
5422
+ const pushToken = () => {
5423
+ if (!token) return;
5424
+ segments.push(token);
5425
+ token = "";
5426
+ };
5427
+ for (let index = 0; index < input.length; index += 1) {
5428
+ const char = input[index];
5429
+ if (char === ".") {
5430
+ pushToken();
5431
+ continue;
5432
+ }
5433
+ if (char !== "[") {
5434
+ token += char;
5435
+ continue;
5436
+ }
5437
+ pushToken();
5438
+ const closeIndex = input.indexOf("]", index + 1);
5439
+ if (closeIndex === -1) throw new Error("unterminated bracket at " + index);
5440
+ const bracket = input.slice(index + 1, closeIndex).trim();
5441
+ if (!bracket) throw new Error("empty bracket at " + index);
5442
+ if (/^\d+$/.test(bracket)) {
5443
+ segments.push(Number(bracket));
5444
+ } else if ((bracket.startsWith('"') && bracket.endsWith('"')) || (bracket.startsWith("'") && bracket.endsWith("'"))) {
5445
+ const quoted = bracket.startsWith("'")
5446
+ ? '"' + bracket.slice(1, -1).replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + '"'
5447
+ : bracket;
5448
+ segments.push(String(JSON.parse(quoted)));
5449
+ } else {
5450
+ segments.push(bracket);
5451
+ }
5452
+ index = closeIndex;
5453
+ }
5454
+ pushToken();
5455
+ return segments;
5456
+ }
5457
+ function resolveJsonProbePath(root, path) {
5458
+ let segments;
5459
+ try {
5460
+ segments = parseJsonProbePathSegments(path);
5461
+ } catch (error) {
5462
+ return { exists: false, error: String(error && error.message ? error.message : error) };
5463
+ }
5464
+ let current = root;
5465
+ for (const segment of segments) {
5466
+ if (typeof segment === "number") {
5467
+ if (!Array.isArray(current) || segment < 0 || segment >= current.length) return { exists: false };
5468
+ current = current[segment];
5469
+ continue;
5470
+ }
5471
+ if (!current || typeof current !== "object" || Array.isArray(current) || !Object.hasOwn(current, segment)) {
5472
+ return { exists: false };
5473
+ }
5474
+ current = current[segment];
5475
+ }
5476
+ return { exists: true, value: current };
5477
+ }
5478
+ function evaluateJsonProbeAssertion(root, assertion) {
5479
+ const resolved = resolveJsonProbePath(root, assertion.path);
5480
+ const errors = [];
5481
+ const result = {
5482
+ label: assertion.label || assertion.path,
5483
+ path: assertion.path,
5484
+ ok: true,
5485
+ exists: resolved.exists,
5486
+ observed_type: resolved.exists ? jsonProbeValueType(resolved.value) : "missing",
5487
+ };
5488
+ if (resolved.exists) attachJsonProbeObservedValue(result, resolved.value);
5489
+ if (resolved.error) errors.push(resolved.error);
5490
+ if (Object.hasOwn(assertion, "exists")) {
5491
+ result.expected_exists = assertion.exists;
5492
+ if (resolved.exists !== assertion.exists) errors.push("expected exists=" + assertion.exists);
5493
+ }
5494
+ if (Object.hasOwn(assertion, "type")) {
5495
+ result.type = assertion.type;
5496
+ if (!resolved.exists || jsonProbeValueType(resolved.value) !== assertion.type) errors.push("expected type " + assertion.type);
5497
+ }
5498
+ if (Object.hasOwn(assertion, "equals")) {
5499
+ result.equals = assertion.equals;
5500
+ if (!resolved.exists || !jsonProbeDeepEqual(resolved.value, assertion.equals)) errors.push("expected JSON value equality");
5501
+ }
5502
+ if (Object.hasOwn(assertion, "not_equals")) {
5503
+ result.not_equals = assertion.not_equals;
5504
+ if (resolved.exists && jsonProbeDeepEqual(resolved.value, assertion.not_equals)) errors.push("expected JSON value inequality");
5505
+ }
5506
+ if (Object.hasOwn(assertion, "contains")) {
5507
+ result.contains = assertion.contains;
5508
+ if (!resolved.exists || !jsonProbeContains(resolved.value, assertion.contains)) errors.push("expected JSON value containment");
5509
+ }
5510
+ result.ok = errors.length === 0;
5511
+ if (errors.length) result.errors = errors;
5512
+ return result;
5513
+ }
5514
+ function evaluateJsonProbeAssertions(text, assertions) {
5515
+ const expected = Array.isArray(assertions) ? assertions.filter((assertion) => assertion && assertion.path) : [];
5516
+ if (!expected.length) return [];
5517
+ let parsed;
5518
+ try {
5519
+ parsed = JSON.parse(text);
5520
+ } catch (error) {
5521
+ const message = "response body is not valid JSON: " + String(error && error.message ? error.message : error).slice(0, 200);
5522
+ return expected.map((assertion) => ({
5523
+ label: assertion.label || assertion.path,
5524
+ path: assertion.path,
5525
+ ok: false,
5526
+ exists: false,
5527
+ observed_type: "missing",
5528
+ errors: [message],
5529
+ }));
5530
+ }
5531
+ return expected.map((assertion) => evaluateJsonProbeAssertion(parsed, assertion));
5532
+ }
5038
5533
  async function collectHttpStatus(check) {
5039
5534
  const url = httpStatusRequestUrl(check, page.url() || targetUrl);
5040
5535
  const method = httpStatusMethod(check);
@@ -5051,6 +5546,7 @@ async function collectHttpStatus(check) {
5051
5546
  const bodyContains = Array.isArray(check.body_contains) ? check.body_contains.filter(Boolean) : [];
5052
5547
  const bodyNotContains = Array.isArray(check.body_not_contains) ? check.body_not_contains.filter(Boolean) : [];
5053
5548
  const bodyNotPatterns = Array.isArray(check.body_not_patterns) ? check.body_not_patterns.filter(Boolean) : [];
5549
+ const bodyJsonAssertions = Array.isArray(check.body_json_assertions) ? check.body_json_assertions.filter((assertion) => assertion && assertion.path) : [];
5054
5550
  const options = {
5055
5551
  method,
5056
5552
  redirect: "follow",
@@ -5076,17 +5572,18 @@ async function collectHttpStatus(check) {
5076
5572
  Object.assign(result, linkProbeResponseFields(response, method));
5077
5573
  result.url = url;
5078
5574
  result.status_text = response.statusText || "";
5079
- 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;
5575
+ 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;
5080
5576
  if (shouldReadBody) {
5081
5577
  try {
5082
5578
  const buffer = await response.arrayBuffer();
5083
5579
  result.bytes = buffer.byteLength;
5084
- if (bodyContains.length || bodyNotContains.length || bodyNotPatterns.length) {
5580
+ if (bodyContains.length || bodyNotContains.length || bodyNotPatterns.length || bodyJsonAssertions.length) {
5085
5581
  const text = new TextDecoder().decode(buffer);
5086
5582
  result.body_sample = text.slice(0, 1000);
5087
5583
  if (bodyContains.length) result.body_contains = Object.fromEntries(bodyContains.map((expected) => [expected, text.includes(expected)]));
5088
5584
  if (bodyNotContains.length) result.body_not_contains = Object.fromEntries(bodyNotContains.map((forbidden) => [forbidden, text.includes(forbidden)]));
5089
5585
  if (bodyNotPatterns.length) result.body_not_patterns = Object.fromEntries(bodyNotPatterns.map((pattern) => [pattern, new RegExp(pattern).test(text)]));
5586
+ if (bodyJsonAssertions.length) result.body_json_assertions = evaluateJsonProbeAssertions(text, bodyJsonAssertions);
5090
5587
  }
5091
5588
  } catch (error) {
5092
5589
  result.error = String(error && error.message ? error.message : error).slice(0, 500);
@@ -5099,6 +5596,7 @@ async function collectHttpStatus(check) {
5099
5596
  && (!bodyContains.length || bodyContains.every((expected) => result.body_contains && result.body_contains[expected] === true))
5100
5597
  && (!bodyNotContains.length || bodyNotContains.every((forbidden) => result.body_not_contains && result.body_not_contains[forbidden] === false))
5101
5598
  && (!bodyNotPatterns.length || bodyNotPatterns.every((pattern) => result.body_not_patterns && result.body_not_patterns[pattern] === false))
5599
+ && (!bodyJsonAssertions.length || (Array.isArray(result.body_json_assertions) && result.body_json_assertions.every((assertion) => assertion.ok === true)))
5102
5600
  && !result.error;
5103
5601
  return result;
5104
5602
  } catch (error) {