@riddledc/riddle-proof 0.7.125 → 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/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,156 @@ 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 deepJsonEqual(left, right) {
295
+ if (left === right) return true;
296
+ if (typeof left !== typeof right) return false;
297
+ if (left === null || right === null) return left === right;
298
+ if (typeof left !== "object" || typeof right !== "object") return false;
299
+ if (Array.isArray(left) || Array.isArray(right)) {
300
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
301
+ return left.every((item, index) => deepJsonEqual(item, right[index]));
302
+ }
303
+ if (!isRecord(left) || !isRecord(right)) return false;
304
+ const leftKeys = Object.keys(left).sort();
305
+ const rightKeys = Object.keys(right).sort();
306
+ if (!deepJsonEqual(leftKeys, rightKeys)) return false;
307
+ return leftKeys.every((key) => deepJsonEqual(left[key], right[key]));
308
+ }
309
+ function jsonContains(observed, expected) {
310
+ if (typeof observed === "string" && typeof expected === "string") {
311
+ return observed.includes(expected);
312
+ }
313
+ if (Array.isArray(observed)) {
314
+ return observed.some((item) => deepJsonEqual(item, expected));
315
+ }
316
+ if (isRecord(observed) && isRecord(expected)) {
317
+ return Object.entries(expected).every(([key, value]) => hasOwn(observed, key) && deepJsonEqual(observed[key], value));
318
+ }
319
+ return false;
320
+ }
321
+ function parseJsonPathSegments(path) {
322
+ let input = path.trim();
323
+ if (!input) throw new Error("path is empty");
324
+ if (input === "$") return [];
325
+ if (input.startsWith("$.")) input = input.slice(2);
326
+ else if (input.startsWith("$[")) input = input.slice(1);
327
+ const segments = [];
328
+ let token = "";
329
+ const pushToken = () => {
330
+ if (!token) return;
331
+ segments.push(token);
332
+ token = "";
333
+ };
334
+ for (let index = 0; index < input.length; index += 1) {
335
+ const char = input[index];
336
+ if (char === ".") {
337
+ pushToken();
338
+ continue;
339
+ }
340
+ if (char !== "[") {
341
+ token += char;
342
+ continue;
343
+ }
344
+ pushToken();
345
+ const closeIndex = input.indexOf("]", index + 1);
346
+ if (closeIndex === -1) throw new Error(`unterminated bracket at ${index}`);
347
+ const bracket = input.slice(index + 1, closeIndex).trim();
348
+ if (!bracket) throw new Error(`empty bracket at ${index}`);
349
+ if (/^\d+$/.test(bracket)) {
350
+ segments.push(Number(bracket));
351
+ } else if (bracket.startsWith('"') && bracket.endsWith('"') || bracket.startsWith("'") && bracket.endsWith("'")) {
352
+ const quoted = bracket.startsWith("'") ? `"${bracket.slice(1, -1).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : bracket;
353
+ segments.push(String(JSON.parse(quoted)));
354
+ } else {
355
+ segments.push(bracket);
356
+ }
357
+ index = closeIndex;
358
+ }
359
+ pushToken();
360
+ return segments;
361
+ }
362
+ function resolveJsonPath(root, path) {
363
+ let segments;
364
+ try {
365
+ segments = parseJsonPathSegments(path);
366
+ } catch (error) {
367
+ return { exists: false, error: String(error instanceof Error ? error.message : error) };
368
+ }
369
+ let current = root;
370
+ for (const segment of segments) {
371
+ if (typeof segment === "number") {
372
+ if (!Array.isArray(current) || segment < 0 || segment >= current.length) return { exists: false };
373
+ current = current[segment];
374
+ continue;
375
+ }
376
+ if (!isRecord(current) || !hasOwn(current, segment)) return { exists: false };
377
+ current = current[segment];
378
+ }
379
+ return { exists: true, value: current };
380
+ }
381
+ function evaluateHttpStatusBodyJsonAssertion(root, assertion) {
382
+ const resolved = resolveJsonPath(root, assertion.path);
383
+ const errors = [];
384
+ const result = {
385
+ label: assertion.label || assertion.path,
386
+ path: assertion.path,
387
+ ok: true,
388
+ exists: resolved.exists,
389
+ observed_type: resolved.exists ? jsonValueType(resolved.value) : "missing"
390
+ };
391
+ if (resolved.exists) result.observed = toJsonValue(resolved.value);
392
+ if (resolved.error) errors.push(resolved.error);
393
+ if (hasOwn(assertion, "exists")) {
394
+ result.expected_exists = assertion.exists;
395
+ if (resolved.exists !== assertion.exists) errors.push(`expected exists=${assertion.exists}`);
396
+ }
397
+ if (hasOwn(assertion, "type")) {
398
+ result.type = assertion.type;
399
+ if (!resolved.exists || jsonValueType(resolved.value) !== assertion.type) errors.push(`expected type ${assertion.type}`);
400
+ }
401
+ if (hasOwn(assertion, "equals")) {
402
+ result.equals = assertion.equals;
403
+ if (!resolved.exists || !deepJsonEqual(resolved.value, assertion.equals)) errors.push("expected JSON value equality");
404
+ }
405
+ if (hasOwn(assertion, "not_equals")) {
406
+ result.not_equals = assertion.not_equals;
407
+ if (resolved.exists && deepJsonEqual(resolved.value, assertion.not_equals)) errors.push("expected JSON value inequality");
408
+ }
409
+ if (hasOwn(assertion, "contains")) {
410
+ result.contains = assertion.contains;
411
+ if (!resolved.exists || !jsonContains(resolved.value, assertion.contains)) errors.push("expected JSON value containment");
412
+ }
413
+ result.ok = errors.length === 0;
414
+ if (errors.length) result.errors = errors;
415
+ return result;
416
+ }
417
+ function evaluateHttpStatusBodyJsonAssertions(bodyText, assertions) {
418
+ const expected = assertions?.filter((assertion) => assertion.path) ?? [];
419
+ if (!expected.length) return [];
420
+ let parsed;
421
+ try {
422
+ parsed = JSON.parse(bodyText);
423
+ } catch (error) {
424
+ const message = `response body is not valid JSON: ${String(error instanceof Error ? error.message : error).slice(0, 200)}`;
425
+ return expected.map((assertion) => ({
426
+ label: assertion.label || assertion.path,
427
+ path: assertion.path,
428
+ ok: false,
429
+ exists: false,
430
+ observed_type: "missing",
431
+ errors: [message]
432
+ }));
433
+ }
434
+ return expected.map((assertion) => evaluateHttpStatusBodyJsonAssertion(parsed, assertion));
435
+ }
283
436
  function compactProfileSetupSummaryText(value, limit = 160) {
284
437
  const text = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
285
438
  if (!text) return void 0;
@@ -643,6 +796,20 @@ function normalizeNetworkMock(input, index) {
643
796
  if (maxHitCount !== void 0 && effectiveRequiredHitCount > maxHitCount) {
644
797
  throw new Error(`target.network_mocks[${index}].max_hit_count cannot be less than its required hit count.`);
645
798
  }
799
+ const sequenceScopeInput = stringValue(
800
+ input.sequence_scope ?? input.sequenceScope ?? input.response_sequence_scope ?? input.responseSequenceScope
801
+ );
802
+ let sequenceScope;
803
+ if (sequenceScopeInput) {
804
+ const normalizedScope = sequenceScopeInput.toLowerCase().replace(/[-\s]+/g, "_");
805
+ if (normalizedScope === "global" || normalizedScope === "profile" || normalizedScope === "run") {
806
+ sequenceScope = "global";
807
+ } else if (normalizedScope === "viewport" || normalizedScope === "per_viewport" || normalizedScope === "viewport_scoped") {
808
+ sequenceScope = "viewport";
809
+ } else {
810
+ throw new Error(`target.network_mocks[${index}].sequence_scope must be "global" or "viewport".`);
811
+ }
812
+ }
646
813
  return {
647
814
  ...payload,
648
815
  label: normalizeName(input.label || input.name, `network-mock-${index + 1}`),
@@ -650,6 +817,7 @@ function normalizeNetworkMock(input, index) {
650
817
  method: stringValue(input.method)?.toUpperCase(),
651
818
  responses,
652
819
  repeat_responses: input.repeat_responses === true || input.repeatResponses === true || input.cycle_responses === true || input.cycleResponses === true,
820
+ sequence_scope: sequenceScope,
653
821
  required_hit_count: requiredHitCount,
654
822
  max_hit_count: maxHitCount,
655
823
  forbidden,
@@ -919,6 +1087,46 @@ function validateRegexPatterns(patterns, label) {
919
1087
  }
920
1088
  }
921
1089
  }
1090
+ function normalizeHttpStatusBodyJsonAssertions(value, label) {
1091
+ if (value === void 0) return void 0;
1092
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
1093
+ if (!value.length) throw new Error(`${label} must not be empty.`);
1094
+ return value.map((item, index) => {
1095
+ const itemLabel = `${label}[${index}]`;
1096
+ if (typeof item === "string") {
1097
+ const path2 = stringValue(item);
1098
+ if (!path2) throw new Error(`${itemLabel} path must not be empty.`);
1099
+ return { path: path2, exists: true };
1100
+ }
1101
+ if (!isRecord(item)) throw new Error(`${itemLabel} must be an object or JSON path string.`);
1102
+ const path = stringFromOwn(item, "path", "json_path", "jsonPath", "key");
1103
+ if (!path) throw new Error(`${itemLabel}.path is required.`);
1104
+ const assertion = {
1105
+ label: stringValue(item.label),
1106
+ path
1107
+ };
1108
+ const exists = booleanValue(valueFromOwn(item, "exists", "present"));
1109
+ if (exists !== void 0) assertion.exists = exists;
1110
+ const type = stringValue(valueFromOwn(item, "type", "value_type", "valueType"));
1111
+ if (type !== void 0) {
1112
+ const allowedTypes = ["array", "boolean", "null", "number", "object", "string"];
1113
+ if (!allowedTypes.includes(type)) {
1114
+ throw new Error(`${itemLabel}.type must be one of ${allowedTypes.join(", ")}.`);
1115
+ }
1116
+ assertion.type = type;
1117
+ }
1118
+ const equalsValue = valueFromOwn(item, "equals", "expected", "expected_value", "expectedValue", "value");
1119
+ if (equalsValue !== void 0) assertion.equals = toJsonValue(equalsValue);
1120
+ const notEqualsValue = valueFromOwn(item, "not_equals", "notEquals", "forbidden", "forbidden_value", "forbiddenValue");
1121
+ if (notEqualsValue !== void 0) assertion.not_equals = toJsonValue(notEqualsValue);
1122
+ const containsValue = valueFromOwn(item, "contains", "includes", "contains_value", "containsValue", "include");
1123
+ if (containsValue !== void 0) assertion.contains = toJsonValue(containsValue);
1124
+ if (assertion.exists === void 0 && assertion.type === void 0 && !hasOwn(assertion, "equals") && !hasOwn(assertion, "not_equals") && !hasOwn(assertion, "contains")) {
1125
+ assertion.exists = true;
1126
+ }
1127
+ return assertion;
1128
+ });
1129
+ }
922
1130
  function isDialogCountCheckType(type) {
923
1131
  return type === "dialog_count_equals" || type === "dialog_accept_count_equals" || type === "dialog_dismiss_count_equals";
924
1132
  }
@@ -1018,6 +1226,10 @@ function normalizeCheck(input, index) {
1018
1226
  `checks[${index}] body_not_patterns`
1019
1227
  ) : void 0;
1020
1228
  if (bodyNotPatterns?.length) validateRegexPatterns(bodyNotPatterns, `checks[${index}] body_not_patterns`);
1229
+ const bodyJsonAssertions = isHttpStatusCheck ? normalizeHttpStatusBodyJsonAssertions(
1230
+ input.body_json_assertions ?? input.bodyJsonAssertions ?? input.json_body_assertions ?? input.jsonBodyAssertions ?? input.json_assertions ?? input.jsonAssertions ?? input.response_json_assertions ?? input.responseJsonAssertions,
1231
+ `checks[${index}] body_json_assertions`
1232
+ ) : void 0;
1021
1233
  if (isLinkStatusCheck) {
1022
1234
  if (minCount !== void 0 && (!Number.isInteger(minCount) || minCount < 0)) {
1023
1235
  throw new Error(`checks[${index}] ${type} min_count must be a non-negative integer.`);
@@ -1043,6 +1255,7 @@ function normalizeCheck(input, index) {
1043
1255
  body_contains: bodyContains,
1044
1256
  body_not_contains: bodyNotContains,
1045
1257
  body_not_patterns: bodyNotPatterns,
1258
+ body_json_assertions: bodyJsonAssertions,
1046
1259
  expected_texts: expectedTexts,
1047
1260
  link_selector: stringValue(input.link_selector) || stringValue(input.linkSelector),
1048
1261
  source_selector: stringValue(input.source_selector) || stringValue(input.sourceSelector),
@@ -1249,6 +1462,34 @@ function httpStatusBodyNotPatternFailures(result, check) {
1249
1462
  const observed = isRecord(result.body_not_patterns) ? result.body_not_patterns : {};
1250
1463
  return forbidden.filter((pattern) => observed[pattern] !== false);
1251
1464
  }
1465
+ function httpStatusBodyJsonAssertionFailures(result, check) {
1466
+ const expected = check.body_json_assertions?.filter((assertion) => assertion.path) ?? [];
1467
+ if (!expected.length) return [];
1468
+ if (!Array.isArray(result.body_json_assertions)) {
1469
+ return expected.map((assertion) => ({
1470
+ label: assertion.label || assertion.path,
1471
+ path: assertion.path,
1472
+ ok: false,
1473
+ exists: false,
1474
+ observed_type: "missing",
1475
+ errors: ["body_json_assertions evidence missing"]
1476
+ }));
1477
+ }
1478
+ return result.body_json_assertions.filter((assertion) => isRecord(assertion) && assertion.ok !== true).map((assertion) => ({
1479
+ label: stringValue(assertion.label) || stringValue(assertion.path) || "json assertion",
1480
+ path: stringValue(assertion.path) || "",
1481
+ ok: false,
1482
+ exists: assertion.exists === true,
1483
+ observed: hasOwn(assertion, "observed") ? toJsonValue(assertion.observed) : void 0,
1484
+ observed_type: stringValue(assertion.observed_type) || "missing",
1485
+ expected_exists: booleanValue(assertion.expected_exists),
1486
+ equals: hasOwn(assertion, "equals") ? toJsonValue(assertion.equals) : void 0,
1487
+ not_equals: hasOwn(assertion, "not_equals") ? toJsonValue(assertion.not_equals) : void 0,
1488
+ contains: hasOwn(assertion, "contains") ? toJsonValue(assertion.contains) : void 0,
1489
+ type: stringValue(assertion.type),
1490
+ errors: Array.isArray(assertion.errors) ? assertion.errors.map(String) : void 0
1491
+ }));
1492
+ }
1252
1493
  function linkStatusResultOk(result, check) {
1253
1494
  const status = numberValue(result.status);
1254
1495
  if (!httpStatusIsAllowed(status, check)) return false;
@@ -1266,6 +1507,7 @@ function linkStatusResultOk(result, check) {
1266
1507
  if (httpStatusBodyContainsFailures(result, check).length) return false;
1267
1508
  if (httpStatusBodyNotContainsFailures(result, check).length) return false;
1268
1509
  if (httpStatusBodyNotPatternFailures(result, check).length) return false;
1510
+ if (httpStatusBodyJsonAssertionFailures(result, check).length) return false;
1269
1511
  return true;
1270
1512
  }
1271
1513
  function responseHeader(response, name) {
@@ -1334,7 +1576,7 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1334
1576
  statusText = typeof response.statusText === "string" ? response.statusText : "";
1335
1577
  result.content_type = responseHeader(response, "content-type");
1336
1578
  result.content_length = responseContentLength(response);
1337
- 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);
1579
+ 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);
1338
1580
  if (shouldReadBody && method !== "HEAD") {
1339
1581
  const body = await responseBodyText(response);
1340
1582
  result.bytes = body.bytes;
@@ -1347,6 +1589,9 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1347
1589
  if (check.body_not_patterns?.length) {
1348
1590
  result.body_not_patterns = Object.fromEntries(check.body_not_patterns.filter(Boolean).map((pattern) => [pattern, new RegExp(pattern).test(body.text)]));
1349
1591
  }
1592
+ if (check.body_json_assertions?.length) {
1593
+ result.body_json_assertions = evaluateHttpStatusBodyJsonAssertions(body.text, check.body_json_assertions);
1594
+ }
1350
1595
  }
1351
1596
  } catch (caught) {
1352
1597
  error = String(caught instanceof Error ? caught.message : caught).slice(0, 500);
@@ -1355,6 +1600,7 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1355
1600
  const bodyContainsMissing = httpStatusBodyContainsFailures(result, check);
1356
1601
  const bodyNotContainsFound = httpStatusBodyNotContainsFailures(result, check);
1357
1602
  const bodyNotPatternsFound = httpStatusBodyNotPatternFailures(result, check);
1603
+ const bodyJsonAssertionsFailed = httpStatusBodyJsonAssertionFailures(result, check);
1358
1604
  const ok = !error && linkStatusResultOk(result, check);
1359
1605
  return {
1360
1606
  index,
@@ -1373,7 +1619,9 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1373
1619
  body_not_contains: isRecord(result.body_not_contains) ? Object.fromEntries(Object.entries(result.body_not_contains).map(([key, value]) => [key, value === true])) : null,
1374
1620
  body_not_contains_found: bodyNotContainsFound,
1375
1621
  body_not_patterns: isRecord(result.body_not_patterns) ? Object.fromEntries(Object.entries(result.body_not_patterns).map(([key, value]) => [key, value === true])) : null,
1376
- body_not_patterns_found: bodyNotPatternsFound
1622
+ body_not_patterns_found: bodyNotPatternsFound,
1623
+ body_json_assertions: Array.isArray(result.body_json_assertions) ? result.body_json_assertions : null,
1624
+ body_json_assertions_failed: bodyJsonAssertionsFailed
1377
1625
  };
1378
1626
  }
1379
1627
  async function preflightRiddleProofProfileHttpStatusChecks(profile, options = {}) {
@@ -1418,6 +1666,7 @@ function summarizeHttpStatusEvidence(viewport, check) {
1418
1666
  const bodyContainsMissing = httpStatusBodyContainsFailures(statusEvidence, check);
1419
1667
  const bodyNotContainsFound = httpStatusBodyNotContainsFailures(statusEvidence, check);
1420
1668
  const bodyNotPatternsFound = httpStatusBodyNotPatternFailures(statusEvidence, check);
1669
+ const bodyJsonAssertionsFailed = httpStatusBodyJsonAssertionFailures(statusEvidence, check);
1421
1670
  if (!linkStatusResultOk(statusEvidence, check)) {
1422
1671
  failures.push({
1423
1672
  code: "http_status_failed",
@@ -1436,6 +1685,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
1436
1685
  body_not_contains_found: bodyNotContainsFound,
1437
1686
  body_not_patterns: check.body_not_patterns ?? null,
1438
1687
  body_not_patterns_found: bodyNotPatternsFound,
1688
+ body_json_assertions: check.body_json_assertions ?? null,
1689
+ body_json_assertions_failed: bodyJsonAssertionsFailed.map((assertion) => toJsonValue(assertion)),
1439
1690
  body_sample: stringValue(statusEvidence.body_sample) ?? null
1440
1691
  });
1441
1692
  }
@@ -1457,6 +1708,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
1457
1708
  body_not_contains_found: bodyNotContainsFound,
1458
1709
  body_not_patterns: isRecord(statusEvidence.body_not_patterns) ? toJsonValue(statusEvidence.body_not_patterns) : null,
1459
1710
  body_not_patterns_found: bodyNotPatternsFound,
1711
+ body_json_assertions: Array.isArray(statusEvidence.body_json_assertions) ? toJsonValue(statusEvidence.body_json_assertions) : null,
1712
+ body_json_assertions_failed: bodyJsonAssertionsFailed.map((assertion) => toJsonValue(assertion)),
1460
1713
  body_sample: stringValue(statusEvidence.body_sample) ?? null,
1461
1714
  failures
1462
1715
  };
@@ -2093,6 +2346,7 @@ function assessCheckFromEvidence(check, evidence) {
2093
2346
  body_contains: check.body_contains ?? [],
2094
2347
  body_not_contains: check.body_not_contains ?? [],
2095
2348
  body_not_patterns: check.body_not_patterns ?? [],
2349
+ body_json_assertions: toJsonValue(check.body_json_assertions ?? []),
2096
2350
  viewports: summaries.map((summary) => toJsonValue(summary)),
2097
2351
  failures: failed.flatMap((summary) => Array.isArray(summary.failures) ? summary.failures.map((failure) => toJsonValue({ viewport: stringValue(summary.viewport) ?? null, failure })) : [])
2098
2352
  },
@@ -2836,6 +3090,36 @@ function httpStatusBodyNotPatternFailures(result, check) {
2836
3090
  : {};
2837
3091
  return forbidden.filter((pattern) => observed[pattern] !== false);
2838
3092
  }
3093
+ function httpStatusBodyJsonAssertionFailures(result, check) {
3094
+ const expected = Array.isArray(check.body_json_assertions) ? check.body_json_assertions.filter((assertion) => assertion && assertion.path) : [];
3095
+ if (!expected.length) return [];
3096
+ if (!Array.isArray(result.body_json_assertions)) {
3097
+ return expected.map((assertion) => ({
3098
+ label: assertion.label || assertion.path,
3099
+ path: assertion.path,
3100
+ ok: false,
3101
+ exists: false,
3102
+ observed_type: "missing",
3103
+ errors: ["body_json_assertions evidence missing"],
3104
+ }));
3105
+ }
3106
+ return result.body_json_assertions
3107
+ .filter((assertion) => assertion && typeof assertion === "object" && assertion.ok !== true)
3108
+ .map((assertion) => ({
3109
+ label: typeof assertion.label === "string" && assertion.label ? assertion.label : typeof assertion.path === "string" && assertion.path ? assertion.path : "json assertion",
3110
+ path: typeof assertion.path === "string" ? assertion.path : "",
3111
+ ok: false,
3112
+ exists: assertion.exists === true,
3113
+ observed: Object.hasOwn(assertion, "observed") ? assertion.observed : undefined,
3114
+ observed_type: typeof assertion.observed_type === "string" && assertion.observed_type ? assertion.observed_type : "missing",
3115
+ expected_exists: typeof assertion.expected_exists === "boolean" ? assertion.expected_exists : undefined,
3116
+ equals: Object.hasOwn(assertion, "equals") ? assertion.equals : undefined,
3117
+ not_equals: Object.hasOwn(assertion, "not_equals") ? assertion.not_equals : undefined,
3118
+ contains: Object.hasOwn(assertion, "contains") ? assertion.contains : undefined,
3119
+ type: typeof assertion.type === "string" ? assertion.type : undefined,
3120
+ errors: Array.isArray(assertion.errors) ? assertion.errors.map(String) : undefined,
3121
+ }));
3122
+ }
2839
3123
  function linkStatusResultOk(result, check) {
2840
3124
  if (!result || typeof result !== "object" || Array.isArray(result)) return false;
2841
3125
  if (!httpStatusIsAllowed(result.status, check)) return false;
@@ -2853,6 +3137,7 @@ function linkStatusResultOk(result, check) {
2853
3137
  if (httpStatusBodyContainsFailures(result, check).length) return false;
2854
3138
  if (httpStatusBodyNotContainsFailures(result, check).length) return false;
2855
3139
  if (httpStatusBodyNotPatternFailures(result, check).length) return false;
3140
+ if (httpStatusBodyJsonAssertionFailures(result, check).length) return false;
2856
3141
  return true;
2857
3142
  }
2858
3143
  function summarizeHttpStatusEvidence(viewport, check) {
@@ -2873,6 +3158,7 @@ function summarizeHttpStatusEvidence(viewport, check) {
2873
3158
  const bodyContainsMissing = httpStatusBodyContainsFailures(statusEvidence, check);
2874
3159
  const bodyNotContainsFound = httpStatusBodyNotContainsFailures(statusEvidence, check);
2875
3160
  const bodyNotPatternsFound = httpStatusBodyNotPatternFailures(statusEvidence, check);
3161
+ const bodyJsonAssertionsFailed = httpStatusBodyJsonAssertionFailures(statusEvidence, check);
2876
3162
  if (!linkStatusResultOk(statusEvidence, check)) {
2877
3163
  failures.push({
2878
3164
  code: "http_status_failed",
@@ -2891,6 +3177,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
2891
3177
  body_not_contains_found: bodyNotContainsFound,
2892
3178
  body_not_patterns: Array.isArray(check.body_not_patterns) ? check.body_not_patterns : null,
2893
3179
  body_not_patterns_found: bodyNotPatternsFound,
3180
+ body_json_assertions: Array.isArray(check.body_json_assertions) ? check.body_json_assertions : null,
3181
+ body_json_assertions_failed: bodyJsonAssertionsFailed,
2894
3182
  body_sample: typeof statusEvidence.body_sample === "string" ? statusEvidence.body_sample : null,
2895
3183
  });
2896
3184
  }
@@ -2918,6 +3206,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
2918
3206
  ? statusEvidence.body_not_patterns
2919
3207
  : null,
2920
3208
  body_not_patterns_found: bodyNotPatternsFound,
3209
+ body_json_assertions: Array.isArray(statusEvidence.body_json_assertions) ? statusEvidence.body_json_assertions : null,
3210
+ body_json_assertions_failed: bodyJsonAssertionsFailed,
2921
3211
  body_sample: typeof statusEvidence.body_sample === "string" ? statusEvidence.body_sample : null,
2922
3212
  failures,
2923
3213
  };
@@ -4301,6 +4591,7 @@ async function setupLocatorVisible(locator, index) {
4301
4591
  async function registerNetworkMocks(mocks) {
4302
4592
  for (const mock of mocks || []) {
4303
4593
  let hitCount = 0;
4594
+ const scopedHitCounts = {};
4304
4595
  await page.route(mock.url, async (route) => {
4305
4596
  const request = route.request();
4306
4597
  const method = request.method ? request.method() : "";
@@ -4316,8 +4607,13 @@ async function registerNetworkMocks(mocks) {
4316
4607
  const responses = Array.isArray(mock.responses) ? mock.responses : [];
4317
4608
  const hitIndex = hitCount;
4318
4609
  hitCount += 1;
4610
+ const sequenceScope = mock.sequence_scope === "viewport" ? "viewport" : "global";
4611
+ const viewportName = activeViewportName || null;
4612
+ const sequenceScopeKey = sequenceScope === "viewport" ? (viewportName || "__unknown_viewport__") : "__global__";
4613
+ const sequenceHitIndex = sequenceScope === "viewport" ? (scopedHitCounts[sequenceScopeKey] || 0) : hitIndex;
4614
+ if (sequenceScope === "viewport") scopedHitCounts[sequenceScopeKey] = sequenceHitIndex + 1;
4319
4615
  const sequenceResponseIndex = responses.length
4320
- ? (mock.repeat_responses ? hitIndex % responses.length : Math.min(hitIndex, responses.length - 1))
4616
+ ? (mock.repeat_responses ? sequenceHitIndex % responses.length : Math.min(sequenceHitIndex, responses.length - 1))
4321
4617
  : null;
4322
4618
  let responseIndex = sequenceResponseIndex;
4323
4619
  let responseSelection = responseIndex === null ? "mock" : "sequence";
@@ -4352,11 +4648,14 @@ async function registerNetworkMocks(mocks) {
4352
4648
  label: mock.label,
4353
4649
  response_label: response.label || null,
4354
4650
  hit_index: hitIndex,
4651
+ sequence_hit_index: responseIndex === null ? undefined : sequenceHitIndex,
4652
+ sequence_scope: responseIndex === null ? undefined : sequenceScope,
4653
+ viewport: viewportName,
4355
4654
  response_index: responseIndex,
4356
4655
  sequence_response_index: responseSelection === "request_body" ? sequenceResponseIndex : undefined,
4357
4656
  response_selection: responseIndex === null ? null : responseSelection,
4358
- sequence_reused: responseSelection === "sequence" && responseIndex !== null && !mock.repeat_responses && hitIndex >= responses.length,
4359
- sequence_cycle: responseSelection === "sequence" && responseIndex !== null && mock.repeat_responses === true && hitIndex >= responses.length,
4657
+ sequence_reused: responseSelection === "sequence" && responseIndex !== null && !mock.repeat_responses && sequenceHitIndex >= responses.length,
4658
+ sequence_cycle: responseSelection === "sequence" && responseIndex !== null && mock.repeat_responses === true && sequenceHitIndex >= responses.length,
4360
4659
  url: request.url(),
4361
4660
  method,
4362
4661
  };
@@ -4398,6 +4697,7 @@ async function registerNetworkMocks(mocks) {
4398
4697
  });
4399
4698
  }
4400
4699
  }
4700
+ let activeViewportName = null;
4401
4701
  async function executeSetupAction(action, ordinal, viewport) {
4402
4702
  const type = setupActionType(action);
4403
4703
  const frameSelector = setupFrameSelector(action);
@@ -5010,6 +5310,155 @@ function linkProbeResponseFields(response, method) {
5010
5310
  content_length: contentLength,
5011
5311
  };
5012
5312
  }
5313
+ function jsonProbeValueType(value) {
5314
+ if (value === null) return "null";
5315
+ if (Array.isArray(value)) return "array";
5316
+ if (typeof value === "boolean") return "boolean";
5317
+ if (typeof value === "number") return "number";
5318
+ if (typeof value === "string") return "string";
5319
+ return "object";
5320
+ }
5321
+ function jsonProbeDeepEqual(left, right) {
5322
+ if (left === right) return true;
5323
+ if (typeof left !== typeof right) return false;
5324
+ if (left === null || right === null) return left === right;
5325
+ if (typeof left !== "object" || typeof right !== "object") return false;
5326
+ if (Array.isArray(left) || Array.isArray(right)) {
5327
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
5328
+ return left.every((item, index) => jsonProbeDeepEqual(item, right[index]));
5329
+ }
5330
+ const leftKeys = Object.keys(left).sort();
5331
+ const rightKeys = Object.keys(right).sort();
5332
+ if (!jsonProbeDeepEqual(leftKeys, rightKeys)) return false;
5333
+ return leftKeys.every((key) => jsonProbeDeepEqual(left[key], right[key]));
5334
+ }
5335
+ function jsonProbeContains(observed, expected) {
5336
+ if (typeof observed === "string" && typeof expected === "string") return observed.includes(expected);
5337
+ if (Array.isArray(observed)) return observed.some((item) => jsonProbeDeepEqual(item, expected));
5338
+ if (observed && expected && typeof observed === "object" && typeof expected === "object" && !Array.isArray(observed) && !Array.isArray(expected)) {
5339
+ return Object.entries(expected).every(([key, value]) => Object.hasOwn(observed, key) && jsonProbeDeepEqual(observed[key], value));
5340
+ }
5341
+ return false;
5342
+ }
5343
+ function parseJsonProbePathSegments(path) {
5344
+ let input = String(path || "").trim();
5345
+ if (!input) throw new Error("path is empty");
5346
+ if (input === "$") return [];
5347
+ if (input.startsWith("$.")) input = input.slice(2);
5348
+ else if (input.startsWith("$[")) input = input.slice(1);
5349
+ const segments = [];
5350
+ let token = "";
5351
+ const pushToken = () => {
5352
+ if (!token) return;
5353
+ segments.push(token);
5354
+ token = "";
5355
+ };
5356
+ for (let index = 0; index < input.length; index += 1) {
5357
+ const char = input[index];
5358
+ if (char === ".") {
5359
+ pushToken();
5360
+ continue;
5361
+ }
5362
+ if (char !== "[") {
5363
+ token += char;
5364
+ continue;
5365
+ }
5366
+ pushToken();
5367
+ const closeIndex = input.indexOf("]", index + 1);
5368
+ if (closeIndex === -1) throw new Error("unterminated bracket at " + index);
5369
+ const bracket = input.slice(index + 1, closeIndex).trim();
5370
+ if (!bracket) throw new Error("empty bracket at " + index);
5371
+ if (/^\d+$/.test(bracket)) {
5372
+ segments.push(Number(bracket));
5373
+ } else if ((bracket.startsWith('"') && bracket.endsWith('"')) || (bracket.startsWith("'") && bracket.endsWith("'"))) {
5374
+ const quoted = bracket.startsWith("'")
5375
+ ? '"' + bracket.slice(1, -1).replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + '"'
5376
+ : bracket;
5377
+ segments.push(String(JSON.parse(quoted)));
5378
+ } else {
5379
+ segments.push(bracket);
5380
+ }
5381
+ index = closeIndex;
5382
+ }
5383
+ pushToken();
5384
+ return segments;
5385
+ }
5386
+ function resolveJsonProbePath(root, path) {
5387
+ let segments;
5388
+ try {
5389
+ segments = parseJsonProbePathSegments(path);
5390
+ } catch (error) {
5391
+ return { exists: false, error: String(error && error.message ? error.message : error) };
5392
+ }
5393
+ let current = root;
5394
+ for (const segment of segments) {
5395
+ if (typeof segment === "number") {
5396
+ if (!Array.isArray(current) || segment < 0 || segment >= current.length) return { exists: false };
5397
+ current = current[segment];
5398
+ continue;
5399
+ }
5400
+ if (!current || typeof current !== "object" || Array.isArray(current) || !Object.hasOwn(current, segment)) {
5401
+ return { exists: false };
5402
+ }
5403
+ current = current[segment];
5404
+ }
5405
+ return { exists: true, value: current };
5406
+ }
5407
+ function evaluateJsonProbeAssertion(root, assertion) {
5408
+ const resolved = resolveJsonProbePath(root, assertion.path);
5409
+ const errors = [];
5410
+ const result = {
5411
+ label: assertion.label || assertion.path,
5412
+ path: assertion.path,
5413
+ ok: true,
5414
+ exists: resolved.exists,
5415
+ observed_type: resolved.exists ? jsonProbeValueType(resolved.value) : "missing",
5416
+ };
5417
+ if (resolved.exists) result.observed = resolved.value;
5418
+ if (resolved.error) errors.push(resolved.error);
5419
+ if (Object.hasOwn(assertion, "exists")) {
5420
+ result.expected_exists = assertion.exists;
5421
+ if (resolved.exists !== assertion.exists) errors.push("expected exists=" + assertion.exists);
5422
+ }
5423
+ if (Object.hasOwn(assertion, "type")) {
5424
+ result.type = assertion.type;
5425
+ if (!resolved.exists || jsonProbeValueType(resolved.value) !== assertion.type) errors.push("expected type " + assertion.type);
5426
+ }
5427
+ if (Object.hasOwn(assertion, "equals")) {
5428
+ result.equals = assertion.equals;
5429
+ if (!resolved.exists || !jsonProbeDeepEqual(resolved.value, assertion.equals)) errors.push("expected JSON value equality");
5430
+ }
5431
+ if (Object.hasOwn(assertion, "not_equals")) {
5432
+ result.not_equals = assertion.not_equals;
5433
+ if (resolved.exists && jsonProbeDeepEqual(resolved.value, assertion.not_equals)) errors.push("expected JSON value inequality");
5434
+ }
5435
+ if (Object.hasOwn(assertion, "contains")) {
5436
+ result.contains = assertion.contains;
5437
+ if (!resolved.exists || !jsonProbeContains(resolved.value, assertion.contains)) errors.push("expected JSON value containment");
5438
+ }
5439
+ result.ok = errors.length === 0;
5440
+ if (errors.length) result.errors = errors;
5441
+ return result;
5442
+ }
5443
+ function evaluateJsonProbeAssertions(text, assertions) {
5444
+ const expected = Array.isArray(assertions) ? assertions.filter((assertion) => assertion && assertion.path) : [];
5445
+ if (!expected.length) return [];
5446
+ let parsed;
5447
+ try {
5448
+ parsed = JSON.parse(text);
5449
+ } catch (error) {
5450
+ const message = "response body is not valid JSON: " + String(error && error.message ? error.message : error).slice(0, 200);
5451
+ return expected.map((assertion) => ({
5452
+ label: assertion.label || assertion.path,
5453
+ path: assertion.path,
5454
+ ok: false,
5455
+ exists: false,
5456
+ observed_type: "missing",
5457
+ errors: [message],
5458
+ }));
5459
+ }
5460
+ return expected.map((assertion) => evaluateJsonProbeAssertion(parsed, assertion));
5461
+ }
5013
5462
  async function collectHttpStatus(check) {
5014
5463
  const url = httpStatusRequestUrl(check, page.url() || targetUrl);
5015
5464
  const method = httpStatusMethod(check);
@@ -5026,6 +5475,7 @@ async function collectHttpStatus(check) {
5026
5475
  const bodyContains = Array.isArray(check.body_contains) ? check.body_contains.filter(Boolean) : [];
5027
5476
  const bodyNotContains = Array.isArray(check.body_not_contains) ? check.body_not_contains.filter(Boolean) : [];
5028
5477
  const bodyNotPatterns = Array.isArray(check.body_not_patterns) ? check.body_not_patterns.filter(Boolean) : [];
5478
+ const bodyJsonAssertions = Array.isArray(check.body_json_assertions) ? check.body_json_assertions.filter((assertion) => assertion && assertion.path) : [];
5029
5479
  const options = {
5030
5480
  method,
5031
5481
  redirect: "follow",
@@ -5051,17 +5501,18 @@ async function collectHttpStatus(check) {
5051
5501
  Object.assign(result, linkProbeResponseFields(response, method));
5052
5502
  result.url = url;
5053
5503
  result.status_text = response.statusText || "";
5054
- 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;
5504
+ 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;
5055
5505
  if (shouldReadBody) {
5056
5506
  try {
5057
5507
  const buffer = await response.arrayBuffer();
5058
5508
  result.bytes = buffer.byteLength;
5059
- if (bodyContains.length || bodyNotContains.length || bodyNotPatterns.length) {
5509
+ if (bodyContains.length || bodyNotContains.length || bodyNotPatterns.length || bodyJsonAssertions.length) {
5060
5510
  const text = new TextDecoder().decode(buffer);
5061
5511
  result.body_sample = text.slice(0, 1000);
5062
5512
  if (bodyContains.length) result.body_contains = Object.fromEntries(bodyContains.map((expected) => [expected, text.includes(expected)]));
5063
5513
  if (bodyNotContains.length) result.body_not_contains = Object.fromEntries(bodyNotContains.map((forbidden) => [forbidden, text.includes(forbidden)]));
5064
5514
  if (bodyNotPatterns.length) result.body_not_patterns = Object.fromEntries(bodyNotPatterns.map((pattern) => [pattern, new RegExp(pattern).test(text)]));
5515
+ if (bodyJsonAssertions.length) result.body_json_assertions = evaluateJsonProbeAssertions(text, bodyJsonAssertions);
5065
5516
  }
5066
5517
  } catch (error) {
5067
5518
  result.error = String(error && error.message ? error.message : error).slice(0, 500);
@@ -5074,6 +5525,7 @@ async function collectHttpStatus(check) {
5074
5525
  && (!bodyContains.length || bodyContains.every((expected) => result.body_contains && result.body_contains[expected] === true))
5075
5526
  && (!bodyNotContains.length || bodyNotContains.every((forbidden) => result.body_not_contains && result.body_not_contains[forbidden] === false))
5076
5527
  && (!bodyNotPatterns.length || bodyNotPatterns.every((pattern) => result.body_not_patterns && result.body_not_patterns[pattern] === false))
5528
+ && (!bodyJsonAssertions.length || (Array.isArray(result.body_json_assertions) && result.body_json_assertions.every((assertion) => assertion.ok === true)))
5077
5529
  && !result.error;
5078
5530
  return result;
5079
5531
  } catch (error) {
@@ -5660,6 +6112,7 @@ async function collectRouteInventory(check, viewport) {
5660
6112
  };
5661
6113
  }
5662
6114
  async function captureViewport(viewport) {
6115
+ activeViewportName = viewport && viewport.name ? viewport.name : null;
5663
6116
  await page.setViewportSize({ width: viewport.width, height: viewport.height });
5664
6117
  let httpStatus = null;
5665
6118
  let navigationError;